"""Sanitized append-only audit ingestion, search, retention, and export metadata.""" from __future__ import annotations import hashlib import json import re from collections.abc import Mapping from datetime import UTC, datetime, timedelta from typing import Any, Protocol from uuid import UUID, uuid4 from pydantic import BaseModel, ConfigDict, Field from .engine import DecisionTrace _SECRET_VALUE = re.compile(r"(?:bearer\s+|eyJ[A-Za-z0-9_-]{20,}|-----BEGIN[^-]+-----)", re.I) _SENSITIVE = re.compile( r"(?:password|secret|token|private|authorization|cookie|credential|api[_-]?key)", re.I ) def sanitize_arguments(value: Any, *, max_bytes: int = 16_384, _depth: int = 0) -> Any: if _depth > 5: return "" result: Any if isinstance(value, Mapping): result = { str(key)[:80]: ( "" if _SENSITIVE.search(str(key)) else sanitize_arguments(item, max_bytes=max_bytes, _depth=_depth + 1) ) for key, item in list(value.items())[:64] } elif isinstance(value, list | tuple): result = [ sanitize_arguments(item, max_bytes=max_bytes, _depth=_depth + 1) for item in list(value)[:64] ] elif isinstance(value, str | int | float | bool) or value is None: if isinstance(value, str) and _SECRET_VALUE.search(value): result = "" else: result = value if not isinstance(value, str) else value[:2048] else: result = f"<{type(value).__name__}>" if len(json.dumps(result, sort_keys=True, default=str).encode()) > max_bytes: return "" return result class AuditEvent(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) id: UUID = Field(default_factory=uuid4) occurred_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) trace_id: str = Field(min_length=1, max_length=128) event_type: str = Field(default="mcp.tool.invoked", max_length=128) workspace: str = Field(min_length=1, max_length=128) project: str = Field(min_length=1, max_length=128) environment: str = Field(min_length=1, max_length=32) principal: str = Field(min_length=1, max_length=256) client_id: UUID | None = None tool: str = Field(min_length=1, max_length=256) policy_decision: str matched_rule_ids: tuple[str, ...] = () policy_trace: tuple[dict[str, Any], ...] = () arguments_hash: str = Field(pattern=r"^[0-9a-f]{64}$") sanitized_arguments: dict[str, Any] = Field(default_factory=dict) duration_ms: int = Field(ge=0, le=120_000, default=0) outcome: str = Field(pattern=r"^(success|denied|error|rate_limited)$") error_code: str | None = None retention_until: datetime | None = None @classmethod def from_context( cls, context: Any, trace: DecisionTrace, *, outcome: str, started: float, error_code: str | None = None, result_summary: dict[str, Any] | None = None, ) -> AuditEvent: import time raw = json.dumps(dict(context.arguments), sort_keys=True, default=str).encode() return cls( trace_id=trace.trace_id, workspace=context.workspace, project=context.project, environment=context.environment, principal=context.principal, client_id=context.client_id, tool=context.tool, policy_decision=trace.decision.value, matched_rule_ids=trace.matched_rule_ids, policy_trace=tuple(item.model_dump(mode="json") for item in trace.steps), arguments_hash=hashlib.sha256(raw).hexdigest(), sanitized_arguments=sanitize_arguments( { **dict(context.arguments), **({"_result": result_summary} if result_summary else {}), } ), duration_ms=min(120_000, int((time.monotonic() - started) * 1000)), outcome=outcome, error_code=error_code, retention_until=datetime.now(UTC) + timedelta(days=30), ) class AuditPage(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) items: tuple[AuditEvent, ...] next_offset: int | None = None class AuditLog(Protocol): def append(self, event: AuditEvent) -> None: ... def search( self, *, workspace: str, project: str, limit: int = 100, offset: int = 0 ) -> AuditPage: ... def export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]: ... class InMemoryAuditLog: """Reference sink used by services and tests; it deliberately has no update/delete API.""" def __init__(self) -> None: self.events: list[AuditEvent] = [] def append(self, event: AuditEvent) -> None: if any(item.id == event.id for item in self.events): raise ValueError("audit event IDs are immutable") self.events.append(event) def search( self, *, workspace: str, project: str, limit: int = 100, offset: int = 0 ) -> AuditPage: if not 1 <= limit <= 500 or offset < 0: raise ValueError("invalid audit pagination") items = tuple( item for item in self.events if item.workspace == workspace and item.project == project ) selected = items[offset : offset + limit] next_offset = offset + len(selected) if offset + len(selected) < len(items) else None return AuditPage(items=selected, next_offset=next_offset) def export_metadata(self, *, workspace: str, project: str) -> dict[str, Any]: events = self.search(workspace=workspace, project=project, limit=500).items digest = hashlib.sha256( "".join(item.model_dump_json() for item in events).encode() ).hexdigest() return { "workspace": workspace, "project": project, "eventCount": len(events), "sha256": digest, "generatedAt": datetime.now(UTC).isoformat(), "format": "audit-event-v1", "access": "policy-restricted", }