diff --git a/SECURITY.md b/SECURITY.md index fd2d99ea..94498b8b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -100,7 +100,8 @@ This project implements multiple layers of security verification. Every release ### Build-Time (CI — every commit) - **9-layer security audit suite** runs on every build: - - Layer 0: Hidden-instruction audit (invisible/bidi/tag Unicode across the whole tree) + - Layer 0: Hidden-instruction audit (invisible/bidi/tag Unicode tree-wide; + prose smuggled into generated parser symbol tables) - Layer 1: Static allow-list for dangerous calls (`system`/`popen`/`fork`) + hardcoded URLs - Layer 2: Binary string audit (URLs, credentials, dangerous commands) - Layer 3: Network egress monitoring via strace (Linux) diff --git a/scripts/security-injection.py b/scripts/security-injection.py index 2db198ac..cb1c94a4 100644 --- a/scripts/security-injection.py +++ b/scripts/security-injection.py @@ -44,6 +44,7 @@ this gate rejects, so an allowlist entry cannot be produced mechanically. """ import hashlib +import re import subprocess import sys from pathlib import Path @@ -116,6 +117,68 @@ def scan(root): yield rel, line_no, line, digest, found +# ── Tier 2: scoped structural checks ─────────────────────────────────── +# +# Every threshold here was MEASURED against this tree before being gated, not +# guessed. A rule with a false-positive rate becomes noise, gets whitelisted, +# and then gets ignored -- so a rule that cannot be made clean is left out +# rather than shipped loose. + +# A generated LR parser's string table holds grammar symbol names and +# punctuation terminals. Multi-word keywords are real ("is not", "not in", +# "static get"), so a bare space is NOT a signal. Measured across all 159 +# vendored grammars: 48,271 literals, 63 contain a space, and the longest +# legitimate one is three words ("hide empty description"). Prose needs more. +# Four is therefore the tightest threshold with zero false positives today, and +# it still catches a four-word instruction like "ignore all previous +# instructions". +PARSER_PROSE_WORDS = 4 + +# DEFERRED -- hiding constructs (`display:none`, `visibility:hidden`, HTML +# comments, `= PARSER_PROSE_WORDS: + line_no = text.count("\n", 0, m.start()) + 1 + yield rel, line_no, ( + f"generated parser holds a {len(words)}-word string " + f"literal (prose does not belong in a symbol table): " + f"{lit[:80]!r}" + ) + continue + + + def load_allowlist(root): """Return {(sha256, path): why}. Entries without a real why are dropped.""" path = root / ALLOWLIST @@ -263,6 +326,12 @@ def main(argv): print(f" {digest} {rel} # ") problems += len(unexplained) + for rel, line_no, detail in tier2_findings(root): + if problems == 0: + print("=== HIDDEN-INSTRUCTION AUDIT: REFUSED ===\n") + print(f"{rel}:{line_no}: {detail}\n") + problems += 1 + stale = set(allowed) - {(d, r) for r, _n, _l, d, _f in hits} for digest, rel in sorted(stale): print(f"FAIL: stale allowlist entry (line no longer present): {digest} {rel}")