From c880c328641edf6ce1c97f13781cd9b45462e86f Mon Sep 17 00:00:00 2001 From: Sandipmandal25 Date: Thu, 2 Apr 2026 15:47:54 +0530 Subject: [PATCH] feat: add Java/Maven resolver with maven attestor support Signed-off-by: Sandipmandal25 --- cmd/generate.go | 2 +- pkg/generator/generator.go | 73 +++++++++- pkg/resolver/java.go | 144 ++++++++++++++++++++ pkg/resolver/java_test.go | 266 +++++++++++++++++++++++++++++++++++++ pkg/resolver/resolver.go | 1 + 5 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 pkg/resolver/java.go create mode 100644 pkg/resolver/java_test.go diff --git a/cmd/generate.go b/cmd/generate.go index c4af136..0ac5a49 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -56,7 +56,7 @@ func init() { generateCmd.Flags().StringVarP(&documentName, "name", "n", "sbomit-sbom", "Name for the SBOM document") generateCmd.Flags().StringVarP(&documentVersion, "version", "v", "0.0.1", "Version for the SBOM document") generateCmd.Flags().StringSliceVar(&authors, "author", []string{}, "Document authors (can be specified multiple times)") - generateCmd.Flags().StringSliceVar(&attestationTypes, "types", []string{"material", "command-run", "product", "network-trace"}, "Attestation types to parse (comma-separated).") + generateCmd.Flags().StringSliceVar(&attestationTypes, "types", []string{"material", "command-run", "product", "network-trace", "maven"}, "Attestation types to parse (comma-separated).") generateCmd.Flags().StringVarP(&catalog, "catalog", "c", "", "Cataloger to run before processing attestations (supported: syft)") generateCmd.Flags().StringVar(&projectDir, "project-dir", "", "Project directory to scan with the cataloger (default: current directory)") } diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index 00b6add..1c4037a 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -37,7 +37,7 @@ func DefaultOptions() *Options { DocumentName: "sbomit-generated-sbom", DocumentVersion: "0.0.1", Authors: []string{}, - AttestationTypes: []string{"material", "command-run", "product", "network-trace"}, + AttestationTypes: []string{"material", "command-run", "product", "network-trace", "maven"}, OutputFormat: "spdx23", Catalog: "", ProjectDir: "", @@ -133,6 +133,10 @@ func (g *Generator) GenerateFromAttestations(attestations []attestation.TypedAtt networkPkgs := g.networkChain.ResolveAll(networkConns) result = mergeNetworkPackages(result, networkPkgs) + // Resolve packages declared in Maven attestor (structured dependency list) + mavenPkgs := extractMavenAttestorPackages(attestations) + result = mergeNewPackages(result, mavenPkgs) + attDoc := g.createDocument(result) if baseDoc != nil { @@ -495,6 +499,73 @@ func sanitizeID(s string) string { return result } +func extractMavenAttestorPackages(attestations []attestation.TypedAttestation) []resolver.PackageInfo { + var packages []resolver.PackageInfo + seen := make(map[string]struct{}) + + for _, att := range attestations { + if att.Type != "maven" { + continue + } + + depsRaw, ok := att.Data["dependencies"].([]interface{}) + if !ok { + continue + } + + for _, d := range depsRaw { + dep, ok := d.(map[string]interface{}) + if !ok { + continue + } + + groupID, _ := dep["groupid"].(string) + artifactID, _ := dep["artifactid"].(string) + version, _ := dep["version"].(string) + + if groupID == "" || artifactID == "" || version == "" { + continue + } + + purl := "pkg:maven/" + groupID + "/" + artifactID + "@" + version + if _, already := seen[purl]; already { + continue + } + seen[purl] = struct{}{} + + packages = append(packages, resolver.PackageInfo{ + Name: artifactID, + Version: version, + Ecosystem: "maven", + PURL: purl, + FoundBy: "attestation:maven", + }) + } + } + + return packages +} + +func mergeNewPackages(result resolver.ResolverResult, newPkgs []resolver.PackageInfo) resolver.ResolverResult { + if len(newPkgs) == 0 { + return result + } + + existingByPURL := make(map[string]struct{}, len(result.Packages)) + for _, pkg := range result.Packages { + existingByPURL[pkg.PURL] = struct{}{} + } + + for _, pkg := range newPkgs { + if _, found := existingByPURL[pkg.PURL]; !found { + result.Packages = append(result.Packages, pkg) + existingByPURL[pkg.PURL] = struct{}{} + } + } + + return result +} + func generateUUID() string { now := time.Now().UnixNano() return fmt.Sprintf("%x-%x-%x-%x-%x", diff --git a/pkg/resolver/java.go b/pkg/resolver/java.go new file mode 100644 index 0000000..efd6b39 --- /dev/null +++ b/pkg/resolver/java.go @@ -0,0 +1,144 @@ +package resolver + +import ( + "path" + "regexp" + "strings" +) + +type JavaResolver struct { + mavenRe *regexp.Regexp + gradleRe *regexp.Regexp +} + +func NewJavaResolver() *JavaResolver { + return &JavaResolver{ + mavenRe: regexp.MustCompile(`\.m2/repository/(.+)/([^/]+)/([^/]+)/[^/]+-[^/]+\.(jar|pom|aar|war|ear)$`), + gradleRe: regexp.MustCompile(`\.gradle/caches/modules-2/files-2\.1/([^/]+)/([^/]+)/([^/]+)/[^/]+/[^/]+\.(jar|pom|aar)$`), + } +} + +func (r *JavaResolver) Name() string { + return "java" +} + +func (r *JavaResolver) Resolve(files []FileInfo) (packages []PackageInfo, remainingFiles []FileInfo) { + seen := make(map[string]int) + + for _, f := range files { + np := path.Clean(f.Path) + + if !r.isJavaPath(np) { + remainingFiles = append(remainingFiles, f) + continue + } + + groupID, artifactID, version, ok := r.extractCoordinates(np) + if !ok { + remainingFiles = append(remainingFiles, f) + continue + } + + key := groupID + ":" + artifactID + "@" + version + if idx, already := seen[key]; already { + for k, v := range f.Hashes { + if _, exists := packages[idx].Hashes[k]; !exists { + if packages[idx].Hashes == nil { + packages[idx].Hashes = make(map[string]string) + } + packages[idx].Hashes[k] = v + } + } + continue + } + + purl := "pkg:maven/" + groupID + "/" + artifactID + "@" + version + pkg := PackageInfo{ + Name: artifactID, + Version: version, + Ecosystem: "maven", + PURL: purl, + Hashes: f.Hashes, + FoundBy: "attestation:java", + } + seen[key] = len(packages) + packages = append(packages, pkg) + } + + return packages, remainingFiles +} + +func (r *JavaResolver) CreateFileFilters(packages []PackageInfo) []PackageFileFilter { + var filters []PackageFileFilter + + for _, pkg := range packages { + if pkg.Ecosystem != "maven" { + continue + } + + rest := strings.TrimPrefix(pkg.PURL, "pkg:maven/") + rest = strings.SplitN(rest, "@", 2)[0] + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 { + continue + } + + filters = append(filters, &javaPackageFilter{ + groupID: parts[0], + artifactID: parts[1], + version: pkg.Version, + }) + } + + return filters +} + +type javaPackageFilter struct { + groupID string + artifactID string + version string +} + +func (f *javaPackageFilter) Matches(p string) bool { + np := path.Clean(p) + npLower := strings.ToLower(np) + + if !strings.Contains(npLower, "/.m2/repository/") && !strings.Contains(npLower, "/.gradle/caches/") { + return false + } + + groupPathLower := strings.ReplaceAll(strings.ToLower(f.groupID), ".", "/") + artifactLower := strings.ToLower(f.artifactID) + versionLower := strings.ToLower(f.version) + + if strings.Contains(npLower, "/.m2/repository/"+groupPathLower+"/"+artifactLower+"/"+versionLower+"/") { + return true + } + + if strings.Contains(npLower, "/.gradle/caches/modules-2/files-2.1/"+strings.ToLower(f.groupID)+"/"+artifactLower+"/"+versionLower+"/") { + return true + } + + return false +} + +func (r *JavaResolver) isJavaPath(p string) bool { + return strings.Contains(p, "/.m2/repository/") || + strings.Contains(p, "/.gradle/caches/modules-2/files-2.1/") +} + +func (r *JavaResolver) extractCoordinates(p string) (groupID, artifactID, version string, ok bool) { + if matches := r.mavenRe.FindStringSubmatch(p); len(matches) >= 4 { + group := strings.ReplaceAll(matches[1], "/", ".") + if group == "" || matches[2] == "" || matches[3] == "" { + return "", "", "", false + } + return group, matches[2], matches[3], true + } + + if matches := r.gradleRe.FindStringSubmatch(p); len(matches) >= 4 { + return matches[1], matches[2], matches[3], true + } + + return "", "", "", false +} diff --git a/pkg/resolver/java_test.go b/pkg/resolver/java_test.go new file mode 100644 index 0000000..4e36cda --- /dev/null +++ b/pkg/resolver/java_test.go @@ -0,0 +1,266 @@ +package resolver + +import ( + "reflect" + "testing" +) + +func TestJavaResolver_Resolve(t *testing.T) { + r := NewJavaResolver() + + tests := []struct { + name string + files []FileInfo + expectPackages []PackageInfo + expectRemaining int + }{ + { + name: "resolves Maven jar from .m2 repository — multi-segment group", + files: []FileInfo{ + {Path: "/root/.m2/repository/org/springframework/spring-core/6.1.0/spring-core-6.1.0.jar"}, + }, + expectPackages: []PackageInfo{ + { + Name: "spring-core", + Version: "6.1.0", + Ecosystem: "maven", + PURL: "pkg:maven/org.springframework/spring-core@6.1.0", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "resolves Maven jar from .m2 repository — single-segment group", + files: []FileInfo{ + {Path: "/root/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar"}, + }, + expectPackages: []PackageInfo{ + { + Name: "junit", + Version: "4.13.2", + Ecosystem: "maven", + PURL: "pkg:maven/junit/junit@4.13.2", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "resolves Maven jar from .m2 repository — three-segment group", + files: []FileInfo{ + {Path: "/root/.m2/repository/com/google/guava/guava/32.0.0-jre/guava-32.0.0-jre.jar"}, + }, + expectPackages: []PackageInfo{ + { + Name: "guava", + Version: "32.0.0-jre", + Ecosystem: "maven", + PURL: "pkg:maven/com.google.guava/guava@32.0.0-jre", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "resolves Maven jar with hyphenated artifactId", + files: []FileInfo{ + {Path: "/root/.m2/repository/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar"}, + }, + expectPackages: []PackageInfo{ + { + Name: "commons-lang3", + Version: "3.12.0", + Ecosystem: "maven", + PURL: "pkg:maven/org.apache.commons/commons-lang3@3.12.0", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "resolves Maven .pom file (not just .jar)", + files: []FileInfo{ + {Path: "/root/.m2/repository/io/grpc/grpc-core/1.62.2/grpc-core-1.62.2.pom"}, + }, + expectPackages: []PackageInfo{ + { + Name: "grpc-core", + Version: "1.62.2", + Ecosystem: "maven", + PURL: "pkg:maven/io.grpc/grpc-core@1.62.2", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "deduplicates .jar and .pom from same coordinate", + files: []FileInfo{ + {Path: "/root/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar"}, + {Path: "/root/.m2/repository/junit/junit/4.13.2/junit-4.13.2.pom"}, + }, + expectPackages: []PackageInfo{ + { + Name: "junit", + Version: "4.13.2", + Ecosystem: "maven", + PURL: "pkg:maven/junit/junit@4.13.2", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "resolves Gradle cache jar", + files: []FileInfo{ + {Path: "/root/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/32.0.0-jre/abc123hash/guava-32.0.0-jre.jar"}, + }, + expectPackages: []PackageInfo{ + { + Name: "guava", + Version: "32.0.0-jre", + Ecosystem: "maven", + PURL: "pkg:maven/com.google.guava/guava@32.0.0-jre", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "resolves multiple distinct Maven packages", + files: []FileInfo{ + {Path: "/root/.m2/repository/org/springframework/spring-core/6.1.0/spring-core-6.1.0.jar"}, + {Path: "/root/.m2/repository/com/google/guava/guava/32.0.0-jre/guava-32.0.0-jre.jar"}, + }, + expectPackages: []PackageInfo{ + { + Name: "spring-core", + Version: "6.1.0", + Ecosystem: "maven", + PURL: "pkg:maven/org.springframework/spring-core@6.1.0", + FoundBy: "attestation:java", + }, + { + Name: "guava", + Version: "32.0.0-jre", + Ecosystem: "maven", + PURL: "pkg:maven/com.google.guava/guava@32.0.0-jre", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 0, + }, + { + name: "passes non-Java paths to remaining files", + files: []FileInfo{ + {Path: "/usr/lib/python3/site-packages/requests-2.28.0.dist-info/METADATA"}, + {Path: "/usr/local/cargo/registry/src/index.crates.io-abc123/serde-1.0.0/src/lib.rs"}, + }, + expectPackages: nil, + expectRemaining: 2, + }, + { + name: "mixed Java and non-Java paths", + files: []FileInfo{ + {Path: "/root/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar"}, + {Path: "/usr/lib/python3/site-packages/requests-2.28.0.dist-info/METADATA"}, + }, + expectPackages: []PackageInfo{ + { + Name: "junit", + Version: "4.13.2", + Ecosystem: "maven", + PURL: "pkg:maven/junit/junit@4.13.2", + FoundBy: "attestation:java", + }, + }, + expectRemaining: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + packages, remaining := r.Resolve(tt.files) + + if len(packages) != len(tt.expectPackages) { + t.Fatalf("expected %d packages, got %d: %+v", len(tt.expectPackages), len(packages), packages) + } + + // Compare by PURL to avoid map-iteration ordering flakiness + byPURL := make(map[string]PackageInfo, len(packages)) + for _, pkg := range packages { + pkg.Hashes = nil + byPURL[pkg.PURL] = pkg + } + for _, want := range tt.expectPackages { + got, ok := byPURL[want.PURL] + if !ok { + t.Errorf("expected package %q not found in results", want.PURL) + continue + } + if !reflect.DeepEqual(got, want) { + t.Errorf("package %q mismatch.\nGot: %+v\nWant: %+v", want.PURL, got, want) + } + } + + if len(remaining) != tt.expectRemaining { + t.Errorf("expected %d remaining files, got %d", tt.expectRemaining, len(remaining)) + } + }) + } +} + +func TestJavaResolver_CreateFileFilters(t *testing.T) { + r := NewJavaResolver() + + packages := []PackageInfo{ + { + Name: "spring-core", + Version: "6.1.0", + Ecosystem: "maven", + PURL: "pkg:maven/org.springframework/spring-core@6.1.0", + }, + { + Name: "guava", + Version: "32.0.0-jre", + Ecosystem: "maven", + PURL: "pkg:maven/com.google.guava/guava@32.0.0-jre", + }, + } + + filters := r.CreateFileFilters(packages) + if len(filters) != 2 { + t.Fatalf("expected 2 filters, got %d", len(filters)) + } + + tests := []struct { + path string + matches bool + }{ + // Maven paths for spring-core + {"/root/.m2/repository/org/springframework/spring-core/6.1.0/spring-core-6.1.0.jar", true}, + {"/root/.m2/repository/org/springframework/spring-core/6.1.0/spring-core-6.1.0.pom", true}, + // Maven paths for guava + {"/root/.m2/repository/com/google/guava/guava/32.0.0-jre/guava-32.0.0-jre.jar", true}, + // Gradle path for guava + {"/root/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/32.0.0-jre/abc123/guava-32.0.0-jre.jar", true}, + // Different version — should not match + {"/root/.m2/repository/com/google/guava/guava/31.0.0-jre/guava-31.0.0-jre.jar", false}, + // Unrelated path + {"/usr/lib/python3/site-packages/requests-2.28.0.dist-info/METADATA", false}, + } + + for _, tt := range tests { + matched := false + for _, f := range filters { + if f.Matches(tt.path) { + matched = true + break + } + } + if matched != tt.matches { + t.Errorf("Matches(%q) = %v, want %v", tt.path, matched, tt.matches) + } + } +} diff --git a/pkg/resolver/resolver.go b/pkg/resolver/resolver.go index c59cf57..e8adf15 100644 --- a/pkg/resolver/resolver.go +++ b/pkg/resolver/resolver.go @@ -46,6 +46,7 @@ func NewResolverChain() *ResolverChain { NewGoResolver(), NewRustResolver(), NewJavaScriptResolver(), + NewJavaResolver(), }, filter: NewFileFilter(), }