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:
parent
86726ed109
commit
5397ba15a1
10 changed files with 233 additions and 18 deletions
|
|
@ -203,11 +203,12 @@ def scan_entropy(text: str, source: Source = Source.INPUT) -> EntropyResult:
|
|||
token = match.group()
|
||||
offset = match.start()
|
||||
|
||||
if _is_suppressed(token, text, offset):
|
||||
continue
|
||||
|
||||
# Decode-and-rescan is independent of the entropy verdict: the lexicon
|
||||
# is the real detector for whatever the encoding hid.
|
||||
# Decode-and-rescan runs BEFORE suppression: it is independent of both
|
||||
# the entropy verdict and false-positive suppression. An attacker can
|
||||
# prefix an injection blob with an SRI/media marker to suppress the
|
||||
# entropy *finding* (below), but the hidden plaintext must still reach
|
||||
# the lexicon. Real media/SRI blobs decode to binary -> try_decode_base64
|
||||
# returns None, so this adds no false rescan candidates.
|
||||
if is_base64_like(token):
|
||||
plain = try_decode_base64(token)
|
||||
if plain is not None:
|
||||
|
|
@ -215,6 +216,10 @@ def scan_entropy(text: str, source: Source = Source.INPUT) -> EntropyResult:
|
|||
DecodedBlob(offset=offset, decoded=plain, evidence=_redact(token))
|
||||
)
|
||||
|
||||
# Suppression gates only the entropy finding below, not the decode above.
|
||||
if _is_suppressed(token, text, offset):
|
||||
continue
|
||||
|
||||
length = len(token)
|
||||
entropy = shannon_entropy(token)
|
||||
severity = _classify(entropy, length)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue