From 947428afb6fc41986c39c49e7227531fe2a8b72b Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 23 May 2026 00:46:18 +0200 Subject: [PATCH] harden notification settings --- README.md | 11 +- ...0d1e2f3a4_add_alert_configuration_audit.py | 82 ++++ backend/app/api/api_v1/endpoints/settings.py | 139 +++++- backend/app/models/alert.py | 19 + backend/app/services/alert_history.py | 63 ++- backend/app/services/notifications.py | 149 ++++++- backend/app/templates/settings.html | 89 +++- backend/app/tests/test_settings.py | 404 +++++++++++++++++- docs/deployment/configuration.md | 12 +- docs/milestones.md | 3 +- docs/user_guide/settings.md | 15 +- 11 files changed, 964 insertions(+), 22 deletions(-) create mode 100644 backend/alembic/versions/b9c0d1e2f3a4_add_alert_configuration_audit.py diff --git a/README.md b/README.md index efea994..2168ff2 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,8 @@ Then visit [http://localhost:8080](http://localhost:8080) DMARQ sends notifications through Apprise target URLs configured in **Settings** > **Notifications**. Add one target URL per line, enable -notifications, and use **Send Test** to verify delivery. Apprise supports email, +notifications, and use **Send Test** to verify delivery. Target URLs are +encrypted in the database and redacted in API responses. Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other targets. Notification settings include alert-rule toggles and thresholds for: @@ -118,11 +119,15 @@ Notification settings include alert-rule toggles and thresholds for: DMARQ can also send daily and weekly summaries. Use **Preview Summary** to see the current summary payload, **Send Summary Now** for an immediate message, and -the daily/weekly toggles to enable scheduled delivery. +the daily/weekly toggles to enable scheduled delivery. Outbound messages are +rate-limited by the configured cooldown and email addresses are redacted by +default before delivery. Alert history is available in **Settings** > **Notifications** after alerts have been evaluated or sent. History rows track active/resolved status, first seen, -last seen, observed count, and alert metadata. +last seen, observed count, and alert metadata. Notification and alert-rule +configuration changes are recorded in the configuration audit trail without +storing raw notification secrets. See [Settings](docs/user_guide/settings.md) and [Configuration](docs/deployment/configuration.md) for examples and available diff --git a/backend/alembic/versions/b9c0d1e2f3a4_add_alert_configuration_audit.py b/backend/alembic/versions/b9c0d1e2f3a4_add_alert_configuration_audit.py new file mode 100644 index 0000000..7e6d20d --- /dev/null +++ b/backend/alembic/versions/b9c0d1e2f3a4_add_alert_configuration_audit.py @@ -0,0 +1,82 @@ +"""add alert configuration audit + +Revision ID: b9c0d1e2f3a4 +Revises: b8c9d0e1f2a3 +Create Date: 2026-05-23 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b9c0d1e2f3a4" +down_revision: Union[str, Sequence[str], None] = "b8c9d0e1f2a3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create alert configuration audit trail rows.""" + op.create_table( + "alert_configuration_audit", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("key", sa.String(length=100), nullable=False), + sa.Column("old_value", sa.Text(), nullable=True), + sa.Column("new_value", sa.Text(), nullable=True), + sa.Column("changed_by", sa.String(length=100), nullable=True), + sa.Column("auth_type", sa.String(length=50), nullable=True), + sa.Column("changed_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_alert_configuration_audit_id"), + "alert_configuration_audit", + ["id"], + unique=False, + ) + op.create_index( + op.f("ix_alert_configuration_audit_key"), + "alert_configuration_audit", + ["key"], + unique=False, + ) + op.create_index( + op.f("ix_alert_configuration_audit_changed_by"), + "alert_configuration_audit", + ["changed_by"], + unique=False, + ) + op.create_index( + op.f("ix_alert_configuration_audit_changed_at"), + "alert_configuration_audit", + ["changed_at"], + unique=False, + ) + op.create_index( + "ix_alert_configuration_audit_key_changed_at", + "alert_configuration_audit", + ["key", "changed_at"], + unique=False, + ) + + +def downgrade() -> None: + """Drop alert configuration audit trail rows.""" + op.drop_index( + "ix_alert_configuration_audit_key_changed_at", + table_name="alert_configuration_audit", + ) + op.drop_index( + op.f("ix_alert_configuration_audit_changed_at"), + table_name="alert_configuration_audit", + ) + op.drop_index( + op.f("ix_alert_configuration_audit_changed_by"), + table_name="alert_configuration_audit", + ) + op.drop_index(op.f("ix_alert_configuration_audit_key"), table_name="alert_configuration_audit") + op.drop_index(op.f("ix_alert_configuration_audit_id"), table_name="alert_configuration_audit") + op.drop_table("alert_configuration_audit") diff --git a/backend/app/api/api_v1/endpoints/settings.py b/backend/app/api/api_v1/endpoints/settings.py index ea6e110..65ff56d 100644 --- a/backend/app/api/api_v1/endpoints/settings.py +++ b/backend/app/api/api_v1/endpoints/settings.py @@ -18,10 +18,16 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session +from app.core.credential_encryption import decrypt_secret, encrypt_secret, is_encrypted_secret from app.core.database import get_db from app.core.security import require_admin_auth from app.models.setting import Setting -from app.services.alert_history import list_alert_history, record_alert_evaluation +from app.services.alert_history import ( + list_alert_config_audit, + list_alert_history, + record_alert_config_change, + record_alert_evaluation, +) from app.services.alert_rules import evaluate_alert_rules, send_current_alerts from app.services.notifications import send_notification from app.services.summary_notifications import build_summary, send_summary_notification @@ -130,6 +136,27 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [ "value_type": "string", "category": "notifications", }, + { + "key": "notifications.min_send_interval_minutes", + "value": "15", + "description": "Minimum minutes between outbound notification deliveries", + "value_type": "integer", + "category": "notifications", + }, + { + "key": "notifications.redact_pii_enabled", + "value": "true", + "description": "Redact email addresses from outbound notification titles and bodies", + "value_type": "boolean", + "category": "notifications", + }, + { + "key": "notifications.last_sent_at", + "value": "", + "description": "Internal timestamp for outbound notification rate limiting", + "value_type": "string", + "category": "notifications", + }, { "key": "notifications.alert_new_sources_enabled", "value": "true", @@ -250,6 +277,7 @@ def _seed_defaults(db: Session) -> None: category=defaults["category"], ) ) + _migrate_plaintext_secret_settings(db) db.commit() @@ -257,6 +285,55 @@ def _get_setting(key: str, db: Session) -> Optional[Setting]: return db.query(Setting).filter(Setting.key == key).first() +def _migrate_plaintext_secret_settings(db: Session) -> None: + """Encrypt legacy plaintext secret settings opportunistically.""" + rows = db.query(Setting).filter(Setting.key.in_(_SECRET_KEYS)).all() + for row in rows: + if row.value and not is_encrypted_secret(row.value): + row.value = encrypt_secret(row.value) + + +def _stored_value_for_setting(key: str, value: Optional[str]) -> Optional[str]: + if key in _SECRET_KEYS: + return encrypt_secret(value) + return value + + +def _plain_value_for_setting(key: str, value: Optional[str]) -> Optional[str]: + if key not in _SECRET_KEYS: + return value + return decrypt_secret(value) + + +def _audit_value_for_setting(key: str, value: Optional[str]) -> Optional[str]: + if key in _SECRET_KEYS: + return "[redacted]" if value else "" + return value + + +def _should_audit_setting(key: str) -> bool: + return key.startswith("notifications.") + + +def _audit_setting_change( + db: Session, + *, + key: str, + old_plain: Optional[str], + new_plain: Optional[str], + auth_context: Optional[Dict[str, Any]], +) -> None: + if not _should_audit_setting(key) or old_plain == new_plain: + return + record_alert_config_change( + db, + key=key, + old_value=_audit_value_for_setting(key, old_plain), + new_value=_audit_value_for_setting(key, new_plain), + auth_context=auth_context, + ) + + def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]: value = row.value if redact_secrets and row.key in _SECRET_KEYS and value: @@ -307,6 +384,7 @@ class NotificationTestResponse(BaseModel): configured_targets: int = 0 invalid_targets: int = 0 skipped: bool = False + rate_limited: bool = False error: Optional[str] = None @@ -329,6 +407,12 @@ class AlertHistoryResponse(BaseModel): history: List[Dict[str, Any]] +class AlertConfigurationAuditResponse(BaseModel): + """Persisted alert configuration audit response.""" + + audit: List[Dict[str, Any]] + + class SummaryResponse(BaseModel): """Current DMARC summary notification preview.""" @@ -427,6 +511,16 @@ async def get_notification_alert_history( return {"history": list_alert_history(db, active=active, limit=max(1, min(limit, 200)))} +@router.get("/notifications/config-audit", response_model=AlertConfigurationAuditResponse) +async def get_notification_config_audit( + limit: int = 50, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> AlertConfigurationAuditResponse: + """Return recent notification and alert-rule configuration changes.""" + return {"audit": list_alert_config_audit(db, limit=max(1, min(limit, 200)))} + + @router.get("/notifications/summary", response_model=SummaryResponse) async def preview_notification_summary( period: str = "daily", @@ -495,23 +589,41 @@ async def update_setting( ) -> SettingResponse: """Update or create a single setting.""" row = _get_setting(key, db) + new_value = payload.value if row is None: # Find matching default metadata default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None) + new_plain = _plain_value_for_setting(key, new_value) row = Setting( key=key, - value=payload.value, + value=_stored_value_for_setting(key, new_value), description=default_meta["description"] if default_meta else None, value_type=default_meta["value_type"] if default_meta else "string", category=default_meta["category"] if default_meta else "general", ) db.add(row) + _audit_setting_change( + db, + key=key, + old_plain=None, + new_plain=new_plain, + auth_context=_auth, + ) else: # For secret keys, only update if not the redacted placeholder if key in _SECRET_KEYS and payload.value == "**redacted**": db.refresh(row) return _row_to_dict(row) - row.value = payload.value + old_plain = _plain_value_for_setting(key, row.value) + new_plain = _plain_value_for_setting(key, new_value) + row.value = _stored_value_for_setting(key, new_value) + _audit_setting_change( + db, + key=key, + old_plain=old_plain, + new_plain=new_plain, + auth_context=_auth, + ) db.commit() db.refresh(row) return _row_to_dict(row) @@ -533,20 +645,37 @@ async def bulk_update_settings( row = _get_setting(key, db) if row is None: default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None) + new_plain = _plain_value_for_setting(key, value) row = Setting( key=key, - value=value, + value=_stored_value_for_setting(key, value), description=default_meta["description"] if default_meta else None, value_type=default_meta["value_type"] if default_meta else "string", category=default_meta["category"] if default_meta else "general", ) db.add(row) + _audit_setting_change( + db, + key=key, + old_plain=None, + new_plain=new_plain, + auth_context=_auth, + ) else: # Skip secret placeholder updates if key in _SECRET_KEYS and value == "**redacted**": results.append(_row_to_dict(row)) continue - row.value = value + old_plain = _plain_value_for_setting(key, row.value) + new_plain = _plain_value_for_setting(key, value) + row.value = _stored_value_for_setting(key, value) + _audit_setting_change( + db, + key=key, + old_plain=old_plain, + new_plain=new_plain, + auth_context=_auth, + ) results.append(_row_to_dict(row)) db.commit() # Re-read rows to get updated_at timestamps diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index 0affe21..4ee8aa3 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -31,3 +31,22 @@ class AlertHistory(Base): def __repr__(self): return f"" + + +class AlertConfigurationAudit(Base): + """Audit trail for notification and alert-rule configuration changes.""" + + __tablename__ = "alert_configuration_audit" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(100), nullable=False, index=True) + old_value = Column(Text, nullable=True) + new_value = Column(Text, nullable=True) + changed_by = Column(String(100), nullable=True, index=True) + auth_type = Column(String(50), nullable=True) + changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + + __table_args__ = (Index("ix_alert_configuration_audit_key_changed_at", "key", "changed_at"),) + + def __repr__(self): + return f"" diff --git a/backend/app/services/alert_history.py b/backend/app/services/alert_history.py index c850f61..e8c6219 100644 --- a/backend/app/services/alert_history.py +++ b/backend/app/services/alert_history.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy.orm import Session -from app.models.alert import AlertHistory +from app.models.alert import AlertConfigurationAudit, AlertHistory def _json_dumps(value: Dict[str, Any]) -> str: @@ -125,3 +125,64 @@ def list_alert_history( query.order_by(AlertHistory.last_seen_at.desc(), AlertHistory.id.desc()).limit(limit).all() ) return [_row_to_dict(row) for row in rows] + + +def _actor_from_auth(auth_context: Optional[Dict[str, Any]]) -> Dict[str, Optional[str]]: + auth_context = auth_context or {} + user_id = auth_context.get("user_id") + if user_id is not None: + changed_by = str(user_id) + elif auth_context.get("payload", {}).get("sub"): + changed_by = str(auth_context["payload"]["sub"]) + else: + changed_by = str(auth_context.get("auth_type") or "unknown") + return { + "changed_by": changed_by, + "auth_type": str(auth_context.get("auth_type") or "unknown"), + } + + +def record_alert_config_change( + db: Session, + *, + key: str, + old_value: Optional[str], + new_value: Optional[str], + auth_context: Optional[Dict[str, Any]] = None, + changed_at: Optional[datetime] = None, +) -> None: + """Record one sanitized alert/notification setting change.""" + actor = _actor_from_auth(auth_context) + db.add( + AlertConfigurationAudit( + key=key, + old_value=old_value, + new_value=new_value, + changed_by=actor["changed_by"], + auth_type=actor["auth_type"], + changed_at=changed_at or datetime.utcnow(), + ) + ) + + +def _config_audit_row_to_dict(row: AlertConfigurationAudit) -> Dict[str, Any]: + return { + "id": row.id, + "key": row.key, + "old_value": row.old_value, + "new_value": row.new_value, + "changed_by": row.changed_by, + "auth_type": row.auth_type, + "changed_at": row.changed_at.isoformat() if row.changed_at else None, + } + + +def list_alert_config_audit(db: Session, *, limit: int = 50) -> List[Dict[str, Any]]: + """Return recent alert/notification configuration changes.""" + rows = ( + db.query(AlertConfigurationAudit) + .order_by(AlertConfigurationAudit.changed_at.desc(), AlertConfigurationAudit.id.desc()) + .limit(limit) + .all() + ) + return [_config_audit_row_to_dict(row) for row in rows] diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py index 0e39c19..dea2e44 100644 --- a/backend/app/services/notifications.py +++ b/backend/app/services/notifications.py @@ -4,11 +4,13 @@ from __future__ import annotations import logging from dataclasses import asdict, dataclass +from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional, Tuple import apprise from sqlalchemy.orm import Session +from app.core.credential_encryption import decrypt_secret from app.models.setting import Setting logger = logging.getLogger(__name__) @@ -23,14 +25,17 @@ class NotificationResult: configured_targets: int = 0 invalid_targets: int = 0 skipped: bool = False + rate_limited: bool = False error: Optional[str] = None def to_dict(self) -> Dict[str, object]: return asdict(self) -def _truthy(value: Optional[str]) -> bool: - return str(value or "").strip().lower() in {"1", "true", "yes", "on"} +def _truthy(value: Optional[str], default: bool = False) -> bool: + if value in (None, ""): + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} def _split_apprise_urls(value: Optional[str]) -> List[str]: @@ -48,6 +53,130 @@ def _notification_settings(db: Session) -> Dict[str, Optional[str]]: return {row.key: row.value for row in rows} +def _decrypted_setting(settings: Dict[str, Optional[str]], key: str) -> Optional[str]: + try: + return decrypt_secret(settings.get(key)) + except ValueError: + logger.exception("Encrypted notification setting could not be decrypted: %s", key) + return None + + +def _int_setting(value: Optional[str], default: int) -> int: + try: + return int(value or default) + except (TypeError, ValueError): + return default + + +def _parse_timestamp(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _set_notification_setting(db: Session, key: str, value: str) -> None: + row = db.query(Setting).filter(Setting.key == key).first() + if row is None: + row = Setting( + key=key, + value=value, + value_type="string", + category="notifications", + ) + db.add(row) + else: + row.value = value + + +def _rate_limit_result(settings: Dict[str, Optional[str]]) -> Optional[NotificationResult]: + interval_minutes = max( + 0, _int_setting(settings.get("notifications.min_send_interval_minutes"), 15) + ) + if interval_minutes <= 0: + return None + + last_sent_at = _parse_timestamp(settings.get("notifications.last_sent_at")) + if last_sent_at is None: + return None + + next_allowed = last_sent_at + timedelta(minutes=interval_minutes) + now = datetime.now(timezone.utc) + if now >= next_allowed: + return None + + retry_after = max(1, int((next_allowed - now).total_seconds() // 60) + 1) + return NotificationResult( + success=False, + skipped=True, + rate_limited=True, + message=f"Notification rate limit active. Try again in about {retry_after} minute(s).", + error="rate_limited", + ) + + +_EMAIL_LOCAL_CHARS = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._%+-" +) +_EMAIL_DOMAIN_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-") +_EMAIL_LEADING_PUNCTUATION = frozenset("\"'(<[{") +_EMAIL_TRAILING_PUNCTUATION = frozenset("\"'.,;:!?)]}>") + + +def _redact_email_token(token: str) -> str: + leading = "" + trailing = "" + core = token + + while core and core[0] in _EMAIL_LEADING_PUNCTUATION: + leading += core[0] + core = core[1:] + while core and core[-1] in _EMAIL_TRAILING_PUNCTUATION: + trailing = core[-1] + trailing + core = core[:-1] + + if core.count("@") != 1: + return token + + local_part, domain_part = core.split("@", 1) + domain_labels = domain_part.split(".") + if ( + not local_part + or not domain_part + or len(domain_labels) < 2 + or len(domain_labels[-1]) < 2 + or not domain_labels[-1].isalpha() + or any(not label for label in domain_labels) + or any(char not in _EMAIL_LOCAL_CHARS for char in local_part) + or any(char not in _EMAIL_DOMAIN_CHARS for char in domain_part) + ): + return token + + return f"{leading}[redacted-email]@{domain_part}{trailing}" + + +def redact_notification_text(value: str) -> str: + """Remove common PII from outbound notification text.""" + redacted = [] + token = [] + for char in value: + if char.isspace(): + if token: + redacted.append(_redact_email_token("".join(token))) + token = [] + redacted.append(char) + else: + token.append(char) + if token: + redacted.append(_redact_email_token("".join(token))) + return "".join(redacted) + + def _add_apprise_targets(notifier: apprise.Apprise, urls: List[str]) -> Tuple[int, int]: configured_targets = 0 invalid_targets = 0 @@ -81,7 +210,11 @@ def send_notification( message="Notifications are disabled.", ) - urls = _split_apprise_urls(settings.get("notifications.apprise_urls")) + rate_limit = None if force else _rate_limit_result(settings) + if rate_limit: + return rate_limit + + urls = _split_apprise_urls(_decrypted_setting(settings, "notifications.apprise_urls")) if not urls: return NotificationResult( success=False, @@ -99,6 +232,9 @@ def send_notification( ) try: + if _truthy(settings.get("notifications.redact_pii_enabled"), default=True): + title = redact_notification_text(title) + body = redact_notification_text(body) success = bool(notifier.notify(title=title, body=body)) except Exception: # pylint: disable=broad-exception-caught logger.exception("Apprise notification delivery failed.") @@ -119,6 +255,13 @@ def send_notification( error="not_delivered", ) + _set_notification_setting( + db, + "notifications.last_sent_at", + datetime.now(timezone.utc).isoformat(), + ) + db.commit() + return NotificationResult( success=True, message="Notification sent.", diff --git a/backend/app/templates/settings.html b/backend/app/templates/settings.html index d7730d7..f26bb34 100644 --- a/backend/app/templates/settings.html +++ b/backend/app/templates/settings.html @@ -229,12 +229,29 @@ - + +
+
+ + +
+
+ +
+
+

Alert Rules

@@ -409,6 +426,44 @@
+
+
+

Configuration Audit

+ +
+ + + +
+ +
+
+