fix(security): structural symlink detection via git diff --raw (closes #15)#20
Conversation
34ba269 to
eb3f692
Compare
🔍 Local review (cycle 1) — security (forced-update: TOCTOU-closed redesign)Initial two-phase detection (
Triage: 1 CRITICAL (redesigned) + 1 MEDIUM (closed) — both addressed in eb3f692. The frozen-tree approach closes the TOCTOU class the same way worktree.mjs's leave-branch closed the capture-then-remove class: remove the mutable state between the two reads. Cycle 2 re-runs on eb3f692 to confirm the frozen-tree redesign is itself sound (write-tree atomicity, diff-tree on tree-object correctness, no remaining window). |
#15) REDESIGN after four cycles of detection-based fixes each found a new TOCTOU bypass (index, refs/replace). Same pattern as worktree-cleanup whack-a-mole — remove the vulnerable path rather than guard it. Neutralize: apply with git -c core.symlinks=false apply --index. Symlink-mode entries materialize as regular text files (target path as content), NOT symlinks. Cannot point at host file, cannot exfil. Class disappears by construction. Byte-preserving patch and --no-ext-diff kept; detection logic removed. Tests (3, green): new symlink -> regular file; retargeted -> regular file; regular file mentioning mode 120000 -> applied normally. Verified: worktree 16/16; full npm test 159 pass / 4 pre-existing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eb3f692 to
579f6b6
Compare
🔍 Local review (cycle 2) — neutralize redesign (forced-update 579f6b6)Cycle 2 found the frozen-tree approach still had a residual: Redesign (same principle as leave-branch): remove the vulnerable path, don't guard it. Instead of detecting symlinks in the patch (which requires reading the patch and a separate check — always a TOCTOU window between two git invocations, across whatever mutable layer git resolves through), NEUTRALIZE: apply with
Neutralize approach (579f6b6): Regression tests (3, all green): new symlink → regular file (content = target path); retargeted existing symlink → regular file; regular file mentioning "mode 120000" → applied normally. Cost: legitimate symlinks in the worktree also land as text files (rare in code; render layer already warns to inspect special files manually). Acceptable for a rescue-transfer path. Cycle 3 re-runs on 579f6b6 to confirm neutralize is airtight. |
🔍 Local review (cycle 3) — neutralize redesign — APPROVEClaude: APPROVE, definitive. Codex: not run (classifier blocked node; will retry post-restart).
The symlink-replay class (#15) is dead by construction. Cycles 1-2 failed because DETECTION requires a second observation (race-able); cycle 3 tells git "do not create symlinks, period" — no race because no second observation. 16/16 tests green. lstatSync (not statSync) correctly distinguishes regular file from symlink-to-file. Merge-ready. |
Security hardening release: leave-branch worktree cleanup (#12) + 4 security fixes (#18 symlink guard, #20 core.symlinks=false neutralize, #22 eliminate info/exclude write, #23 accumulation warn). rescue default --background (#8). test-teardown (#5). All verified with cycle-review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes #15 (reopened — #19 missed a bypass).
The bypass in #19
#19 detected symlinks by scanning the patch text for a
mode 120000header line. Codex review found that an existing tracked symlink that Codex retargets emitsindex <oldsha>..<newsha> 120000— with NOnew file mode/old|new modeline — so the regex missed it.git applythen redirected the symlink to the attacker-chosen host path in repoRoot.Reproduced: tracked
link -> /tmp/benign, retargeted to-> /etc/passwd, produced a patch with onlyindex d0a3aa8..3594a94 120000;git apply --indexrecreatedlink -> /etc/passwd.Fix — structural, format-independent
Replace patch-text scanning with
git diff --cached --raw, which reports:<oldmode> <newmode> <oldsha> <newsha> <status>\t<path>for every changed entry. Parse the two mode columns; reject if either is120000. This catches all symlink variants uniformly — new, retargeted, mode-changed, renamed — because it reads git's structural mode data, not a rendered header string. Patch generation still usesgit diff --cached --binary --output(byte-safe);--rawis only the detection pass.Leave-branch invariant preserved (worktree stays on refusal).
Regression tests (3, all green)
applied:false, repoRoot symlink unchanged (the bypass fix(security): reject symlink-mode blobs in the worktree patch (closes #15) #19 missed).mode 120000→ still applied (no false positive — structural check reads mode columns, not patch text).Verified:
worktree.test.mjs16/16; fullnpm test159 pass / 4 pre-existing (unchanged).