diff --git a/internal/detector/rules/engine_test.go b/internal/detector/rules/engine_test.go index af254931..15eacf56 100644 --- a/internal/detector/rules/engine_test.go +++ b/internal/detector/rules/engine_test.go @@ -61,6 +61,8 @@ func fileResult(t *testing.T, scan model.RuleScan, ruleID string) model.RuleFile func TestScanRegexMatch(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "proj/.github/setup.js", "const x = eval(atob('benign'))\n") + // Decoy: same filename, wrong directory — must be rejected by the full-path check. + writeFile(t, dir, "proj/other/setup.js", "const x = eval(atob('benign'))\n") rs := prep(t, RuleSet{Rules: []Rule{{ ID: "dropper", Revision: "rev1", FileGlobs: []string{"**/.github/setup.js"}, @@ -161,6 +163,20 @@ func TestScanExistenceOnly(t *testing.T) { } } +func TestScan_WildcardFilenameGlob(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "nested/payload.mjs", "anything") + + rs := prep(t, RuleSet{Rules: []Rule{{ + ID: "wildcard", FileGlobs: []string{"**/*.mjs", "**/payload.mjs"}, + }}}) + scan := newTestEngine(t, DefaultCaps()).Scan(context.Background(), rs, []string{dir}) + fm := fileResult(t, scan, "wildcard") + if fm.MatchedGlob != "**/*.mjs" { + t.Errorf("MatchedGlob got %q, want %q", fm.MatchedGlob, "**/*.mjs") + } +} + func TestScanSizeGuard(t *testing.T) { dir := t.TempDir() big := strings.Repeat("A", 2048) diff --git a/internal/detector/rules/roots.go b/internal/detector/rules/roots.go index 465f2ade..eb7bbfcf 100644 --- a/internal/detector/rules/roots.go +++ b/internal/detector/rules/roots.go @@ -4,7 +4,9 @@ import ( "context" "errors" "io/fs" + "path" "path/filepath" + "strings" ) // errWalkStop unwinds filepath.WalkDir when a global budget is hit or the @@ -45,16 +47,25 @@ type relMatcher struct { } // walkRoots performs one TCC-aware walk per search root, matching each regular -// file against every relative glob. Symlinks are never followed (the walk does -// not descend into symlinked directories, and symlinked files are not regular, -// so they are skipped) — the symlink-escape guard. Sets st.globalStop if a -// global file/time budget is hit. +// file against relative globs indexed by fixed filename. A bundle containing a +// wildcard filename falls back to the original all-glob loop. Symlinks are +// never followed (the walk does not descend into symlinked directories, and +// symlinked files are not regular, so they are skipped) — the symlink-escape +// guard. Sets st.globalStop if a global file/time budget is hit. func (e *Engine) walkRoots(ctx context.Context, st *scanState, searchDirs []string) { + matchersByName := make(map[string][]relMatcher) var matchers []relMatcher for _, rstate := range st.states { for _, cg := range rstate.rule.globs { if !cg.absolute { - matchers = append(matchers, relMatcher{rstate: rstate, cg: cg}) + matcher := relMatcher{rstate: rstate, cg: cg} + matchers = append(matchers, matcher) + name := path.Base(cg.raw) + if strings.ContainsAny(name, "*?") { + matchersByName = nil // preserve original ordering for wildcard filenames + } else if matchersByName != nil { + matchersByName[name] = append(matchersByName[name], matcher) + } } } } @@ -66,7 +77,7 @@ func (e *Engine) walkRoots(ctx context.Context, st *scanState, searchDirs []stri if root == "" { continue } - if e.walkOneRoot(ctx, st, root, matchers) { + if e.walkOneRoot(ctx, st, root, matchersByName, matchers) { st.globalStop = true return } @@ -75,7 +86,7 @@ func (e *Engine) walkRoots(ctx context.Context, st *scanState, searchDirs []stri // walkOneRoot walks a single root. Returns true if the whole scan should stop // (global budget or context cancellation). -func (e *Engine) walkOneRoot(ctx context.Context, st *scanState, root string, matchers []relMatcher) (stopped bool) { +func (e *Engine) walkOneRoot(ctx context.Context, st *scanState, root string, matchersByName map[string][]relMatcher, matchers []relMatcher) (stopped bool) { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil // unreadable entry — skip, never fail the run @@ -92,17 +103,22 @@ func (e *Engine) walkOneRoot(ctx context.Context, st *scanState, root string, ma if !d.Type().IsRegular() { return nil // skip symlinks/sockets/etc. } + candidates := matchers + if matchersByName != nil { + candidates = matchersByName[d.Name()] + if len(candidates) == 0 { + return nil + } + } rel, rerr := filepath.Rel(root, path) if rerr != nil { return nil } relSlashed := filepath.ToSlash(rel) - for _, m := range matchers { - if m.cg.re.MatchString(relSlashed) { - if e.evaluate(st, m.rstate, path, m.cg.raw) { - return errWalkStop - } + for _, m := range candidates { + if m.cg.re.MatchString(relSlashed) && e.evaluate(st, m.rstate, path, m.cg.raw) { + return errWalkStop } } return nil