From fb1c53038d22b7fa47f73d3609673dd1091bad78 Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Wed, 16 Sep 2026 12:16:47 +0530 Subject: [PATCH 1/2] XRAY-160044 - Skip for local Go 'replace' modules --- commands/curation/curationaudit.go | 7 ++ commands/curation/curationaudit_test.go | 15 ++++- sca/bom/buildinfo/technologies/go/golang.go | 63 ++++++++++++++++-- .../buildinfo/technologies/go/golang_test.go | 65 +++++++++++++++++++ .../go/curation-project/go.mod | 4 ++ .../go/curation-project/hello.go | 2 + .../go/curation-project/localmod/go.mod | 3 + .../go/curation-project/localmod/localmod.go | 5 ++ .../go/go-local-replace-project/go.mod.txt | 10 +++ 9 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod create mode 100644 tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go create mode 100644 tests/testdata/projects/package-managers/go/go-local-replace-project/go.mod.txt diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index d12fcc5b3..770546163 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -39,6 +39,7 @@ import ( "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/docker" + _go "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/go" npmtech "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/npm" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/python" "github.com/jfrog/jfrog-cli-security/utils" @@ -1147,11 +1148,17 @@ func getNugetNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []strin // output: downloadUrl: /api/go/go/github.com/kennygrant/sanitize/@v/v1.2.4.zip func getGoNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []string, name, scope, version string) { id = strings.TrimPrefix(id, techutils.Go.String()+"://") + // A module satisfied by a filesystem 'replace' directive was never published - skip probing it. + isLocalReplace := strings.HasSuffix(id, _go.LocalReplaceMarker) + id = strings.TrimSuffix(id, _go.LocalReplaceMarker) nameVersion := strings.Split(id, ":") name = nameVersion[0] if len(nameVersion) > 1 { version = nameVersion[1] } + if isLocalReplace { + return nil, name, "", version + } url := strings.TrimSuffix(artiUrl, "/") + "/api/go/" + repo + "/" + name + "/@v/" + version + ".zip" return []string{url}, name, "", version } diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index b50d1a4fb..a564eb964 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -16,6 +16,7 @@ import ( "sync" "testing" + _go "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/go" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/java" "github.com/jfrog/jfrog-cli-security/utils/formats" @@ -628,7 +629,8 @@ func getTestCasesForDoCurationAudit() []testCase { "v0.0.0-20170915032832-14c0d48ead0c.info": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.info"), }, requestToFail: map[string]bool{ - "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip": false, + "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip": false, + "/api/go/go-virtual/example.com/localmod/@v/v0.0.0.zip": false, }, expectedResp: map[string]*CurationReport{ "github.com/you/hello": {packagesStatus: []*PackageStatus{ @@ -667,7 +669,7 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, - totalNumberOfPackages: 3, + totalNumberOfPackages: 4, }, }, }, @@ -1121,6 +1123,15 @@ func Test_getGoNameScopeAndVersion(t *testing.T) { compName: "github.com/kennygrant/sanitize", version: "v1.2.4", }, + { + name: "local replace go component id is skipped", + compId: "go://github.com/example/localmod:v0.0.0" + _go.LocalReplaceMarker, + rtUrl: "http://test/artifactory", + repo: "test", + downloadUrls: nil, + compName: "github.com/example/localmod", + version: "v0.0.0", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/sca/bom/buildinfo/technologies/go/golang.go b/sca/bom/buildinfo/technologies/go/golang.go index 59cbe9431..fc769a82e 100644 --- a/sca/bom/buildinfo/technologies/go/golang.go +++ b/sca/bom/buildinfo/technologies/go/golang.go @@ -2,6 +2,8 @@ package _go import ( "errors" + "os" + "path/filepath" "strings" biutils "github.com/jfrog/build-info-go/utils" @@ -14,11 +16,15 @@ import ( "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/log" xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" + "golang.org/x/mod/modfile" ) const ( goPackageTypeIdentifier = "go://" goSourceCodePrefix = "github.com/golang/go:v" + // LocalReplaceMarker tags a tree node whose module is satisfied by a local 'replace' directive. + // It's never published, so never probe it. + LocalReplaceMarker = ":local-replace" ) func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (dependencyTree []*xrayUtils.GraphNode, uniqueDeps []string, err error) { @@ -65,13 +71,21 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen if err != nil { return } + + // Modules satisfied by a local filesystem 'replace' directive were never published and must not be + // probed against Artifactory during curation-audit. + var localReplaceModules map[string]bool + if params.IsCurationCmd { + localReplaceModules = getLocalReplaceModules(currentDir) + } + // Parse the dependencies into Xray dependency tree format rootNode := &xrayUtils.GraphNode{ Id: goPackageTypeIdentifier + rootModuleName, Nodes: []*xrayUtils.GraphNode{}, } uniqueDepsSet := datastructures.MakeSet[string]() - populateGoDependencyTree(rootNode, dependenciesGraph, dependenciesList, uniqueDepsSet) + populateGoDependencyTree(rootNode, dependenciesGraph, dependenciesList, uniqueDepsSet, localReplaceModules) // In case of curation command, go version is not relevant as it can't be resolved from go repo if !params.IsCurationCmd { @@ -106,26 +120,65 @@ func handleCurationGoError(err error) (bool, error) { return false, nil } -func populateGoDependencyTree(currNode *xrayUtils.GraphNode, dependenciesGraph map[string][]string, dependenciesList map[string]bool, uniqueDepsSet *datastructures.Set[string]) { +func populateGoDependencyTree(currNode *xrayUtils.GraphNode, dependenciesGraph map[string][]string, dependenciesList map[string]bool, uniqueDepsSet *datastructures.Set[string], localReplaceModules map[string]bool) { if currNode.NodeHasLoop() { return } uniqueDepsSet.Add(currNode.Id) - currDepChildren := dependenciesGraph[strings.TrimPrefix(currNode.Id, goPackageTypeIdentifier)] + // Strip our marker before the graph lookup, so a local module's own (real) dependencies are still found. + graphKey := strings.TrimSuffix(strings.TrimPrefix(currNode.Id, goPackageTypeIdentifier), LocalReplaceMarker) + currDepChildren := dependenciesGraph[graphKey] // Recursively create & append all node's dependencies. for _, childName := range currDepChildren { if !dependenciesList[childName] { // 'go list all' is more accurate than 'go graph' so we filter out deps that don't exist in go list continue } + childId := goPackageTypeIdentifier + childName + if isLocalReplaceModule(childName, localReplaceModules) { + childId += LocalReplaceMarker + } childNode := &xrayUtils.GraphNode{ - Id: goPackageTypeIdentifier + childName, + Id: childId, Nodes: []*xrayUtils.GraphNode{}, Parent: currNode, } currNode.Nodes = append(currNode.Nodes, childNode) - populateGoDependencyTree(childNode, dependenciesGraph, dependenciesList, uniqueDepsSet) + populateGoDependencyTree(childNode, dependenciesGraph, dependenciesList, uniqueDepsSet, localReplaceModules) + } +} + +// isLocalReplaceModule reports whether childName (":") is in localReplaceModules. +func isLocalReplaceModule(childName string, localReplaceModules map[string]bool) bool { + modulePath, _, found := strings.Cut(childName, ":") + if !found { + modulePath = childName + } + return localReplaceModules[modulePath] +} + +// getLocalReplaceModules returns module paths that go.mod at projectDir replaces with a local directory. +// Module-to-module replaces are excluded - those resolve to a real, published module and must still be probed. +// Fails open (empty set + warning) on any read/parse error, so a broken go.mod only regresses to today's behavior instead of aborting the audit. +func getLocalReplaceModules(projectDir string) map[string]bool { + localReplaceModules := map[string]bool{} + goModPath := filepath.Join(projectDir, "go.mod") + data, err := os.ReadFile(goModPath) + if err != nil { + log.Warn("curation-audit: failed reading go.mod to detect local 'replace' directives, local modules will not be skipped: " + err.Error()) + return localReplaceModules + } + modFile, err := modfile.Parse(goModPath, data, nil) + if err != nil { + log.Warn("curation-audit: failed parsing go.mod to detect local 'replace' directives, local modules will not be skipped: " + err.Error()) + return localReplaceModules + } + for _, r := range modFile.Replace { + if modfile.IsDirectoryPath(r.New.Path) { + localReplaceModules[r.Old.Path] = true + } } + return localReplaceModules } func getGoVersionAsDependency() (*xrayUtils.GraphNode, error) { diff --git a/sca/bom/buildinfo/technologies/go/golang_test.go b/sca/bom/buildinfo/technologies/go/golang_test.go index d4dbc0dd5..f4d58cbc1 100644 --- a/sca/bom/buildinfo/technologies/go/golang_test.go +++ b/sca/bom/buildinfo/technologies/go/golang_test.go @@ -12,10 +12,12 @@ import ( "github.com/jfrog/jfrog-cli-security/utils/techutils" "github.com/jfrog/build-info-go/utils" + "github.com/jfrog/gofrog/datastructures" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" "github.com/stretchr/testify/assert" ) @@ -81,6 +83,69 @@ func removeTxtSuffix(txtFileName string) error { return fileutils.MoveFile(txtFileName, strings.TrimSuffix(txtFileName, ".txt")) } +// TestGetLocalReplaceModules: go.mod replaces example.com/localmod with a local directory. +// getLocalReplaceModules must report that module path as local. +func TestGetLocalReplaceModules(t *testing.T) { + _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "go", "go-local-replace-project")) + defer cleanUp() + + assert.NoError(t, removeTxtSuffix("go.mod.txt")) + + currentDir, err := os.Getwd() + assert.NoError(t, err) + localReplaceModules := getLocalReplaceModules(currentDir) + assert.Equal(t, map[string]bool{"example.com/localmod": true}, localReplaceModules) +} + +// TestPopulateGoDependencyTree_LocalReplace: given a graph/list shaped like real 'go mod graph'/'go list' +// output for a local-replaced module, the tree must tag that module but leave its real dependency and an +// unrelated real dependency untouched. +func TestPopulateGoDependencyTree_LocalReplace(t *testing.T) { + dependenciesGraph := map[string][]string{ + "testGoLocalReplace": { + "example.com/localmod:v0.0.0", + "rsc.io/quote:v1.5.2", + }, + "example.com/localmod:v0.0.0": { + "golang.org/x/text:v0.3.3", + }, + "rsc.io/quote:v1.5.2": { + "rsc.io/sampler:v1.3.0", + }, + } + dependenciesList := map[string]bool{ + "example.com/localmod:v0.0.0": true, + "golang.org/x/text:v0.3.3": true, + "rsc.io/quote:v1.5.2": true, + "rsc.io/sampler:v1.3.0": true, + } + localReplaceModules := map[string]bool{"example.com/localmod": true} + + rootNode := &xrayUtils.GraphNode{Id: goPackageTypeIdentifier + "testGoLocalReplace", Nodes: []*xrayUtils.GraphNode{}} + uniqueDepsSet := datastructures.MakeSet[string]() + populateGoDependencyTree(rootNode, dependenciesGraph, dependenciesList, uniqueDepsSet, localReplaceModules) + + expectedUniqueDeps := []string{ + goPackageTypeIdentifier + "testGoLocalReplace", + goPackageTypeIdentifier + "example.com/localmod:v0.0.0" + LocalReplaceMarker, + goPackageTypeIdentifier + "golang.org/x/text:v0.3.3", + goPackageTypeIdentifier + "rsc.io/quote:v1.5.2", + goPackageTypeIdentifier + "rsc.io/sampler:v1.3.0", + } + assert.ElementsMatch(t, uniqueDepsSet.ToSlice(), expectedUniqueDeps, "First is actual, Second is Expected") + + // The locally-replaced module is tagged with the marker... + localReplaceNode := tests.GetAndAssertNode(t, rootNode.Nodes, "example.com/localmod:v0.0.0"+LocalReplaceMarker) + // ...but its own real, published dependency is still present, walked, and NOT marked. + assert.Len(t, localReplaceNode.Nodes, 1) + tests.GetAndAssertNode(t, localReplaceNode.Nodes, "golang.org/x/text:v0.3.3") + + // An unrelated real dependency (and its own transitive dependency) is completely untouched. + realDep := tests.GetAndAssertNode(t, rootNode.Nodes, "rsc.io/quote:v1.5.2") + assert.Len(t, realDep.Nodes, 1) + tests.GetAndAssertNode(t, realDep.Nodes, "rsc.io/sampler:v1.3.0") +} + func Test_handleCurationGoError(t *testing.T) { tests := []struct { diff --git a/tests/testdata/projects/package-managers/go/curation-project/go.mod b/tests/testdata/projects/package-managers/go/curation-project/go.mod index 309e9d797..dc7e90e8a 100644 --- a/tests/testdata/projects/package-managers/go/curation-project/go.mod +++ b/tests/testdata/projects/package-managers/go/curation-project/go.mod @@ -7,3 +7,7 @@ require rsc.io/quote v1.5.2 require ( rsc.io/sampler v1.3.0 // indirect ) + +require example.com/localmod v0.0.0 + +replace example.com/localmod => ./localmod diff --git a/tests/testdata/projects/package-managers/go/curation-project/hello.go b/tests/testdata/projects/package-managers/go/curation-project/hello.go index 0a29866c4..4c374733e 100644 --- a/tests/testdata/projects/package-managers/go/curation-project/hello.go +++ b/tests/testdata/projects/package-managers/go/curation-project/hello.go @@ -3,9 +3,11 @@ package main import ( "fmt" + "example.com/localmod" "rsc.io/quote" ) func main() { fmt.Println(quote.Hello()) + fmt.Println(localmod.Hello()) } diff --git a/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod b/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod new file mode 100644 index 000000000..07f6d8a3b --- /dev/null +++ b/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod @@ -0,0 +1,3 @@ +module example.com/localmod + +go 1.20 diff --git a/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go b/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go new file mode 100644 index 000000000..ce570f2be --- /dev/null +++ b/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go @@ -0,0 +1,5 @@ +package localmod + +func Hello() string { + return "local" +} diff --git a/tests/testdata/projects/package-managers/go/go-local-replace-project/go.mod.txt b/tests/testdata/projects/package-managers/go/go-local-replace-project/go.mod.txt new file mode 100644 index 000000000..fd4d88e36 --- /dev/null +++ b/tests/testdata/projects/package-managers/go/go-local-replace-project/go.mod.txt @@ -0,0 +1,10 @@ +module testGoLocalReplace + +go 1.16 + +require ( + example.com/localmod v0.0.0 + rsc.io/quote v1.5.2 +) + +replace example.com/localmod => ./localmod From 1f40533706d1a86cf7783a159da963bf1e7f049c Mon Sep 17 00:00:00 2001 From: Phavya Jayakumar Date: Thu, 17 Sep 2026 09:21:40 +0530 Subject: [PATCH 2/2] Review changes --- commands/curation/curationaudit_test.go | 19 +++++++++++++++++++ go.mod | 2 +- .../go/curation-project/go.mod | 1 + .../go/curation-project/localmod/go.mod | 2 ++ .../go/curation-project/localmod/localmod.go | 4 +++- 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index c0da139c4..c73d91400 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -696,12 +696,31 @@ func getTestCasesForDoCurationAudit() []testCase { "v0.0.0-20170915032832-14c0d48ead0c.zip": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.zip"), "v0.0.0-20170915032832-14c0d48ead0c.info": filepath.Join("resources", "text-v0.0.0-20170915032832-14c0d48ead0c.info"), }, + // example.com/localmod is a tripwire, not an expected call: it's local-replaced and must never + // be probed. If a regression ever probes it, this mock 403s it, breaking expectedResp below. requestToFail: map[string]bool{ "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip": false, "/api/go/go-virtual/example.com/localmod/@v/v0.0.0.zip": false, }, expectedResp: map[string]*CurationReport{ "github.com/you/hello": {packagesStatus: []*PackageStatus{ + { + Action: "blocked", + ParentName: "example.com/localmod", + ParentVersion: "v0.0.0", + BlockedPackageUrl: "/api/go/go-virtual/rsc.io/sampler/@v/v1.3.0.zip", + PackageName: "rsc.io/sampler", + PackageVersion: "v1.3.0", + BlockingReason: "Policy violations", + DepRelation: "indirect", + PkgType: "go", + Policy: []Policy{ + { + Policy: "pol1", + Condition: "cond1", + }, + }, + }, { Action: "blocked", ParentName: "rsc.io/quote", diff --git a/go.mod b/go.mod index 19de20fdb..2a9d68053 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/urfave/cli v1.22.17 github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 + golang.org/x/mod v0.37.0 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 golang.org/x/text v0.40.0 @@ -147,7 +148,6 @@ require ( go.opentelemetry.io/otel/trace v1.42.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.52.0 // indirect - golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/term v0.45.0 // indirect diff --git a/tests/testdata/projects/package-managers/go/curation-project/go.mod b/tests/testdata/projects/package-managers/go/curation-project/go.mod index dc7e90e8a..a3e7620e0 100644 --- a/tests/testdata/projects/package-managers/go/curation-project/go.mod +++ b/tests/testdata/projects/package-managers/go/curation-project/go.mod @@ -5,6 +5,7 @@ go 1.20 require rsc.io/quote v1.5.2 require ( + golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect rsc.io/sampler v1.3.0 // indirect ) diff --git a/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod b/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod index 07f6d8a3b..f8cdfbc40 100644 --- a/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod +++ b/tests/testdata/projects/package-managers/go/curation-project/localmod/go.mod @@ -1,3 +1,5 @@ module example.com/localmod go 1.20 + +require rsc.io/sampler v1.3.0 diff --git a/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go b/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go index ce570f2be..321e49970 100644 --- a/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go +++ b/tests/testdata/projects/package-managers/go/curation-project/localmod/localmod.go @@ -1,5 +1,7 @@ package localmod +import "rsc.io/sampler" + func Hello() string { - return "local" + return sampler.Hello() }