1
0
Fork 0

fix(security): harden 5 adversarial-review findings (M1/M2/M3 + m4/m6) via TDD

Pre-release hardening from an independent adversarial review; each fixed
test-first (failing test -> fix -> green). 214 tests pass.

- entropy (M1): decode-and-rescan now runs BEFORE false-positive suppression,
  so an SRI/media-prefixed injection blob is still decoded and lexicon-rescanned.
  Suppression gates only the entropy finding, never the decode.
- output/disposition (M3): the invisible-carrier invariant now holds on the
  persist gate. scan_output flags zero-width/BIDI presence and disposition
  treats those + lexicon:unicode-tags-present as any-tier carriers, so a carrier
  in model output fails secure even under a trusted policy.
- contract (M2): assert_credential_allowlist catches a bare <PROVIDER>_KEY
  (e.g. STRIPE_KEY) that the old regex silently missed (fail-open). Deliberately
  broad: also flags PARTITION_KEY/SORT_KEY as loud, allowlistable FPs -- fail-loud
  beats fail-silent for an isolation control.
- disposition (m6): guard runs decide inside its guarded block -> total
  fail-closed even on a malformed report.
- output (m4): egress placeholder suppression anchors word markers (example,
  todo, ...) to a word boundary, closing a fail-open where a real secret merely
  containing such a word was suppressed.

Docs: CHANGELOG Security subsection; README honest-limit for lexicon dedup (m5,
documented tradeoff, not fixed).

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-05 10:45:05 +02:00
commit 5397ba15a1
10 changed files with 233 additions and 18 deletions

View file

@ -28,6 +28,11 @@ input-side scanners do not cover:
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.
5. **Invisible carrier presence** (:func:`_scan_invisible_carriers`) zero-width
and BIDI override/isolate characters in the emitted artifact. Output is
report-only and never sanitized, so this is the persist-gate analogue of
``sanitize``'s input-side stripping; disposition treats the labels as
any-tier carriers. Unicode-tag / PUA stego is already surfaced by step 1.
**Security property (this module specifically).** A finding's ``evidence`` never
contains the secret value it matched only a human description and the match
@ -148,9 +153,19 @@ _SECRET_PATTERNS: list[_SecretPattern] = [
]
# 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",
# Structural markers are legitimate anywhere in the value (a template fragment
# like ``<your-key>`` or ``your-api-key-here``), so they match as a substring.
_PLACEHOLDER_STRUCTURAL = ("your-", "your_", "<", ">", "***")
# Word markers are matched on a word boundary, NOT as a bare substring: a real
# secret that merely *contains* one ("todoAppSecretKey12" contains "todo") must
# not be suppressed — that would be a fail-open egress miss. Genuine placeholders
# ("example-secret", "changeme") still match at their boundaries.
_PLACEHOLDER_WORDS = (
"example", "placeholder", "replace", "changeme",
"xxx", "todo", "fixme", "dummy", "sample",
)
_PLACEHOLDER_WORD_RE = re.compile(
r"\b(?:" + "|".join(_PLACEHOLDER_WORDS) + r")\b", re.IGNORECASE
)
_VARREF_TOKENS = ("${", "$(", "%{", "env[", "os.environ", "process.env")
@ -158,7 +173,9 @@ _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):
if any(token in low for token in _PLACEHOLDER_STRUCTURAL):
return True
if _PLACEHOLDER_WORD_RE.search(value):
return True
if any(token in low for token in _VARREF_TOKENS):
return True
@ -197,6 +214,37 @@ def scan_secret_egress(text: str, source: Source = Source.OUTPUT) -> Report:
return report
# --- invisible carrier presence (BRIEF §4.7) --------------------------------
# The output-gate analogue of sanitize's input-side stripping: model output is
# report-only and never sanitized, so an invisible carrier reaching the persist
# gate must be flagged here. Zero-width / soft-hyphen and BIDI override/isolate
# code points; Unicode-tag / PUA stego is already surfaced by scan_lexicon
# (``lexicon:unicode-tags-present``), so it is not repeated. Disposition treats
# these labels as any-tier carriers (FAIL_SECURE regardless of trust).
_ZERO_WIDTH_CPS = frozenset({0x200B, 0x200C, 0x200D, 0xFEFF, 0x00AD})
_BIDI_CPS = frozenset({0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069})
def _scan_invisible_carriers(text: str, source: Source) -> Report:
"""Flag invisible zero-width / BIDI carriers present in ``text`` (report-only)."""
report = Report()
zero_width = sum(1 for ch in text if ord(ch) in _ZERO_WIDTH_CPS)
bidi = sum(1 for ch in text if ord(ch) in _BIDI_CPS)
if zero_width:
report.add(Finding(
label="output:zero-width-present", severity=Severity.HIGH,
source=source, detector="output", count=zero_width, owasp="LLM01",
evidence="invisible zero-width/soft-hyphen characters present",
))
if bidi:
report.add(Finding(
label="output:bidi-present", severity=Severity.HIGH,
source=source, detector="output", count=bidi, owasp="LLM01",
evidence="BIDI override/isolate characters present",
))
return report
def scan_output(
text: str,
source: Source = Source.OUTPUT,
@ -249,4 +297,9 @@ def scan_output(
# 4. Secret / credential egress (OWASP LLM02).
report.extend(scan_secret_egress(scan_text, source).findings)
# 5. Invisible carriers present in the artifact (zero-width / BIDI). Output
# is never sanitized, so this is the persist-gate carrier signal; the
# unicode-tag case is already covered by the lexicon scan in step 1.
report.extend(_scan_invisible_carriers(scan_text, source).findings)
return report