Skip symlinks during traversal#68
Open
dolph wants to merge 1 commit into
Open
Conversation
`find-replace` uses `File.Info()` (a thin wrapper around `os.Stat`) to
decide whether a directory entry is a directory. `os.Stat` follows
symbolic links, so any symlink inside the working tree was transparently
resolved and walked — letting `find-replace` rewrite file contents and
rename files inside the symlink's target, anywhere on the filesystem.
This violates the documented contract ("Searches are performed
recursively from the current working directory") and is a real
privilege-escalation primitive when the tool is run with elevated
permissions (CI, root). See issue #2 for the reproducer.
Fix: in `WalkDir`, check the `fs.ModeSymlink` bit on the
`fs.DirEntry` returned by `os.ReadDir` and skip the entry before it
reaches `HandleFile`. The DirEntry already carries the symlink-bit from
the underlying `getdents64`/`readdir` call, so no extra `lstat` is
needed. The skip is logged as "Skipping symlink: <path>" so the
behavior is visible to operators.
No CLI surface change: there is intentionally no `--follow-symlinks`
opt-in flag in this commit. That would be additive and is filed as a
follow-up. Issue #13 (drop the per-entry `os.Stat` in `HandleFile`) is
related but requires a separate refactor of `HandleFile`'s dispatch and
is left for a follow-up.
Tests cover: outside-the-root file targets, outside-the-root directory
targets, inside-the-root targets (the symlink is still skipped, the
real path is still rewritten exactly once), broken symlinks (silently
skipped, no error recorded), and sanity checks for regular files and
directories.
Fixes #2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a
priority: criticalsecurity bug:find-replacewas silentlyfollowing symbolic links inside the working tree because it dispatched
on
os.Stat(which follows symlinks) rather than the symlink-awarefs.DirEntryalready returned byos.ReadDir. Any symlink to a pathoutside the current working directory —
tmp -> /etc,home -> /home/victim, or a malicious dependency droppingnode_modules/something/etc -> /etc— was transparently resolved andwalked, letting the tool rewrite file contents and rename files
inside the symlink target. This violates the documented contract
("Searches are performed recursively from the current working
directory") and is a real privilege-escalation primitive when the tool
is run with elevated permissions (CI, root).
Threat model & fix
In
WalkDir, the per-entryfs.DirEntry.Type()already carries thesymlink bit from the underlying
getdents64/readdirsyscall — noextra
lstatis needed. Before constructing a*Fileand dispatchinginto
HandleFile, we now maskentry.Type()&fs.ModeSymlinkand skipthe entry with a one-line
log.Printf("Skipping symlink: …")so theoperator can see what was skipped. The downstream
File.Info()os.Statis left in place — only the dispatch decision is changed.Scope
--follow-symlinksopt-in flag in this PR (additive CLI surface,would require
release:minor, and is out of scope for a securitybug fix). File-as-a-follow-up if desired.
os.StatinHandleFile's dispatch)is related but deliberately deferred: this fix touches only the
symlink check in
WalkDirand leavesHandleFile'sdirectory-vs-file dispatch alone, per the single-purpose-PR
guidance in
AGENTS.md. The relevant refactor will be a follow-up.Tests (TDD)
Six new tests in
find_replace_test.go, allt.TempDir()-hermetic andWindows-skipped because creating symlinks on Windows typically requires
elevation:
TestWalkDir_SymlinkToFileOutsideCWDNotFollowed— symlink targetis a file outside the search root; the target must remain untouched
and the symlink itself must remain a symlink.
TestWalkDir_SymlinkToDirOutsideCWDNotTraversed— symlink targetis a directory outside the search root; nothing inside it may be
rewritten or renamed.
TestWalkDir_SymlinkToFileInsideCWDNotFollowed— even when thetarget is inside the search root, the symlink is skipped, and the
target is rewritten exactly once via its real path.
TestWalkDir_BrokenSymlinkSkippedWithoutError— a dangling symlinkis silently skipped, not reported as an error.
TestWalkDir_RegularFileStillProcessed— sanity check fornon-symlink files.
TestWalkDir_RegularDirStillTraversed— sanity check fornon-symlink directories.
Each was confirmed to fail for the right reason (assertion failure
rather than compile error) against the unpatched
mainbefore theproduction change was applied.
Closes #2
Test plan
gofmt -l .— cleango vet ./...— cleango build ./...— cleango test -race ./...— all pass, including 6 new symlink tests./build.sh— succeedsmain(assertion failures) beforethe fix was applied
golangci-lint(errcheck) issues areintroduced; the 7 pre-existing warnings on
mainare unchangedhttps://claude.ai/code/session_01Tep5t8h97Q9KKbpLMbUEJr
Generated by Claude Code