"""report — the structured findings type shared across all detectors. Pure data. Detection everywhere in this library is ``text -> findings`` (design principle 3): a detector never mutates its input, performs no I/O, and returns ``Finding`` objects collected into a ``Report``. Disposition (WARN / QUARANTINE_REVIEW / FAIL_SECURE) is decided by the caller from these findings — it is not baked into the Finding itself (design principle 4). """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Iterable, Optional class Severity(str, Enum): """Finding severity tiers. Ordered by :func:`severity_rank`.""" CRITICAL = "critical" HIGH = "high" MEDIUM = "medium" LOW = "low" INFO = "info" _RANK = { Severity.CRITICAL: 4, Severity.HIGH: 3, Severity.MEDIUM: 2, Severity.LOW: 1, Severity.INFO: 0, } def severity_rank(severity: Severity) -> int: """Return an integer rank for a severity — higher is more severe.""" return _RANK[severity] class Source(str, Enum): """Which side of the pipeline a finding came from. ``INPUT`` — the untrusted content fed into the model. ``OUTPUT`` — the model's emitted text, scanned before it is persisted (the RAG-poisoning gate). """ INPUT = "input" OUTPUT = "output" @dataclass(frozen=True) class Finding: """A single detection. Immutable and hashable. ``label`` is the finding class (e.g. ``"override:ignore-previous"``), ``detector`` is the producing module (``sanitize`` / ``lexicon`` / ``entropy`` / ``output`` / ...). ``evidence`` is a redacted, human-readable fragment — never raw payload content in alerts. """ label: str severity: Severity source: Source detector: str count: int = 1 offset: Optional[int] = None evidence: Optional[str] = None owasp: Optional[str] = None @dataclass class Report: """A mutable collection of findings with convenience aggregates.""" findings: list[Finding] = field(default_factory=list) def add(self, finding: Finding) -> None: self.findings.append(finding) def extend(self, findings: Iterable[Finding]) -> None: self.findings.extend(findings) @property def found(self) -> bool: return bool(self.findings) def max_severity(self) -> Optional[Severity]: """The most severe finding's severity, or ``None`` if empty.""" if not self.findings: return None return max((f.severity for f in self.findings), key=severity_rank) def counts(self) -> dict[Severity, int]: """Count of findings per severity tier (all tiers present, zero-filled).""" counts = {severity: 0 for severity in Severity} for finding in self.findings: counts[finding.severity] += 1 return counts