You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
What sanitize_repo.py handles in an untrusted repo, and what it does with each
item. Generated from the script's DIR_NAMES / FILE_NAMES / GLOB_PATTERNS,
the Phase-2 filename pre-scan, and the Phase-3 content-WARN parser — the script
itself remains the source of truth; if this doc and the code disagree, the code
wins.
How it neutralizes things (four mechanisms)
Quarantine (the default action). The target is moved into
<repo>/.quarantine/ and renamed — files get a .quarantined.txt suffix;
a quarantined directory is moved wholesale and every file inside it is
suffixed. Nothing is ever deleted. Renaming is the neutralizer: an editor only
auto-runs .vscode/tasks.json at that exact path, Claude only loads CLAUDE.md
by that exact name — moved + suffixed, the file is inert but still readable
for the manual skim (what a repo tried to auto-run is evidence, and an
executable config like hardhat.config.ts/build.gradle is still readable as
*.quarantined.txt for scope).
Fail-closed (for symlinks AND hostile filename codepoints). Real symlinks,
and any filename containing a control / bidi / zero-width codepoint, make the
script refuse to run (exit 2) and refuse the --check gate (exit 1). See
"Symlink exfiltration" and "Filename structural defenses" below.
Filename WARN (non-gating). Printable non-ASCII filenames and macOS
case-collisions are flagged in the manifest but do not move anything or fail the
gate.
Content WARN (fail-open, non-gating). Left-live declarative files
(foundry.toml, Anchor.toml, …) are parsed for high-signal danger keys and
flagged in the manifest. A parse error is itself a WARN — it never changes the
exit code.
The quarantine vs left-live decision rule:quarantine a config iff it is
executed as code by an editor/agent/toolchain, OR auto-fires on folder-open / a
non-build command, OR is a pure execution vector with no scope value; leave live
a declarative data manifest (TOML/JSON/XML) an auditor reads to understand scope
that only a deliberate build/test tool consumes (the read-only review process
already forbids running those).
Every run also writes a manifest (.quarantine/MANIFEST.txt — one append-only
RUN: block per invocation) and a host-controlled sentinel under .git/ that
proves sanitize ran (used by the coldclone.sh push freshness gate). Quarantine
is idempotent — a re-run produces no new MOVES (it may re-emit current-state WARNs
in a new RUN block; the manifest is append-only audit history, and --check
re-derives state from the tree, not the manifest).
Filter-driver smudge/clean on checkout (CVE-2021-21300, CVE-2025-26625). Belt-and-suspenders: the host clone already neutralized LFS filters, and no documented workflow step reads it after checkout — so it is safe to quarantine.
Quarantine file
.gitmodules
—
Submodule URL poisoning (CVE-2025-48384). LEFT LIVE (see the Left-live table + content-WARN): plain coldclone.sh fetch, the manual registered-path flow, and the in-isolation submodule fallback all read it at its real path after sanitize, so quarantining would strand them. The clone-time vector is already mitigated by the hardened --no-recurse-submodules clone + coldclone.sh path/protocol validation.
Left live + content-WARN
The .gitattributes-quarantined / .gitmodules-live split is the deliberate
asymmetry: only .gitmodules is still read by a workflow step after sanitize.
Solidity linter (solhint) executable config (doesn't match *.config.*)
Quarantine file
.solcover.js, .solcover.cjs, .solcover.mjs
name
solidity-coverage executable config
Quarantine file
14. Monorepo orchestrators
Target
Match
Disposition
nx.json, turbo.json
—
LEFT LIVE + content-WARN (declarative JSON task graphs; fire only via nx/turbo CLI; carry project-graph scope value — treated like Makefile). Content-WARN flags targetDefaults executor/command + tasksRunnerOptions (nx) and pipeline/tasks entries (turbo).
A single os.walk over the worktree (excluding .git/, never following
symlinks) checks every path component, in two tiers:
Fail-closed (exit 2 sanitize / exit 1 --check): any component containing a
codepoint with no legitimate filename use — C0 controls (0x00–0x1F), DEL (0x7F),
C1 controls (0x80–0x9F), bidi marks LRM/RLM/ALM (U+200E/200F/061C), bidi
overrides + isolates (U+202A–202E, U+2066–2069), and zero-width / invisible / BOM
(U+200B–200D, U+2060, U+2061–2064, U+FEFF). These are Trojan-Source / review-
evasion vectors. NUL (0x00) cannot occur in a POSIX filename — the kernel
rejects it at creat/rename — so the C0 range covers it with no NUL-specific
code. Runs in the same fail-fast position as the symlink check, before any
rename and before the sentinel write.
WARN (non-gating): any other non-ASCII codepoint (printable, e.g. CJK /
emoji) — flagged with its Unicode name. A blanket non-ASCII flag (not a brittle
homoglyph mapping table) catches confusables/homoglyphs in one predicate. And
macOS case-collisions — two entries in a directory whose casefolded names are
equal — best-effort: on a case-insensitive host volume (the macOS default this
runs on) the collision already collapsed at clone time and is undetectable, so
this fires on a case-sensitive volume / Linux host.
The split is load-bearing for --check: fail-closed conditions gate the push
freshness check; WARN conditions never do (a tree with a CJK filename must still
ship).
Content WARN over left-live files (Phase 3 — scan_content_warn, FAIL-OPEN)
Left-live files can't be neutralized by renaming, so the script parses them for
high-signal danger keys and emits manifest WARNs — a skim-time nudge, not a
security boundary (the isolation environment is). Every handler is wrapped: a malformed/odd file
yields WARN could not parse <f>; review manually and the scan continues — it
never changes the exit code or gates --check. Stdlib only (tomllib ≥3.11,
json); on an older interpreter the TOML handlers degrade to the fail-open path.
Display safety (no raw clickable URL). Every attacker-controlled token a WARN
interpolates — a value, path, key, or label — is rendered defanged
(hxxp[:]//evil[.]example instead of http://evil.example) via _defang_repr
(repr'd values) / _defang_if_url (bare labels) / _defang (URL tokens), so a
reviewer skimming flagged output cannot fat-finger-click a hostile link and a
terminal / editor / CI-log renderer will not auto-link or auto-fetch it. Defanging
is display-only and never feeds a detection decision (which is taken from the raw
value).
File
Flagged keys
foundry.toml
ffi = true (any profile); non-system solc/solc_version/solc_path (repo-local / not under /usr,/opt,/home,/Users,…; /home & /Users are system so svm-managed ~/.svm/.../solc is NOT flagged); fs_permissionsonly on write/read-write grants (a read-only grant is hardening, not flagged)
non-standard [build-system] build-backend / backend-path; [tool.hatch.build.hooks.*]; setuptools cmdclass/ext-modules; [tool.pytest.ini_options]addopts/plugins; [tool.setuptools] data_files/data-files installing to a system/home destination (/etc,/usr,/bin,/var,~/$HOME/${HOME}, component-aware so /etcetera is not flagged) — writes outside site-packages on pip install (both the {dest: […]} table and [[dest, […]]] list-of-pairs shapes)
parsed with stdlib ElementTree: known-dangerous plugins (exec-maven-plugin, maven-antrun-plugin, groovy-maven-plugin/gmaven-plugin, maven-invoker-plugin, frontend-maven-plugin, maven-enforcer-plugin) matched on groupId:artifactIdor artifactId-alone (Maven defaults an omitted <groupId> to org.apache.maven.plugins, so a no-groupId declaration cannot evade) + inline <script> elements; a clean pom still emits a present/left-live nudge. DoS guard: a pom declaring <!DOCTYPE/<!ENTITY> is refused parsing (stdlib ET expands internal entities → billion-laughs), and a >5MB left-live file is skipped — both WARN. XML build logic beyond the plugin allow-list is still trivially evaded (the isolation environment is the boundary).
.gitmodules
submodule url using ext::/file: (code-exec class) andgit:///ftp:///ftps:// (weak-transport, informational) and localhost/loopback hosts (SSRF-equivalent, informational); path that is absolute / has a ..component / contains control or CR (CVE-2025-48384). Component-aware: a benign third_party/foo..bar path and a relative-to-origin ../sibling.git url are NOT flagged.
nx.json
targetDefaults entries with executor/command; tasksRunnerOptions present (custom task runner may exec)
scripts values matching a loud danger pattern (IGNORECASE, the script body is never echoed — the WARN names the script + signal): fetch-pipe-to-shell (curl/wget … | sh/bash/node), base64 -d/atob(/xxd -r decode-run, node -e/python -c/eval/bash -c inline-eval, named-secret exfil ($NPM_TOKEN/$GITHUB_TOKEN/$AWS_*/printenv/env |), home//etc writes (~/$HOME/${HOME}.ssh/.bashrc/.profile, /etc/); stager URLs in any script value (raw.githubusercontent/gist/pastebin/…, link shorteners, .sh/.ps1-path URLs, bare-IP URLs — shown defanged, DNS-label-boundary so a known host as a prefix of another domain is not a FP). Output capped (WARN_PKG_SCRIPT_CAP scripts, WARN_SCRIPT_VALUE_MAX bytes/value).
Cargo.toml
[package] build = "<name>" where <name> ≠ build.rs (a custom build script that evades the build.rs basename quarantine); dependency path escaping the cloned repo root (.. component / absolute / Windows-drive / UNC) across [dependencies]/[dev-dependencies]/[build-dependencies]and target-specific [target.<triple>.*] tables; [patch] (2-level) / [replace] (1-level) supply-chain overrides + their git/registry stager URLs (override keys may be URLs → shown defanged). One shared WARN_CARGO_DEP_CAP counter bounds all dep + override traversal.
go.mod / go.work
line-scanned (single-line + replace ( … ) block, //-comments stripped). Two-tier: ONE summary line if any replace directive exists (dependency-source override), plus a louder per-entry WARN when the => RHS is a filesystem path escaping the repo root (../absolute/Windows-drive/UNC). require-side ignored (normal declarations). Capped WARN_GO_REPLACE_CAP/WARN_LINE_CAP.
composer.json
scripts lifecycle events (pre-/post-install-cmd, post-autoload-dump, …) — REUSES the package.json loud corpus (incl. php -r; php -d is NOT flagged) + stager URLs, same display contract (name event + signal, never echo body). Values may be a string or an array of strings (both scanned). LIMIT (conscious): PHP callable / @php / Vendor\Class::method scripts run in-process (no shell) and are NOT matched — the isolation boundary is the defense (cf. the pom.xml plugin allow-list admission).
requirements family (requirements*.txt, *-requirements.txt, constraints*.txt, requirements/*.txt — a name PREDICATE, since the split layout is the norm)
logical-line scan (joins \-continuations, strips #-comments). Flags: --index-url/--extra-index-url/--find-links/-i index redirects (dependency-confusion); git+/hg+/svn+/bzr+ VCS installs; direct https?://…(.tar.gz/.tgz/.whl/.zip/.tar.bz2) and PEP 508 pkg @ URL archive installs; -e/--editable VCS/URL (defanged) or root-escaping path (raw); -r/-c includes (WARN, not followed by the sanitizer; the IoC scan follows them). Flag tokens anchored to line-start/whitespace so a path containing -i/-e is not a FP. URL tokens defanged; .in pip-tools / Pipfile deferred.
Deliberately LEFT LIVE (needed to read scope; fire only on a build/test tool)
A first-class peer of the quarantine tables — quarantining any of these would
blind the auditor to scope (or break a workflow step).
index/find-links redirects, VCS/git+ installs, direct-archive URLs, editable VCS/URL or escaping-path, -r/-c includes
Makefile/justfile/Taskfile.yml
build recipes
when you run them
— (high-FP recipe scan deferred)
.git/
verify commit hash (git rev-parse HEAD)
— (fresh clone fetches no remote hooks)
—
EVM/Solana asymmetry (why it's not an inconsistency): executable configs are
quarantined — hardhat.config.ts/build.gradle are evaluated as code, so they're
moved (the readable .quarantined.txt copy preserves their scope value). The
declarative chain-native scope manifests — foundry.toml, Anchor.toml,
Cargo.toml — are pure data a tool only reads on an explicit build, so they stay
live.
Quarantine-evasion defenses (the repo fighting the sanitizer)
Scenario
What the script does
Repo ships its own root .quarantine/ (possibly a forged manifest)
First run: stashed as REPO-SUPPLIED-quarantine/, fully quarantined, WARNING logged
A nested.quarantine/ hiding a CLAUDE.md
Descended into and quarantined like any directory — only the root.quarantine/ (our output) is skipped
A live trigger dropped into.quarantine/ after a run
Swept + suffixed; --check flags it so a mutated tree can't be pushed
Forged "already sanitized" state
Trust is keyed on the git-dir sentinel (a checkout can't write under .git/), not the worktree manifest
Name collision (x and x.quarantined.txt both present)
A committed symlink such as lib/dep -> ~/.ssh/id_ed25519 would, when followed by
scp -r, exfiltrate host credential contents. The defense is the
core.symlinks=false clone (symlinks materialize as inert text files); the
script enforces it — any real symlink → refuse to run, exit 2 — and never
traverses or moves through a symlink.
Limits — what this script cannot catch
Filename-based sanitization is necessary but not sufficient. It does not catch:
Source-embedded prompt injection — instructions hidden in a docstring,
comment, README, or obfuscated block in files you must read.
Renderer / parser / extension zero-days — a malicious .svg, .ipynb
output, Markdown/Mermaid preview, or extension webview bug.
Trojan Source in file CONTENT — bidirectional/invisible Unicode inside
source. NOTE: the Phase-2 fail-closed scan now catches these codepoints in
filenames, so this is partially mitigated; in-content Trojan Source still
survives (the isolation boundary is the defense).
Pre-compiled Python bytecode — a .pyc in __pycache__/ whose bytecode
doesn't match the visible .py. Requires disassembly, not filename matching.
CI/CD workflow files (.github/workflows/*.yml, .circleci/config.yml,
Jenkinsfile) — they run on the CI platform after a push, not on local open, so
they are left in place for scope reading (some agentic PR-event workflows are an
exception, but they fire on the forge, not your host).
These are why the work should run inside a disposable isolation environment with
no host credentials: the residual costs an environment delete, not your gh
token / SSH agent / funds. The sanitizer shrinks the attack surface; the isolation
boundary bounds the blast radius.
A sibling pre-check, the IoC gate (ioc_scan.py + ioc-list.txt), runs
before this sanitizer in coldclone.sh prep (greps lockfiles for known-
malicious dependency names; HALTs on a hit). A complementary content scanner,
GuardDog, adds behavioral detection over
JS/Python tooling. Both are different layers from this script and complementary.