diff --git a/cmd/check.go b/cmd/check.go index 9ccd1bd..2d906dd 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -50,7 +50,7 @@ func newCheckCommand() *cobra.Command { return fmt.Errorf("resolve scan paths: %w", err) } - engine, err := scanner.NewEngine(cfg, allow) + engine, err := scanner.NewEngineWithRoot(cfg, allow, repoRoot) if err != nil { return fmt.Errorf("create scanner: %w", err) } diff --git a/cmd/check_test.go b/cmd/check_test.go index f1b0b4c..8692340 100644 --- a/cmd/check_test.go +++ b/cmd/check_test.go @@ -114,6 +114,43 @@ func TestCheckCommandExplicitMissingPathStillErrors(t *testing.T) { assert.Contains(t, err.Error(), "stat path") } +func TestCheckCommandFingerprintStableAcrossWorkingDirectories(t *testing.T) { + repoRoot := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, ".git", "hooks"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(repoRoot, "nested"), 0o755)) + + target := filepath.Join(repoRoot, "secret.js") + require.NoError(t, os.WriteFile(target, []byte("const key = \"AKIA1234567890ABCDEF\";\n"), 0o644)) + + runCheck := func(t *testing.T, workdir string, arg string) scanner.Finding { + t.Helper() + chdirForTest(t, workdir) + + cmd := newCheckCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + output := &bytes.Buffer{} + cmd.SetOut(output) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{arg, "--json"}) + + err := cmd.Execute() + require.ErrorIs(t, err, ErrFindings) + + var findings []scanner.Finding + require.NoError(t, json.Unmarshal(output.Bytes(), &findings)) + require.Len(t, findings, 1) + return findings[0] + } + + rootFinding := runCheck(t, repoRoot, "secret.js") + nestedFinding := runCheck(t, filepath.Join(repoRoot, "nested"), "../secret.js") + + assert.Equal(t, "secret.js", rootFinding.File) + assert.Equal(t, "secret.js", nestedFinding.File) + assert.Equal(t, rootFinding.Fingerprint, nestedFinding.Fingerprint) +} + func chdirForTest(t *testing.T, dir string) { t.Helper() wd, err := os.Getwd() diff --git a/scanner/scanner.go b/scanner/scanner.go index 30f0292..c1aba80 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -35,10 +35,16 @@ type Engine struct { allow allowlist.Set patterns []PatternRule warnings []string + rootDir string } // NewEngine constructs a scanner engine. func NewEngine(cfg config.Config, allow allowlist.Set) (*Engine, error) { + return NewEngineWithRoot(cfg, allow, mustGetwd()) +} + +// NewEngineWithRoot constructs a scanner engine with a stable root directory for paths and fingerprints. +func NewEngineWithRoot(cfg config.Config, allow allowlist.Set, rootDir string) (*Engine, error) { patterns, err := AllPatterns(cfg) if err != nil { return nil, fmt.Errorf("build pattern set: %w", err) @@ -46,7 +52,14 @@ func NewEngine(cfg config.Config, allow allowlist.Set) (*Engine, error) { if allow == nil { allow = allowlist.Set{} } - return &Engine{cfg: cfg, allow: allow, patterns: patterns}, nil + if rootDir == "" { + rootDir = mustGetwd() + } + rootDir, err = filepath.Abs(rootDir) + if err != nil { + return nil, fmt.Errorf("resolve root dir %s: %w", rootDir, err) + } + return &Engine{cfg: cfg, allow: allow, patterns: patterns, rootDir: rootDir}, nil } // Warnings returns non-fatal scan warnings, such as skipped oversized files. @@ -149,7 +162,7 @@ func (e *Engine) scanFile(path string) ([]Finding, error) { } defer file.Close() - relative, err := filepath.Rel(mustGetwd(), path) + relative, err := filepath.Rel(e.rootDir, path) if err != nil { relative = path }