diff --git a/go.mod b/go.mod index 90c6f1f..41486c5 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/CycloneDX/cyclonedx-go v0.8.0 // indirect github.com/anchore/go-struct-converter v0.0.0-20230627203149-c72ef8859ca9 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum index f60f913..dabad9f 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/anchore/go-struct-converter v0.0.0-20230627203149-c72ef8859ca9 h1:6CO github.com/anchore/go-struct-converter v0.0.0-20230627203149-c72ef8859ca9/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= diff --git a/pkg/attestation/extractor.go b/pkg/attestation/extractor.go index dfe8824..5707469 100644 --- a/pkg/attestation/extractor.go +++ b/pkg/attestation/extractor.go @@ -65,6 +65,8 @@ func (c *ExtractorChain) ExtractAll(attestations []TypedAttestation, typeFilter extracted := extractor.Extract(att.Data) for _, f := range extracted { + ValidatePath(f.Path) + if _, seen := seenPaths[f.Path]; !seen { seenPaths[f.Path] = struct{}{} files = append(files, f) diff --git a/pkg/attestation/filter.go b/pkg/attestation/filter.go new file mode 100644 index 0000000..9098c8f --- /dev/null +++ b/pkg/attestation/filter.go @@ -0,0 +1,32 @@ +package attestation + +import ( + "fmt" + "os" + + "github.com/bmatcuk/doublestar/v4" +) + +// ValidatePath checks if an attestation path looks suspicious (e.g. logs or git directories). +// It prints a warning but does not enforce exclusion. +func ValidatePath(path string) { + if matchesPattern(path) { + fmt.Fprintf(os.Stderr, "WARNING: unexpected file in attestation: %s\n", path) + } +} + +func matchesPattern(path string) bool { + patterns := []string{ + "**/*.log", + "**/.git/**", + ".git/**", + } + + for _, pattern := range patterns { + if matched, err := doublestar.Match(pattern, path); err == nil && matched { + return true + } + } + + return false +} diff --git a/pkg/attestation/filter_test.go b/pkg/attestation/filter_test.go new file mode 100644 index 0000000..3240aaf --- /dev/null +++ b/pkg/attestation/filter_test.go @@ -0,0 +1,81 @@ +package attestation + +import ( + "bytes" + "io" + "os" + "strings" + "testing" +) + +func TestMatchesPattern(t *testing.T) { + tests := []struct { + name string + path string + expected bool + }{ + // Positive cases + {"Git config", ".git/config", true}, + {"Nested git hooks", "repo/.git/hooks/pre-commit", true}, + {"Git directory itself", ".git", true}, + {"App log", "logs/app.log", true}, + {"Absolute path log", "/var/tmp/debug/output.log", true}, + + // Negative cases + {"Go source", "src/main.go", false}, + {"Markdown reader", "README.md", false}, + {"HTML docs", "docs/index.html", false}, + {"Empty path", "", false}, + {"Subtly spoofed git", "mygit/config", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesPattern(tt.path) + if result != tt.expected { + t.Errorf("matchesPattern(%q) = %v; want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestValidatePath(t *testing.T) { + // captureStderr is a helper to intercept os.Stderr output + captureStderr := func(f func()) string { + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + // Execute function + f() + + w.Close() + os.Stderr = origStderr + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() + } + + t.Run("Suspicious path prints warning", func(t *testing.T) { + path := ".git/config" + output := captureStderr(func() { + ValidatePath(path) + }) + + if !strings.Contains(output, "WARNING") { + t.Errorf("ValidatePath(%q) expected to print warning, got output: %q", path, output) + } + }) + + t.Run("Safe path does not print warning", func(t *testing.T) { + path := "src/main.go" + output := captureStderr(func() { + ValidatePath(path) + }) + + if output != "" { + t.Errorf("ValidatePath(%q) expected no output, got: %q", path, output) + } + }) +} diff --git a/pkg/resolver/resolver.go b/pkg/resolver/resolver.go index c59cf57..34662ee 100644 --- a/pkg/resolver/resolver.go +++ b/pkg/resolver/resolver.go @@ -7,7 +7,7 @@ type PackageInfo struct { PURL string `json:"purl"` Licenses []string `json:"licenses,omitempty"` Hashes map[string]string `json:"hashes,omitempty"` - FoundBy string `json:"found_by"` // which resolver found this + FoundBy string `json:"found_by"` // which resolver found this DownloadURL string `json:"download_url,omitempty"` // set by network resolvers DownloadIP string `json:"download_ip,omitempty"` // set by network resolvers } diff --git a/pkg/resolver/rust.go b/pkg/resolver/rust.go index 3bd3ebf..456f347 100644 --- a/pkg/resolver/rust.go +++ b/pkg/resolver/rust.go @@ -3,6 +3,7 @@ package resolver import ( "path" "regexp" + "sort" "strings" ) @@ -73,7 +74,14 @@ func (r *RustResolver) Resolve(files []FileInfo) (packages []PackageInfo, remain remainingFiles = append(remainingFiles, f) } - for name, version := range chosenByName { + var names []string + for name := range chosenByName { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + version := chosenByName[name] if name == "" || version == "" { continue } diff --git a/pkg/resolver/rust_test.go b/pkg/resolver/rust_test.go new file mode 100644 index 0000000..018e1a6 --- /dev/null +++ b/pkg/resolver/rust_test.go @@ -0,0 +1,44 @@ +package resolver + +import ( + "testing" +) + +func TestRustResolver_DeterministicOrder(t *testing.T) { + r := NewRustResolver() + + files := []FileInfo{ + {Path: "/registry/src/github.com/a/tokio-1.0.0/src/lib.rs"}, + {Path: "/registry/src/github.com/a/serde-1.0.0/src/lib.rs"}, + } + + pkgs, _ := r.Resolve(files) + + if len(pkgs) != 2 { + t.Fatalf("expected 2 packages, got %d", len(pkgs)) + } + + if pkgs[0].Name != "serde" { + t.Errorf("expected serde first, got %s", pkgs[0].Name) + } + + if pkgs[1].Name != "tokio" { + t.Errorf("expected tokio second, got %s", pkgs[1].Name) + } +} + +func TestRustResolver_StableOutput(t *testing.T) { + r := NewRustResolver() + + files := []FileInfo{ + {Path: "/registry/src/github.com/a/zeta-1.0.0/src/lib.rs"}, + {Path: "/registry/src/github.com/a/alpha-1.0.0/src/lib.rs"}, + } + + pkgs1, _ := r.Resolve(files) + pkgs2, _ := r.Resolve(files) + + if pkgs1[0].Name != pkgs2[0].Name { + t.Errorf("output is not stable across runs") + } +}