1
0
Fork 0

feat(output): report-only OUTPUT gate — compose + secret egress (TDD) [skip-docs]

Module 7 of the build order: the last gate before model output is persisted.
Composes scan_lexicon + scan_entropy over the emitted text, feeds each base64
blob entropy decoded back through the lexicon (decode-and-rescan → decoded:*
findings with blob offset), and adds the LLM02 secret/credential egress layer
(cloud/provider keys, PEM headers, DB conn-strings, JWT, labelled
password/secret/api-key assignments) with placeholder/varref FP-suppression.

Report-only (never mutates; neutralize is the separate opt-in mutator). Evidence
carries only a description + match length — never the secret value. Self-safe:
single input-size cap, linear egress patterns. PEM patterns use -{5} form so the
module itself never trips a secret scanner. 26 new tests; 108 green total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
This commit is contained in:
Kjell Tore Guttormsen 2026-07-04 20:18:46 +02:00
commit 19981623f5
2 changed files with 490 additions and 0 deletions

View file

@ -0,0 +1,252 @@
"""output — the report-only OUTPUT gate (compose + secret egress).
Query-time guardrails guard the answer; this guards the *persisted artifact*.
``output`` is the last gate before model output is written to a wiki, doc, or
knowledge base: it scans the emitted text and reports what must not be persisted.
It is **report-only** (design principles 3 & 4) it never mutates the text.
Mutation is ``neutralize``'s separate, opt-in job; disposition (WARN /
QUARANTINE_REVIEW / FAIL_SECURE) is the caller's, decided from this ``Report``.
It composes the existing detectors over the output and adds the egress layer the
input-side scanners do not cover:
1. :func:`~llm_ingestion_guard.lexicon.scan_lexicon` over the output injection
strings the model reproduced into the artifact (RAG poisoning).
2. :func:`~llm_ingestion_guard.entropy.scan_entropy` over the output encoded /
high-entropy carrier blobs.
3. **Decode-and-rescan** every base64 blob ``entropy`` decoded to printable
text is fed back through ``scan_lexicon``. This is what turns "a blob is
present" into "an injection is hidden *inside* this blob". Findings from the
decoded plaintext are re-labelled ``decoded:<label>`` and carry the blob's
offset in the original text. (Scope: base64 only ``entropy`` exposes
decoded plaintext for base64, not hex; a base64-*wrapped secret* is a
documented gap, since decode-rescan feeds the lexicon, not the egress set.)
4. **Secret / credential egress** (:func:`scan_secret_egress`, OWASP LLM02
Sensitive Information Disclosure) cloud/provider API keys, PEM private-key
headers, DB connection strings, JWTs, and labelled password/secret/api-key
assignments, with false-positive suppression for placeholders and variable
references. Ported from the ``llm-security`` ``knowledge/secrets-patterns.md``
seed. Classic PII (email / national-ID / card numbers) is intentionally out
of scope for v1 high false-positive risk, not in the seed.
**Security property (this module specifically).** A finding's ``evidence`` never
contains the secret value it matched only a human description and the match
length. The report is meant to be logged; it must not become the leak.
**Self-safety (OWASP LLM10).** The output is capped once to ``max_scan_chars``
and a single ``output:oversize-input`` finding is emitted if it was truncated;
every sub-scanner then sees bounded input. The egress patterns are linear
(anchored prefixes / negated character classes no nested quantifiers).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, replace
from typing import Optional, Union
from .entropy import scan_entropy
from .lexicon import MAX_SCAN_CHARS, scan_lexicon
from .report import Finding, Report, Severity, Source
# --- secret / credential egress patterns (OWASP LLM02) ----------------------
# Ported from knowledge/secrets-patterns.md. ``value_group`` names the capturing
# group holding the matched secret *value*: when set, value-based false-positive
# suppression (placeholder / variable-ref / all-same-char / short) is applied to
# it — these are the low-specificity "labelled" patterns the seed warns about.
# When ``value_group`` is None the pattern is a high-specificity token (a unique
# prefix + length) with, per the seed, no known false positives; it is always
# reported. PEM headers use ``-{5}`` rather than five literal dashes so neither
# this source nor a scanned example trips a secret scanner on the module itself.
@dataclass(frozen=True)
class _SecretPattern:
id: str
regex: re.Pattern[str]
severity: Severity
desc: str
value_group: Optional[Union[int, str]] = None
def _p(pattern: str, flags: int = 0) -> re.Pattern[str]:
return re.compile(pattern, flags)
_I = re.IGNORECASE
_SECRET_PATTERNS: list[_SecretPattern] = [
# --- cloud / provider keys (high-specificity: always reported) ----------
_SecretPattern("aws-access-key-id", _p(r"\bAKIA[0-9A-Z]{16}\b"),
Severity.CRITICAL, "AWS access key ID"),
_SecretPattern("gcp-api-key", _p(r"\bAIza[0-9A-Za-z_\-]{35}\b"),
Severity.HIGH, "Google Cloud / Firebase API key"),
_SecretPattern("gcp-service-account-json", _p(r'"type"\s*:\s*"service_account"'),
Severity.CRITICAL, "GCP service-account credential marker"),
_SecretPattern("github-pat-classic", _p(r"\bghp_[A-Za-z0-9]{36}\b"),
Severity.CRITICAL, "GitHub classic personal access token"),
_SecretPattern("github-pat-fine-grained", _p(r"\bgithub_pat_[A-Za-z0-9_]{82}\b"),
Severity.CRITICAL, "GitHub fine-grained personal access token"),
_SecretPattern("github-oauth-token", _p(r"\bgho_[A-Za-z0-9]{36}\b"),
Severity.CRITICAL, "GitHub OAuth access token"),
_SecretPattern("github-server-token", _p(r"\bghs_[A-Za-z0-9]{36}\b"),
Severity.HIGH, "GitHub App / Actions token"),
_SecretPattern("npm-token", _p(r"\bnpm_[A-Za-z0-9]{36}\b"),
Severity.CRITICAL, "npm automation / publish token"),
_SecretPattern("openai-api-key-legacy",
_p(r"\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b"),
Severity.CRITICAL, "OpenAI API key (legacy format)"),
_SecretPattern("openai-project-key", _p(r"\bsk-proj-[A-Za-z0-9\-_]{40,}\b"),
Severity.CRITICAL, "OpenAI project-scoped API key"),
_SecretPattern("anthropic-api-key", _p(r"\bsk-ant-api03-[A-Za-z0-9\-_]{93}\b"),
Severity.CRITICAL, "Anthropic Claude API key"),
_SecretPattern("azure-storage-key", _p(r"AccountKey=([A-Za-z0-9+/]{86}==)", _I),
Severity.CRITICAL, "Azure Storage account key"),
# --- PEM private-key headers (header alone is sufficient to flag) --------
_SecretPattern("rsa-private-key", _p(r"-{5}BEGIN RSA PRIVATE KEY-{5}"),
Severity.CRITICAL, "PEM RSA private key header"),
_SecretPattern("ec-private-key",
_p(r"-{5}BEGIN (?:EC|DSA|OPENSSH|ENCRYPTED) PRIVATE KEY-{5}"),
Severity.CRITICAL, "PEM EC/DSA/OpenSSH private key header"),
_SecretPattern("pkcs8-private-key", _p(r"-{5}BEGIN PRIVATE KEY-{5}"),
Severity.CRITICAL, "PEM PKCS#8 private key header"),
# --- DB connection strings (suppress placeholder passwords) -------------
_SecretPattern("postgres-connstr",
_p(r"postgres(?:ql)?://[^:@\s]+:(?P<val>[^@\s]+)@[^\s'\"]+"),
Severity.CRITICAL, "PostgreSQL connection string with credentials",
value_group="val"),
_SecretPattern("mongodb-connstr",
_p(r"mongodb(?:\+srv)?://[^:@\s]+:(?P<val>[^@\s]+)@[^\s'\"]+"),
Severity.CRITICAL, "MongoDB connection string with credentials",
value_group="val"),
_SecretPattern("mysql-connstr",
_p(r"mysql(?:2)?://[^:@\s]+:(?P<val>[^@\s]+)@[^\s'\"]+"),
Severity.CRITICAL, "MySQL/MariaDB connection string with credentials",
value_group="val"),
_SecretPattern("redis-connstr", _p(r"redis://:(?P<val>[^@\s]+)@[^\s'\"]+"),
Severity.HIGH, "Redis connection string with password",
value_group="val"),
# --- JWT (high false-positive rate -> MEDIUM, flag for review) ----------
_SecretPattern("jwt-token",
_p(r"\beyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b"),
Severity.MEDIUM, "JSON Web Token"),
# --- labelled / generic (value-based FP suppression applied) ------------
_SecretPattern("generic-api-key",
_p(r"\bapi[_\-]?key\s*[:=]\s*[\"']([A-Za-z0-9\-._]{16,64})[\"']", _I),
Severity.HIGH, "generic api_key assignment", value_group=1),
_SecretPattern("bearer-token",
_p(r"Authorization\s*[:=]\s*[\"']?Bearer\s+([A-Za-z0-9\-._~+/]+=*)", _I),
Severity.HIGH, "Bearer token in Authorization header", value_group=1),
_SecretPattern("azure-client-secret",
_p(r"client[_\-]?secret[\"'\s]*[:=][\"'\s]*([A-Za-z0-9~._\-]{34,40})", _I),
Severity.CRITICAL, "Azure AD client secret", value_group=1),
_SecretPattern("config-password",
_p(r"(?:^|[\s,;{(])\bpass(?:word|wd)?\s*[:=]\s*[\"']([^\"'$<>{}\s]{6,})[\"']", _I),
Severity.HIGH, "password assignment", value_group=1),
_SecretPattern("config-secret",
_p(r"(?:^|[\s,;{(])\bsecret\b\s*[:=]\s*[\"']([^\"'$<>{}\s]{8,})[\"']", _I),
Severity.HIGH, "secret assignment", value_group=1),
]
# Value fragments that mark a placeholder / template rather than a real secret.
_PLACEHOLDER_TOKENS = (
"your-", "your_", "<", ">", "example", "placeholder", "replace", "changeme",
"xxx", "***", "todo", "fixme", "dummy", "sample",
)
_VARREF_TOKENS = ("${", "$(", "%{", "env[", "os.environ", "process.env")
def _is_fp_value(value: str) -> bool:
"""True if ``value`` is a placeholder / variable ref / trivial, not a secret."""
low = value.lower()
if any(token in low for token in _PLACEHOLDER_TOKENS):
return True
if any(token in low for token in _VARREF_TOKENS):
return True
if len(set(value)) == 1: # all-same-character (e.g. "xxxxxxxx")
return True
if len(value) < 8: # too short for a generic-pattern secret
return True
return False
def scan_secret_egress(text: str, source: Source = Source.OUTPUT) -> Report:
"""Scan ``text`` for secret/credential egress; report-only, evidence-safe.
Each finding is labelled ``egress:<id>`` with OWASP ``LLM02``. Evidence is a
human description plus the match length never the secret value itself.
"""
report = Report()
for pattern in _SECRET_PATTERNS:
for match in pattern.regex.finditer(text):
if pattern.value_group is not None:
value = match.group(pattern.value_group)
if value is None or _is_fp_value(value):
continue
report.add(
Finding(
label=f"egress:{pattern.id}",
severity=pattern.severity,
source=source,
detector="output",
offset=match.start(),
owasp="LLM02",
# Length only — the evidence must never carry the secret.
evidence=f"{pattern.desc} (match len {len(match.group(0))})",
)
)
return report
def scan_output(
text: str,
source: Source = Source.OUTPUT,
max_scan_chars: int = MAX_SCAN_CHARS,
) -> Report:
"""Scan model OUTPUT before persist; return the merged, report-only findings.
Composes lexicon + entropy + decode-and-rescan + secret egress over ``text``.
Never mutates ``text``. Bounds runtime by capping the scanned length once.
"""
report = Report()
truncated = len(text) > max_scan_chars
scan_text = text[:max_scan_chars] if truncated else text
if truncated:
report.add(
Finding(
label="output:oversize-input",
severity=Severity.MEDIUM,
source=source,
detector="output",
count=len(text),
owasp="LLM10",
evidence=f"output {len(text)} chars exceeds cap {max_scan_chars}; scanned prefix only",
)
)
# 1. Injection strings the model reproduced into the artifact. scan_text is
# already <= cap, so the lexicon will not emit a second oversize finding.
report.extend(scan_lexicon(scan_text, source, max_scan_chars).findings)
# 2. Encoded / high-entropy carrier blobs, plus the decoded plaintext blobs.
entropy_result = scan_entropy(scan_text, source)
report.extend(entropy_result.report.findings)
# 3. Decode-and-rescan: run the lexicon over each decoded blob's plaintext,
# re-labelled so the finding is attributable to the hiding blob.
for blob in entropy_result.decoded:
hidden = scan_lexicon(blob.decoded, source, max_scan_chars)
for finding in hidden.findings:
report.add(
replace(
finding,
label=f"decoded:{finding.label}",
offset=blob.offset,
evidence=f"{finding.evidence} (in base64 blob @ {blob.offset})",
)
)
# 4. Secret / credential egress (OWASP LLM02).
report.extend(scan_secret_egress(scan_text, source).findings)
return report