Files
gh-christianlouis-dmarq/backend/app/services/ai_assistance.py
T
2026-05-23 20:45:19 +02:00

318 lines
12 KiB
Python

"""Privacy-preserving AI and agent assistance helpers."""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List
from sqlalchemy.orm import Session
from app.core.redaction import SENSITIVE_VALUE, redact_sensitive_text
from app.models.domain import Domain
from app.models.setting import Setting
from app.services.report_persistence import hydrate_report_store_from_db
from app.services.report_store import ReportStore
AI_DEFAULTS = {
"ai.enabled": "false",
"ai.provider": "template",
"ai.model": "",
"ai.remote_base_url": "",
"ai.redaction_mode": "strict",
"ai.action_tools_enabled": "false",
"mcp.enabled": "false",
}
EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@([A-Z0-9.-]+\.[A-Z]{2,})\b", re.IGNORECASE)
LONG_TOKEN_PATTERN = re.compile(r"\b[A-Za-z0-9._~+/=-]{24,}\b")
@dataclass(frozen=True)
class AssistanceConfig:
"""Operator-controlled automation settings."""
ai_enabled: bool
provider: str
model: str
remote_base_url: str
redaction_mode: str
action_tools_enabled: bool
mcp_enabled: bool
def to_dict(self) -> Dict[str, Any]:
"""Return a UI/API-safe provider configuration."""
return {
"ai_enabled": self.ai_enabled,
"provider": self.provider,
"model": self.model,
"remote_base_url_configured": bool(self.remote_base_url),
"redaction_mode": self.redaction_mode,
"action_tools_enabled": self.action_tools_enabled,
"mcp_enabled": self.mcp_enabled,
"data_handling": {
"default_provider": "template",
"secrets_in_prompts": "never",
"raw_message_content": "never",
"remote_provider_requires_opt_in": True,
},
}
def _setting_value(db: Session, key: str) -> str:
row = db.query(Setting).filter(Setting.key == key).first()
if row is None:
return AI_DEFAULTS.get(key, "")
return row.value or ""
def _setting_bool(db: Session, key: str) -> bool:
return _setting_value(db, key).strip().lower() in {"1", "true", "yes", "on"}
def get_assistance_config(db: Session) -> AssistanceConfig:
"""Load AI/MCP settings with safe defaults."""
provider = (_setting_value(db, "ai.provider") or "template").strip().lower()
if provider not in {"template", "local", "remote"}:
provider = "template"
redaction_mode = (_setting_value(db, "ai.redaction_mode") or "strict").strip().lower()
if redaction_mode not in {"strict", "balanced"}:
redaction_mode = "strict"
return AssistanceConfig(
ai_enabled=_setting_bool(db, "ai.enabled"),
provider=provider,
model=_setting_value(db, "ai.model").strip(),
remote_base_url=_setting_value(db, "ai.remote_base_url").strip(),
redaction_mode=redaction_mode,
action_tools_enabled=_setting_bool(db, "ai.action_tools_enabled"),
mcp_enabled=_setting_bool(db, "mcp.enabled"),
)
def redact_safe_value(value: Any, *, mode: str = "strict") -> Any:
"""Redact values before they can be shared with model or agent surfaces."""
if isinstance(value, dict):
return {str(key): redact_safe_value(item, mode=mode) for key, item in value.items()}
if isinstance(value, list):
return [redact_safe_value(item, mode=mode) for item in value]
if not isinstance(value, str):
return value
redacted = redact_sensitive_text(value)
redacted = LONG_TOKEN_PATTERN.sub(SENSITIVE_VALUE, redacted)
if mode == "strict":
redacted = EMAIL_PATTERN.sub(r"*@\1", redacted)
return redacted
def _domain_exists(db: Session, store: ReportStore, domain: str) -> bool:
if domain in store.get_domains():
return True
return (
db.query(Domain.id).filter(Domain.name == domain, Domain.active.is_(True)).first()
is not None
)
def _evidence(label: str, value: Any, href: str) -> Dict[str, str]:
return {
"label": label,
"value": str(value),
"href": href,
}
def build_safe_context(db: Session, domain: str) -> Dict[str, Any]:
"""Build a redacted, evidence-linked context bundle for one domain."""
store = ReportStore.get_instance()
hydrate_report_store_from_db(db, store)
if not _domain_exists(db, store, domain):
raise ValueError("Domain not found")
config = get_assistance_config(db)
summary = store.get_domain_summary(domain)
total = int(summary.get("total_count", 0) or 0)
passed = int(summary.get("passed_count", 0) or 0)
failed = int(summary.get("failed_count", max(0, total - passed)) or 0)
compliance = float(summary.get("compliance_rate", 0.0) or 0.0)
reports_processed = int(summary.get("reports_processed", 0) or 0)
sources = store.get_domain_sources(domain, days=30)[:5]
reports = store.get_domain_reports(domain, limit=5)
context = {
"domain": domain,
"generated_at": datetime.utcnow().isoformat() + "Z",
"config": config.to_dict(),
"summary": {
"total_messages": total,
"passed_messages": passed,
"failed_messages": failed,
"compliance_rate": compliance,
"reports_processed": reports_processed,
"policy": summary.get("policy", "unknown"),
},
"top_sources": [
{
"source_ip": source.get("source_ip", "unknown"),
"count": int(source.get("count", 0) or 0),
"spf": source.get("spf_result", "unknown"),
"dkim": source.get("dkim_result", "unknown"),
"dmarc": source.get("dmarc_result", "unknown"),
"disposition": source.get("disposition", "none"),
}
for source in sources
],
"recent_reports": [
{
"report_id": report.get("report_id", "unknown"),
"org_name": report.get("org_name", "Unknown Organization"),
"total_messages": int(report.get("summary", {}).get("total_count", 0) or 0),
"pass_rate": report.get("pass_rate", 0.0),
}
for report in reports
],
"evidence": [
_evidence("Domain summary", domain, f"/domain/{domain}"),
_evidence("Total messages", total, f"/domain/{domain}#compliance-chart"),
_evidence("Compliance rate", f"{compliance}%", f"/domain/{domain}#compliance-chart"),
_evidence("Failed messages", failed, f"/domain/{domain}#sending-sources"),
],
"redaction": {
"mode": config.redaction_mode,
"applied": True,
"rules": [
"secret-like key/value fragments",
"bearer tokens",
"long opaque tokens",
"email local-parts in strict mode",
],
},
}
return redact_safe_value(context, mode=config.redaction_mode)
def _headline_for_context(context: Dict[str, Any]) -> str:
summary = context["summary"]
total = int(summary["total_messages"])
failed = int(summary["failed_messages"])
compliance = float(summary["compliance_rate"])
if total == 0:
return "No DMARC aggregate volume has been observed yet."
if failed == 0 and compliance >= 99:
return "Observed DMARC traffic is passing cleanly."
if compliance >= 90:
return "DMARC posture is mostly healthy, with a small failure set to review."
return "DMARC posture needs remediation before policy enforcement."
def build_evidence_summary(db: Session, domain: str) -> Dict[str, Any]:
"""Return an evidence-first operator summary and remediation plan."""
context = build_safe_context(db, domain)
summary = context["summary"]
failed = int(summary["failed_messages"])
total = int(summary["total_messages"])
compliance = float(summary["compliance_rate"])
recommendations: List[Dict[str, Any]] = []
if total == 0:
recommendations.append(
{
"priority": "medium",
"title": "Confirm report ingestion",
"detail": "No aggregate reports are available for this domain.",
"action": "Check mailbox sources and senders for rua delivery.",
"evidence": [context["evidence"][0]],
}
)
elif failed > 0:
recommendations.append(
{
"priority": "high" if compliance < 90 else "medium",
"title": "Review failing sending sources",
"detail": f"{failed} of {total} observed messages failed DMARC alignment.",
"action": (
"Open the sending-source evidence and confirm whether each failing "
"source is legitimate."
),
"evidence": [context["evidence"][2], context["evidence"][3]],
}
)
unknown_or_failing_sources = [
source
for source in context["top_sources"]
if source.get("dmarc") in {"fail", "mixed", "unknown", "none"}
]
if unknown_or_failing_sources:
recommendations.append(
{
"priority": "medium",
"title": "Triage top unauthenticated sources",
"detail": "At least one high-volume source is not consistently passing DMARC.",
"action": "Verify ownership before adding SPF mechanisms or enabling DKIM signing.",
"evidence": [
_evidence(
"Top source",
f"{source['source_ip']} ({source['count']} messages)",
f"/domain/{context['domain']}#sending-sources",
)
for source in unknown_or_failing_sources[:3]
],
}
)
return {
"enabled": get_assistance_config(db).ai_enabled,
"provider": context["config"],
"summary": {
"domain": context["domain"],
"headline": _headline_for_context(context),
"total_messages": total,
"failed_messages": failed,
"compliance_rate": compliance,
},
"recommendations": recommendations,
"safe_context": context,
}
def _proposal_id(payload: Dict[str, Any]) -> str:
serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:16]
def build_action_proposals(db: Session, domain: str) -> Dict[str, Any]:
"""Generate reviewable, reproducible action proposals without mutating state."""
summary = build_evidence_summary(db, domain)
proposals = []
for index, recommendation in enumerate(summary["recommendations"], start=1):
payload = {
"domain": domain,
"index": index,
"title": recommendation["title"],
"action": recommendation["action"],
"evidence": recommendation.get("evidence", []),
}
proposal_id = _proposal_id(payload)
proposals.append(
{
"proposal_id": proposal_id,
"domain": domain,
"status": "proposed",
"title": recommendation["title"],
"rationale": recommendation["detail"],
"proposed_action": recommendation["action"],
"requires_human_confirmation": True,
"confirmation_text": proposal_id,
"mutates_state": False,
"evidence": recommendation.get("evidence", []),
}
)
return {
"domain": domain,
"action_tools_enabled": get_assistance_config(db).action_tools_enabled,
"proposals": proposals,
}