diff --git a/.console/log.md b/.console/log.md index 612e738..200e6f4 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,3 +1,15 @@ +## 2026-06-20 — feat: INJ1 prompt-injection signature detector + +New audit_kit detector (HARNESS_TRUST_HARDENING §2.2.6, the outer INJ layer): +detect_inj1 scans tracked text for invisible/bidi control characters (the +unambiguous injection/homoglyph-smuggling signal). Mirrors the boundary-detector +shape; wired into the runner as deprecated=True so it is SKIPPED by the default +gate (opt-in via --only INJ1 --include-deprecated) — a repo's own injection- +handling code must not red the fleet-wide audit. Reports codepoint+position only +(never surrounding text, D-INJ-3); legitimate handlers opt out via a +custodian:allow-invisible-chars content marker; \u escapes so it never +self-triggers. 7 tests; full suite 1126 passed. + # Log _Chronological continuity log. Decisions, stop points, what changed and why._ @@ -366,34 +378,5 @@ Part of the PM context-management completeness-audit train (PM #74). | call_graph scans tests_root as extra_roots | F1/D1 false positives for fields/functions used in tests but not in src; extra_roots contribute only usages, not definitions | 2026-04-30 | | F1 skips dataclasses with serialization methods | to_dict/model_dump/asdict expose all fields indirectly; attribute-level analysis can't see this | 2026-04-30 | | T2 recognizes mock assertions and raise AssertionError | mock.assert_called_once() / raise AssertionError(...) are legitimate test mechanisms | 2026-04-30 | -| C18 excludes f after quote chars | `"f", "h"` list elements matched `f", "` as f-string; add (? DetectorResult``; +``build_injection_detectors()``) per HARNESS_TRUST_HARDENING.md §2.2.6. It is the +**outer** layer of the INJ defense — never load-bearing (the load-bearing control +is the reviewer's code-computed typed verdict). Registered ``deprecated=True`` so +it is SKIPPED by the default audit gate: a repo's own injection-handling code +(regexes that *match* these chars) would otherwise trip it fleet-wide. It is meant +to be invoked deliberately (``--only INJ1 --include-deprecated``) against ingested +PR content, where a hit drives the reviewer to the stricter deterministic path +(D-INJ-2: degrade, never fail-closed-to-human). + +A file that legitimately contains such characters (an injection sanitizer, a +unicode test fixture) is exempted by carrying the marker +``custodian:allow-invisible-chars`` anywhere in its text — identified by content, +so consumers needn't maintain a path exclude list. +""" + +from __future__ import annotations + +import re + +from custodian.audit_kit.detector import ( + LOW, + AuditContext, + Detector, + DetectorResult, +) +from custodian.audit_kit.detectors.boundary import _is_binary, _tracked_files + +_MAX_SAMPLES = 8 +_EXEMPT_MARKER = "custodian:allow-invisible-chars" + +# Invisible + bidirectional control characters (\u escapes so THIS file never +# trips its own rule). Covers: zero-width space/non-joiner/joiner, LTR/RTL marks, +# the bidi embedding/override/isolate controls, and a mid-file BOM. +_INVISIBLE = re.compile( + "[" + "\u200b\u200c\u200d\u200e\u200f" # ZWSP ZWNJ ZWJ LRM RLM + "\u202a\u202b\u202c\u202d\u202e" # LRE RLE PDF LRO RLO + "\u2066\u2067\u2068\u2069" # LRI RLI FSI PDI + "\ufeff\u00ad" # BOM/ZWNBSP, SHY + "]" +) + + +def detect_inj1(context: AuditContext) -> DetectorResult: + """Flag tracked text files containing invisible / bidi control characters.""" + samples: list[str] = [] + count = 0 + for path in _tracked_files(context.repo_root): + if _is_binary(path): + continue + try: + rel = path.relative_to(context.repo_root) + except ValueError: + continue + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if _EXEMPT_MARKER in text: + continue # a legitimate handler / fixture opted out + for lineno, line in enumerate(text.splitlines(), start=1): + for m in _INVISIBLE.finditer(line): + count += 1 + if len(samples) < _MAX_SAMPLES: + cp = f"U+{ord(m.group()):04X}" + # Report codepoint + position only — never the surrounding + # text (that would re-launder attacker content through a + # trusted channel, the very thing D-INJ-3 forbids). + samples.append(f"{rel}:{lineno}: invisible/bidi control char {cp}") + break # one finding per line is enough + return DetectorResult(count=count, samples=samples) + + +def build_injection_detectors() -> list[Detector]: + return [ + Detector( + "INJ1", + "Tracked file contains invisible / bidirectional control characters " + "(prompt-injection / homoglyph smuggling signature)", + "open", + detect_inj1, + LOW, + frozenset(), + deprecated=True, # outer defense; opt-in, never the fleet-wide gate + ), + ] diff --git a/src/custodian/cli/runner.py b/src/custodian/cli/runner.py index 3396a6c..a8251cc 100644 --- a/src/custodian/cli/runner.py +++ b/src/custodian/cli/runner.py @@ -18,6 +18,7 @@ from custodian.audit_kit.detectors.imports import build_import_detectors from custodian.audit_kit.detectors.naming import build_naming_detectors from custodian.audit_kit.detectors.boundary import build_boundary_detectors +from custodian.audit_kit.detectors.injection import build_injection_detectors from custodian.audit_kit.detectors.capability_refs import build_capability_detectors from custodian.audit_kit.detectors.cross_repo import build_cross_repo_detectors from custodian.audit_kit.detectors.plumbing import build_plumbing_detectors @@ -141,6 +142,7 @@ def run_repo_audit( + build_docs_detectors() + build_naming_detectors() + build_boundary_detectors() + + build_injection_detectors() + build_readme_detectors() + build_doc_convention_detectors() + build_repo_meta_detectors() diff --git a/tests/test_injection.py b/tests/test_injection.py new file mode 100644 index 0000000..8c5f536 --- /dev/null +++ b/tests/test_injection.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 ProtocolWarden +"""Tests for the INJ1 prompt-injection signature detector.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from custodian.audit_kit.detector import AuditContext +from custodian.audit_kit.detectors.injection import ( + build_injection_detectors, + detect_inj1, +) + +# Build the invisible chars from codepoints so THIS test file contains none +# (it would otherwise trip the very rule it tests). +_ZWSP = chr(0x200B) +_RLO = chr(0x202E) +_BOM = chr(0xFEFF) +_EXEMPT = "custodian:allow-invisible-chars" + + +def _ctx(repo_root: Path) -> AuditContext: + src_root = repo_root / "src" + tests_root = repo_root / "tests" + src_root.mkdir(parents=True, exist_ok=True) + tests_root.mkdir(parents=True, exist_ok=True) + return AuditContext( + repo_root=repo_root, + src_root=src_root, + tests_root=tests_root, + config={}, + plugin_modules=[], + graph=None, + ) + + +def _git_init(root: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + + +class TestInj1: + def test_flags_zero_width_space(self, tmp_path: Path) -> None: + (tmp_path / "evil.py").write_text(f"x = 1{_ZWSP} # hidden\n", encoding="utf-8") + _git_init(tmp_path) + result = detect_inj1(_ctx(tmp_path)) + assert result.count == 1 + assert "U+200B" in result.samples[0] + assert "evil.py" in result.samples[0] + + def test_flags_bidi_override(self, tmp_path: Path) -> None: + (tmp_path / "readme.md").write_text(f"approve{_RLO}reject\n", encoding="utf-8") + _git_init(tmp_path) + assert detect_inj1(_ctx(tmp_path)).count == 1 + + def test_flags_mid_file_bom(self, tmp_path: Path) -> None: + (tmp_path / "a.txt").write_text(f"line1\npre{_BOM}post\n", encoding="utf-8") + _git_init(tmp_path) + assert detect_inj1(_ctx(tmp_path)).count == 1 + + def test_clean_repo_no_findings(self, tmp_path: Path) -> None: + (tmp_path / "ok.py").write_text("def f():\n return 42\n", encoding="utf-8") + (tmp_path / "doc.md").write_text("# Title\n\nNormal prose.\n", encoding="utf-8") + _git_init(tmp_path) + assert detect_inj1(_ctx(tmp_path)).count == 0 + + def test_exempt_marker_skips_file(self, tmp_path: Path) -> None: + # A legitimate handler / fixture that opts out by carrying the marker. + (tmp_path / "sanitizer.py").write_text( + f"# {_EXEMPT}\nPATTERN = '{_ZWSP}'\n", encoding="utf-8" + ) + _git_init(tmp_path) + assert detect_inj1(_ctx(tmp_path)).count == 0 + + def test_reports_codepoint_not_surrounding_text(self, tmp_path: Path) -> None: + # D-INJ-3: never re-launder attacker content; only the codepoint+position. + secret = "ATTACKER-PAYLOAD-DO-NOT-LEAK" + (tmp_path / "x.py").write_text(f"# {secret}{_ZWSP}\n", encoding="utf-8") + _git_init(tmp_path) + sample = detect_inj1(_ctx(tmp_path)).samples[0] + assert secret not in sample + assert "U+200B" in sample + + def test_detector_is_deprecated_non_gating(self) -> None: + # Outer defense: must be skipped by the default gate (opt-in only) so a + # repo's own injection-handling code can't red the fleet-wide audit. + dets = build_injection_detectors() + assert len(dets) == 1 + assert dets[0].id == "INJ1" + assert dets[0].deprecated is True