scan-hidden-unicode.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. #!/usr/bin/env python3
  2. """Scan files or stdin for hidden / direction-altering Unicode used in prompt injection.
  3. Usage: scan-hidden-unicode.py [OPTIONS] [PATH ...]
  4. Input: file/dir paths as argv, or content on stdin with --stdin
  5. Output: stdout = findings (TSV by default, JSON envelope with --json)
  6. Stderr: human-readable progress, per-file summary, errors
  7. Exit: 0 clean, 2 usage, 3 not-found, 4 validation, 5 missing-catalog,
  8. 10 INDICATOR_FOUND (dangerous codepoints present)
  9. Examples:
  10. scan-hidden-unicode.py CLAUDE.md AGENTS.md
  11. scan-hidden-unicode.py --json . | jq '.data[]'
  12. rg -l . | scan-hidden-unicode.py - # scan a file list (paths on argv)
  13. cat suspicious.md | scan-hidden-unicode.py --stdin
  14. scan-hidden-unicode.py --strict docs/ # also flag medium/low + homoglyphs
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import json
  19. import sys
  20. import unicodedata
  21. from pathlib import Path
  22. # Windows console is cp1252 by default; force UTF-8 so U+XXXX names never crash --help.
  23. try:
  24. sys.stdout.reconfigure(encoding="utf-8")
  25. sys.stderr.reconfigure(encoding="utf-8")
  26. except Exception:
  27. pass
  28. EXIT_OK = 0
  29. EXIT_ERROR = 1
  30. EXIT_USAGE = 2
  31. EXIT_NOT_FOUND = 3
  32. EXIT_VALIDATION = 4
  33. EXIT_PRECONDITION = 5
  34. EXIT_INDICATOR = 10 # tool-specific: dangerous codepoints found
  35. SEVERITY_ORDER = {"benign": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
  36. # Files always scanned when walking a directory, regardless of --include globs.
  37. INSTRUCTION_NAMES = {
  38. "CLAUDE.md", "AGENTS.md", "GEMINI.md", "COPILOT.md", "CURSOR.md", "WARP.md",
  39. ".cursorrules", ".windsurfrules", ".clinerules", "SKILL.md",
  40. }
  41. DEFAULT_INCLUDE = ["*.md", "*.mdc", "*.txt", "*.json"]
  42. DEFAULT_CATALOG = Path(__file__).resolve().parent.parent / "assets" / "dangerous-codepoints.json"
  43. # Scripts treated as a confusable risk when mixed within one token (--strict only).
  44. CONFUSABLE_SCRIPTS = ("LATIN", "CYRILLIC", "GREEK", "ARMENIAN")
  45. def log(level: str, msg: str, quiet: bool = False) -> None:
  46. if quiet and level == "INFO":
  47. return
  48. print(f"[{level}] {msg}", file=sys.stderr)
  49. def die(message: str, code: str, exit_code: int, as_json: bool, details: dict | None = None):
  50. if as_json:
  51. obj = {"error": {"code": code, "message": message}}
  52. if details:
  53. obj["error"]["details"] = details
  54. print(json.dumps(obj))
  55. print(f"ERROR: {message}", file=sys.stderr)
  56. sys.exit(exit_code)
  57. def parse_cp(token: str) -> int:
  58. """'U+202E' / '202E' -> int."""
  59. return int(token.replace("U+", "").replace("u+", ""), 16)
  60. def load_catalog(path: Path, as_json: bool) -> list[dict]:
  61. if not path.exists():
  62. die(f"codepoint catalog not found: {path}", "MISSING_DEPENDENCY", EXIT_PRECONDITION,
  63. as_json, details={"expected": str(path)})
  64. try:
  65. raw = json.loads(path.read_text(encoding="utf-8"))
  66. except (json.JSONDecodeError, OSError) as e:
  67. die(f"catalog unreadable: {e}", "VALIDATION_ERROR", EXIT_VALIDATION, as_json)
  68. bands = []
  69. for b in raw.get("bands", []):
  70. try:
  71. bands.append({
  72. "id": b["id"],
  73. "name": b["name"],
  74. "start": parse_cp(b["start"]),
  75. "end": parse_cp(b["end"]),
  76. "severity": b["severity"],
  77. "strip_level": b.get("strip_level", "standard"),
  78. })
  79. except (KeyError, ValueError) as e:
  80. die(f"malformed band in catalog: {e}", "VALIDATION_ERROR", EXIT_VALIDATION, as_json)
  81. # Sort so smaller/more-specific bands match before the broad PUA ranges.
  82. bands.sort(key=lambda x: (x["end"] - x["start"], x["start"]))
  83. return bands
  84. def classify(cp: int, bands: list[dict]) -> dict | None:
  85. for b in bands:
  86. if b["start"] <= cp <= b["end"]:
  87. return b
  88. return None
  89. def script_of(ch: str) -> str | None:
  90. """Heuristic script family from the Unicode name prefix (LATIN/CYRILLIC/GREEK...)."""
  91. if not ch.isalpha():
  92. return None
  93. try:
  94. name = unicodedata.name(ch)
  95. except ValueError:
  96. return None
  97. return name.split(" ", 1)[0]
  98. def find_mixed_script_tokens(text: str, lineno: int) -> list[dict]:
  99. """--strict heuristic: a single word mixing confusable scripts (e.g. Latin + Cyrillic 'аdmin')."""
  100. findings = []
  101. col = 0
  102. token = ""
  103. token_col = 0
  104. scripts: set[str] = set()
  105. def flush():
  106. nonlocal token, scripts
  107. confusable = {s for s in scripts if s in CONFUSABLE_SCRIPTS}
  108. if len(confusable) >= 2 and len(token) >= 2:
  109. findings.append({
  110. "type": "mixed-script",
  111. "line": lineno, "col": token_col + 1,
  112. "codepoint": "", "char_name": "",
  113. "band": "homoglyph", "severity": "high",
  114. "context": f"token '{token}' mixes scripts: {'+'.join(sorted(confusable))}",
  115. })
  116. token = ""
  117. scripts = set()
  118. for ch in text:
  119. col += 1
  120. s = script_of(ch)
  121. if ch.isalpha() and s:
  122. if not token:
  123. token_col = col - 1
  124. token += ch
  125. scripts.add(s)
  126. else:
  127. flush()
  128. flush()
  129. return findings
  130. def scan_text(text: str, bands: list[dict], strict: bool, whitelist: bool) -> list[dict]:
  131. findings: list[dict] = []
  132. for lineno, line in enumerate(text.splitlines(), start=1):
  133. for col, ch in enumerate(line, start=1):
  134. cp = ord(ch)
  135. if cp < 0x80:
  136. continue
  137. band = classify(cp, bands)
  138. if band is None:
  139. continue
  140. sev = band["severity"]
  141. # Emoji whitelist: VS16 + ZWJ are load-bearing in emoji; never flag unless asked.
  142. if whitelist and sev == "benign":
  143. continue
  144. # BOM is legitimate only at absolute file start (line 1 col 1).
  145. if band["id"] == "bom-zwnbsp" and lineno == 1 and col == 1:
  146. continue
  147. # Default fails on critical+high; --strict adds medium+low+benign.
  148. min_sev = "benign" if strict else "high"
  149. if SEVERITY_ORDER[sev] < SEVERITY_ORDER[min_sev]:
  150. continue
  151. try:
  152. cname = unicodedata.name(ch)
  153. except ValueError:
  154. cname = "<unnamed>"
  155. findings.append({
  156. "type": "codepoint",
  157. "line": lineno, "col": col,
  158. "codepoint": f"U+{cp:04X}", "char_name": cname,
  159. "band": band["id"], "severity": sev,
  160. "context": band["name"],
  161. })
  162. if strict:
  163. findings.extend(find_mixed_script_tokens(line, lineno))
  164. return findings
  165. def iter_target_files(paths: list[str], includes: list[str]) -> list[Path]:
  166. out: list[Path] = []
  167. seen: set[Path] = set()
  168. def add(p: Path):
  169. rp = p.resolve()
  170. if rp not in seen and rp.is_file():
  171. seen.add(rp)
  172. out.append(p)
  173. for raw in paths:
  174. p = Path(raw)
  175. if p.is_dir():
  176. for f in sorted(p.rglob("*")):
  177. if not f.is_file():
  178. continue
  179. if f.name in INSTRUCTION_NAMES or any(f.match(g) for g in includes):
  180. add(f)
  181. else:
  182. add(p) # explicit file: scan regardless of extension
  183. return out
  184. def main() -> int:
  185. ap = argparse.ArgumentParser(
  186. prog="scan-hidden-unicode.py", add_help=False,
  187. description="Scan files or stdin for hidden / direction-altering Unicode (prompt injection).")
  188. ap.add_argument("paths", nargs="*", help="files or directories to scan")
  189. ap.add_argument("--stdin", action="store_true", help="read content from stdin instead of paths")
  190. ap.add_argument("--strict", action="store_true",
  191. help="also flag medium/low bands + mixed-script homoglyph tokens")
  192. ap.add_argument("--no-emoji-whitelist", action="store_true",
  193. help="flag VS16/ZWJ too (noisy: hits every emoji)")
  194. ap.add_argument("--include", action="append", metavar="GLOB",
  195. help=f"filename glob when walking dirs (repeatable; default {DEFAULT_INCLUDE})")
  196. ap.add_argument("--catalog", metavar="PATH", help="override codepoint catalog path")
  197. ap.add_argument("--json", action="store_true", help="machine-readable output to stdout")
  198. ap.add_argument("-q", "--quiet", action="store_true", help="suppress INFO stderr")
  199. ap.add_argument("-h", "--help", action="store_true", help="show this help and exit")
  200. args = ap.parse_args()
  201. if args.help:
  202. print(__doc__)
  203. return EXIT_OK
  204. as_json = args.json
  205. includes = args.include or DEFAULT_INCLUDE
  206. catalog_path = Path(args.catalog) if args.catalog else DEFAULT_CATALOG
  207. bands = load_catalog(catalog_path, as_json)
  208. whitelist = not args.no_emoji_whitelist
  209. all_findings: list[dict] = []
  210. scanned = 0
  211. if args.stdin:
  212. data = sys.stdin.buffer.read().decode("utf-8", errors="replace")
  213. scanned = 1
  214. for f in scan_text(data, bands, args.strict, whitelist):
  215. f["file"] = "<stdin>"
  216. all_findings.append(f)
  217. else:
  218. if not args.paths:
  219. die("no paths given (and --stdin not set)", "USAGE", EXIT_USAGE, as_json)
  220. targets = iter_target_files(args.paths, includes)
  221. missing = [p for p in args.paths if not Path(p).exists()]
  222. if missing and not targets:
  223. die(f"path not found: {missing[0]}", "NOT_FOUND", EXIT_NOT_FOUND, as_json,
  224. details={"missing": missing})
  225. for path in targets:
  226. try:
  227. data = path.read_text(encoding="utf-8")
  228. except UnicodeDecodeError:
  229. log("WARN", f"skip non-UTF-8 file: {path}", args.quiet)
  230. continue
  231. except OSError as e:
  232. log("WARN", f"skip unreadable file: {path} ({e})", args.quiet)
  233. continue
  234. scanned += 1
  235. for f in scan_text(data, bands, args.strict, whitelist):
  236. f["file"] = str(path)
  237. all_findings.append(f)
  238. # ---- output ------------------------------------------------------------
  239. worst = max((SEVERITY_ORDER[f["severity"]] for f in all_findings), default=0)
  240. failed = bool(all_findings)
  241. if as_json:
  242. print(json.dumps({
  243. "data": all_findings,
  244. "meta": {
  245. "count": len(all_findings),
  246. "files_scanned": scanned,
  247. "strict": args.strict,
  248. "worst_severity": next((k for k, v in SEVERITY_ORDER.items() if v == worst), "benign"),
  249. "schema": "claude-mods.prompt-injection.scan/v1",
  250. },
  251. }))
  252. else:
  253. for f in all_findings:
  254. # TSV: file line col codepoint severity band context
  255. print(f"{f['file']}\t{f['line']}\t{f['col']}\t{f['codepoint']}\t"
  256. f"{f['severity']}\t{f['band']}\t{f['context']}")
  257. if failed:
  258. log("ERROR",
  259. f"{len(all_findings)} hidden-unicode finding(s) across {scanned} file(s); "
  260. f"worst severity = {next((k for k,v in SEVERITY_ORDER.items() if v==worst),'?')}", args.quiet)
  261. return EXIT_INDICATOR
  262. log("INFO", f"clean: no hidden-unicode indicators in {scanned} file(s)", args.quiet)
  263. return EXIT_OK
  264. if __name__ == "__main__":
  265. sys.exit(main())