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
|
|
@ -131,6 +131,32 @@ def test_credential_allowlist_reports_multiple_leaks_sorted():
|
|||
assert excinfo.value.details == ("AWS_SECRET_ACCESS_KEY", "GITHUB_TOKEN")
|
||||
|
||||
|
||||
def test_credential_allowlist_catches_bare_provider_key():
|
||||
# M2: a bare `<PROVIDER>_KEY` (e.g. STRIPE_KEY) is credential-shaped and must
|
||||
# be caught off-allowlist. A silent miss would fail *open* — the key leaks
|
||||
# into a stage that was never granted it. Fail-loud > fail-silent here.
|
||||
env = {"ANTHROPIC_API_KEY": "x", "STRIPE_KEY": "x"}
|
||||
with pytest.raises(ContractViolation) as excinfo:
|
||||
assert_credential_allowlist(env, ("ANTHROPIC_API_KEY",))
|
||||
assert excinfo.value.details == ("STRIPE_KEY",)
|
||||
# scoped_env then drops it, so the assert passes by construction.
|
||||
scoped = scoped_env(env, ("ANTHROPIC_API_KEY",))
|
||||
assert "STRIPE_KEY" not in scoped
|
||||
assert assert_credential_allowlist(scoped, ("ANTHROPIC_API_KEY",)) is None
|
||||
|
||||
|
||||
def test_bare_key_rule_is_broad_by_design_dynamodb_keys_are_loud_fps():
|
||||
# Deliberate tradeoff (M2): the bare `_KEY` rule also flags non-secret keys
|
||||
# like DynamoDB's PARTITION_KEY / SORT_KEY. This is a LOUD false positive
|
||||
# (raises, one-line allowlist fix) chosen over a silent fail-open on some new
|
||||
# provider's `<X>_KEY`. Fail-loud > fail-silent for an isolation control.
|
||||
assert credential_env_names({"PARTITION_KEY": "pk", "SORT_KEY": "sk"}) == (
|
||||
"PARTITION_KEY", "SORT_KEY",
|
||||
)
|
||||
env = {"PARTITION_KEY": "pk", "SORT_KEY": "sk"}
|
||||
assert assert_credential_allowlist(env, ("PARTITION_KEY", "SORT_KEY")) is None
|
||||
|
||||
|
||||
# --- (c) capability isolation (env scoping) --------------------------------
|
||||
|
||||
def test_scoped_env_strips_off_allowlist_credentials_keeps_the_rest():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue