Skip to content

Commit 47102c2

Browse files
committed
feat: add configurable exclude globs
- add repeatable --exclude and --no-default-excludes flags - implement doublestar aware excluder and normalize paths - report skipped paths in verbose mode during discovery - expand tests/docs and bump zerolog to 1.35.0
1 parent acd8d4d commit 47102c2

17 files changed

Lines changed: 843 additions & 93 deletions

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ You should see `ghostscan dev (commit none)` from a plain source build, or a rea
8282
ghostscan [flags] [path]
8383
8484
Flags:
85+
--exclude strings exclude files or directories matching this glob; repeatable
8586
--max-file-size int skip files larger than this many bytes
8687
-n, --no-color disable color
88+
--no-default-excludes disable built-in exclude globs
8789
--silent suppress the startup banner
8890
--verbose print detailed structured finding blocks
8991
-v, --version print version
@@ -107,6 +109,12 @@ ghostscan --silent --no-color .
107109
# Show detailed findings
108110
ghostscan --silent --no-color --verbose ./testdata/mixed/correlated_decoder_near_payload.js
109111

112+
# Add repeatable exclude globs
113+
ghostscan . --exclude "**/*.min.js" --exclude "vendor/**"
114+
115+
# Disable built-in excludes and use only an explicit glob
116+
ghostscan . --no-default-excludes --exclude "**/*.gen.js"
117+
110118
# Enforce a smaller max file size
111119
ghostscan --max-file-size 1048576 .
112120
```
@@ -122,6 +130,13 @@ ghostscan --max-file-size 1048576 .
122130
- rule ID
123131
- fingerprint
124132

133+
Verbose mode also reports exclusions during traversal:
134+
135+
```text
136+
SKIP dist/app.min.js (matched exclude: "**/*.min.js")
137+
SKIP vendor (matched exclude: "vendor/**")
138+
```
139+
125140
Exit codes:
126141

127142
| Exit code | Description |
@@ -138,7 +153,10 @@ The current scanner behavior is intentionally narrow and real:
138153
- Does not follow symlinks.
139154
- Treats files containing a NUL byte as binary and skips them.
140155
- Uses a default max file size of `5 MiB`.
141-
- Skips `.git`, `node_modules`, `vendor`, `dist`, `build`, `target`, `out`, and `coverage`.
156+
- Matches excludes against the full normalized relative path with `/` separators.
157+
- Supports repeatable `--exclude` globs with `**` matching zero or more path segments and `filepath.Match` semantics for other segments.
158+
- Applies built-in excludes by default: `.git/**`, `node_modules/**`, `vendor/**`, `dist/**`, `build/**`, `target/**`, `out/**`, and `coverage/**`.
159+
- `--no-default-excludes` disables the built-in exclude set completely.
142160
- Never executes scanned code or fetches network resources.
143161

144162
## FAQ

cmd/root.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,15 @@ func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
6161
var verbose bool
6262
var silent bool
6363
var maxFileSize int64
64+
var excludes []string
65+
var noDefaultExcludes bool
6466
flags.BoolVarP(&noColor, "no-color", "n", false, "disable color")
6567
flags.BoolVarP(&version, "version", "v", false, "print version")
6668
flags.BoolVar(&verbose, "verbose", false, "print detailed structured finding blocks")
6769
flags.BoolVar(&silent, "silent", false, "suppress the startup banner")
6870
flags.Int64Var(&maxFileSize, "max-file-size", 0, "skip files larger than this many bytes")
71+
flags.StringArrayVar(&excludes, "exclude", nil, "exclude files or directories matching this glob; repeatable")
72+
flags.BoolVar(&noDefaultExcludes, "no-default-excludes", false, "disable built-in exclude globs")
6973

7074
if err := flags.Parse(args); err != nil {
7175
if errors.Is(err, pflag.ErrHelp) {
@@ -95,13 +99,15 @@ func execute(ctx context.Context, args []string, stdout, stderr io.Writer) int {
9599
}
96100

97101
result, err := app.Run(ctx, app.Options{
98-
Path: path,
99-
Stdout: stdout,
100-
Color: !noColor,
101-
Verbose: verbose,
102-
Silent: silent,
103-
MaxFileSize: maxFileSize,
104-
Version: Version,
102+
Path: path,
103+
Stdout: stdout,
104+
Color: !noColor,
105+
Verbose: verbose,
106+
Silent: silent,
107+
MaxFileSize: maxFileSize,
108+
Excludes: excludes,
109+
UseDefaultExcludes: !noDefaultExcludes,
110+
Version: Version,
105111
})
106112
if err != nil {
107113
_, _ = fmt.Fprintln(stderr, err)

cmd/root_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,22 @@ func TestExecute(t *testing.T) {
9696
wantCode: exitcode.ExecutionError,
9797
wantErr: "--max-file-size must be zero or greater",
9898
},
99+
{
100+
name: "repeated excludes",
101+
args: []string{"--no-color", "--exclude", "**/*.min.js", "--exclude", "vendor/**", filepath.Join("..", "testdata", "mixed")},
102+
wantCode: exitcode.FindingsDetected,
103+
},
104+
{
105+
name: "invalid exclude glob",
106+
args: []string{"--exclude", "bad["},
107+
wantCode: exitcode.ExecutionError,
108+
wantErr: "configure excludes",
109+
},
110+
{
111+
name: "no default excludes includes vendor fixture",
112+
args: []string{"--no-color", "--no-default-excludes", filepath.Join("..", "testdata", "clean")},
113+
wantCode: exitcode.Success,
114+
},
99115
}
100116

101117
for _, tt := range tests {
@@ -163,6 +179,8 @@ func TestExecuteHelp(t *testing.T) {
163179
"Usage:\n ghostscan [flags] [path]",
164180
"Optional file or directory to scan. Flags must come before the path.",
165181
"--verbose",
182+
"--exclude",
183+
"--no-default-excludes",
166184
"--silent",
167185
"--max-file-size",
168186
} {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.26.1
44

55
require github.com/fatih/color v1.19.0
66

7-
require github.com/rs/zerolog v1.34.0
7+
require github.com/rs/zerolog v1.35.0
88

99
require github.com/spf13/pflag v1.0.10
1010

go.sum

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
1-
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
21
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
32
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
4-
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
5-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
63
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
74
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
8-
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
9-
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
105
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
116
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
12-
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
13-
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
14-
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
15-
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
7+
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
8+
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
169
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
1710
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
18-
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
1911
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20-
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
2112
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
2213
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

internal/app/run.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ import (
3636
)
3737

3838
type Options struct {
39-
Path string
40-
Stdout io.Writer
41-
Color bool
42-
Verbose bool
43-
Silent bool
44-
MaxFileSize int64
45-
Version string
39+
Path string
40+
Stdout io.Writer
41+
Color bool
42+
Verbose bool
43+
Silent bool
44+
MaxFileSize int64
45+
Excludes []string
46+
UseDefaultExcludes bool
47+
Version string
4648
}
4749

4850
type Result struct {
@@ -75,7 +77,28 @@ func Run(ctx context.Context, opts Options) (Result, error) {
7577
maxFileSize = filesystem.DefaultMaxFileSize
7678
}
7779

78-
discovery, err := filesystem.Discover(path, maxFileSize)
80+
useDefaultExcludes := true
81+
if opts.Excludes != nil || !opts.UseDefaultExcludes {
82+
useDefaultExcludes = opts.UseDefaultExcludes
83+
}
84+
85+
excluder, err := filesystem.NewExcluder(opts.Excludes, useDefaultExcludes)
86+
if err != nil {
87+
return Result{}, fmt.Errorf("configure excludes: %w", err)
88+
}
89+
headerWritten := false
90+
if opts.Verbose {
91+
if err := report.WriteHeader(opts.Stdout, opts.Version, opts.Silent); err != nil {
92+
return Result{}, fmt.Errorf("write report header: %w", err)
93+
}
94+
headerWritten = true
95+
}
96+
97+
discovery, err := filesystem.Discover(path, filesystem.DiscoverOptions{
98+
MaxFileSize: maxFileSize,
99+
Excluder: excluder,
100+
OnExclude: buildExcludeReporter(opts.Stdout, opts.Verbose),
101+
})
79102
if err != nil {
80103
return Result{}, fmt.Errorf("discover files from %q: %w", path, err)
81104
}
@@ -96,10 +119,11 @@ func Run(ctx context.Context, opts Options) (Result, error) {
96119
finding.Sort(findings)
97120

98121
if err := report.WriteHuman(opts.Stdout, findings, report.Options{
99-
Version: opts.Version,
100-
Color: opts.Color,
101-
Verbose: opts.Verbose,
102-
Silent: opts.Silent,
122+
Version: opts.Version,
123+
Color: opts.Color,
124+
Verbose: opts.Verbose,
125+
Silent: opts.Silent,
126+
HeaderWritten: headerWritten,
103127
Runtime: report.RuntimeStats{
104128
WalkDuration: walkDuration,
105129
ScanDuration: scanDuration,
@@ -121,6 +145,16 @@ func Run(ctx context.Context, opts Options) (Result, error) {
121145
}, nil
122146
}
123147

148+
func buildExcludeReporter(w io.Writer, verbose bool) func(path, pattern string) {
149+
if !verbose {
150+
return nil
151+
}
152+
153+
return func(path, pattern string) {
154+
_, _ = fmt.Fprintf(w, "SKIP %s (matched exclude: %q)\n", path, pattern)
155+
}
156+
}
157+
124158
func scanCandidates(ctx context.Context, engine *scan.Engine, paths []string) ([]fileScanResult, []error) {
125159
if len(paths) == 0 {
126160
return nil, nil

internal/app/run_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,33 @@ func TestRunSilentSuppressesBanner(t *testing.T) {
196196
t.Fatalf("stdout = %q, want clean report", output)
197197
}
198198
}
199+
200+
func TestRunVerboseReportsExcludedFilesDuringTraversal(t *testing.T) {
201+
t.Parallel()
202+
203+
root := t.TempDir()
204+
if err := os.WriteFile(filepath.Join(root, "keep.js"), []byte("const x = 1;\n"), 0o644); err != nil {
205+
t.Fatalf("WriteFile() error = %v", err)
206+
}
207+
if err := os.WriteFile(filepath.Join(root, "app.min.js"), []byte("const y = 1;\n"), 0o644); err != nil {
208+
t.Fatalf("WriteFile() error = %v", err)
209+
}
210+
211+
var stdout bytes.Buffer
212+
_, err := Run(context.Background(), Options{
213+
Path: root,
214+
Stdout: &stdout,
215+
Verbose: true,
216+
Silent: true,
217+
UseDefaultExcludes: true,
218+
Excludes: []string{"**/*.min.js"},
219+
})
220+
if err != nil {
221+
t.Fatalf("Run() error = %v", err)
222+
}
223+
224+
output := stdout.String()
225+
if !strings.Contains(output, "SKIP app.min.js (matched exclude: \"**/*.min.js\")") {
226+
t.Fatalf("stdout = %q, want skip line", output)
227+
}
228+
}

internal/filesystem/discover_bench_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,18 @@ func BenchmarkDiscover(b *testing.B) {
2323

2424
for _, tc := range cases {
2525
b.Run(tc.name, func(b *testing.B) {
26+
excluder, err := NewExcluder(nil, true)
27+
if err != nil {
28+
b.Fatal(err)
29+
}
30+
2631
b.ReportAllocs()
2732
b.ResetTimer()
2833
for i := 0; i < b.N; i++ {
29-
benchDiscovery, benchErr = Discover(tc.root, DefaultMaxFileSize)
34+
benchDiscovery, benchErr = Discover(tc.root, DiscoverOptions{
35+
MaxFileSize: DefaultMaxFileSize,
36+
Excluder: excluder,
37+
})
3038
if benchErr != nil {
3139
b.Fatal(benchErr)
3240
}

0 commit comments

Comments
 (0)