From 027c029316bd809023e55b841c76874b7e93adae Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Mon, 6 May 2024 11:06:42 -0700 Subject: [PATCH] redact passwords, keys, and secret tokens in admin UI --- archivebox/config.py | 4 +- archivebox/core/admin.py | 14 +++- archivebox/core/apps.py | 16 +++++ archivebox/core/settings.py | 19 ++++++ archivebox/core/views.py | 133 +++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 6 files changed, 183 insertions(+), 4 deletions(-) diff --git a/archivebox/config.py b/archivebox/config.py index efd0bc6d..22da3700 100644 --- a/archivebox/config.py +++ b/archivebox/config.py @@ -112,7 +112,7 @@ CONFIG_SCHEMA: Dict[str, ConfigDefaultDict] = { 'LDAP_FIRSTNAME_ATTR': {'type': str, 'default': None}, 'LDAP_LASTNAME_ATTR': {'type': str, 'default': None}, 'LDAP_EMAIL_ATTR': {'type': str, 'default': None}, - 'LDAP_CREATE_SUPERUSER': {'type': bool, 'default': False}, + 'LDAP_CREATE_SUPERUSER': {'type': bool, 'default': False}, }, 'ARCHIVE_METHOD_TOGGLES': { @@ -265,7 +265,7 @@ CONFIG_ALIASES = { for key, default in section.items() for alias in default.get('aliases', ()) } -USER_CONFIG = {key for section in CONFIG_SCHEMA.values() for key in section.keys()} +USER_CONFIG = {key: section[key] for section in CONFIG_SCHEMA.values() for key in section.keys()} def get_real_name(key: str) -> str: """get the current canonical name for a given deprecated config key""" diff --git a/archivebox/core/admin.py b/archivebox/core/admin.py index 62111200..41e2db68 100644 --- a/archivebox/core/admin.py +++ b/archivebox/core/admin.py @@ -13,9 +13,11 @@ from django.utils.safestring import mark_safe from django.shortcuts import render, redirect from django.contrib.auth import get_user_model from django import forms -# monkey patch django-signals-webhooks to change how it shows up in Admin UI + + from signal_webhooks.apps import DjangoSignalWebhooksConfig from signal_webhooks.admin import WebhookAdmin, WebhookModel + from ..util import htmldecode, urldecode, ansi_to_html from core.models import Snapshot, ArchiveResult, Tag @@ -117,6 +119,16 @@ archivebox_admin.register(APIToken) archivebox_admin.register(WebhookModel, WebhookAdmin) archivebox_admin.disable_action('delete_selected') + +# patch admin with methods to add data views +from admin_data_views.admin import get_app_list, admin_data_index_view, get_admin_data_urls, get_urls + +archivebox_admin.get_app_list = get_app_list.__get__(archivebox_admin, ArchiveBoxAdmin) +archivebox_admin.admin_data_index_view = admin_data_index_view.__get__(archivebox_admin, ArchiveBoxAdmin) +archivebox_admin.get_admin_data_urls = get_admin_data_urls.__get__(archivebox_admin, ArchiveBoxAdmin) +archivebox_admin.get_urls = get_urls(archivebox_admin.get_urls).__get__(archivebox_admin, ArchiveBoxAdmin) + + class ArchiveResultInline(admin.TabularInline): model = ArchiveResult diff --git a/archivebox/core/apps.py b/archivebox/core/apps.py index 91a1b81b..f955cb7d 100644 --- a/archivebox/core/apps.py +++ b/archivebox/core/apps.py @@ -7,6 +7,22 @@ class CoreConfig(AppConfig): name = 'core' def ready(self): + # register our custom admin as the primary django admin + from django.contrib import admin + from django.contrib.admin import sites + from core.admin import archivebox_admin + + admin.site = archivebox_admin + sites.site = archivebox_admin + + + # register signal handlers from .auth import register_signals register_signals() + + + +# from django.contrib.admin.apps import AdminConfig +# class CoreAdminConfig(AdminConfig): +# default_site = "core.admin.get_admin_site" diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py index 5c1183fd..dca68674 100644 --- a/archivebox/core/settings.py +++ b/archivebox/core/settings.py @@ -64,6 +64,8 @@ INSTALLED_APPS = [ 'core', 'api', + 'admin_data_views', + 'signal_webhooks', 'django_extensions', ] @@ -416,3 +418,20 @@ SIGNAL_WEBHOOKS = { "api.models.APIToken": ..., }, } + + +ADMIN_DATA_VIEWS = { + "NAME": "configuration", + "URLS": [ + { + "route": "live/", + "view": "core.views.live_config_list_view", + "name": "live", + "items": { + "route": "/", + "view": "core.views.live_config_value_view", + "name": "live_config_value", + }, + }, + ], +} diff --git a/archivebox/core/views.py b/archivebox/core/views.py index 6cd146f4..f53c7888 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -1,10 +1,12 @@ __package__ = 'archivebox.core' +from typing import Callable + from io import StringIO from contextlib import redirect_stdout from django.shortcuts import render, redirect -from django.http import HttpResponse, Http404 +from django.http import HttpRequest, HttpResponse, Http404 from django.utils.html import format_html, mark_safe from django.views import View, static from django.views.generic.list import ListView @@ -14,6 +16,10 @@ from django.contrib.auth.mixins import UserPassesTestMixin from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator +from admin_data_views.typing import TableContext, ItemContext +from admin_data_views.utils import render_with_table_view, render_with_item_view, ItemLink + + from core.models import Snapshot from core.forms import AddLinkForm @@ -26,6 +32,10 @@ from ..config import ( COMMIT_HASH, FOOTER_INFO, SNAPSHOTS_PER_PAGE, + CONFIG, + CONFIG_SCHEMA, + DYNAMIC_CONFIG_SCHEMA, + USER_CONFIG, ) from ..main import add from ..util import base_url, ansi_to_html @@ -312,3 +322,124 @@ class HealthCheckView(View): content_type='text/plain', status=200 ) + + +def find_config_section(key: str) -> str: + matching_sections = [ + name for name, opts in CONFIG_SCHEMA.items() if key in opts + ] + section = matching_sections[0] if matching_sections else 'DYNAMIC' + return section + +def find_config_default(key: str) -> str: + default_val = USER_CONFIG.get(key, {}).get('default', lambda: None) + if isinstance(default_val, Callable): + return None + else: + default_val = repr(default_val) + return default_val + +def find_config_type(key: str) -> str: + if key in USER_CONFIG: + return USER_CONFIG[key]['type'].__name__ + elif key in DYNAMIC_CONFIG_SCHEMA: + return type(CONFIG[key]).__name__ + return 'str' + +def key_is_safe(key: str) -> bool: + for term in ('key', 'password', 'secret', 'token'): + if term in key.lower(): + return False + return True + +@render_with_table_view +def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext: + + assert request.user.is_superuser, 'Must be a superuser to view configuration settings.' + + rows = { + "Section": [], + "Key": [], + "Type": [], + "Value": [], + "Default": [], + # "Documentation": [], + "Aliases": [], + } + + for section in CONFIG_SCHEMA.keys(): + for key in CONFIG_SCHEMA[section].keys(): + rows['Section'].append(section.replace('_', ' ').title().replace(' Config', '')) + rows['Key'].append(ItemLink(key, key=key)) + rows['Type'].append(mark_safe(f'{find_config_type(key)}')) + rows['Value'].append(mark_safe(f'{CONFIG[key]}') if key_is_safe(key) else '******** (redacted)') + rows['Default'].append(mark_safe(f'{find_config_default(key) or 'See here...'}')) + # rows['Documentation'].append(mark_safe(f'Wiki: {key}')) + rows['Aliases'].append(', '.join(CONFIG_SCHEMA[section][key].get('aliases', []))) + + section = 'DYNAMIC' + for key in DYNAMIC_CONFIG_SCHEMA.keys(): + rows['Section'].append(section.replace('_', ' ').title().replace(' Config', '')) + rows['Key'].append(ItemLink(key, key=key)) + rows['Type'].append(mark_safe(f'{find_config_type(key)}')) + rows['Value'].append(mark_safe(f'{CONFIG[key]}') if key_is_safe(key) else '******** (redacted)') + rows['Default'].append(mark_safe(f'{find_config_default(key) or 'See here...'}')) + # rows['Documentation'].append(mark_safe(f'Wiki: {key}')) + rows['Aliases'].append(ItemLink(key, key=key) if key in USER_CONFIG else '') + + return TableContext( + title="Computed Configuration Values", + table=rows, + ) + +@render_with_item_view +def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemContext: + + assert request.user.is_superuser, 'Must be a superuser to view configuration settings.' + + aliases = USER_CONFIG.get(key, {}).get("aliases", []) + + return ItemContext( + slug=key, + title=key, + data=[ + { + "name": mark_safe(f'data / ArchiveBox.conf   [{find_config_section(key)}]   {key}' if key in USER_CONFIG else f'[DYNAMIC CONFIG]   {key}   (calculated at runtime)'), + "description": None, + "fields": { + 'Key': key, + 'Type': find_config_type(key), + 'Value': CONFIG[key] if key_is_safe(key) else '********', + }, + "help_texts": { + 'Key': mark_safe(f''' + Documentation   + + Aliases: {", ".join(aliases)} + + '''), + 'Type': mark_safe(f''' + + See full definition in archivebox/config.py... + + '''), + 'Value': mark_safe(f''' + {'Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)

' if not key_is_safe(key) else ''} + Default: + {find_config_default(key) or 'See 1here...'} + +

+

+ To change this value, edit data/ArchiveBox.conf or run: +

+ archivebox config --set {key}="{ + val.strip("'") + if (val := find_config_default(key)) else + (repr(CONFIG[key] if key_is_safe(key) else '********')).strip("'") + }" +

+ '''), + }, + }, + ], + ) diff --git a/pyproject.toml b/pyproject.toml index 8f009769..e3544a80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ # - scihubdl # - See Github issues for more... "django-signal-webhooks>=0.3.0", + "django-admin-data-views>=0.3.1", ] homepage = "https://github.com/ArchiveBox/ArchiveBox"