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
|
|
@ -78,10 +78,15 @@ def assert_tool_less(request: Mapping[str, object]) -> None:
|
|||
# --- (b) per-stage credential allowlist ------------------------------------
|
||||
|
||||
# Credential-shaped env-var names, matched on the underscore-delimited word
|
||||
# boundary so ``SECRETARY_NAME`` does not false-positive. Ported verbatim from
|
||||
# the reference pipeline's proven ``CREDENTIAL_NAME_RE``.
|
||||
# boundary so ``SECRETARY_NAME`` does not false-positive. Extends the reference
|
||||
# pipeline's ``CREDENTIAL_NAME_RE`` with a bare ``KEY`` alternative so a
|
||||
# provider key like ``STRIPE_KEY`` is caught rather than silently missed. This
|
||||
# is deliberately broad: it also flags non-secret keys (``PARTITION_KEY``,
|
||||
# ``SORT_KEY``), but that is a LOUD false positive (raises; fixed with a
|
||||
# one-line allowlist entry), chosen over a silent fail-open on a new provider's
|
||||
# ``<X>_KEY`` — for an isolation control, fail-loud beats fail-silent (§4.7).
|
||||
CREDENTIAL_NAME_RE = re.compile(
|
||||
r"(^|_)(API_?KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)(_|$)"
|
||||
r"(^|_)(API_?KEY|APIKEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)(_|$)"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -69,11 +69,17 @@ class DispositionResult:
|
|||
|
||||
|
||||
# Invisible carriers have no legitimate place in a reference file: they block in
|
||||
# any tier regardless of trust or provenance (BRIEF §4.7).
|
||||
# any tier regardless of trust or provenance (BRIEF §4.7). Both the input-side
|
||||
# (``sanitize:*``, which strips) and the output-side presence labels are listed
|
||||
# so the invariant holds on the persist gate too — model output is never
|
||||
# sanitized, so its carrier signal arrives via ``output``/``lexicon`` instead.
|
||||
_CARRIER_LABELS = frozenset({
|
||||
"sanitize:zero-width",
|
||||
"sanitize:bidi-override",
|
||||
"sanitize:unicode-tag",
|
||||
"output:zero-width-present",
|
||||
"output:bidi-present",
|
||||
"lexicon:unicode-tags-present",
|
||||
})
|
||||
|
||||
_DISPOSITION_RANK = {
|
||||
|
|
@ -197,18 +203,20 @@ def guard(
|
|||
"""Run ``scan_fn`` and dispose the result, failing *closed* on error.
|
||||
|
||||
A scanner that raises (a missing detector dependency, a crash on crafted
|
||||
input) must never yield an auto-persist: an un-scannable artifact is a BLOCK
|
||||
(BRIEF §4.6, fail-closed).
|
||||
input) — or a disposition that raises (a malformed report) — must never
|
||||
yield an auto-persist: an un-scannable / un-disposable artifact is a BLOCK
|
||||
(BRIEF §4.6, fail-closed). ``decide`` runs inside the guarded block so the
|
||||
fail-closed guarantee is total.
|
||||
"""
|
||||
try:
|
||||
report = scan_fn()
|
||||
except Exception as exc: # noqa: BLE001 — fail closed on ANY scanner error
|
||||
return decide(report, policy, provenance=provenance, transform_failed=transform_failed)
|
||||
except Exception as exc: # noqa: BLE001 — fail closed on ANY scan/dispose error
|
||||
return DispositionResult(
|
||||
Disposition.FAIL_SECURE,
|
||||
(f"fail-closed: scanner error: {type(exc).__name__}",),
|
||||
(f"fail-closed: scan/dispose error: {type(exc).__name__}",),
|
||||
None,
|
||||
)
|
||||
return decide(report, policy, provenance=provenance, transform_failed=transform_failed)
|
||||
|
||||
|
||||
PRESET_TRUSTED_SOURCE = Policy(trust=Trust.TRUSTED)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue