From 73cd691493161ad2e186fc3ba88484ea3eecbc21 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 09:40:39 +0200 Subject: [PATCH 01/16] chore: add tree-sitter-go dependency for Go reachability analysis --- LICENSE-3rdparty.csv | 3 ++- go.mod | 1 + go.sum | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index c77a4098..fd0b0d94 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -4,6 +4,7 @@ core,github.com/BurntSushi/toml,MIT,Copyright (c) 2013 TOML authors core,github.com/BurntSushi/toml/internal,MIT,Copyright (c) 2013 TOML authors core,github.com/CycloneDX/cyclonedx-go,Apache-2.0,Copyright & License | Copyright (c) OWASP Foundation | Copyright (c) OWASP Foundation. All Rights Reserved | Copyright OWASP Foundation core,github.com/DataDog/jsonapi,Apache-2.0,"Copyright [2022-Present] Datadog, Inc" +core,github.com/Masterminds/semver/v3,MIT,"Copyright (C) 2014-2019, Matt Butcher and Matt Farina" core,github.com/Microsoft/go-winio,MIT,Copyright (c) 2015 Microsoft core,github.com/Microsoft/go-winio/internal/fs,MIT,Copyright (c) 2015 Microsoft core,github.com/Microsoft/go-winio/internal/socket,MIT,Copyright (c) 2015 Microsoft @@ -137,7 +138,6 @@ core,github.com/kevinburke/ssh_config,MIT,"Copyright (c) 2013 - 2017 Thomas Pell core,github.com/kr/pretty,MIT,Copyright 2012 Keith Rarick core,github.com/kr/text,MIT,Copyright 2012 Keith Rarick core,github.com/maruel/natural,Apache-2.0,Copyright 2018 Marc-Antoine Ruel -core,github.com/Masterminds/semver/v3,MIT,"Copyright (C) 2014-2019, Matt Butcher and Matt Farina" core,github.com/mattn/go-pointer,MIT,Copyright (c) 2019 Yasuhiro Matsumoto core,github.com/mattn/go-runewidth,MIT,Copyright (c) 2016 Yasuhiro Matsumoto core,github.com/package-url/packageurl-go,MIT,Copyright (c) the purl authors @@ -155,6 +155,7 @@ core,github.com/tidwall/match,MIT,Copyright (c) 2016 Josh Baker core,github.com/tidwall/pretty,MIT,Copyright (c) 2017 Josh Baker core,github.com/tidwall/sjson,MIT,Copyright (c) 2016 Josh Baker core,github.com/tree-sitter/go-tree-sitter,MIT,Copyright (c) 2024 Amaan Qureshi +core,github.com/tree-sitter/tree-sitter-go/bindings/go,MIT,Copyright (c) 2014 Max Brunsfeld core,github.com/tree-sitter/tree-sitter-java/bindings/go,MIT,Copyright (c) 2017 Ayman Nadeem core,github.com/tree-sitter/tree-sitter-ruby/bindings/go,MIT,Copyright (c) 2016 Rob Rix core,github.com/urfave/cli/v2,MIT,Copyright (c) 2022 urfave/cli maintainers diff --git a/go.mod b/go.mod index 751dc08f..2c476f8b 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/tidwall/jsonc v0.3.3 github.com/tree-sitter/go-tree-sitter v0.25.0 + github.com/tree-sitter/tree-sitter-go v0.25.0 github.com/tree-sitter/tree-sitter-java v0.23.5 github.com/tree-sitter/tree-sitter-ruby v0.23.1 github.com/urfave/cli/v2 v2.27.6 diff --git a/go.sum b/go.sum index 5c7f0b6b..f6638404 100644 --- a/go.sum +++ b/go.sum @@ -127,8 +127,8 @@ github.com/tree-sitter/tree-sitter-cpp v0.23.4 h1:LaWZsiqQKvR65yHgKmnaqA+uz6tlDJ github.com/tree-sitter/tree-sitter-cpp v0.23.4/go.mod h1:doqNW64BriC7WBCQ1klf0KmJpdEvfxyXtoEybnBo6v8= github.com/tree-sitter/tree-sitter-embedded-template v0.23.2 h1:nFkkH6Sbe56EXLmZBqHHcamTpmz3TId97I16EnGy4rg= github.com/tree-sitter/tree-sitter-embedded-template v0.23.2/go.mod h1:HNPOhN0qF3hWluYLdxWs5WbzP/iE4aaRVPMsdxuzIaQ= -github.com/tree-sitter/tree-sitter-go v0.23.4 h1:yt5KMGnTHS+86pJmLIAZMWxukr8W7Ae1STPvQUuNROA= -github.com/tree-sitter/tree-sitter-go v0.23.4/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040= +github.com/tree-sitter/tree-sitter-go v0.25.0 h1:cEB0Q3LHgZtS+ECHx9wcP7AwzoOddJFQCVmytX42cVU= +github.com/tree-sitter/tree-sitter-go v0.25.0/go.mod h1:Jrx8QqYN0v7npv1fJRH1AznddllYiCMUChtVjxPK040= github.com/tree-sitter/tree-sitter-html v0.23.2 h1:1UYDV+Yd05GGRhVnTcbP58GkKLSHHZwVaN+lBZV11Lc= github.com/tree-sitter/tree-sitter-html v0.23.2/go.mod h1:gpUv/dG3Xl/eebqgeYeFMt+JLOY9cgFinb/Nw08a9og= github.com/tree-sitter/tree-sitter-java v0.23.5 h1:J9YeMGMwXYlKSP3K4Us8CitC6hjtMjqpeOf2GGo6tig= From cf7ba6e922b3c1738cd487ee463d57b98b1db514 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 09:40:52 +0200 Subject: [PATCH 02/16] feat: add Detector interface and ReachabilityGo skeleton --- pkg/reachability/codefile/detector.go | 19 ++++ pkg/reachability/codefile/golang.go | 90 +++++++++++++++++++ pkg/reachability/codefile/golang_test.go | 38 ++++++++ .../codefile/testdata/vulnerable-function.go | 7 ++ 4 files changed, 154 insertions(+) create mode 100644 pkg/reachability/codefile/detector.go create mode 100644 pkg/reachability/codefile/golang.go create mode 100644 pkg/reachability/codefile/golang_test.go create mode 100644 pkg/reachability/codefile/testdata/vulnerable-function.go diff --git a/pkg/reachability/codefile/detector.go b/pkg/reachability/codefile/detector.go new file mode 100644 index 00000000..a7c1b29b --- /dev/null +++ b/pkg/reachability/codefile/detector.go @@ -0,0 +1,19 @@ +package codefile + +import ( + "context" + + "github.com/DataDog/datadog-sbom-generator/pkg/models" +) + +// Detector parses source files for one language and matches vulnerable symbols against how +// those symbols are actually imported and called in that source. +type Detector interface { + Detect(ctx context.Context, dir string, path string, detectionResults models.DetectionResults, advisoriesToCheck []models.AdvisoryToCheck) error + Close() +} + +var ( + _ Detector = (*ReachabilityJava)(nil) + _ Detector = (*ReachabilityGo)(nil) +) diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go new file mode 100644 index 00000000..f942d117 --- /dev/null +++ b/pkg/reachability/codefile/golang.go @@ -0,0 +1,90 @@ +package codefile + +import ( + "context" + "fmt" + + "github.com/DataDog/datadog-sbom-generator/pkg/models" + "github.com/DataDog/datadog-sbom-generator/pkg/reporter" + + treesitter "github.com/tree-sitter/go-tree-sitter" + tree_sitter_go "github.com/tree-sitter/tree-sitter-go/bindings/go" +) + +const tsQueryForGoImports = ` +(import_spec + name: (package_identifier)? @alias + path: (interpreted_string_literal (interpreted_string_literal_content) @path)) +` + +const tsQueryForGoCall = ` +(call_expression + function: (selector_expression + operand: (identifier) @pkg + field: (field_identifier) @fn) @selector) +` + +type ReachabilityGo struct { + tsParser *treesitter.Parser + importQuery *treesitter.Query + callQuery *treesitter.Query + + aliasCaptureIdx uint + pathCaptureIdx uint + pkgCaptureIdx uint + fnCaptureIdx uint + selectorCaptureIdx uint + + reporter reporter.Reporter +} + +// NewGoReachableDetector creates a new ReachabilityGo instance that once instantiated can be +// used to parse Go files. You should call Close() on the instance once you're finished parsing. +func NewGoReachableDetector(r reporter.Reporter) (*ReachabilityGo, error) { + tsLanguage := treesitter.NewLanguage(tree_sitter_go.Language()) + + tsParser := treesitter.NewParser() + if err := tsParser.SetLanguage(tsLanguage); err != nil { + return nil, fmt.Errorf("failed to set tree-sitter Go language on parser: %w", err) + } + + importQuery, err := treesitter.NewQuery(tsLanguage, tsQueryForGoImports) + if err != nil { + return nil, fmt.Errorf("failed to create tree-sitter query for Go imports: %w", err) + } + + callQuery, err := treesitter.NewQuery(tsLanguage, tsQueryForGoCall) + if err != nil { + return nil, fmt.Errorf("failed to create tree-sitter query for Go calls: %w", err) + } + + aliasCaptureIdx, _ := importQuery.CaptureIndexForName("alias") + pathCaptureIdx, _ := importQuery.CaptureIndexForName("path") + pkgCaptureIdx, _ := callQuery.CaptureIndexForName("pkg") + fnCaptureIdx, _ := callQuery.CaptureIndexForName("fn") + selectorCaptureIdx, _ := callQuery.CaptureIndexForName("selector") + + return &ReachabilityGo{ + tsParser: tsParser, + importQuery: importQuery, + callQuery: callQuery, + aliasCaptureIdx: aliasCaptureIdx, + pathCaptureIdx: pathCaptureIdx, + pkgCaptureIdx: pkgCaptureIdx, + fnCaptureIdx: fnCaptureIdx, + selectorCaptureIdx: selectorCaptureIdx, + reporter: reporter.Effective(r), + }, nil +} + +// Close closes all hanging tree-sitter related resources. +// This should only be called once you're finished parsing all Go files. +func (r *ReachabilityGo) Close() { + r.tsParser.Close() + r.importQuery.Close() + r.callQuery.Close() +} + +func (r *ReachabilityGo) Detect(_ context.Context, _ string, _ string, _ models.DetectionResults, _ []models.AdvisoryToCheck) error { + return nil +} diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go new file mode 100644 index 00000000..5a23b57f --- /dev/null +++ b/pkg/reachability/codefile/golang_test.go @@ -0,0 +1,38 @@ +package codefile + +import ( + "context" + "testing" + + "github.com/DataDog/datadog-sbom-generator/pkg/models" + "github.com/DataDog/datadog-sbom-generator/pkg/reporter" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewGoReachableDetector(t *testing.T) { + t.Parallel() + detector, err := NewGoReachableDetector(&reporter.VoidReporter{}) + require.NoError(t, err) + defer detector.Close() + + assert.NotNil(t, detector) +} + +func Test_Detect_Go_NoAdvisories(t *testing.T) { + t.Parallel() + detector, err := NewGoReachableDetector(&reporter.VoidReporter{}) + require.NoError(t, err) + defer detector.Close() + + advisoriesToCheck := make([]models.AdvisoryToCheck, 0) + detectionResults := models.DetectionResults{} + + ctx := context.Background() + + err = detector.Detect(ctx, "", "testdata/vulnerable-function.go", detectionResults, advisoriesToCheck) + + require.NoError(t, err) + assert.Empty(t, detectionResults) +} diff --git a/pkg/reachability/codefile/testdata/vulnerable-function.go b/pkg/reachability/codefile/testdata/vulnerable-function.go new file mode 100644 index 00000000..e0a89854 --- /dev/null +++ b/pkg/reachability/codefile/testdata/vulnerable-function.go @@ -0,0 +1,7 @@ +package vulnerable + +import bar "github.com/foo/bar" + +func Run() { + bar.Parse("x") +} From 2bef3a62a5281303702ccb63b7c4f22757f02562 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 10:06:05 +0200 Subject: [PATCH 03/16] feat: resolve Go import aliases for reachability matching --- pkg/reachability/codefile/golang.go | 54 ++++++++++++++ pkg/reachability/codefile/golang_test.go | 94 ++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index f942d117..b6a52252 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -3,7 +3,9 @@ package codefile import ( "context" "fmt" + "strings" + "github.com/DataDog/datadog-sbom-generator/internal/cachedregexp" "github.com/DataDog/datadog-sbom-generator/pkg/models" "github.com/DataDog/datadog-sbom-generator/pkg/reporter" @@ -24,6 +26,8 @@ const tsQueryForGoCall = ` field: (field_identifier) @fn) @selector) ` +var majorVersionSuffixPattern = cachedregexp.MustCompile(`^v[0-9]+$`) + type ReachabilityGo struct { tsParser *treesitter.Parser importQuery *treesitter.Query @@ -85,6 +89,56 @@ func (r *ReachabilityGo) Close() { r.callQuery.Close() } +// resolveImportAliases walks all import specs in the parsed tree and returns a map of module +// import path -> local identifiers used to reference that module in this file. Unaliased +// imports are assigned a heuristic identifier: the last path segment, with a trailing +// major-version suffix (e.g. "/v2") stripped, per Go module convention. Dot imports and blank +// imports are not callable via a package selector (e.g. "pkg.Func"), so they never produce a +// call-site match regardless of how they're keyed here. +func (r *ReachabilityGo) resolveImportAliases(tree *treesitter.Tree, fileContent []byte, queryCursor *treesitter.QueryCursor) map[string][]string { + moduleToAliases := make(map[string][]string) + + matches := queryCursor.Matches(r.importQuery, tree.RootNode(), fileContent) + for match := matches.Next(); match != nil; match = matches.Next() { + var alias, modulePath string + + for _, capture := range match.Captures { + switch capture.Index { + case uint32(r.aliasCaptureIdx): //nolint:gosec + alias = capture.Node.Utf8Text(fileContent) + case uint32(r.pathCaptureIdx): //nolint:gosec + modulePath = capture.Node.Utf8Text(fileContent) + } + } + + if modulePath == "" { + continue + } + + if alias == "" { + alias = defaultIdentifierForModulePath(modulePath) + } + + moduleToAliases[modulePath] = append(moduleToAliases[modulePath], alias) + } + + return moduleToAliases +} + +// defaultIdentifierForModulePath derives the package identifier Go code would use for an +// unaliased import, using the last path segment and stripping a trailing major-version suffix +// (e.g. "github.com/foo/bar/v2" -> "bar"). +func defaultIdentifierForModulePath(modulePath string) string { + segments := strings.Split(modulePath, "/") + identifier := segments[len(segments)-1] + + if len(segments) > 1 && majorVersionSuffixPattern.MatchString(identifier) { + identifier = segments[len(segments)-2] + } + + return identifier +} + func (r *ReachabilityGo) Detect(_ context.Context, _ string, _ string, _ models.DetectionResults, _ []models.AdvisoryToCheck) error { return nil } diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index 5a23b57f..87df8673 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + treesitter "github.com/tree-sitter/go-tree-sitter" ) func Test_NewGoReachableDetector(t *testing.T) { @@ -36,3 +37,96 @@ func Test_Detect_Go_NoAdvisories(t *testing.T) { require.NoError(t, err) assert.Empty(t, detectionResults) } + +//nolint:paralleltest +func Test_resolveImportAliases(t *testing.T) { + detector, err := NewGoReachableDetector(&reporter.VoidReporter{}) + require.NoError(t, err) + defer detector.Close() + + tests := []struct { + name string + src string + expected map[string][]string + }{ + { + name: "aliased import", + src: `package main + +import bar "github.com/foo/bar" + +func main() { + bar.Parse("x") +} +`, + expected: map[string][]string{"github.com/foo/bar": {"bar"}}, + }, + { + name: "unaliased import uses last path segment", + src: `package main + +import "github.com/foo/bar" + +func main() { + bar.Parse("x") +} +`, + expected: map[string][]string{"github.com/foo/bar": {"bar"}}, + }, + { + name: "unaliased versioned module path strips major version suffix", + src: `package main + +import "github.com/foo/bar/v2" + +func main() { + bar.Parse("x") +} +`, + expected: map[string][]string{"github.com/foo/bar/v2": {"bar"}}, + }, + { + name: "no imports", + src: `package main + +func main() {} +`, + expected: map[string][]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree := detector.tsParser.Parse([]byte(tt.src), nil) + defer tree.Close() + + queryCursor := treesitter.NewQueryCursor() + defer queryCursor.Close() + + result := detector.resolveImportAliases(tree, []byte(tt.src), queryCursor) + assert.Equal(t, tt.expected, result) + }) + } +} + +func Test_defaultIdentifierForModulePath(t *testing.T) { + t.Parallel() + + tests := []struct { + modulePath string + expected string + }{ + {modulePath: "github.com/foo/bar", expected: "bar"}, + {modulePath: "github.com/foo/bar/v2", expected: "bar"}, + {modulePath: "github.com/foo/bar/v10", expected: "bar"}, + {modulePath: "rsc.io/quote", expected: "quote"}, + {modulePath: "singlesegment", expected: "singlesegment"}, + } + + for _, tt := range tests { + t.Run(tt.modulePath, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, defaultIdentifierForModulePath(tt.modulePath)) + }) + } +} From 6c5068a385badc9fdb0fa257e4ddf15bdc468eaa Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 10:21:48 +0200 Subject: [PATCH 04/16] feat: match Go call sites against vulnerable function symbols --- pkg/reachability/codefile/golang.go | 114 +++++++++++++++- pkg/reachability/codefile/golang_test.go | 127 ++++++++++++++++++ .../CVE-2025-5678/aliased-import/main.go | 7 + .../CVE-2025-5678/default-import/main.go | 7 + .../versioned-module-path/main.go | 7 + 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 pkg/reachability/codefile/testdata/CVE-2025-5678/aliased-import/main.go create mode 100644 pkg/reachability/codefile/testdata/CVE-2025-5678/default-import/main.go create mode 100644 pkg/reachability/codefile/testdata/CVE-2025-5678/versioned-module-path/main.go diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index b6a52252..ad96031c 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -3,9 +3,12 @@ package codefile import ( "context" "fmt" + "slices" "strings" "github.com/DataDog/datadog-sbom-generator/internal/cachedregexp" + "github.com/DataDog/datadog-sbom-generator/internal/utility/converter" + "github.com/DataDog/datadog-sbom-generator/internal/utility/fileposition" "github.com/DataDog/datadog-sbom-generator/pkg/models" "github.com/DataDog/datadog-sbom-generator/pkg/reporter" @@ -13,6 +16,9 @@ import ( tree_sitter_go "github.com/tree-sitter/tree-sitter-go/bindings/go" ) +// symbolTypeFunction is the only Go symbol type currently understood. +const symbolTypeFunction = "function" + const tsQueryForGoImports = ` (import_spec name: (package_identifier)? @alias @@ -139,6 +145,112 @@ func defaultIdentifierForModulePath(modulePath string) string { return identifier } -func (r *ReachabilityGo) Detect(_ context.Context, _ string, _ string, _ models.DetectionResults, _ []models.AdvisoryToCheck) error { +func (r *ReachabilityGo) Detect(ctx context.Context, dir string, path string, detectionResults models.DetectionResults, advisoriesToCheck []models.AdvisoryToCheck) error { + fileContent, err := readFileContent(path) + if err != nil { + return err + } + + readCallback := func(offset int, position treesitter.Point) []byte { + if ctx.Err() != nil { + return []byte{} + } + if offset >= len(fileContent) { + return []byte{} + } + + return fileContent[offset:] + } + + tree := r.tsParser.ParseWithOptions(readCallback, nil, &treesitter.ParseOptions{ + ProgressCallback: func(_ treesitter.ParseState) bool { + return ctx.Err() != nil + }, + }) + defer tree.Close() + + if len(advisoriesToCheck) == 0 { + return nil + } + + importCursor := treesitter.NewQueryCursor() + defer importCursor.Close() + moduleToAliases := r.resolveImportAliases(tree, fileContent, importCursor) + + callCursor := treesitter.NewQueryCursor() + defer callCursor.Close() + + for _, advisoryToCheck := range advisoriesToCheck { + for _, s := range advisoryToCheck.Symbols { + if s.Type != symbolTypeFunction { + r.reporter.Warnf("No Go detection support for symbol type %s", s.Type) + continue + } + + aliases, moduleImported := moduleToAliases[s.Value] + if !moduleImported { + continue + } + + matches := callCursor.Matches(r.callQuery, tree.RootNode(), fileContent) + for match := matches.Next(); match != nil; match = matches.Next() { + var pkgText, fnText string + var selectorNode treesitter.Node + + for _, capture := range match.Captures { + switch capture.Index { + case uint32(r.pkgCaptureIdx): //nolint:gosec + pkgText = capture.Node.Utf8Text(fileContent) + case uint32(r.fnCaptureIdx): //nolint:gosec + fnText = capture.Node.Utf8Text(fileContent) + case uint32(r.selectorCaptureIdx): //nolint:gosec + selectorNode = capture.Node + } + } + + if fnText != s.Name || !slices.Contains(aliases, pkgText) { + continue + } + + startPosition := selectorNode.StartPosition() + endPosition := selectorNode.EndPosition() + + if _, ok := detectionResults[advisoryToCheck.Purl]; !ok { + detectionResults[advisoryToCheck.Purl] = make(map[string]models.ReachableSymbolLocations) + } + if _, ok := detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID]; !ok { + detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID] = make(models.ReachableSymbolLocations, 0) + } + + packageLocation := models.PackageLocation{ + Filename: fileposition.ToRelativePath(dir, path), + } + packageLocation.LineStart, err = converter.SafeUIntToInt(startPosition.Row + 1) + if err != nil { + return err + } + packageLocation.LineEnd, err = converter.SafeUIntToInt(endPosition.Row + 1) + if err != nil { + return err + } + packageLocation.ColumnStart, err = converter.SafeUIntToInt(startPosition.Column + 1) + if err != nil { + return err + } + packageLocation.ColumnEnd, err = converter.SafeUIntToInt(endPosition.Column + 1) + if err != nil { + return err + } + + detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID] = append( + detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID], + models.ReachableSymbolLocation{ + Symbol: selectorNode.Utf8Text(fileContent), + PackageLocation: packageLocation, + }) + } + } + } + return nil } diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index 87df8673..5875c51e 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -130,3 +130,130 @@ func Test_defaultIdentifierForModulePath(t *testing.T) { }) } } + +func Test_Detect_Go_FunctionSymbolFound(t *testing.T) { + t.Parallel() + + fixtures := map[string]struct { + path string + advisoriesToCheck []models.AdvisoryToCheck + }{ + "aliased import": { + path: "testdata/CVE-2025-5678/aliased-import/main.go", + advisoriesToCheck: []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + {Type: "function", Value: "github.com/foo/bar", Name: "Parse"}, + }, + }, + }, + }, + "default import": { + path: "testdata/CVE-2025-5678/default-import/main.go", + advisoriesToCheck: []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + {Type: "function", Value: "github.com/foo/bar", Name: "Parse"}, + }, + }, + }, + }, + "versioned module import": { + path: "testdata/CVE-2025-5678/versioned-module-path/main.go", + advisoriesToCheck: []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + {Type: "function", Value: "github.com/foo/bar/v2", Name: "Parse"}, + }, + }, + }, + }, + } + + for name, tc := range fixtures { + t.Run(name, func(t *testing.T) { + t.Parallel() + + detector, err := NewGoReachableDetector(&reporter.VoidReporter{}) + require.NoError(t, err) + defer detector.Close() + + detectionResults := models.DetectionResults{} + err = detector.Detect(context.Background(), ".", tc.path, detectionResults, tc.advisoriesToCheck) + require.NoError(t, err) + + advisories, ok := detectionResults["pkg:golang/github.com/foo/bar@1.2.3"] + require.True(t, ok) + reachableSymbols, ok := advisories["CVE-2025-5678"] + require.True(t, ok) + require.Len(t, reachableSymbols, 1) + + assert.Equal(t, "bar.Parse", reachableSymbols[0].Symbol) + assert.Equal(t, tc.path, reachableSymbols[0].Filename) + assert.Equal(t, 6, reachableSymbols[0].LineStart) + assert.Equal(t, 6, reachableSymbols[0].LineEnd) + assert.Equal(t, 2, reachableSymbols[0].ColumnStart) + assert.Equal(t, 11, reachableSymbols[0].ColumnEnd) + }) + } +} + +func Test_Detect_Go_NoMatchWhenFunctionNameDiffers(t *testing.T) { + t.Parallel() + + detector, err := NewGoReachableDetector(&reporter.VoidReporter{}) + require.NoError(t, err) + defer detector.Close() + + advisoriesToCheck := []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + { + Type: "function", + Value: "github.com/foo/bar", + Name: "SomeOtherFunc", + }, + }, + }, + } + + detectionResults := models.DetectionResults{} + err = detector.Detect(context.Background(), ".", "testdata/CVE-2025-5678/aliased-import/main.go", detectionResults, advisoriesToCheck) + require.NoError(t, err) + assert.Empty(t, detectionResults) +} + +func Test_Detect_Go_UnknownSymbolType(t *testing.T) { + t.Parallel() + + detector, err := NewGoReachableDetector(&reporter.VoidReporter{}) + require.NoError(t, err) + defer detector.Close() + + advisoriesToCheck := []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + { + Type: "class", + Value: "github.com/foo/bar", + Name: "Parse", + }, + }, + }, + } + + detectionResults := models.DetectionResults{} + err = detector.Detect(context.Background(), ".", "testdata/CVE-2025-5678/aliased-import/main.go", detectionResults, advisoriesToCheck) + require.NoError(t, err) + assert.Empty(t, detectionResults) +} diff --git a/pkg/reachability/codefile/testdata/CVE-2025-5678/aliased-import/main.go b/pkg/reachability/codefile/testdata/CVE-2025-5678/aliased-import/main.go new file mode 100644 index 00000000..23caafe0 --- /dev/null +++ b/pkg/reachability/codefile/testdata/CVE-2025-5678/aliased-import/main.go @@ -0,0 +1,7 @@ +package main + +import bar "github.com/foo/bar" + +func main() { + bar.Parse("x") +} diff --git a/pkg/reachability/codefile/testdata/CVE-2025-5678/default-import/main.go b/pkg/reachability/codefile/testdata/CVE-2025-5678/default-import/main.go new file mode 100644 index 00000000..1c21d0bc --- /dev/null +++ b/pkg/reachability/codefile/testdata/CVE-2025-5678/default-import/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/foo/bar" + +func main() { + bar.Parse("x") +} diff --git a/pkg/reachability/codefile/testdata/CVE-2025-5678/versioned-module-path/main.go b/pkg/reachability/codefile/testdata/CVE-2025-5678/versioned-module-path/main.go new file mode 100644 index 00000000..88aac28f --- /dev/null +++ b/pkg/reachability/codefile/testdata/CVE-2025-5678/versioned-module-path/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/foo/bar/v2" + +func main() { + bar.Parse("x") +} From d36b66d8a71c1cedb1a5b448ccdcbb65cf8a4575 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Thu, 2 Jul 2026 14:11:37 +0200 Subject: [PATCH 05/16] refactor: generalize reachability detector pool to support multiple languages --- pkg/reachability/reachability.go | 105 ++++++++++++++++++------------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/pkg/reachability/reachability.go b/pkg/reachability/reachability.go index de321aa8..f27c21b5 100644 --- a/pkg/reachability/reachability.go +++ b/pkg/reachability/reachability.go @@ -17,6 +17,19 @@ import ( "golang.org/x/sync/errgroup" ) +// extensionToLanguageKey maps a file extension to the language key used both to look up +// advisories to check and to select a detector pool. +var extensionToLanguageKey = map[string]string{ + ".java": "java", + ".go": "go", +} + +// languageKeyToDetectorFactory constructs a new Detector for a given language key. +var languageKeyToDetectorFactory = map[string]func(reporter.Reporter) (codefile.Detector, error){ + "java": func(r reporter.Reporter) (codefile.Detector, error) { return codefile.NewJavaReachableDetector(r) }, + "go": func(r reporter.Reporter) (codefile.Detector, error) { return codefile.NewGoReachableDetector(r) }, +} + // PerformReachabilityAnalysis performs a reachability analysis on the given PURLs. func PerformReachabilityAnalysis(r reporter.Reporter, purls []string, directoryPaths []string, excludePaths []string, repoRoot string, configExcludePaths []string, ddBaseURL string, ddJwtToken string) models.ReachabilityAnalysis { r.Infof("[reachability] Fetching symbols...") @@ -34,27 +47,33 @@ func PerformReachabilityAnalysis(r reporter.Reporter, purls []string, directoryP var detectionMutex sync.Mutex workerCount := runtime.NumCPU() - detectorPool := make(chan *codefile.ReachabilityJava, workerCount) - - eg, ctx := errgroup.WithContext(context.Background()) - eg.SetLimit(workerCount) - for range workerCount { - detector, err := codefile.NewJavaReachableDetector(r) - if err != nil { - r.Errorf("[reachability] Failed to create Java reachability detector: %v", err) - return models.ReachabilityAnalysis{} + detectorPools := make(map[string]chan codefile.Detector, len(languageKeyToDetectorFactory)) + for languageKey, factory := range languageKeyToDetectorFactory { + pool := make(chan codefile.Detector, workerCount) + for range workerCount { + detector, err := factory(r) + if err != nil { + r.Errorf("[reachability] Failed to create %s reachability detector: %v", languageKey, err) + return models.ReachabilityAnalysis{} + } + pool <- detector } - detectorPool <- detector + detectorPools[languageKey] = pool } defer func() { - close(detectorPool) - for detector := range detectorPool { - detector.Close() + for _, pool := range detectorPools { + close(pool) + for detector := range pool { + detector.Close() + } } }() + eg, ctx := errgroup.WithContext(context.Background()) + eg.SetLimit(workerCount) + for _, dir := range directoryPaths { err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil { @@ -92,39 +111,41 @@ func PerformReachabilityAnalysis(r reporter.Reporter, purls []string, directoryP return nil } - switch filepath.Ext(d.Name()) { - case ".java": - eg.Go(func() error { - // Get a detector from the pool - detector := <-detectorPool - // Return detector to pool after it's finished - defer func() { - detectorPool <- detector - }() - - localResults := make(models.DetectionResults) - err := detector.Detect(ctx, dir, path, localResults, advisoriesToCheckPerLanguage["java"]) - if err != nil { - return err - } + languageKey, supported := extensionToLanguageKey[filepath.Ext(d.Name())] + if !supported { + return nil + } + + pool := detectorPools[languageKey] - // Merge local results back to main detectionResults with mutex protection - detectionMutex.Lock() - for purl, advisoryMap := range localResults { - if _, exists := detectionResults[purl]; !exists { - detectionResults[purl] = make(map[string]models.ReachableSymbolLocations) - } - for advisoryID, locations := range advisoryMap { - detectionResults[purl][advisoryID] = append(detectionResults[purl][advisoryID], locations...) - } + eg.Go(func() error { + // Get a detector from the pool + detector := <-pool + // Return detector to pool after it's finished + defer func() { + pool <- detector + }() + + localResults := make(models.DetectionResults) + err := detector.Detect(ctx, dir, path, localResults, advisoriesToCheckPerLanguage[languageKey]) + if err != nil { + return err + } + + // Merge local results back to main detectionResults with mutex protection + detectionMutex.Lock() + for purl, advisoryMap := range localResults { + if _, exists := detectionResults[purl]; !exists { + detectionResults[purl] = make(map[string]models.ReachableSymbolLocations) } - detectionMutex.Unlock() + for advisoryID, locations := range advisoryMap { + detectionResults[purl][advisoryID] = append(detectionResults[purl][advisoryID], locations...) + } + } + detectionMutex.Unlock() - return nil - }) - default: return nil - } + }) return err }) From 5d958eef069e005a1466f3b0425481a11c0bca2f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Thu, 2 Jul 2026 14:24:47 +0200 Subject: [PATCH 06/16] fix: derive reachability language key from PURL type instead of hardcoding java --- pkg/reachability/reachability.go | 2 +- pkg/reachability/utils.go | 26 +++++++++- pkg/reachability/utils_test.go | 86 +++++++++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/pkg/reachability/reachability.go b/pkg/reachability/reachability.go index f27c21b5..38540715 100644 --- a/pkg/reachability/reachability.go +++ b/pkg/reachability/reachability.go @@ -41,7 +41,7 @@ func PerformReachabilityAnalysis(r reporter.Reporter, purls []string, directoryP return models.ReachabilityAnalysis{} } - advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(resp) + advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(r, resp) detectionResults := make(models.DetectionResults) var detectionMutex sync.Mutex diff --git a/pkg/reachability/utils.go b/pkg/reachability/utils.go index e62c3a70..1e3a7964 100644 --- a/pkg/reachability/utils.go +++ b/pkg/reachability/utils.go @@ -3,14 +3,36 @@ package reachability import ( "github.com/DataDog/datadog-sbom-generator/internal/http" "github.com/DataDog/datadog-sbom-generator/pkg/models" + "github.com/DataDog/datadog-sbom-generator/pkg/reporter" + + "github.com/package-url/packageurl-go" ) +// purlTypeToLanguageKey maps a PURL type to the language key used to route vulnerable symbols +// to the correct reachability detector. +var purlTypeToLanguageKey = map[string]string{ + packageurl.TypeMaven: "java", + packageurl.TypeGolang: "go", +} + // getAdvisoriesToCheckPerLanguage returns a map of language to advisories with symbols to check. -func getAdvisoriesToCheckPerLanguage(resp http.ResolveVulnerableSymbolsResponse) models.AdvisoriesToCheckPerLanguage { +// PURLs that fail to parse, or whose type has no known reachability detector, are skipped with +// a warning rather than aborting the whole reachability pass. +func getAdvisoriesToCheckPerLanguage(r reporter.Reporter, resp http.ResolveVulnerableSymbolsResponse) models.AdvisoriesToCheckPerLanguage { output := models.AdvisoriesToCheckPerLanguage{} for _, result := range resp.Results { - language := "java" + parsedPurl, err := packageurl.FromString(result.Purl) + if err != nil { + r.Warnf("[reachability] Skipping %s: failed to parse PURL: %v", result.Purl, err) + continue + } + + language, languageSupported := purlTypeToLanguageKey[parsedPurl.Type] + if !languageSupported { + r.Warnf("[reachability] Skipping %s: no reachability support for PURL type %s", result.Purl, parsedPurl.Type) + continue + } // Initialize a slice for the language if it doesn't exist if _, languageExists := output[language]; !languageExists { diff --git a/pkg/reachability/utils_test.go b/pkg/reachability/utils_test.go index 3399a6a1..8d27c7ef 100644 --- a/pkg/reachability/utils_test.go +++ b/pkg/reachability/utils_test.go @@ -5,6 +5,7 @@ import ( "github.com/DataDog/datadog-sbom-generator/internal/http" "github.com/DataDog/datadog-sbom-generator/pkg/models" + "github.com/DataDog/datadog-sbom-generator/pkg/reporter" "github.com/stretchr/testify/assert" ) @@ -19,7 +20,7 @@ func Test_getAdvisoriesToCheckPerLanguage_NoAdvisoriesToCheck(t *testing.T) { expected := models.AdvisoriesToCheckPerLanguage{} - advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(resolveVulnerableSymbolsResponse) + advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(&reporter.VoidReporter{}, resolveVulnerableSymbolsResponse) assert.Equal(t, expected, advisoriesToCheckPerLanguage) } @@ -121,7 +122,88 @@ func Test_getAdvisoriesToCheckPerLanguage_HasAdvisoriesToCheck(t *testing.T) { }, } - advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(resolveVulnerableSymbolsResponse) + advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(&reporter.VoidReporter{}, resolveVulnerableSymbolsResponse) + + assert.Equal(t, expected, advisoriesToCheckPerLanguage) +} + +func Test_getAdvisoriesToCheckPerLanguage_GoPurlRoutesToGoLanguage(t *testing.T) { + t.Parallel() + + resolveVulnerableSymbolsResponse := http.ResolveVulnerableSymbolsResponse{ + ID: "testing-123", + Results: []http.SymbolsForPurl{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + VulnerableSymbols: []http.SymbolDetails{ + { + AdvisoryID: "CVE-2025-5678", + Symbols: []http.Symbol{ + { + Type: "function", + Value: "github.com/foo/bar", + Name: "Parse", + }, + }, + }, + }, + }, + }, + } + + expected := models.AdvisoriesToCheckPerLanguage{ + "go": { + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + { + Type: "function", + Value: "github.com/foo/bar", + Name: "Parse", + }, + }, + }, + }, + } + + advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(&reporter.VoidReporter{}, resolveVulnerableSymbolsResponse) + + assert.Equal(t, expected, advisoriesToCheckPerLanguage) +} + +func Test_getAdvisoriesToCheckPerLanguage_UnsupportedOrMalformedPurlsAreSkipped(t *testing.T) { + t.Parallel() + + resolveVulnerableSymbolsResponse := http.ResolveVulnerableSymbolsResponse{ + ID: "testing-123", + Results: []http.SymbolsForPurl{ + { + Purl: "pkg:npm/lodash@4.17.21", + VulnerableSymbols: []http.SymbolDetails{ + { + AdvisoryID: "CVE-2025-1111", + Symbols: []http.Symbol{ + {Type: "function", Value: "lodash", Name: "merge"}, + }, + }, + }, + }, + { + Purl: "not-a-purl", + VulnerableSymbols: []http.SymbolDetails{ + { + AdvisoryID: "CVE-2025-2222", + Symbols: []http.Symbol{{Type: "function", Value: "x", Name: "y"}}, + }, + }, + }, + }, + } + + expected := models.AdvisoriesToCheckPerLanguage{} + + advisoriesToCheckPerLanguage := getAdvisoriesToCheckPerLanguage(&reporter.VoidReporter{}, resolveVulnerableSymbolsResponse) assert.Equal(t, expected, advisoriesToCheckPerLanguage) } From f776907f61b6ad7a7b4b2761b1ef9012d5eedfda Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 10:23:01 +0200 Subject: [PATCH 07/16] test: add end-to-end Go reachability coverage --- pkg/reachability/reachability_test.go | 102 ++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 5 deletions(-) diff --git a/pkg/reachability/reachability_test.go b/pkg/reachability/reachability_test.go index eda9fa99..1318766a 100644 --- a/pkg/reachability/reachability_test.go +++ b/pkg/reachability/reachability_test.go @@ -57,6 +57,41 @@ public class ExampleApp { } }` +const vulnerableGoSymbolsResponse = `{ + "data": { + "id": "833c8b78-f95d-11ef-a104-9ec2f3c6472d", + "type": "resolve-vulnerable-symbols-response", + "attributes": { + "results": [ + { + "purl": "pkg:golang/github.com/foo/bar@1.2.3", + "vulnerable_symbols": [ + { + "advisory_id": "CVE-2025-5678", + "symbols": [ + { + "type": "function", + "value": "github.com/foo/bar", + "name": "Parse" + } + ] + } + ] + } + ] + } + } +}` + +const vulnerableGoFile = `package main + +import bar "github.com/foo/bar" + +func main() { + bar.Parse("x") +} +` + func Test_PerformReachabilityAnalysis(t *testing.T) { t.Setenv("DD_API_KEY", "test-dd-api-key") t.Setenv("DD_APP_KEY", "test-dd-app-key") @@ -64,7 +99,7 @@ func Test_PerformReachabilityAnalysis(t *testing.T) { // Create a mock server to simulate the API response // The server will return a successful response with the vulnerable symbols - mockServer := createMockServer(http.StatusOK, vulnerableSymbolsResponse) + mockServer := createMockServer(vulnerableSymbolsResponse) defer mockServer.Close() // Create a temporary directory with a mock Java file @@ -127,7 +162,7 @@ func Test_PerformReachabilityAnalysis_ExcludePath(t *testing.T) { // Create a mock server to simulate the API response // The server will return a successful response with the vulnerable symbols - mockServer := createMockServer(http.StatusOK, vulnerableSymbolsResponse) + mockServer := createMockServer(vulnerableSymbolsResponse) defer mockServer.Close() // Create a temporary directory with a mock Java file @@ -175,7 +210,7 @@ func Test_PerformReachabilityAnalysis_ConfigExcludePath(t *testing.T) { // Create a mock server to simulate the API response // The server will return a successful response with the vulnerable symbols - mockServer := createMockServer(http.StatusOK, vulnerableSymbolsResponse) + mockServer := createMockServer(vulnerableSymbolsResponse) defer mockServer.Close() // Create a temporary directory with a mock Java file @@ -217,9 +252,66 @@ func Test_PerformReachabilityAnalysis_ConfigExcludePath(t *testing.T) { assert.Equal(t, expected, result) } -func createMockServer(statusCode int, data string) *httptest.Server { +func Test_PerformReachabilityAnalysis_Go(t *testing.T) { + t.Setenv("DD_API_KEY", "test-dd-api-key") + t.Setenv("DD_APP_KEY", "test-dd-app-key") + ddJwtToken := "" + + mockServer := createMockServer(vulnerableGoSymbolsResponse) + defer mockServer.Close() + + tempDir := t.TempDir() + err := os.Mkdir(filepath.Join(tempDir, "subdir"), 0755) + require.NoError(t, err) + + mockGoFile := filepath.Join(tempDir, "subdir", "main.go") + err = os.WriteFile(mockGoFile, []byte(vulnerableGoFile), 0600) + require.NoError(t, err) + + mockReporter := createMockReporter(t) + + result := PerformReachabilityAnalysis( + mockReporter, + []string{}, + []string{tempDir}, + []string{}, + "", + []string{}, + mockServer.URL, + ddJwtToken, + ) + + expected := models.ReachabilityAnalysis{ + PurlToReachabilityAnalysisResults: models.PurlToReachabilityAnalysisResults{ + "pkg:golang/github.com/foo/bar@1.2.3": &models.ReachabilityAnalysisResults{ + AdvisoryIdsChecked: []string{"CVE-2025-5678"}, + ReachableVulnerabilities: []models.ReachableVulnerability{ + { + AdvisoryID: "CVE-2025-5678", + ReachableSymbolLocations: []models.ReachableSymbolLocation{ + { + Symbol: "bar.Parse", + PackageLocation: models.PackageLocation{ + Filename: "subdir/main.go", + LineStart: 6, + LineEnd: 6, + ColumnStart: 2, + ColumnEnd: 11, + }, + }, + }, + }, + }, + }, + }, + } + + assert.Equal(t, expected, result) +} + +func createMockServer(data string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(statusCode) + w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(data)) })) } From 83083f301dbd5c3114dcd64079ea5c910d00ff95 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Mon, 6 Jul 2026 11:41:34 +0200 Subject: [PATCH 08/16] Extract shared tree-sitter helpers from Java and Go reachability detectors --- pkg/reachability/codefile/common.go | 93 +++++++++++++++++++++++++++++ pkg/reachability/codefile/golang.go | 53 +--------------- pkg/reachability/codefile/java.go | 71 +--------------------- 3 files changed, 99 insertions(+), 118 deletions(-) create mode 100644 pkg/reachability/codefile/common.go diff --git a/pkg/reachability/codefile/common.go b/pkg/reachability/codefile/common.go new file mode 100644 index 00000000..53aac99f --- /dev/null +++ b/pkg/reachability/codefile/common.go @@ -0,0 +1,93 @@ +package codefile + +import ( + "context" + "os" + + "github.com/DataDog/datadog-sbom-generator/internal/utility/converter" + "github.com/DataDog/datadog-sbom-generator/internal/utility/fileposition" + "github.com/DataDog/datadog-sbom-generator/pkg/models" + + treesitter "github.com/tree-sitter/go-tree-sitter" +) + +// readFileContent is a thin wrapper over os.ReadFile that reads the content of a file +// and returns it as a byte slice. +// TODO(daniel.strong): find a better place for this function +func readFileContent(filePath string) ([]byte, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + return data, nil +} + +// parseFile parses fileContent with tsParser, canceling the parse as soon as ctx is done. +func parseFile(ctx context.Context, tsParser *treesitter.Parser, fileContent []byte) *treesitter.Tree { + readCallback := func(offset int, position treesitter.Point) []byte { + if ctx.Err() != nil { + return []byte{} + } + if offset >= len(fileContent) { + return []byte{} + } + + return fileContent[offset:] + } + + return tsParser.ParseWithOptions(readCallback, nil, &treesitter.ParseOptions{ + // ProgressCallback returns true to cancel parsing + // We use ctx.Err() != nil to cancel the parse if the context is canceled + // See: https://github.com/tree-sitter/go-tree-sitter/blob/adc13ffd8b2c0b01b878fda9f7c422ce0df5fad3/parser.go#L319 + ProgressCallback: func(_ treesitter.ParseState) bool { + return ctx.Err() != nil + }, + }) +} + +// buildPackageLocation converts a tree-sitter start/end position pair into a models.PackageLocation +// for a match found in the file at path (relative to dir). +func buildPackageLocation(dir string, path string, start treesitter.Point, end treesitter.Point) (models.PackageLocation, error) { + packageLocation := models.PackageLocation{ + Filename: fileposition.ToRelativePath(dir, path), + } + + var err error + packageLocation.LineStart, err = converter.SafeUIntToInt(start.Row + 1) + if err != nil { + return models.PackageLocation{}, err + } + packageLocation.LineEnd, err = converter.SafeUIntToInt(end.Row + 1) + if err != nil { + return models.PackageLocation{}, err + } + packageLocation.ColumnStart, err = converter.SafeUIntToInt(start.Column + 1) + if err != nil { + return models.PackageLocation{}, err + } + packageLocation.ColumnEnd, err = converter.SafeUIntToInt(end.Column + 1) + if err != nil { + return models.PackageLocation{}, err + } + + return packageLocation, nil +} + +// recordMatch appends a reachable symbol match to detectionResults, initializing the +// per-purl and per-advisory maps if this is the first match for either. +func recordMatch(detectionResults models.DetectionResults, purl string, advisoryID string, symbol string, location models.PackageLocation) { + if _, ok := detectionResults[purl]; !ok { + detectionResults[purl] = make(map[string]models.ReachableSymbolLocations) + } + if _, ok := detectionResults[purl][advisoryID]; !ok { + detectionResults[purl][advisoryID] = make(models.ReachableSymbolLocations, 0) + } + + detectionResults[purl][advisoryID] = append( + detectionResults[purl][advisoryID], + models.ReachableSymbolLocation{ + Symbol: symbol, + PackageLocation: location, + }) +} diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index ad96031c..6cef98c2 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -7,8 +7,6 @@ import ( "strings" "github.com/DataDog/datadog-sbom-generator/internal/cachedregexp" - "github.com/DataDog/datadog-sbom-generator/internal/utility/converter" - "github.com/DataDog/datadog-sbom-generator/internal/utility/fileposition" "github.com/DataDog/datadog-sbom-generator/pkg/models" "github.com/DataDog/datadog-sbom-generator/pkg/reporter" @@ -151,22 +149,7 @@ func (r *ReachabilityGo) Detect(ctx context.Context, dir string, path string, de return err } - readCallback := func(offset int, position treesitter.Point) []byte { - if ctx.Err() != nil { - return []byte{} - } - if offset >= len(fileContent) { - return []byte{} - } - - return fileContent[offset:] - } - - tree := r.tsParser.ParseWithOptions(readCallback, nil, &treesitter.ParseOptions{ - ProgressCallback: func(_ treesitter.ParseState) bool { - return ctx.Err() != nil - }, - }) + tree := parseFile(ctx, r.tsParser, fileContent) defer tree.Close() if len(advisoriesToCheck) == 0 { @@ -212,42 +195,12 @@ func (r *ReachabilityGo) Detect(ctx context.Context, dir string, path string, de continue } - startPosition := selectorNode.StartPosition() - endPosition := selectorNode.EndPosition() - - if _, ok := detectionResults[advisoryToCheck.Purl]; !ok { - detectionResults[advisoryToCheck.Purl] = make(map[string]models.ReachableSymbolLocations) - } - if _, ok := detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID]; !ok { - detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID] = make(models.ReachableSymbolLocations, 0) - } - - packageLocation := models.PackageLocation{ - Filename: fileposition.ToRelativePath(dir, path), - } - packageLocation.LineStart, err = converter.SafeUIntToInt(startPosition.Row + 1) - if err != nil { - return err - } - packageLocation.LineEnd, err = converter.SafeUIntToInt(endPosition.Row + 1) - if err != nil { - return err - } - packageLocation.ColumnStart, err = converter.SafeUIntToInt(startPosition.Column + 1) - if err != nil { - return err - } - packageLocation.ColumnEnd, err = converter.SafeUIntToInt(endPosition.Column + 1) + packageLocation, err := buildPackageLocation(dir, path, selectorNode.StartPosition(), selectorNode.EndPosition()) if err != nil { return err } - detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID] = append( - detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID], - models.ReachableSymbolLocation{ - Symbol: selectorNode.Utf8Text(fileContent), - PackageLocation: packageLocation, - }) + recordMatch(detectionResults, advisoryToCheck.Purl, advisoryToCheck.AdvisoryID, selectorNode.Utf8Text(fileContent), packageLocation) } } } diff --git a/pkg/reachability/codefile/java.go b/pkg/reachability/codefile/java.go index f9106ddc..06cc68bc 100644 --- a/pkg/reachability/codefile/java.go +++ b/pkg/reachability/codefile/java.go @@ -3,11 +3,7 @@ package codefile import ( "context" "fmt" - "os" - "github.com/DataDog/datadog-sbom-generator/internal/utility/converter" - - "github.com/DataDog/datadog-sbom-generator/internal/utility/fileposition" "github.com/DataDog/datadog-sbom-generator/pkg/models" "github.com/DataDog/datadog-sbom-generator/pkg/reporter" @@ -74,26 +70,8 @@ func (r *ReachabilityJava) Detect(ctx context.Context, dir string, path string, if err != nil { return err } - // Callback function that checks if there is an error - readCallback := func(offset int, position treesitter.Point) []byte { - if ctx.Err() != nil { - return []byte{} - } - if offset >= len(fileContent) { - return []byte{} - } - return fileContent[offset:] - } - - tree := r.tsParser.ParseWithOptions(readCallback, nil, &treesitter.ParseOptions{ - // ProgressCallback returns true to cancel parsing - // We use ctx.Err() != nil to cancel the parse if the context is canceled - // See: https://github.com/tree-sitter/go-tree-sitter/blob/adc13ffd8b2c0b01b878fda9f7c422ce0df5fad3/parser.go#L319 - ProgressCallback: func(_ treesitter.ParseState) bool { - return ctx.Err() != nil - }, - }) + tree := parseFile(ctx, r.tsParser, fileContent) defer tree.Close() queryCursor := treesitter.NewQueryCursor() @@ -126,43 +104,12 @@ func (r *ReachabilityJava) Detect(ctx context.Context, dir string, path string, Note: This logic is specific to class type and will need to be updated in the future when we build out further symbols. */ if matchedText == s.Name || matchedText == fmt.Sprintf("%s.%s", s.Value, s.Name) { - startPosition := match.Captures[index].Node.StartPosition() - endPosition := match.Captures[index].Node.EndPosition() - - if _, ok := detectionResults[advisoryToCheck.Purl]; !ok { - detectionResults[advisoryToCheck.Purl] = make(map[string]models.ReachableSymbolLocations) - } - - if _, ok := detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID]; !ok { - detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID] = make(models.ReachableSymbolLocations, 0) - } - - packageLocation := models.PackageLocation{ - Filename: fileposition.ToRelativePath(dir, path), - } - packageLocation.LineStart, err = converter.SafeUIntToInt(startPosition.Row + 1) - if err != nil { - return err - } - packageLocation.LineEnd, err = converter.SafeUIntToInt(endPosition.Row + 1) - if err != nil { - return err - } - packageLocation.ColumnStart, err = converter.SafeUIntToInt(startPosition.Column + 1) - if err != nil { - return err - } - packageLocation.ColumnEnd, err = converter.SafeUIntToInt(endPosition.Column + 1) + packageLocation, err := buildPackageLocation(dir, path, match.Captures[index].Node.StartPosition(), match.Captures[index].Node.EndPosition()) if err != nil { return err } - detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID] = append( - detectionResults[advisoryToCheck.Purl][advisoryToCheck.AdvisoryID], - models.ReachableSymbolLocation{ - Symbol: matchedText, - PackageLocation: packageLocation, - }) + recordMatch(detectionResults, advisoryToCheck.Purl, advisoryToCheck.AdvisoryID, matchedText, packageLocation) } } } @@ -170,15 +117,3 @@ func (r *ReachabilityJava) Detect(ctx context.Context, dir string, path string, return nil } - -// readFileContent is a thin wrapper over os.ReadFile that reads the content of a file -// and returns it as a byte slice. -// TODO(daniel.strong): find a better place for this function -func readFileContent(filePath string) ([]byte, error) { - data, err := os.ReadFile(filePath) - if err != nil { - return nil, err - } - - return data, nil -} From d37c5c166f8bcb4d962b3070abe1384919a65957 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 10:24:40 +0200 Subject: [PATCH 09/16] fix: handle gopkg.in-style dotted major-version suffixes in unaliased import identifiers --- pkg/reachability/codefile/golang.go | 11 ++++++++--- pkg/reachability/codefile/golang_test.go | 2 ++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index 6cef98c2..db8078de 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -31,6 +31,7 @@ const tsQueryForGoCall = ` ` var majorVersionSuffixPattern = cachedregexp.MustCompile(`^v[0-9]+$`) +var dottedMajorVersionSuffixPattern = cachedregexp.MustCompile(`\.v[0-9]+$`) type ReachabilityGo struct { tsParser *treesitter.Parser @@ -130,14 +131,18 @@ func (r *ReachabilityGo) resolveImportAliases(tree *treesitter.Tree, fileContent } // defaultIdentifierForModulePath derives the package identifier Go code would use for an -// unaliased import, using the last path segment and stripping a trailing major-version suffix -// (e.g. "github.com/foo/bar/v2" -> "bar"). +// unaliased import, using the last path segment and stripping a trailing major-version suffix. +// Two conventions are handled: the path-segment style (e.g. "github.com/foo/bar/v2" -> "bar") +// and the dotted gopkg.in style (e.g. "gopkg.in/yaml.v3" -> "yaml"). func defaultIdentifierForModulePath(modulePath string) string { segments := strings.Split(modulePath, "/") identifier := segments[len(segments)-1] - if len(segments) > 1 && majorVersionSuffixPattern.MatchString(identifier) { + switch { + case len(segments) > 1 && majorVersionSuffixPattern.MatchString(identifier): identifier = segments[len(segments)-2] + case dottedMajorVersionSuffixPattern.MatchString(identifier): + identifier = identifier[:strings.LastIndex(identifier, ".")] } return identifier diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index 5875c51e..58d806b4 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -121,6 +121,8 @@ func Test_defaultIdentifierForModulePath(t *testing.T) { {modulePath: "github.com/foo/bar/v10", expected: "bar"}, {modulePath: "rsc.io/quote", expected: "quote"}, {modulePath: "singlesegment", expected: "singlesegment"}, + {modulePath: "gopkg.in/yaml.v3", expected: "yaml"}, + {modulePath: "gopkg.in/check.v1", expected: "check"}, } for _, tt := range tests { From 06d4f0d356cdbe02ffecd553868efb6cff17fc3d Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 10:26:48 +0200 Subject: [PATCH 10/16] fix: skip scheduling detector goroutine when a language has no advisories to check --- pkg/reachability/reachability.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/reachability/reachability.go b/pkg/reachability/reachability.go index 38540715..31c5e925 100644 --- a/pkg/reachability/reachability.go +++ b/pkg/reachability/reachability.go @@ -116,6 +116,10 @@ func PerformReachabilityAnalysis(r reporter.Reporter, purls []string, directoryP return nil } + if len(advisoriesToCheckPerLanguage[languageKey]) == 0 { + return nil + } + pool := detectorPools[languageKey] eg.Go(func() error { From f01a1e3e5dca91e00ff576cd861f68b07d82d453 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Mon, 13 Jul 2026 14:33:13 +0200 Subject: [PATCH 11/16] Skip blank and dot imports instead of defaulting their alias Codex flagged that _ and . imports fell through to defaultIdentifierForModulePath, which could collide with an unrelated import resolving to the same default alias and produce a false-positive reachability match. --- pkg/reachability/codefile/golang.go | 43 ++++++++++++++---------- pkg/reachability/codefile/golang_test.go | 20 +++++++++++ 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index db8078de..04fac288 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -20,6 +20,8 @@ const symbolTypeFunction = "function" const tsQueryForGoImports = ` (import_spec name: (package_identifier)? @alias + name: (dot)? @blankOrDotImport + name: (blank_identifier)? @blankOrDotImport path: (interpreted_string_literal (interpreted_string_literal_content) @path)) ` @@ -38,11 +40,12 @@ type ReachabilityGo struct { importQuery *treesitter.Query callQuery *treesitter.Query - aliasCaptureIdx uint - pathCaptureIdx uint - pkgCaptureIdx uint - fnCaptureIdx uint - selectorCaptureIdx uint + aliasCaptureIdx uint + blankOrDotCaptureIdx uint + pathCaptureIdx uint + pkgCaptureIdx uint + fnCaptureIdx uint + selectorCaptureIdx uint reporter reporter.Reporter } @@ -68,21 +71,23 @@ func NewGoReachableDetector(r reporter.Reporter) (*ReachabilityGo, error) { } aliasCaptureIdx, _ := importQuery.CaptureIndexForName("alias") + blankOrDotCaptureIdx, _ := importQuery.CaptureIndexForName("blankOrDotImport") pathCaptureIdx, _ := importQuery.CaptureIndexForName("path") pkgCaptureIdx, _ := callQuery.CaptureIndexForName("pkg") fnCaptureIdx, _ := callQuery.CaptureIndexForName("fn") selectorCaptureIdx, _ := callQuery.CaptureIndexForName("selector") return &ReachabilityGo{ - tsParser: tsParser, - importQuery: importQuery, - callQuery: callQuery, - aliasCaptureIdx: aliasCaptureIdx, - pathCaptureIdx: pathCaptureIdx, - pkgCaptureIdx: pkgCaptureIdx, - fnCaptureIdx: fnCaptureIdx, - selectorCaptureIdx: selectorCaptureIdx, - reporter: reporter.Effective(r), + tsParser: tsParser, + importQuery: importQuery, + callQuery: callQuery, + aliasCaptureIdx: aliasCaptureIdx, + blankOrDotCaptureIdx: blankOrDotCaptureIdx, + pathCaptureIdx: pathCaptureIdx, + pkgCaptureIdx: pkgCaptureIdx, + fnCaptureIdx: fnCaptureIdx, + selectorCaptureIdx: selectorCaptureIdx, + reporter: reporter.Effective(r), }, nil } @@ -98,25 +103,29 @@ func (r *ReachabilityGo) Close() { // import path -> local identifiers used to reference that module in this file. Unaliased // imports are assigned a heuristic identifier: the last path segment, with a trailing // major-version suffix (e.g. "/v2") stripped, per Go module convention. Dot imports and blank -// imports are not callable via a package selector (e.g. "pkg.Func"), so they never produce a -// call-site match regardless of how they're keyed here. +// imports are skipped entirely: they don't bind a package selector (e.g. "pkg.Func"), so +// defaulting them to an identifier would risk matching an unrelated import that happens to +// resolve to the same default alias. func (r *ReachabilityGo) resolveImportAliases(tree *treesitter.Tree, fileContent []byte, queryCursor *treesitter.QueryCursor) map[string][]string { moduleToAliases := make(map[string][]string) matches := queryCursor.Matches(r.importQuery, tree.RootNode(), fileContent) for match := matches.Next(); match != nil; match = matches.Next() { var alias, modulePath string + var isBlankOrDotImport bool for _, capture := range match.Captures { switch capture.Index { case uint32(r.aliasCaptureIdx): //nolint:gosec alias = capture.Node.Utf8Text(fileContent) + case uint32(r.blankOrDotCaptureIdx): //nolint:gosec + isBlankOrDotImport = true case uint32(r.pathCaptureIdx): //nolint:gosec modulePath = capture.Node.Utf8Text(fileContent) } } - if modulePath == "" { + if modulePath == "" || isBlankOrDotImport { continue } diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index 58d806b4..72bd6c18 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -89,6 +89,26 @@ func main() { name: "no imports", src: `package main +func main() {} +`, + expected: map[string][]string{}, + }, + { + name: "blank import is not resolved to a default alias", + src: `package main + +import _ "github.com/foo/bar" + +func main() {} +`, + expected: map[string][]string{}, + }, + { + name: "dot import is not resolved to a default alias", + src: `package main + +import . "github.com/foo/bar" + func main() {} `, expected: map[string][]string{}, From ec7c42e280fa507eff7dee7aa4dbb7df04b95240 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Mon, 13 Jul 2026 14:35:12 +0200 Subject: [PATCH 12/16] Strip go- prefix when deriving default import identifier Codex flagged that github.com/redis/go-redis/v9 (package name "redis") was defaulting to "go-redis", missing reachability for that convention. Go identifiers can't contain hyphens, so any hyphenated last segment is never the real package name; strip the common go- repo-naming prefix. --- pkg/reachability/codefile/golang.go | 11 +++++++++-- pkg/reachability/codefile/golang_test.go | 2 ++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index 04fac288..6b7c56e8 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -141,8 +141,11 @@ func (r *ReachabilityGo) resolveImportAliases(tree *treesitter.Tree, fileContent // defaultIdentifierForModulePath derives the package identifier Go code would use for an // unaliased import, using the last path segment and stripping a trailing major-version suffix. -// Two conventions are handled: the path-segment style (e.g. "github.com/foo/bar/v2" -> "bar") -// and the dotted gopkg.in style (e.g. "gopkg.in/yaml.v3" -> "yaml"). +// Two version conventions are handled: the path-segment style (e.g. "github.com/foo/bar/v2" -> +// "bar") and the dotted gopkg.in style (e.g. "gopkg.in/yaml.v3" -> "yaml"). A leading "go-" +// repository-naming convention is also stripped (e.g. "github.com/redis/go-redis/v9" -> +// "redis"), since Go identifiers can't contain hyphens, so a hyphenated segment is never the +// real package name. func defaultIdentifierForModulePath(modulePath string) string { segments := strings.Split(modulePath, "/") identifier := segments[len(segments)-1] @@ -154,6 +157,10 @@ func defaultIdentifierForModulePath(modulePath string) string { identifier = identifier[:strings.LastIndex(identifier, ".")] } + if rest, ok := strings.CutPrefix(identifier, "go-"); ok && rest != "" { + identifier = rest + } + return identifier } diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index 72bd6c18..bbc5e44f 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -143,6 +143,8 @@ func Test_defaultIdentifierForModulePath(t *testing.T) { {modulePath: "singlesegment", expected: "singlesegment"}, {modulePath: "gopkg.in/yaml.v3", expected: "yaml"}, {modulePath: "gopkg.in/check.v1", expected: "check"}, + {modulePath: "github.com/redis/go-redis/v9", expected: "redis"}, + {modulePath: "github.com/mattn/go-sqlite3", expected: "sqlite3"}, } for _, tt := range tests { From 72fa48502a8988d90bff41931635e378c0025e48 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Mon, 13 Jul 2026 16:27:26 +0200 Subject: [PATCH 13/16] Strip trailing -go suffix when deriving default import identifier --- pkg/reachability/codefile/golang.go | 10 ++++++---- pkg/reachability/codefile/golang_test.go | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index 6b7c56e8..23246f90 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -142,10 +142,10 @@ func (r *ReachabilityGo) resolveImportAliases(tree *treesitter.Tree, fileContent // defaultIdentifierForModulePath derives the package identifier Go code would use for an // unaliased import, using the last path segment and stripping a trailing major-version suffix. // Two version conventions are handled: the path-segment style (e.g. "github.com/foo/bar/v2" -> -// "bar") and the dotted gopkg.in style (e.g. "gopkg.in/yaml.v3" -> "yaml"). A leading "go-" -// repository-naming convention is also stripped (e.g. "github.com/redis/go-redis/v9" -> -// "redis"), since Go identifiers can't contain hyphens, so a hyphenated segment is never the -// real package name. +// "bar") and the dotted gopkg.in style (e.g. "gopkg.in/yaml.v3" -> "yaml"). Leading "go-" and +// trailing "-go" repository-naming conventions are also stripped (e.g. "github.com/redis/go-redis/v9" +// -> "redis", "github.com/CycloneDX/cyclonedx-go" -> "cyclonedx"), since Go identifiers can't +// contain hyphens, so a hyphenated segment is never the real package name. func defaultIdentifierForModulePath(modulePath string) string { segments := strings.Split(modulePath, "/") identifier := segments[len(segments)-1] @@ -159,6 +159,8 @@ func defaultIdentifierForModulePath(modulePath string) string { if rest, ok := strings.CutPrefix(identifier, "go-"); ok && rest != "" { identifier = rest + } else if rest, ok := strings.CutSuffix(identifier, "-go"); ok && rest != "" { + identifier = rest } return identifier diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index bbc5e44f..8688cd95 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -145,6 +145,7 @@ func Test_defaultIdentifierForModulePath(t *testing.T) { {modulePath: "gopkg.in/check.v1", expected: "check"}, {modulePath: "github.com/redis/go-redis/v9", expected: "redis"}, {modulePath: "github.com/mattn/go-sqlite3", expected: "sqlite3"}, + {modulePath: "github.com/CycloneDX/cyclonedx-go", expected: "cyclonedx"}, } for _, tt := range tests { From acfcd15fc4e4017a12238beb9ff0f334e799ab90 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 10:31:08 +0200 Subject: [PATCH 14/16] Share language-key constants across reachability dispatch maps --- pkg/reachability/reachability.go | 17 +++++++++++++---- pkg/reachability/utils.go | 4 ++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/reachability/reachability.go b/pkg/reachability/reachability.go index 31c5e925..fc4bec87 100644 --- a/pkg/reachability/reachability.go +++ b/pkg/reachability/reachability.go @@ -17,17 +17,26 @@ import ( "golang.org/x/sync/errgroup" ) +// languageKeyGo and languageKeyJava are the language keys used to route vulnerable symbols to +// the correct reachability detector. They're shared across extensionToLanguageKey, +// languageKeyToDetectorFactory, and purlTypeToLanguageKey (utils.go) so a typo in one map can't +// silently drift from the others. +const ( + languageKeyGo = "go" + languageKeyJava = "java" +) + // extensionToLanguageKey maps a file extension to the language key used both to look up // advisories to check and to select a detector pool. var extensionToLanguageKey = map[string]string{ - ".java": "java", - ".go": "go", + ".java": languageKeyJava, + ".go": languageKeyGo, } // languageKeyToDetectorFactory constructs a new Detector for a given language key. var languageKeyToDetectorFactory = map[string]func(reporter.Reporter) (codefile.Detector, error){ - "java": func(r reporter.Reporter) (codefile.Detector, error) { return codefile.NewJavaReachableDetector(r) }, - "go": func(r reporter.Reporter) (codefile.Detector, error) { return codefile.NewGoReachableDetector(r) }, + languageKeyJava: func(r reporter.Reporter) (codefile.Detector, error) { return codefile.NewJavaReachableDetector(r) }, + languageKeyGo: func(r reporter.Reporter) (codefile.Detector, error) { return codefile.NewGoReachableDetector(r) }, } // PerformReachabilityAnalysis performs a reachability analysis on the given PURLs. diff --git a/pkg/reachability/utils.go b/pkg/reachability/utils.go index 1e3a7964..febe28c1 100644 --- a/pkg/reachability/utils.go +++ b/pkg/reachability/utils.go @@ -11,8 +11,8 @@ import ( // purlTypeToLanguageKey maps a PURL type to the language key used to route vulnerable symbols // to the correct reachability detector. var purlTypeToLanguageKey = map[string]string{ - packageurl.TypeMaven: "java", - packageurl.TypeGolang: "go", + packageurl.TypeMaven: languageKeyJava, + packageurl.TypeGolang: languageKeyGo, } // getAdvisoriesToCheckPerLanguage returns a map of language to advisories with symbols to check. From 17febdca5f4d218baa75d95faca78a9b314a78b5 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 09:25:38 +0200 Subject: [PATCH 15/16] Add end-to-end Detect() coverage for blank/dot imports and go-/-go heuristic --- pkg/reachability/codefile/golang_test.go | 58 +++++++++++++++++++ .../CVE-2025-5678/blank-import/main.go | 5 ++ .../testdata/CVE-2025-5678/dot-import/main.go | 5 ++ .../CVE-2025-5678/go-prefix-import/main.go | 7 +++ .../CVE-2025-5678/go-suffix-import/main.go | 7 +++ 5 files changed, 82 insertions(+) create mode 100644 pkg/reachability/codefile/testdata/CVE-2025-5678/blank-import/main.go create mode 100644 pkg/reachability/codefile/testdata/CVE-2025-5678/dot-import/main.go create mode 100644 pkg/reachability/codefile/testdata/CVE-2025-5678/go-prefix-import/main.go create mode 100644 pkg/reachability/codefile/testdata/CVE-2025-5678/go-suffix-import/main.go diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index 8688cd95..dd71d160 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -199,6 +199,30 @@ func Test_Detect_Go_FunctionSymbolFound(t *testing.T) { }, }, }, + "go- prefixed module import": { + path: "testdata/CVE-2025-5678/go-prefix-import/main.go", + advisoriesToCheck: []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + {Type: "function", Value: "github.com/foo/go-bar", Name: "Parse"}, + }, + }, + }, + }, + "-go suffixed module import": { + path: "testdata/CVE-2025-5678/go-suffix-import/main.go", + advisoriesToCheck: []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + {Type: "function", Value: "github.com/foo/bar-go", Name: "Parse"}, + }, + }, + }, + }, } for name, tc := range fixtures { @@ -256,6 +280,40 @@ func Test_Detect_Go_NoMatchWhenFunctionNameDiffers(t *testing.T) { assert.Empty(t, detectionResults) } +func Test_Detect_Go_BlankAndDotImportsNotReachable(t *testing.T) { + t.Parallel() + + paths := map[string]string{ + "blank import": "testdata/CVE-2025-5678/blank-import/main.go", + "dot import": "testdata/CVE-2025-5678/dot-import/main.go", + } + + for name, path := range paths { + t.Run(name, func(t *testing.T) { + t.Parallel() + + detector, err := NewGoReachableDetector(&reporter.VoidReporter{}) + require.NoError(t, err) + defer detector.Close() + + advisoriesToCheck := []models.AdvisoryToCheck{ + { + Purl: "pkg:golang/github.com/foo/bar@1.2.3", + AdvisoryID: "CVE-2025-5678", + Symbols: []models.Symbols{ + {Type: "function", Value: "github.com/foo/bar", Name: "Parse"}, + }, + }, + } + + detectionResults := models.DetectionResults{} + err = detector.Detect(context.Background(), ".", path, detectionResults, advisoriesToCheck) + require.NoError(t, err) + assert.Empty(t, detectionResults) + }) + } +} + func Test_Detect_Go_UnknownSymbolType(t *testing.T) { t.Parallel() diff --git a/pkg/reachability/codefile/testdata/CVE-2025-5678/blank-import/main.go b/pkg/reachability/codefile/testdata/CVE-2025-5678/blank-import/main.go new file mode 100644 index 00000000..dd37b8ac --- /dev/null +++ b/pkg/reachability/codefile/testdata/CVE-2025-5678/blank-import/main.go @@ -0,0 +1,5 @@ +package main + +import _ "github.com/foo/bar" + +func main() {} diff --git a/pkg/reachability/codefile/testdata/CVE-2025-5678/dot-import/main.go b/pkg/reachability/codefile/testdata/CVE-2025-5678/dot-import/main.go new file mode 100644 index 00000000..fb9ca00f --- /dev/null +++ b/pkg/reachability/codefile/testdata/CVE-2025-5678/dot-import/main.go @@ -0,0 +1,5 @@ +package main + +import . "github.com/foo/bar" + +func main() {} diff --git a/pkg/reachability/codefile/testdata/CVE-2025-5678/go-prefix-import/main.go b/pkg/reachability/codefile/testdata/CVE-2025-5678/go-prefix-import/main.go new file mode 100644 index 00000000..7361627b --- /dev/null +++ b/pkg/reachability/codefile/testdata/CVE-2025-5678/go-prefix-import/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/foo/go-bar" + +func main() { + bar.Parse("x") +} diff --git a/pkg/reachability/codefile/testdata/CVE-2025-5678/go-suffix-import/main.go b/pkg/reachability/codefile/testdata/CVE-2025-5678/go-suffix-import/main.go new file mode 100644 index 00000000..b55a339e --- /dev/null +++ b/pkg/reachability/codefile/testdata/CVE-2025-5678/go-suffix-import/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/foo/bar-go" + +func main() { + bar.Parse("x") +} From 34c3d6596c081d672634e5165974c38be7ec44dd Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cibois Date: Wed, 15 Jul 2026 12:15:08 +0200 Subject: [PATCH 16/16] Don't strip v0/v1 as a Go module major-version suffix --- pkg/reachability/codefile/golang.go | 13 ++++++++----- pkg/reachability/codefile/golang_test.go | 2 ++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/reachability/codefile/golang.go b/pkg/reachability/codefile/golang.go index 23246f90..a3452b31 100644 --- a/pkg/reachability/codefile/golang.go +++ b/pkg/reachability/codefile/golang.go @@ -32,7 +32,7 @@ const tsQueryForGoCall = ` field: (field_identifier) @fn) @selector) ` -var majorVersionSuffixPattern = cachedregexp.MustCompile(`^v[0-9]+$`) +var majorVersionSuffixPattern = cachedregexp.MustCompile(`^v([2-9]|[1-9][0-9]+)$`) var dottedMajorVersionSuffixPattern = cachedregexp.MustCompile(`\.v[0-9]+$`) type ReachabilityGo struct { @@ -142,10 +142,13 @@ func (r *ReachabilityGo) resolveImportAliases(tree *treesitter.Tree, fileContent // defaultIdentifierForModulePath derives the package identifier Go code would use for an // unaliased import, using the last path segment and stripping a trailing major-version suffix. // Two version conventions are handled: the path-segment style (e.g. "github.com/foo/bar/v2" -> -// "bar") and the dotted gopkg.in style (e.g. "gopkg.in/yaml.v3" -> "yaml"). Leading "go-" and -// trailing "-go" repository-naming conventions are also stripped (e.g. "github.com/redis/go-redis/v9" -// -> "redis", "github.com/CycloneDX/cyclonedx-go" -> "cyclonedx"), since Go identifiers can't -// contain hyphens, so a hyphenated segment is never the real package name. +// "bar") and the dotted gopkg.in style (e.g. "gopkg.in/yaml.v3" -> "yaml"). The path-segment style +// only strips "v2" and above: Go's semantic-import-versioning convention never produces a "v0" or +// "v1" module-major-version suffix, so a trailing "v0"/"v1" segment (e.g. "k8s.io/api/core/v1") is +// always a real package name, not a version marker. Leading "go-" and trailing "-go" +// repository-naming conventions are also stripped (e.g. "github.com/redis/go-redis/v9" -> "redis", +// "github.com/CycloneDX/cyclonedx-go" -> "cyclonedx"), since Go identifiers can't contain hyphens, +// so a hyphenated segment is never the real package name. func defaultIdentifierForModulePath(modulePath string) string { segments := strings.Split(modulePath, "/") identifier := segments[len(segments)-1] diff --git a/pkg/reachability/codefile/golang_test.go b/pkg/reachability/codefile/golang_test.go index dd71d160..0d898a92 100644 --- a/pkg/reachability/codefile/golang_test.go +++ b/pkg/reachability/codefile/golang_test.go @@ -139,6 +139,8 @@ func Test_defaultIdentifierForModulePath(t *testing.T) { {modulePath: "github.com/foo/bar", expected: "bar"}, {modulePath: "github.com/foo/bar/v2", expected: "bar"}, {modulePath: "github.com/foo/bar/v10", expected: "bar"}, + {modulePath: "k8s.io/api/core/v1", expected: "v1"}, + {modulePath: "github.com/foo/bar/v0", expected: "v0"}, {modulePath: "rsc.io/quote", expected: "quote"}, {modulePath: "singlesegment", expected: "singlesegment"}, {modulePath: "gopkg.in/yaml.v3", expected: "yaml"},