Build order steps 1-2 of docs/PLAN.md: - pyproject.toml (llm-ingestion-guard, stdlib-only core, extras [ml]/[judge]/[dev]), LICENSE (MIT) - report: Finding/Report/Severity/Source shared type (pure data) - sanitize: carrier stripping (zero-width, BIDI, Unicode-tag, HTML comment, data: URI) with the byte-identical / removes-only invariant - docs/PLAN.md: v1 implementation plan (positioning A, gap-expanded scope, llm-security reuse map) 15 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K8GmKRCdsPjWYAKWsNgeQS
99 lines
2.8 KiB
Python
99 lines
2.8 KiB
Python
"""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
|