Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,80 @@ func TestMissingRootIsInfoLevelDiagnostic(t *testing.T) {
}
}

// TestFileSymlinkDoesNotExfiltrateOutOfScope verifies that a file-typed
// symlink planted inside a scan root (the supply-chain attack shape: a
// malicious package's postinstall hook drops a symlink at
// node_modules/<pkg>/package.json pointing at an unrelated JSON config
// outside the scan root) does NOT cause the target file's fields to be
// emitted under the npm ecosystem. The walker must skip the symlink so
// the parser never sees it; before the fix, the scanner read through
// the link and emitted a record carrying the target's name/version,
// effectively exfiltrating fields from out-of-tree files via the
// configured records sink.
func TestFileSymlinkDoesNotExfiltrateOutOfScope(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin on Windows")
}
root := t.TempDir()

// Legitimate in-scope package whose record we still want to see.
proj := filepath.Join(root, "proj")
writeFile(t, filepath.Join(proj, "node_modules", "good", "package.json"),
`{"name":"good","version":"1.0.0"}`)

// Attacker-controlled out-of-scope JSON file. Field shape matches
// the parser's expectations so a successful exfil would be obvious.
outOfScope := filepath.Join(root, "elsewhere", "stolen.json")
writeFile(t, outOfScope, `{"name":"SHOULD-NOT-LEAK","version":"v-leaked"}`)

// Plant the symlink under the scan root.
symlinkPath := filepath.Join(proj, "node_modules", "evil", "package.json")
if err := os.MkdirAll(filepath.Dir(symlinkPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outOfScope, symlinkPath); err != nil {
t.Skipf("symlink unsupported: %v", err)
}

stdout := &bytes.Buffer{}
em := output.New(stdout, &bytes.Buffer{}, "r")
_, err := Run(context.Background(), Config{
Roots: []Root{{Path: proj, Kind: model.RootKindProject}},
Profile: model.ProfileProject,
MaxFileSize: 1 << 20,
MaxDuration: 5 * time.Second,
Concurrency: 2,
Emitter: em,
})
if err != nil {
t.Fatalf("Run: %v", err)
}

for _, line := range bytes.Split(bytes.TrimRight(stdout.Bytes(), "\n"), []byte{'\n'}) {
if len(line) == 0 {
continue
}
var r model.Record
if jerr := json.Unmarshal(line, &r); jerr != nil {
continue
}
if r.RecordType != model.RecordTypePackage {
continue
}
if r.PackageName == "SHOULD-NOT-LEAK" || r.Version == "v-leaked" {
t.Errorf("out-of-scope field leaked via file symlink: name=%q version=%q source_file=%q",
r.PackageName, r.Version, r.SourceFile)
}
if strings.Contains(r.SourceFile, "/evil/") {
t.Errorf("symlink path was surfaced as source_file: %q", r.SourceFile)
}
}

if em.RecordsEmitted < 1 {
t.Errorf("expected at least 1 record from the legitimate package, got %d", em.RecordsEmitted)
}
}

func TestSymlinkLoopSafety(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin on Windows")
Expand Down
26 changes: 19 additions & 7 deletions internal/walk/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
// The walker visits directories under configured roots, applying:
// - exclude-directory matching by name
// - symlink-loop protection via visited-inode tracking
// - symlink entries are never surfaced to the visitor (neither
// directory-typed nor file-typed) so the walker never crosses into
// an unrelated subtree by indirection
// - bounded recursion: it does not descend into node_modules subtrees
// beyond what targeted scanners need (those scanners walk their own
// bounded depth)
Expand Down Expand Up @@ -209,13 +212,6 @@ func walkOne(root string, excludes map[string]struct{}, seen map[string]struct{}
if isExcluded(path, d.Name(), excludes) {
return filepath.SkipDir
}
// Directory symlinks are never descended into. filepath.WalkDir
// does not follow them on its own, and we explicitly skip any
// directory-shaped symlink we encounter so the walker never
// crosses into an unrelated subtree by indirection.
if info, lerr := os.Lstat(path); lerr == nil && info.Mode()&os.ModeSymlink != 0 {
return filepath.SkipDir
}
// Symlink-loop guard via device+inode.
if key, ok := dirKey(path); ok {
if _, dup := seen[key]; dup {
Expand All @@ -224,6 +220,22 @@ func walkOne(root string, excludes map[string]struct{}, seen map[string]struct{}
seen[key] = struct{}{}
}
}
// Symlink entries — directory-typed or file-typed — are never
// surfaced to the visitor. filepath.WalkDir does not descend
// into directory symlinks on its own, but it does report
// file-typed symlinks as regular entries to the callback, and
// scanners open files through os.Open which follows the link.
// Without this guard, an attacker who can plant one symlink
// inside a scan root (e.g. via a malicious package's postinstall
// hook placing node_modules/<pkg>/package.json -> some other
// JSON config under the user's home) can have the walker parse
// that out-of-tree file and emit its fields under an unrelated
// ecosystem — leaking the target file's contents through the
// configured records sink. Skip every symlink-typed entry to
// keep the "walker never crosses by indirection" invariant.
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if verr := visit(path, d); verr != nil {
if errors.Is(verr, filepath.SkipDir) {
return filepath.SkipDir
Expand Down
61 changes: 61 additions & 0 deletions internal/walk/walk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,67 @@ func TestWalkSkipsExcludedLibrarySubtrees(t *testing.T) {
}
}

// TestWalkSkipsFileSymlinks verifies that file-typed symlinks under a
// scan root are not surfaced to the visitor. Without this, a single
// planted symlink at, say, node_modules/<pkg>/package.json pointing at
// an unrelated JSON file outside the scan root would be parsed by the
// ecosystem scanners (which open through os.Open and follow the link),
// causing the target file's name/version-shaped fields to be emitted as
// if they belonged to a real installed package. The walker's contract
// is that it never crosses into an unrelated subtree by indirection.
func TestWalkSkipsFileSymlinks(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin on Windows")
}
root := t.TempDir()
// In-scope regular file the walker should visit.
inScope := filepath.Join(root, "proj", "package-lock.json")
mustMkdir(t, filepath.Dir(inScope))
mustWrite(t, inScope, "{}")

// Out-of-scope target the symlink will point at.
outOfScope := filepath.Join(root, "elsewhere", "target.json")
mustMkdir(t, filepath.Dir(outOfScope))
mustWrite(t, outOfScope, `{"name":"out-of-scope","version":"1.0.0"}`)

// Plant a file-typed symlink inside the scan root pointing at the
// out-of-scope target.
symlinkPath := filepath.Join(root, "proj", "node_modules", "evil", "package.json")
mustMkdir(t, filepath.Dir(symlinkPath))
if err := os.Symlink(outOfScope, symlinkPath); err != nil {
t.Skipf("symlink unsupported: %v", err)
}

scanRoot := filepath.Join(root, "proj")
var seen []string
err := Walk(Options{Roots: []string{scanRoot}}, func(path string, d fs.DirEntry) error {
if !d.IsDir() {
seen = append(seen, path)
}
return nil
})
if err != nil {
t.Fatalf("Walk: %v", err)
}

for _, p := range seen {
if p == symlinkPath {
t.Errorf("file-typed symlink was surfaced to visitor: %q", p)
}
}

foundInScope := false
for _, p := range seen {
if p == inScope {
foundInScope = true
break
}
}
if !foundInScope {
t.Errorf("expected to visit %q; saw %v", inScope, seen)
}
}

func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
Expand Down