From e7ab6f37f39102d44289a8fbe3cf2b0a65df4ff2 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Wed, 10 Dec 2025 14:17:06 +0200 Subject: [PATCH 01/21] Add Docker curation support --- cli/docs/flags.go | 8 +- cli/scancommands.go | 1 + commands/audit/auditbasicparams.go | 12 ++ commands/audit/auditparams.go | 2 + commands/curation/curationaudit.go | 72 ++++++- commands/curation/curationaudit_test.go | 140 ++++++++++++- sca/bom/buildinfo/buildinfobom.go | 3 + sca/bom/buildinfo/technologies/common.go | 2 + .../buildinfo/technologies/docker/docker.go | 185 ++++++++++++++++++ .../technologies/docker/docker_test.go | 159 +++++++++++++++ 10 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 sca/bom/buildinfo/technologies/docker/docker.go create mode 100644 sca/bom/buildinfo/technologies/docker/docker_test.go diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 58da8569d..5e97eee8a 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -149,7 +149,8 @@ const ( AnalyzerManagerCustomPath = "analyzer-manager-path" // Unique curation flags - CurationOutput = "curation-format" + CurationOutput = "curation-format" + DockerImageName = "image" SolutionPath = "solution-path" // Unique git flags @@ -206,7 +207,7 @@ var commandFlags = map[string][]string{ StaticSca, XrayLibPluginBinaryCustomPath, AnalyzerManagerCustomPath, AddSastRules, }, CurationAudit: { - CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, SolutionPath, + CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, SolutionPath,DockerImageName, }, GitCountContributors: { InputFile, ScmType, ScmApiUrl, Token, Owner, RepoName, Months, DetailedSummary, InsecureTls, @@ -330,6 +331,9 @@ var flagsMap = map[string]components.Flag{ AddSastRules: components.NewStringFlag(AddSastRules, "Incorporate any additional SAST rules (in JSON format, with absolute path) into this local scan."), + // Docker flags + DockerImageName: components.NewStringFlag(DockerImageName, "[Docker] Defines the Docker image name to audit. Format: 'repo/path/image:tag'. For example: 'curation-docker/dweomer/nginx-auth-ldap:1.13.5' or 'repo/image:tag'. If no tag is provided, 'latest' is used."), + // Git flags InputFile: components.NewStringFlag(InputFile, "Path to an input file in YAML format contains multiple git providers. With this option, all other scm flags will be ignored and only git servers mentioned in the file will be examined.."), ScmType: components.NewStringFlag(ScmType, fmt.Sprintf("SCM type. Possible values are: %s.", contributors.NewScmType().GetValidScmTypeString()), components.SetMandatory()), diff --git a/cli/scancommands.go b/cli/scancommands.go index 0e134abdb..9464be020 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -648,6 +648,7 @@ func getCurationCommand(c *components.Context) (*curation.CurationAuditCommand, SetInsecureTls(c.GetBoolFlagValue(flags.InsecureTls)). SetNpmScope(c.GetStringFlagValue(flags.DepType)). SetPipRequirementsFile(c.GetStringFlagValue(flags.RequirementsFile)). + SetDockerImageName(c.GetStringFlagValue(flags.DockerImageName)). SetSolutionFilePath(c.GetStringFlagValue(flags.SolutionPath)) return curationAuditCommand, nil } diff --git a/commands/audit/auditbasicparams.go b/commands/audit/auditbasicparams.go index 7ea2c0722..bff1a24b0 100644 --- a/commands/audit/auditbasicparams.go +++ b/commands/audit/auditbasicparams.go @@ -49,6 +49,8 @@ type AuditParamsInterface interface { AllowPartialResults() bool GetXrayVersion() string GetConfigProfile() *xscservices.ConfigProfile + DockerImageName() string + SetDockerImageName(dockerImageName string) *AuditBasicParams SolutionFilePath() string SetSolutionFilePath(solutionFilePath string) *AuditBasicParams } @@ -81,6 +83,7 @@ type AuditBasicParams struct { xrayVersion string xscVersion string configProfile *xscservices.ConfigProfile + dockerImageName string solutionFilePath string } @@ -334,6 +337,15 @@ func (abp *AuditBasicParams) GetConfigProfile() *xscservices.ConfigProfile { return abp.configProfile } +func (abp *AuditBasicParams) DockerImageName() string { + return abp.dockerImageName +} + +func (abp *AuditBasicParams) SetDockerImageName(dockerImageName string) *AuditBasicParams { + abp.dockerImageName = dockerImageName + return abp +} + func (abp *AuditBasicParams) SolutionFilePath() string { return abp.solutionFilePath } diff --git a/commands/audit/auditparams.go b/commands/audit/auditparams.go index cc0d28c8d..85fd326ed 100644 --- a/commands/audit/auditparams.go +++ b/commands/audit/auditparams.go @@ -232,6 +232,8 @@ func (params *AuditParams) ToBuildInfoBomGenParams() (bomParams technologies.Bui PipRequirementsFile: params.PipRequirementsFile(), // Pnpm params MaxTreeDepth: params.MaxTreeDepth(), + // Docker params + DockerImageName: params.DockerImageName(), } return } diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index bc13212fa..20b8c792d 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -37,6 +37,7 @@ import ( "github.com/jfrog/jfrog-cli-security/commands/audit" "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" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/python" "github.com/jfrog/jfrog-cli-security/utils" "github.com/jfrog/jfrog-cli-security/utils/formats" @@ -102,6 +103,9 @@ var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (boo techutils.Gem: func(ca *CurationAuditCommand) (bool, error) { return ca.checkSupportByVersionOrEnv(techutils.Gem, MinArtiGradleGemSupport) }, + techutils.Docker: func(ca *CurationAuditCommand) (bool, error) { + return ca.checkDockerSupport() + }, } func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Technology, minArtiVersion string) (bool, error) { @@ -128,6 +132,17 @@ func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Techno return true, nil } +func (ca *CurationAuditCommand) checkDockerSupport() (bool, error) { + dockerImageName := ca.DockerImageName() + if dockerImageName == "" { + return false, nil + } + if !strings.Contains(dockerImageName, "/") { + return false, errorutils.CheckErrorf("invalid docker image format: '%s'. Expected format: 'repo/image:tag' or 'repo/path/image:tag'", dockerImageName) + } + return true, nil +} + func (ca *CurationAuditCommand) getRtVersion(tech techutils.Technology) (string, error) { rtManager, _, err := ca.getRtManagerAndAuth(tech) if err != nil { @@ -350,6 +365,9 @@ func getPolicyAndConditionId(policy, condition string) string { func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error { techs := techutils.DetectedTechnologiesList() + if ca.DockerImageName() != "" { + techs = []string{techutils.Docker.String()} + } for _, tech := range techs { supportedFunc, ok := supportedTech[techutils.Technology(tech)] if !ok { @@ -390,8 +408,20 @@ func (ca *CurationAuditCommand) getRtManagerAndAuth(tech techutils.Technology) ( func (ca *CurationAuditCommand) GetAuth(tech techutils.Technology) (serverDetails *config.ServerDetails, err error) { if ca.PackageManagerConfig == nil { - if err = ca.SetRepo(tech); err != nil { - return + if tech == techutils.Docker { + serverDetails, err = ca.ServerDetails() + if err != nil { + return + } + repoConfig, err := docker.GetDockerRepositoryConfig(serverDetails, ca.DockerImageName()) + if err != nil { + return nil, err + } + ca.setPackageManagerConfig(repoConfig) + } else { + if err = ca.SetRepo(tech); err != nil { + return + } } } serverDetails, err = ca.PackageManagerConfig.ServerDetails() @@ -426,6 +456,8 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn NpmOverwritePackageLock: true, // Python params PipRequirementsFile: ca.PipRequirementsFile(), + // Docker params + DockerImageName: ca.DockerImageName(), // NuGet params SolutionFilePath: ca.SolutionFilePath(), }, err @@ -950,6 +982,8 @@ func getUrlNameAndVersionByTech(tech techutils.Technology, node *xrayUtils.Graph case techutils.Nuget: downloadUrls, name, version = getNugetNameScopeAndVersion(node.Id, artiUrl, repo) return + case techutils.Docker: + return getDockerNameScopeAndVersion(node.Id, artiUrl, repo) } return } @@ -1134,6 +1168,40 @@ func buildNpmDownloadUrl(url, repo, name, scope, version string) []string { return []string{packageUrl} } +func getDockerNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []string, name, scope, version string) { + if id == "" { + return + } + + id = strings.TrimPrefix(id, "docker://") + var lastColonIndex int + if strings.Contains(id, ":sha256:") { + sha256Index := strings.Index(id, ":sha256:") + if sha256Index > 0 { + lastColonIndex = sha256Index + } else { + lastColonIndex = strings.LastIndex(id, ":") + } + } else { + lastColonIndex = strings.LastIndex(id, ":") + } + + if lastColonIndex > 0 { + name = id[:lastColonIndex] + version = id[lastColonIndex+1:] + } else { + name = id + version = "latest" + } + + if artiUrl != "" && repo != "" { + downloadUrls = []string{fmt.Sprintf("%s/api/docker/%s/v2/%s/manifests/%s", + strings.TrimSuffix(artiUrl, "/"), repo, name, version)} + } + + return +} + func GetCurationOutputFormat(formatFlagVal string) (format outFormat.OutputFormat, err error) { // Default print format is table. format = outFormat.Table diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 2c27c7884..f7c0fae4d 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -427,7 +427,7 @@ func TestDoCurationAudit(t *testing.T) { cleanUp := createCurationTestEnv(t, basePathToTests, tt, config) defer cleanUp() // Create audit command, and run it - results, err := createCurationCmdAndRun(tt) + results, err := createCurationCmdAndRun(tt, config) // Validate the results if tt.requestToError == nil { assert.NoError(t, err) @@ -505,7 +505,7 @@ func runPreTestExec(t *testing.T, basePathToTests string, testCase testCase) { callbackPreTest() } -func createCurationCmdAndRun(tt testCase) (cmdResults map[string]*CurationReport, err error) { +func createCurationCmdAndRun(tt testCase, serverDetails *config.ServerDetails) (cmdResults map[string]*CurationReport, err error) { curationCmd := NewCurationAuditCommand() curationCmd.SetIsCurationCmd(true) curationCmd.parallelRequests = 3 @@ -513,6 +513,11 @@ func createCurationCmdAndRun(tt testCase) (cmdResults map[string]*CurationReport curationCmd.SetInsecureTls(true) curationCmd.SetIgnoreConfigFile(tt.shouldIgnoreConfigFile) curationCmd.SetInsecureTls(tt.allowInsecureTls) + if tt.dockerImageName != "" { + curationCmd.SetDockerImageName(tt.dockerImageName) + // Docker requires server details to be set explicitly + curationCmd.SetServerDetails(serverDetails) + } cmdResults = map[string]*CurationReport{} err = curationCmd.doCurateAudit(cmdResults) return @@ -568,6 +573,7 @@ type testCase struct { tech techutils.Technology createServerWithoutCreds bool allowInsecureTls bool + dockerImageName string } func (tc testCase) getPathToTests() string { @@ -985,6 +991,43 @@ func getTestCasesForDoCurationAudit() []testCase { }, allowInsecureTls: true, }, + { + name: "docker tree - one blocked package", + tech: techutils.Docker, + pathToProject: filepath.Join("projects", "package-managers", "docker", "curation-project"), + dockerImageName: "repo-test-docker/dweomer/nginx-auth-ldap:1.13.5-on-alpine-3.5", + requestToFail: map[string]bool{ + "/api/docker/repo-test-docker/v2/dweomer/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": true, + }, + expectedRequest: map[string]bool{ + "/api/docker/repo-test-docker/v2/dweomer/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": false, + }, + expectedResp: map[string]*CurationReport{ + "root:latest": { + packagesStatus: []*PackageStatus{ + { + Action: "blocked", + ParentName: "dweomer/nginx-auth-ldap", + ParentVersion: "1.13.5-on-alpine-3.5", + BlockedPackageUrl: "/api/docker/repo-test-docker/v2/dweomer/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5", + PackageName: "dweomer/nginx-auth-ldap", + PackageVersion: "1.13.5-on-alpine-3.5", + DepRelation: "direct", + PkgType: "docker", + BlockingReason: "Policy violations", + Policy: []Policy{ + { + Policy: "pol1", + Condition: "cond1", + }, + }, + }, + }, + totalNumberOfPackages: 0, + }, + }, + allowInsecureTls: true, + }, } return tests } @@ -1187,6 +1230,99 @@ func Test_getGemNameScopeAndVersion(t *testing.T) { } } +func Test_getDockerNameScopeAndVersion(t *testing.T) { + tests := []struct { + name string + id string + artiUrl string + repo string + wantDownloadUrls []string + wantName string + wantScope string + wantVersion string + }{ + { + name: "Basic docker image with tag", + id: "docker://nginx:1.21.0", + artiUrl: "http://test.jfrog.io/artifactory", + repo: "docker-remote", + wantDownloadUrls: []string{"http://test.jfrog.io/artifactory/api/docker/docker-remote/v2/nginx/manifests/1.21.0"}, + wantName: "nginx", + wantScope: "", + wantVersion: "1.21.0", + }, + { + name: "Docker image with registry prefix", + id: "docker://registry.example.com/nginx:1.21.0", + artiUrl: "http://test.jfrog.io/artifactory", + repo: "docker-remote", + wantDownloadUrls: []string{"http://test.jfrog.io/artifactory/api/docker/docker-remote/v2/registry.example.com/nginx/manifests/1.21.0"}, + wantName: "registry.example.com/nginx", + wantScope: "", + wantVersion: "1.21.0", + }, + { + name: "Docker image with sha256 digest", + id: "docker://nginx:sha256:abc123def456", + artiUrl: "http://test.jfrog.io/artifactory", + repo: "docker-remote", + wantDownloadUrls: []string{"http://test.jfrog.io/artifactory/api/docker/docker-remote/v2/nginx/manifests/sha256:abc123def456"}, + wantName: "nginx", + wantScope: "", + wantVersion: "sha256:abc123def456", + }, + { + name: "Docker image without version defaults to latest", + id: "docker://nginx", + artiUrl: "http://test.jfrog.io/artifactory", + repo: "docker-remote", + wantDownloadUrls: []string{"http://test.jfrog.io/artifactory/api/docker/docker-remote/v2/nginx/manifests/latest"}, + wantName: "nginx", + wantScope: "", + wantVersion: "latest", + }, + { + name: "Empty id returns empty values", + id: "", + artiUrl: "http://test.jfrog.io/artifactory", + repo: "docker-remote", + wantDownloadUrls: nil, + wantName: "", + wantScope: "", + wantVersion: "", + }, + { + name: "Without artiUrl and repo, no download URL", + id: "docker://nginx:1.21.0", + artiUrl: "", + repo: "", + wantDownloadUrls: nil, + wantName: "nginx", + wantScope: "", + wantVersion: "1.21.0", + }, + { + name: "Artifactory URL with trailing slash", + id: "docker://nginx:1.21.0", + artiUrl: "http://test.jfrog.io/artifactory/", + repo: "docker-remote", + wantDownloadUrls: []string{"http://test.jfrog.io/artifactory/api/docker/docker-remote/v2/nginx/manifests/1.21.0"}, + wantName: "nginx", + wantScope: "", + wantVersion: "1.21.0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDownloadUrls, gotName, gotScope, gotVersion := getDockerNameScopeAndVersion(tt.id, tt.artiUrl, tt.repo) + assert.Equal(t, tt.wantDownloadUrls, gotDownloadUrls, "downloadUrls mismatch") + assert.Equal(t, tt.wantName, gotName, "name mismatch") + assert.Equal(t, tt.wantScope, gotScope, "scope mismatch") + assert.Equal(t, tt.wantVersion, gotVersion, "version mismatch") + }) + } +} + func Test_getNugetNameScopeAndVersion(t *testing.T) { tests := []struct { name string diff --git a/sca/bom/buildinfo/buildinfobom.go b/sca/bom/buildinfo/buildinfobom.go index fe767126c..a95627b68 100644 --- a/sca/bom/buildinfo/buildinfobom.go +++ b/sca/bom/buildinfo/buildinfobom.go @@ -29,6 +29,7 @@ import ( "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/cocoapods" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/conan" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/docker" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/gem" _go "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/go" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/java" @@ -225,6 +226,8 @@ func GetTechDependencyTree(params technologies.BuildInfoBomGeneratorParams, arti return depTreeResult, fmt.Errorf("your xray version %s does not allow swift scanning", params.XrayVersion) } depTreeResult.FullDepTrees, uniqueDepsIds, err = swift.BuildDependencyTree(params) + case techutils.Docker: + depTreeResult.FullDepTrees, uniqueDepsIds, err = docker.BuildDependencyTree(params) default: err = errorutils.CheckErrorf("%s is currently not supported", string(tech)) } diff --git a/sca/bom/buildinfo/technologies/common.go b/sca/bom/buildinfo/technologies/common.go index 18df8c650..10aa1c276 100644 --- a/sca/bom/buildinfo/technologies/common.go +++ b/sca/bom/buildinfo/technologies/common.go @@ -58,6 +58,8 @@ type BuildInfoBomGeneratorParams struct { NpmOverwritePackageLock bool // Pnpm params MaxTreeDepth string + // Docker params + DockerImageName string // NuGet params SolutionFilePath string } diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go new file mode 100644 index 000000000..dae4543ec --- /dev/null +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -0,0 +1,185 @@ +package docker + +import ( + "encoding/json" + "fmt" + "os/exec" + "regexp" + "runtime" + "strings" + + "github.com/jfrog/jfrog-cli-core/v2/common/project" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + "github.com/jfrog/jfrog-client-go/utils/log" + xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" +) + +const dockerPackagePrefix = "docker://" + +type DockerImageInfo struct { + Registry string + Repo string + Image string + Tag string +} + +type dockerManifestList struct { + Manifests []struct { + Digest string `json:"digest"` + Platform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` + } `json:"platform"` + } `json:"manifests"` +} + +var ( + jfrogSubdomainPattern = regexp.MustCompile(`^([a-zA-Z0-9]+)-([a-zA-Z0-9-]+)\.jfrog\.io$`) + ipAddressPattern = regexp.MustCompile(`^\d+\.`) +) + +func ParseDockerImage(imageName string) (*DockerImageInfo, error) { + imageName = strings.TrimSpace(imageName) + if imageName == "" { + return nil, fmt.Errorf("docker image name is required") + } + if idx := strings.Index(imageName, ","); idx > 0 { + imageName = strings.TrimSpace(imageName[:idx]) + } + imageName = strings.TrimSuffix(imageName, "/") + + info := &DockerImageInfo{Tag: "latest"} + + if idx := strings.LastIndex(imageName, ":"); idx > 0 { + afterColon := imageName[idx+1:] + if !strings.Contains(afterColon, "/") { + info.Tag = afterColon + imageName = imageName[:idx] + } + } + + parts := strings.Split(imageName, "/") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid docker image format: '%s'", imageName) + } + + info.Registry = parts[0] + info.Repo, info.Image = parseRegistryAndExtract(info.Registry, parts[1:]) + + if info.Image == "" { + return nil, fmt.Errorf("invalid docker image format: '%s'", imageName) + } + if info.Repo == "" { + return nil, fmt.Errorf("could not determine repository from: '%s'", imageName) + } + + log.Debug(fmt.Sprintf("Parsed Docker image - Registry: %s, Repo: %s, Image: %s, Tag: %s", + info.Registry, info.Repo, info.Image, info.Tag)) + + return info, nil +} + +func parseRegistryAndExtract(registry string, remainingParts []string) (repo, image string) { + image = strings.Join(remainingParts, "/") + + // SaaS subdomain: -.jfrog.io/image:tag (repo in subdomain, check first) + if matches := jfrogSubdomainPattern.FindStringSubmatch(registry); matches != nil { + repo = matches[2] + return + } + + // Subdomain pattern: ./image:tag (repo in subdomain, not IP, check first) + registryParts := strings.Split(registry, ".") + if len(registryParts) >= 3 && !strings.HasSuffix(registry, ".jfrog.io") && !ipAddressPattern.MatchString(registry) { + repo = registryParts[0] + return + } + + // Repository path: //image:tag (2+ parts means repo in path) + if len(remainingParts) >= 2 { + repo = remainingParts[0] + image = strings.Join(remainingParts[1:], "/") + return + } + + // Port method: :/image:tag (port IS the repo, single part only) + if strings.Contains(registry, ":") { + _, repo, _ = strings.Cut(registry, ":") + return + } + + return "", "" +} + +func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ([]*xrayUtils.GraphNode, []string, error) { + if params.DockerImageName == "" { + return nil, nil, fmt.Errorf("docker image name is required") + } + + imageInfo, err := ParseDockerImage(params.DockerImageName) + if err != nil { + return nil, nil, err + } + + archDigest, err := getArchDigestUsingDocker(params.DockerImageName) + if err != nil { + return nil, nil, err + } + + imageRef := dockerPackagePrefix + imageInfo.Image + ":" + if archDigest != "" { + imageRef += archDigest + } else { + imageRef += imageInfo.Tag + } + + log.Debug(fmt.Sprintf("Docker image reference: %s", imageRef)) + + return []*xrayUtils.GraphNode{{Id: "root", Nodes: []*xrayUtils.GraphNode{{Id: imageRef}}}}, + []string{imageRef}, nil +} + +func getArchDigestUsingDocker(fullImageName string) (string, error) { + cmd := exec.Command("docker", "buildx", "imagetools", "inspect", "--raw", fullImageName) + output, err := cmd.CombinedOutput() + if err != nil { + outputStr := string(output) + if strings.Contains(outputStr, "403") || strings.Contains(outputStr, "Forbidden") { + return "", nil + } + return "", fmt.Errorf("%s", strings.TrimSpace(outputStr)) + } + + var manifestList dockerManifestList + if err := json.Unmarshal(output, &manifestList); err != nil { + return "", nil + } + + if len(manifestList.Manifests) == 0 { + return "", nil + } + + currentArch := runtime.GOARCH + for _, manifest := range manifestList.Manifests { + if manifest.Platform.Architecture == currentArch && manifest.Digest != "" { + return manifest.Digest, nil + } + } + + return "", nil +} + +func GetDockerRepositoryConfig(serverDetails *config.ServerDetails, imageName string) (*project.RepositoryConfig, error) { + imageInfo, err := ParseDockerImage(imageName) + if err != nil { + return nil, err + } + return GetDockerRepositoryConfigFromInfo(serverDetails, imageInfo) +} + +func GetDockerRepositoryConfigFromInfo(serverDetails *config.ServerDetails, imageInfo *DockerImageInfo) (*project.RepositoryConfig, error) { + repoConfig := &project.RepositoryConfig{} + repoConfig.SetServerDetails(serverDetails).SetTargetRepo(imageInfo.Repo) + return repoConfig, nil +} diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go new file mode 100644 index 000000000..0648b0291 --- /dev/null +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -0,0 +1,159 @@ +package docker + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDockerImage(t *testing.T) { + tests := []struct { + name string + imageName string + expectedRepo string + expectedImg string + expectedTag string + expectError bool + }{ + // SaaS: Repository path + { + name: "SaaS repository path", + imageName: "acme.jfrog.io/docker-local/nginx:1.21", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "SaaS repository path with nested image", + imageName: "acme.jfrog.io/docker-local/bitnami/kubectl:latest", + expectedRepo: "docker-local", + expectedImg: "bitnami/kubectl", + expectedTag: "latest", + }, + // SaaS: Subdomain + { + name: "SaaS subdomain format", + imageName: "acme-docker-local.jfrog.io/nginx:1.21", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "SaaS subdomain with nested image", + imageName: "acme-docker-remote.jfrog.io/bitnami/redis:7.0", + expectedRepo: "docker-remote", + expectedImg: "bitnami/redis", + expectedTag: "7.0", + }, + // Subdomain CNAME + { + name: "Subdomain CNAME format", + imageName: "docker-local.acme.com/nginx:alpine", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "alpine", + }, + // Self-Managed: Repository path + { + name: "Self-managed repository path", + imageName: "myartifactory.com/docker-local/redis:7.0", + expectedRepo: "docker-local", + expectedImg: "redis", + expectedTag: "7.0", + }, + // Self-Managed: Subdomain + { + name: "Self-managed subdomain", + imageName: "docker-virtual.myartifactory.com/alpine:3.18", + expectedRepo: "docker-virtual", + expectedImg: "alpine", + expectedTag: "3.18", + }, + // Port method (port IS the repo, no repo in path) + { + name: "Port method", + imageName: "myartifactory.com:8876/nginx:1.21", + expectedRepo: "8876", + expectedImg: "nginx", + expectedTag: "1.21", + }, + // Registry with port (repo in path) + { + name: "Localhost with port and repo", + imageName: "localhost:8046/docker-local/nginx:1.21", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "IP address with port and repo", + imageName: "192.168.50.230:8046/docker-local/nginx:1.21", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "IP address with port and nested image", + imageName: "192.168.50.230:8046/docker-local/bitnami/kubectl:latest", + expectedRepo: "docker-local", + expectedImg: "bitnami/kubectl", + expectedTag: "latest", + }, + // Default tag + { + name: "No tag defaults to latest", + imageName: "acme.jfrog.io/docker-local/nginx", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "latest", + }, + // Error cases + { + name: "Empty image name", + imageName: "", + expectError: true, + }, + { + name: "No registry", + imageName: "nginx:latest", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := ParseDockerImage(tt.imageName) + if tt.expectError { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expectedRepo, info.Repo) + assert.Equal(t, tt.expectedImg, info.Image) + assert.Equal(t, tt.expectedTag, info.Tag) + }) + } +} + +func TestBuildDependencyTree(t *testing.T) { + tests := []struct { + name string + dockerImageName string + expectError bool + }{ + {name: "Empty image name", dockerImageName: "", expectError: true}, + {name: "No registry", dockerImageName: "image:tag", expectError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := technologies.BuildInfoBomGeneratorParams{DockerImageName: tt.dockerImageName} + _, _, err := BuildDependencyTree(params) + if tt.expectError { + assert.Error(t, err) + } + }) + } +} From a7b6843c3f939276c8d661962a0e37c92f72634a Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Wed, 10 Dec 2025 23:20:52 +0200 Subject: [PATCH 02/21] Improved Docker curation to use docker pull and add Accept headers for manifest requests --- cli/docs/flags.go | 6 +-- commands/curation/curationaudit.go | 26 ++++------ .../buildinfo/technologies/docker/docker.go | 48 ++++++++----------- 3 files changed, 31 insertions(+), 49 deletions(-) diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 5e97eee8a..c91dda02f 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -151,7 +151,7 @@ const ( // Unique curation flags CurationOutput = "curation-format" DockerImageName = "image" - SolutionPath = "solution-path" + SolutionPath = "solution-path" // Unique git flags InputFile = "input-file" @@ -207,7 +207,7 @@ var commandFlags = map[string][]string{ StaticSca, XrayLibPluginBinaryCustomPath, AnalyzerManagerCustomPath, AddSastRules, }, CurationAudit: { - CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, SolutionPath,DockerImageName, + CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, SolutionPath, DockerImageName, }, GitCountContributors: { InputFile, ScmType, ScmApiUrl, Token, Owner, RepoName, Months, DetailedSummary, InsecureTls, @@ -332,7 +332,7 @@ var flagsMap = map[string]components.Flag{ AddSastRules: components.NewStringFlag(AddSastRules, "Incorporate any additional SAST rules (in JSON format, with absolute path) into this local scan."), // Docker flags - DockerImageName: components.NewStringFlag(DockerImageName, "[Docker] Defines the Docker image name to audit. Format: 'repo/path/image:tag'. For example: 'curation-docker/dweomer/nginx-auth-ldap:1.13.5' or 'repo/image:tag'. If no tag is provided, 'latest' is used."), + DockerImageName: components.NewStringFlag(DockerImageName, "[Docker] Defines the Docker image name to audit. Uses the same format as Docker client with Artifactory. Examples: 'acme.jfrog.io/docker-local/nginx:1.21' (repository path) or 'acme-docker-local.jfrog.io/nginx:1.21' (subdomain). Supports all Artifactory hosting methods."), // Git flags InputFile: components.NewStringFlag(InputFile, "Path to an input file in YAML format contains multiple git providers. With this option, all other scm flags will be ignored and only git servers mentioned in the file will be examined.."), diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 20b8c792d..03ca549d6 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -853,6 +853,9 @@ func (nc *treeAnalyzer) fetchNodeStatus(node xrayUtils.GraphNode, p *sync.Map) e if scope != "" { name = scope + "/" + name } + if nc.tech == techutils.Docker { + nc.httpClientDetails.Headers["Accept"] = "application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json" + } for _, packageUrl := range packageUrls { resp, _, err := nc.rtManager.Client().SendHead(packageUrl, &nc.httpClientDetails) if err != nil { @@ -1174,24 +1177,13 @@ func getDockerNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []stri } id = strings.TrimPrefix(id, "docker://") - var lastColonIndex int - if strings.Contains(id, ":sha256:") { - sha256Index := strings.Index(id, ":sha256:") - if sha256Index > 0 { - lastColonIndex = sha256Index - } else { - lastColonIndex = strings.LastIndex(id, ":") - } - } else { - lastColonIndex = strings.LastIndex(id, ":") - } - if lastColonIndex > 0 { - name = id[:lastColonIndex] - version = id[lastColonIndex+1:] + if idx := strings.Index(id, ":sha256:"); idx > 0 { + name, version = id[:idx], id[idx+1:] + } else if idx := strings.LastIndex(id, ":"); idx > 0 { + name, version = id[:idx], id[idx+1:] } else { - name = id - version = "latest" + name, version = id, "latest" } if artiUrl != "" && repo != "" { @@ -1199,7 +1191,7 @@ func getDockerNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []stri strings.TrimSuffix(artiUrl, "/"), repo, name, version)} } - return + return downloadUrls, name, scope, version } func GetCurationOutputFormat(formatFlagVal string) (format outFormat.OutputFormat, err error) { diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index dae4543ec..4978a1e8c 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -1,11 +1,9 @@ package docker import ( - "encoding/json" "fmt" "os/exec" "regexp" - "runtime" "strings" "github.com/jfrog/jfrog-cli-core/v2/common/project" @@ -24,19 +22,10 @@ type DockerImageInfo struct { Tag string } -type dockerManifestList struct { - Manifests []struct { - Digest string `json:"digest"` - Platform struct { - Architecture string `json:"architecture"` - OS string `json:"os"` - } `json:"platform"` - } `json:"manifests"` -} - var ( jfrogSubdomainPattern = regexp.MustCompile(`^([a-zA-Z0-9]+)-([a-zA-Z0-9-]+)\.jfrog\.io$`) ipAddressPattern = regexp.MustCompile(`^\d+\.`) + hexDigestPattern = regexp.MustCompile(`[a-fA-F0-9]{64}`) ) func ParseDockerImage(imageName string) (*DockerImageInfo, error) { @@ -141,33 +130,34 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ([]*xr } func getArchDigestUsingDocker(fullImageName string) (string, error) { - cmd := exec.Command("docker", "buildx", "imagetools", "inspect", "--raw", fullImageName) - output, err := cmd.CombinedOutput() + log.Debug(fmt.Sprintf("Pulling Docker image: %s", fullImageName)) + pullCmd := exec.Command("docker", "pull", fullImageName) + output, err := pullCmd.CombinedOutput() if err != nil { outputStr := string(output) - if strings.Contains(outputStr, "403") || strings.Contains(outputStr, "Forbidden") { - return "", nil + if strings.Contains(outputStr, "curation service") { + return extractDigestFromBlockedMessage(outputStr), nil } - return "", fmt.Errorf("%s", strings.TrimSpace(outputStr)) + return "", fmt.Errorf("docker pull failed: %s", strings.TrimSpace(outputStr)) } - var manifestList dockerManifestList - if err := json.Unmarshal(output, &manifestList); err != nil { + inspectCmd := exec.Command("docker", "inspect", fullImageName, "--format", "{{index .RepoDigests 0}}") + output, err = inspectCmd.CombinedOutput() + if err != nil { return "", nil } - - if len(manifestList.Manifests) == 0 { - return "", nil + repoDigest := strings.TrimSpace(string(output)) + if idx := strings.Index(repoDigest, "@"); idx > 0 { + return repoDigest[idx+1:], nil } + return "", nil +} - currentArch := runtime.GOARCH - for _, manifest := range manifestList.Manifests { - if manifest.Platform.Architecture == currentArch && manifest.Digest != "" { - return manifest.Digest, nil - } +func extractDigestFromBlockedMessage(output string) string { + if match := hexDigestPattern.FindString(output); match != "" { + return "sha256:" + match } - - return "", nil + return "" } func GetDockerRepositoryConfig(serverDetails *config.ServerDetails, imageName string) (*project.RepositoryConfig, error) { From a814b8d2f83bab60ef8b5f63fa227099fe218ecc Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Wed, 10 Dec 2025 23:52:04 +0200 Subject: [PATCH 03/21] Simplify Docker curation code --- commands/curation/curationaudit.go | 3 -- commands/curation/curationaudit_test.go | 30 ------------------- .../buildinfo/technologies/docker/docker.go | 16 ---------- .../technologies/docker/docker_test.go | 11 ------- 4 files changed, 60 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 03ca549d6..93625e7c1 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -137,9 +137,6 @@ func (ca *CurationAuditCommand) checkDockerSupport() (bool, error) { if dockerImageName == "" { return false, nil } - if !strings.Contains(dockerImageName, "/") { - return false, errorutils.CheckErrorf("invalid docker image format: '%s'. Expected format: 'repo/image:tag' or 'repo/path/image:tag'", dockerImageName) - } return true, nil } diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index f7c0fae4d..66639558c 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -1281,36 +1281,6 @@ func Test_getDockerNameScopeAndVersion(t *testing.T) { wantScope: "", wantVersion: "latest", }, - { - name: "Empty id returns empty values", - id: "", - artiUrl: "http://test.jfrog.io/artifactory", - repo: "docker-remote", - wantDownloadUrls: nil, - wantName: "", - wantScope: "", - wantVersion: "", - }, - { - name: "Without artiUrl and repo, no download URL", - id: "docker://nginx:1.21.0", - artiUrl: "", - repo: "", - wantDownloadUrls: nil, - wantName: "nginx", - wantScope: "", - wantVersion: "1.21.0", - }, - { - name: "Artifactory URL with trailing slash", - id: "docker://nginx:1.21.0", - artiUrl: "http://test.jfrog.io/artifactory/", - repo: "docker-remote", - wantDownloadUrls: []string{"http://test.jfrog.io/artifactory/api/docker/docker-remote/v2/nginx/manifests/1.21.0"}, - wantName: "nginx", - wantScope: "", - wantVersion: "1.21.0", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index 4978a1e8c..c3a415df6 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -30,16 +30,7 @@ var ( func ParseDockerImage(imageName string) (*DockerImageInfo, error) { imageName = strings.TrimSpace(imageName) - if imageName == "" { - return nil, fmt.Errorf("docker image name is required") - } - if idx := strings.Index(imageName, ","); idx > 0 { - imageName = strings.TrimSpace(imageName[:idx]) - } - imageName = strings.TrimSuffix(imageName, "/") - info := &DockerImageInfo{Tag: "latest"} - if idx := strings.LastIndex(imageName, ":"); idx > 0 { afterColon := imageName[idx+1:] if !strings.Contains(afterColon, "/") { @@ -56,13 +47,6 @@ func ParseDockerImage(imageName string) (*DockerImageInfo, error) { info.Registry = parts[0] info.Repo, info.Image = parseRegistryAndExtract(info.Registry, parts[1:]) - if info.Image == "" { - return nil, fmt.Errorf("invalid docker image format: '%s'", imageName) - } - if info.Repo == "" { - return nil, fmt.Errorf("could not determine repository from: '%s'", imageName) - } - log.Debug(fmt.Sprintf("Parsed Docker image - Registry: %s, Repo: %s, Image: %s, Tag: %s", info.Registry, info.Repo, info.Image, info.Tag)) diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go index 0648b0291..531b0cab1 100644 --- a/sca/bom/buildinfo/technologies/docker/docker_test.go +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -109,17 +109,6 @@ func TestParseDockerImage(t *testing.T) { expectedImg: "nginx", expectedTag: "latest", }, - // Error cases - { - name: "Empty image name", - imageName: "", - expectError: true, - }, - { - name: "No registry", - imageName: "nginx:latest", - expectError: true, - }, } for _, tt := range tests { From ac16c65383851fc066bd5c56fee6c03d2b7c9100 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Sun, 14 Dec 2025 13:28:48 +0200 Subject: [PATCH 04/21] Fixed unit test for Docker curation --- commands/curation/curationaudit_test.go | 65 +++++++++++++++---- .../buildinfo/technologies/docker/docker.go | 13 +--- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 66639558c..0cb556488 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -21,6 +21,7 @@ import ( biutils "github.com/jfrog/build-info-go/utils" "github.com/jfrog/gofrog/datastructures" + rtUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" coreCommonTests "github.com/jfrog/jfrog-cli-core/v2/common/tests" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" @@ -506,6 +507,9 @@ func runPreTestExec(t *testing.T, basePathToTests string, testCase testCase) { } func createCurationCmdAndRun(tt testCase, serverDetails *config.ServerDetails) (cmdResults map[string]*CurationReport, err error) { + if tt.tech == techutils.Docker && tt.mockDepTree != nil { + return runDockerCurationWithMockTree(tt, serverDetails) + } curationCmd := NewCurationAuditCommand() curationCmd.SetIsCurationCmd(true) curationCmd.parallelRequests = 3 @@ -523,6 +527,36 @@ func createCurationCmdAndRun(tt testCase, serverDetails *config.ServerDetails) ( return } +func runDockerCurationWithMockTree(tt testCase, serverDetails *config.ServerDetails) (map[string]*CurationReport, error) { + rtAuth, _ := serverDetails.CreateArtAuthConfig() + rtManager, _ := rtUtils.CreateServiceManager(serverDetails, 2, 0, false) + + analyzer := treeAnalyzer{ + rtManager: rtManager, + extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate), + rtAuth: rtAuth, + httpClientDetails: rtAuth.CreateHttpClientDetails(), + url: rtAuth.GetUrl(), + repo: strings.SplitN(tt.dockerImageName, "/", 2)[0], + tech: techutils.Docker, + parallelRequests: 3, + } + + packagesStatusMap := sync.Map{} + rootNodes := map[string]struct{}{tt.mockDepTree.Id: {}} + _ = analyzer.fetchNodesStatus(tt.mockDepTree, &packagesStatusMap, rootNodes) + + var packagesStatus []*PackageStatus + analyzer.GraphsRelations([]*xrayUtils.GraphNode{tt.mockDepTree}, &packagesStatusMap, &packagesStatus) + + return map[string]*CurationReport{ + tt.mockDepTree.Id: { + packagesStatus: packagesStatus, + totalNumberOfPackages: len(tt.mockDepTree.Nodes), + }, + }, nil +} + func validateCurationResults(t *testing.T, testCase testCase, results map[string]*CurationReport, config *config.ServerDetails) { // Add the mock server to the expected blocked message url for key := range testCase.expectedResp { @@ -574,6 +608,8 @@ type testCase struct { createServerWithoutCreds bool allowInsecureTls bool dockerImageName string + // mockDepTree is used for Docker tests to bypass docker pull + mockDepTree *xrayUtils.GraphNode } func (tc testCase) getPathToTests() string { @@ -992,26 +1028,26 @@ func getTestCasesForDoCurationAudit() []testCase { allowInsecureTls: true, }, { - name: "docker tree - one blocked package", + name: "docker tree - malicious package blocked", tech: techutils.Docker, pathToProject: filepath.Join("projects", "package-managers", "docker", "curation-project"), - dockerImageName: "repo-test-docker/dweomer/nginx-auth-ldap:1.13.5-on-alpine-3.5", + dockerImageName: "docker-curation/ganodndentcom/drupal:latest", requestToFail: map[string]bool{ - "/api/docker/repo-test-docker/v2/dweomer/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": true, + "/api/docker/docker-curation/v2/ganodndentcom/drupal/manifests/latest": true, }, expectedRequest: map[string]bool{ - "/api/docker/repo-test-docker/v2/dweomer/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": false, + "/api/docker/docker-curation/v2/ganodndentcom/drupal/manifests/latest": false, }, expectedResp: map[string]*CurationReport{ - "root:latest": { + "ganodndentcom/drupal:latest": { packagesStatus: []*PackageStatus{ { Action: "blocked", - ParentName: "dweomer/nginx-auth-ldap", - ParentVersion: "1.13.5-on-alpine-3.5", - BlockedPackageUrl: "/api/docker/repo-test-docker/v2/dweomer/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5", - PackageName: "dweomer/nginx-auth-ldap", - PackageVersion: "1.13.5-on-alpine-3.5", + ParentName: "ganodndentcom/drupal", + ParentVersion: "latest", + BlockedPackageUrl: "/api/docker/docker-curation/v2/ganodndentcom/drupal/manifests/latest", + PackageName: "ganodndentcom/drupal", + PackageVersion: "latest", DepRelation: "direct", PkgType: "docker", BlockingReason: "Policy violations", @@ -1023,10 +1059,17 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, }, - totalNumberOfPackages: 0, + totalNumberOfPackages: 1, }, }, allowInsecureTls: true, + // Mock dependency tree to bypass docker pull + mockDepTree: &xrayUtils.GraphNode{ + Id: "ganodndentcom/drupal:latest", + Nodes: []*xrayUtils.GraphNode{ + {Id: "docker://ganodndentcom/drupal:latest"}, + }, + }, }, } return tests diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index c3a415df6..b0ee833e2 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -109,7 +109,8 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ([]*xr log.Debug(fmt.Sprintf("Docker image reference: %s", imageRef)) - return []*xrayUtils.GraphNode{{Id: "root", Nodes: []*xrayUtils.GraphNode{{Id: imageRef}}}}, + rootId := imageInfo.Image + ":" + imageInfo.Tag + return []*xrayUtils.GraphNode{{Id: rootId, Nodes: []*xrayUtils.GraphNode{{Id: imageRef}}}}, []string{imageRef}, nil } @@ -124,16 +125,6 @@ func getArchDigestUsingDocker(fullImageName string) (string, error) { } return "", fmt.Errorf("docker pull failed: %s", strings.TrimSpace(outputStr)) } - - inspectCmd := exec.Command("docker", "inspect", fullImageName, "--format", "{{index .RepoDigests 0}}") - output, err = inspectCmd.CombinedOutput() - if err != nil { - return "", nil - } - repoDigest := strings.TrimSpace(string(output)) - if idx := strings.Index(repoDigest, "@"); idx > 0 { - return repoDigest[idx+1:], nil - } return "", nil } From 3171ad96f35298043fc791dacfe3eaa26506a7be Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Tue, 16 Dec 2025 02:10:50 +0200 Subject: [PATCH 05/21] improved the code --- commands/curation/curationaudit.go | 35 +++++----- commands/curation/curationaudit_test.go | 67 ++++++++----------- .../buildinfo/technologies/docker/docker.go | 16 +++-- 3 files changed, 55 insertions(+), 63 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 93625e7c1..571e8d749 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -405,20 +405,8 @@ func (ca *CurationAuditCommand) getRtManagerAndAuth(tech techutils.Technology) ( func (ca *CurationAuditCommand) GetAuth(tech techutils.Technology) (serverDetails *config.ServerDetails, err error) { if ca.PackageManagerConfig == nil { - if tech == techutils.Docker { - serverDetails, err = ca.ServerDetails() - if err != nil { - return - } - repoConfig, err := docker.GetDockerRepositoryConfig(serverDetails, ca.DockerImageName()) - if err != nil { - return nil, err - } - ca.setPackageManagerConfig(repoConfig) - } else { - if err = ca.SetRepo(tech); err != nil { - return - } + if err = ca.SetRepo(tech); err != nil { + return } } serverDetails, err = ca.PackageManagerConfig.ServerDetails() @@ -747,6 +735,16 @@ func (ca *CurationAuditCommand) CommandName() string { } func (ca *CurationAuditCommand) SetRepo(tech techutils.Technology) error { + // If the technology is Docker, we need to get the repository config from the Docker image name + if tech == techutils.Docker { + repoConfig, err := docker.GetDockerRepositoryConfig(ca.DockerImageName()) + if err != nil { + return err + } + ca.setPackageManagerConfig(repoConfig) + return nil + } + resolverParams, err := ca.getRepoParams(techutils.TechToProjectType[tech]) if err != nil { return err @@ -1176,11 +1174,14 @@ func getDockerNameScopeAndVersion(id, artiUrl, repo string) (downloadUrls []stri id = strings.TrimPrefix(id, "docker://") if idx := strings.Index(id, ":sha256:"); idx > 0 { - name, version = id[:idx], id[idx+1:] + name = id[:idx] + version = id[idx+1:] } else if idx := strings.LastIndex(id, ":"); idx > 0 { - name, version = id[:idx], id[idx+1:] + name = id[:idx] + version = id[idx+1:] } else { - name, version = id, "latest" + name = id + version = "latest" } if artiUrl != "" && repo != "" { diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 0cb556488..a35321d01 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -16,6 +16,7 @@ import ( "sync" "testing" + "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/docker" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/java" "github.com/jfrog/jfrog-cli-security/utils/formats" @@ -507,8 +508,9 @@ func runPreTestExec(t *testing.T, basePathToTests string, testCase testCase) { } func createCurationCmdAndRun(tt testCase, serverDetails *config.ServerDetails) (cmdResults map[string]*CurationReport, err error) { - if tt.tech == techutils.Docker && tt.mockDepTree != nil { - return runDockerCurationWithMockTree(tt, serverDetails) + // For Docker, building dep tree manually (since its not possible to do docker pull ) + if tt.tech == techutils.Docker { + return runDockerCurationTest(tt, serverDetails) } curationCmd := NewCurationAuditCommand() curationCmd.SetIsCurationCmd(true) @@ -517,19 +519,18 @@ func createCurationCmdAndRun(tt testCase, serverDetails *config.ServerDetails) ( curationCmd.SetInsecureTls(true) curationCmd.SetIgnoreConfigFile(tt.shouldIgnoreConfigFile) curationCmd.SetInsecureTls(tt.allowInsecureTls) - if tt.dockerImageName != "" { - curationCmd.SetDockerImageName(tt.dockerImageName) - // Docker requires server details to be set explicitly - curationCmd.SetServerDetails(serverDetails) - } + curationCmd.SetDockerImageName(tt.dockerImageName) cmdResults = map[string]*CurationReport{} err = curationCmd.doCurateAudit(cmdResults) return } -func runDockerCurationWithMockTree(tt testCase, serverDetails *config.ServerDetails) (map[string]*CurationReport, error) { - rtAuth, _ := serverDetails.CreateArtAuthConfig() +func runDockerCurationTest(tt testCase, serverDetails *config.ServerDetails) (map[string]*CurationReport, error) { + imageInfo, _ := docker.ParseDockerImage(tt.dockerImageName) + rootId := imageInfo.Image + ":" + imageInfo.Tag + rtManager, _ := rtUtils.CreateServiceManager(serverDetails, 2, 0, false) + rtAuth, _ := serverDetails.CreateArtAuthConfig() analyzer := treeAnalyzer{ rtManager: rtManager, @@ -537,24 +538,19 @@ func runDockerCurationWithMockTree(tt testCase, serverDetails *config.ServerDeta rtAuth: rtAuth, httpClientDetails: rtAuth.CreateHttpClientDetails(), url: rtAuth.GetUrl(), - repo: strings.SplitN(tt.dockerImageName, "/", 2)[0], + repo: imageInfo.Repo, tech: techutils.Docker, parallelRequests: 3, } - packagesStatusMap := sync.Map{} - rootNodes := map[string]struct{}{tt.mockDepTree.Id: {}} - _ = analyzer.fetchNodesStatus(tt.mockDepTree, &packagesStatusMap, rootNodes) + tree := []*xrayUtils.GraphNode{{Id: rootId, Nodes: []*xrayUtils.GraphNode{{Id: "docker://" + rootId}}}} + statusMap := sync.Map{} + var status []*PackageStatus - var packagesStatus []*PackageStatus - analyzer.GraphsRelations([]*xrayUtils.GraphNode{tt.mockDepTree}, &packagesStatusMap, &packagesStatus) + analyzer.fetchNodesStatus(tree[0], &statusMap, map[string]struct{}{rootId: {}}) + analyzer.GraphsRelations(tree, &statusMap, &status) - return map[string]*CurationReport{ - tt.mockDepTree.Id: { - packagesStatus: packagesStatus, - totalNumberOfPackages: len(tt.mockDepTree.Nodes), - }, - }, nil + return map[string]*CurationReport{rootId: {packagesStatus: status, totalNumberOfPackages: 1}}, nil } func validateCurationResults(t *testing.T, testCase testCase, results map[string]*CurationReport, config *config.ServerDetails) { @@ -608,8 +604,6 @@ type testCase struct { createServerWithoutCreds bool allowInsecureTls bool dockerImageName string - // mockDepTree is used for Docker tests to bypass docker pull - mockDepTree *xrayUtils.GraphNode } func (tc testCase) getPathToTests() string { @@ -1028,26 +1022,26 @@ func getTestCasesForDoCurationAudit() []testCase { allowInsecureTls: true, }, { - name: "docker tree - malicious package blocked", + name: "docker tree - one blocked package", tech: techutils.Docker, pathToProject: filepath.Join("projects", "package-managers", "docker", "curation-project"), - dockerImageName: "docker-curation/ganodndentcom/drupal:latest", + dockerImageName: "repo-test-docker/dweomer/nginx-auth-ldap:1.13.5-on-alpine-3.5", requestToFail: map[string]bool{ - "/api/docker/docker-curation/v2/ganodndentcom/drupal/manifests/latest": true, + "/api/docker/dweomer/v2/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": true, }, expectedRequest: map[string]bool{ - "/api/docker/docker-curation/v2/ganodndentcom/drupal/manifests/latest": false, + "/api/docker/dweomer/v2/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": false, }, expectedResp: map[string]*CurationReport{ - "ganodndentcom/drupal:latest": { + "nginx-auth-ldap:1.13.5-on-alpine-3.5": { packagesStatus: []*PackageStatus{ { Action: "blocked", - ParentName: "ganodndentcom/drupal", - ParentVersion: "latest", - BlockedPackageUrl: "/api/docker/docker-curation/v2/ganodndentcom/drupal/manifests/latest", - PackageName: "ganodndentcom/drupal", - PackageVersion: "latest", + ParentName: "nginx-auth-ldap", + ParentVersion: "1.13.5-on-alpine-3.5", + BlockedPackageUrl: "/api/docker/dweomer/v2/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5", + PackageName: "nginx-auth-ldap", + PackageVersion: "1.13.5-on-alpine-3.5", DepRelation: "direct", PkgType: "docker", BlockingReason: "Policy violations", @@ -1063,13 +1057,6 @@ func getTestCasesForDoCurationAudit() []testCase { }, }, allowInsecureTls: true, - // Mock dependency tree to bypass docker pull - mockDepTree: &xrayUtils.GraphNode{ - Id: "ganodndentcom/drupal:latest", - Nodes: []*xrayUtils.GraphNode{ - {Id: "docker://ganodndentcom/drupal:latest"}, - }, - }, }, } return tests diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index b0ee833e2..1866e7f24 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -45,7 +45,9 @@ func ParseDockerImage(imageName string) (*DockerImageInfo, error) { } info.Registry = parts[0] - info.Repo, info.Image = parseRegistryAndExtract(info.Registry, parts[1:]) + repo, image := parseRegistryAndExtract(info.Registry, parts[1:]) + info.Repo = repo + info.Image = image log.Debug(fmt.Sprintf("Parsed Docker image - Registry: %s, Repo: %s, Image: %s, Tag: %s", info.Registry, info.Repo, info.Image, info.Tag)) @@ -57,7 +59,7 @@ func parseRegistryAndExtract(registry string, remainingParts []string) (repo, im image = strings.Join(remainingParts, "/") // SaaS subdomain: -.jfrog.io/image:tag (repo in subdomain, check first) - if matches := jfrogSubdomainPattern.FindStringSubmatch(registry); matches != nil { + if matches := jfrogSubdomainPattern.FindStringSubmatch(registry); len(matches) > 2 { repo = matches[2] return } @@ -135,15 +137,17 @@ func extractDigestFromBlockedMessage(output string) string { return "" } -func GetDockerRepositoryConfig(serverDetails *config.ServerDetails, imageName string) (*project.RepositoryConfig, error) { +func GetDockerRepositoryConfig(imageName string) (*project.RepositoryConfig, error) { imageInfo, err := ParseDockerImage(imageName) if err != nil { return nil, err } - return GetDockerRepositoryConfigFromInfo(serverDetails, imageInfo) -} -func GetDockerRepositoryConfigFromInfo(serverDetails *config.ServerDetails, imageInfo *DockerImageInfo) (*project.RepositoryConfig, error) { + serverDetails, err := config.GetDefaultServerConf() + if err != nil { + return nil, err + } + repoConfig := &project.RepositoryConfig{} repoConfig.SetServerDetails(serverDetails).SetTargetRepo(imageInfo.Repo) return repoConfig, nil From ecd4a996ba94ed049bd66e7061a8fb0b329e0020 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Tue, 16 Dec 2025 02:17:52 +0200 Subject: [PATCH 06/21] fixed unit test commit --- cli/docs/flags.go | 2 +- commands/curation/curationaudit_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/docs/flags.go b/cli/docs/flags.go index ca841121f..0b7b502ad 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -332,7 +332,7 @@ var flagsMap = map[string]components.Flag{ AddSastRules: components.NewStringFlag(AddSastRules, "Incorporate any additional SAST rules (in JSON format, with absolute path) into this local scan."), // Docker flags - DockerImageName: components.NewStringFlag(DockerImageName, "[Docker] Defines the Docker image name to audit. Uses the same format as Docker client with Artifactory. Examples: 'acme.jfrog.io/docker-local/nginx:1.21' (repository path) or 'acme-docker-local.jfrog.io/nginx:1.21' (subdomain). Supports all Artifactory hosting methods."), + DockerImageName: components.NewStringFlag(DockerImageName, "[Docker] Defines the Docker image name to audit. Uses the same format as Docker client with Artifactory. Examples: 'acme.jfrog.io/docker-local/nginx:1.21' (repository path) or 'acme-docker-local.jfrog.io/nginx:1.21' (subdomain)."), // Git flags InputFile: components.NewStringFlag(InputFile, "Path to an input file in YAML format contains multiple git providers. With this option, all other scm flags will be ignored and only git servers mentioned in the file will be examined.."), diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index a35321d01..f63256197 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -547,7 +547,7 @@ func runDockerCurationTest(tt testCase, serverDetails *config.ServerDetails) (ma statusMap := sync.Map{} var status []*PackageStatus - analyzer.fetchNodesStatus(tree[0], &statusMap, map[string]struct{}{rootId: {}}) + _ = analyzer.fetchNodesStatus(tree[0], &statusMap, map[string]struct{}{rootId: {}}) analyzer.GraphsRelations(tree, &statusMap, &status) return map[string]*CurationReport{rootId: {packagesStatus: status, totalNumberOfPackages: 1}}, nil From 4c0adc9f8b6fb4effa86aeb6a46bd808d1c44ddd Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Tue, 16 Dec 2025 13:24:32 +0200 Subject: [PATCH 07/21] added get version for docker --- commands/curation/curationaudit_test.go | 78 +------------------------ 1 file changed, 2 insertions(+), 76 deletions(-) diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index f63256197..4c630a5d2 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -16,13 +16,11 @@ import ( "sync" "testing" - "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/docker" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies/java" "github.com/jfrog/jfrog-cli-security/utils/formats" biutils "github.com/jfrog/build-info-go/utils" "github.com/jfrog/gofrog/datastructures" - rtUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" coreCommonTests "github.com/jfrog/jfrog-cli-core/v2/common/tests" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" @@ -429,7 +427,7 @@ func TestDoCurationAudit(t *testing.T) { cleanUp := createCurationTestEnv(t, basePathToTests, tt, config) defer cleanUp() // Create audit command, and run it - results, err := createCurationCmdAndRun(tt, config) + results, err := createCurationCmdAndRun(tt) // Validate the results if tt.requestToError == nil { assert.NoError(t, err) @@ -507,11 +505,7 @@ func runPreTestExec(t *testing.T, basePathToTests string, testCase testCase) { callbackPreTest() } -func createCurationCmdAndRun(tt testCase, serverDetails *config.ServerDetails) (cmdResults map[string]*CurationReport, err error) { - // For Docker, building dep tree manually (since its not possible to do docker pull ) - if tt.tech == techutils.Docker { - return runDockerCurationTest(tt, serverDetails) - } +func createCurationCmdAndRun(tt testCase) (cmdResults map[string]*CurationReport, err error) { curationCmd := NewCurationAuditCommand() curationCmd.SetIsCurationCmd(true) curationCmd.parallelRequests = 3 @@ -519,40 +513,11 @@ func createCurationCmdAndRun(tt testCase, serverDetails *config.ServerDetails) ( curationCmd.SetInsecureTls(true) curationCmd.SetIgnoreConfigFile(tt.shouldIgnoreConfigFile) curationCmd.SetInsecureTls(tt.allowInsecureTls) - curationCmd.SetDockerImageName(tt.dockerImageName) cmdResults = map[string]*CurationReport{} err = curationCmd.doCurateAudit(cmdResults) return } -func runDockerCurationTest(tt testCase, serverDetails *config.ServerDetails) (map[string]*CurationReport, error) { - imageInfo, _ := docker.ParseDockerImage(tt.dockerImageName) - rootId := imageInfo.Image + ":" + imageInfo.Tag - - rtManager, _ := rtUtils.CreateServiceManager(serverDetails, 2, 0, false) - rtAuth, _ := serverDetails.CreateArtAuthConfig() - - analyzer := treeAnalyzer{ - rtManager: rtManager, - extractPoliciesRegex: regexp.MustCompile(extractPoliciesRegexTemplate), - rtAuth: rtAuth, - httpClientDetails: rtAuth.CreateHttpClientDetails(), - url: rtAuth.GetUrl(), - repo: imageInfo.Repo, - tech: techutils.Docker, - parallelRequests: 3, - } - - tree := []*xrayUtils.GraphNode{{Id: rootId, Nodes: []*xrayUtils.GraphNode{{Id: "docker://" + rootId}}}} - statusMap := sync.Map{} - var status []*PackageStatus - - _ = analyzer.fetchNodesStatus(tree[0], &statusMap, map[string]struct{}{rootId: {}}) - analyzer.GraphsRelations(tree, &statusMap, &status) - - return map[string]*CurationReport{rootId: {packagesStatus: status, totalNumberOfPackages: 1}}, nil -} - func validateCurationResults(t *testing.T, testCase testCase, results map[string]*CurationReport, config *config.ServerDetails) { // Add the mock server to the expected blocked message url for key := range testCase.expectedResp { @@ -603,7 +568,6 @@ type testCase struct { tech techutils.Technology createServerWithoutCreds bool allowInsecureTls bool - dockerImageName string } func (tc testCase) getPathToTests() string { @@ -1021,43 +985,6 @@ func getTestCasesForDoCurationAudit() []testCase { }, allowInsecureTls: true, }, - { - name: "docker tree - one blocked package", - tech: techutils.Docker, - pathToProject: filepath.Join("projects", "package-managers", "docker", "curation-project"), - dockerImageName: "repo-test-docker/dweomer/nginx-auth-ldap:1.13.5-on-alpine-3.5", - requestToFail: map[string]bool{ - "/api/docker/dweomer/v2/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": true, - }, - expectedRequest: map[string]bool{ - "/api/docker/dweomer/v2/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5": false, - }, - expectedResp: map[string]*CurationReport{ - "nginx-auth-ldap:1.13.5-on-alpine-3.5": { - packagesStatus: []*PackageStatus{ - { - Action: "blocked", - ParentName: "nginx-auth-ldap", - ParentVersion: "1.13.5-on-alpine-3.5", - BlockedPackageUrl: "/api/docker/dweomer/v2/nginx-auth-ldap/manifests/1.13.5-on-alpine-3.5", - PackageName: "nginx-auth-ldap", - PackageVersion: "1.13.5-on-alpine-3.5", - DepRelation: "direct", - PkgType: "docker", - BlockingReason: "Policy violations", - Policy: []Policy{ - { - Policy: "pol1", - Condition: "cond1", - }, - }, - }, - }, - totalNumberOfPackages: 1, - }, - }, - allowInsecureTls: true, - }, } return tests } @@ -1322,7 +1249,6 @@ func Test_getDockerNameScopeAndVersion(t *testing.T) { }) } } - func Test_getNugetNameScopeAndVersion(t *testing.T) { tests := []struct { name string From 958ee030d22afa9548e4d2943ff1d20827c1c867 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Thu, 18 Dec 2025 10:28:04 +0200 Subject: [PATCH 08/21] Add Docker curation audit integration test --- .github/workflows/test.yml | 4 ++++ curation_test.go | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e2c3ff7e1..715979922 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -309,6 +309,10 @@ jobs: - name: Install and Setup Dependencies uses: ./.github/actions/install-and-setup + - name: Docker Login + if: runner.os == 'Linux' + run: echo "${{ secrets.PLATFORM_ADMIN_TOKEN }}" | docker login ${{ secrets.CONTAINER_REGISTRY }} -u admin --password-stdin + # Test - name: Run tests run: go test ${{ env.GO_COMMON_TEST_ARGS }} --test.curation --test.enrich --ci.runId=${{ runner.os }}-sec-test diff --git a/curation_test.go b/curation_test.go index f27e8fcef..6a9679db9 100644 --- a/curation_test.go +++ b/curation_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/jfrog/jfrog-cli-security/cli" "github.com/jfrog/jfrog-cli-security/commands/curation" securityTests "github.com/jfrog/jfrog-cli-security/tests" securityTestUtils "github.com/jfrog/jfrog-cli-security/tests/utils" @@ -105,6 +106,53 @@ func getCurationExpectedResponse(config *config.ServerDetails) []curation.Packag return expectedResp } +func TestDockerCurationAudit(t *testing.T) { + integration.InitCurationTest(t) + if securityTests.ContainerRegistry == nil || *securityTests.ContainerRegistry == "" { + t.Skip("Skipping Docker curation test - container registry not configured") + } + + cleanUpJfrogHome, err := coreTests.SetJfrogHome() + assert.NoError(t, err) + defer cleanUpJfrogHome() + + serverDetails := &config.ServerDetails{ + ServerId: "default", + Url: *securityTests.JfrogUrl, + ArtifactoryUrl: *securityTests.JfrogUrl + securityTests.ArtifactoryEndpoint, + XrayUrl: *securityTests.JfrogUrl + securityTests.XrayEndpoint, + AccessToken: *securityTests.JfrogAccessToken, + } + configCmd := commonCommands.NewConfigCommand(commonCommands.AddOrEdit, serverDetails.ServerId). + SetDetails(serverDetails). + SetInteractive(false) + assert.NoError(t, configCmd.Run()) + + testCli := integration.GetXrayTestCli(cli.GetJfrogCliSecurityApp(), false) + + testImage := fmt.Sprintf("%s/%s/%s", *securityTests.ContainerRegistry, "docker-curation", "ganodndentcom/drupal") + + output := testCli.WithoutCredentials().RunCliCmdWithOutput(t, "curation-audit", + "--image="+testImage, + "--format="+string(format.Json)) + + if strings.Contains(output, "docker.sock") || strings.Contains(output, "docker daemon") { + t.Skip("Skipping Docker curation test - Docker is not running") + } + + var results []curation.PackageStatus + bracketIndex := strings.Index(output, "[") + err = json.Unmarshal([]byte(output[bracketIndex:]), &results) + require.NoError(t, err) + + require.NotEmpty(t, results, "Expected at least one blocked package") + assert.Equal(t, "blocked", results[0].Action) + assert.Equal(t, "ganodndentcom/drupal", results[0].PackageName) + assert.Equal(t, curation.BlockingReasonPolicy, results[0].BlockingReason) + require.NotEmpty(t, results[0].Policy, "Expected at least one policy violation") + assert.Equal(t, "Malicious package", results[0].Policy[0].Condition) +} + func curationServer(t *testing.T, expectedRequest map[string]bool, requestToFail map[string]bool) (*httptest.Server, *config.ServerDetails) { mapLockReadWrite := sync.Mutex{} serverMock, config, _ := commonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { From b22d8cff7bfaf97e79b6373d6f7ec1888d76a891 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Thu, 18 Dec 2025 10:39:24 +0200 Subject: [PATCH 09/21] Add Docker login in test and fix panic on parse error --- .github/workflows/test.yml | 4 ---- curation_test.go | 11 ++++++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 715979922..e2c3ff7e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -309,10 +309,6 @@ jobs: - name: Install and Setup Dependencies uses: ./.github/actions/install-and-setup - - name: Docker Login - if: runner.os == 'Linux' - run: echo "${{ secrets.PLATFORM_ADMIN_TOKEN }}" | docker login ${{ secrets.CONTAINER_REGISTRY }} -u admin --password-stdin - # Test - name: Run tests run: go test ${{ env.GO_COMMON_TEST_ARGS }} --test.curation --test.enrich --ci.runId=${{ runner.os }}-sec-test diff --git a/curation_test.go b/curation_test.go index 6a9679db9..b83ae8dd7 100644 --- a/curation_test.go +++ b/curation_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os/exec" "path/filepath" "strings" "sync" @@ -128,6 +129,12 @@ func TestDockerCurationAudit(t *testing.T) { SetInteractive(false) assert.NoError(t, configCmd.Run()) + loginCmd := exec.Command("docker", "login", *securityTests.ContainerRegistry, "-u", "admin", "--password-stdin") + loginCmd.Stdin = strings.NewReader(*securityTests.JfrogAccessToken) + if loginOutput, err := loginCmd.CombinedOutput(); err != nil { + t.Skipf("Skipping Docker curation test - Docker login failed: %s", string(loginOutput)) + } + testCli := integration.GetXrayTestCli(cli.GetJfrogCliSecurityApp(), false) testImage := fmt.Sprintf("%s/%s/%s", *securityTests.ContainerRegistry, "docker-curation", "ganodndentcom/drupal") @@ -140,8 +147,10 @@ func TestDockerCurationAudit(t *testing.T) { t.Skip("Skipping Docker curation test - Docker is not running") } - var results []curation.PackageStatus bracketIndex := strings.Index(output, "[") + require.GreaterOrEqual(t, bracketIndex, 0, "Expected JSON array in output, got: %s", output) + + var results []curation.PackageStatus err = json.Unmarshal([]byte(output[bracketIndex:]), &results) require.NoError(t, err) From d1a5492c9bc3a1c704500cf991129a1b3f75f7a1 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Thu, 18 Dec 2025 11:02:04 +0200 Subject: [PATCH 10/21] Use same test setup pattern as Docker scan tests --- curation_test.go | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/curation_test.go b/curation_test.go index b83ae8dd7..f064eb14e 100644 --- a/curation_test.go +++ b/curation_test.go @@ -113,27 +113,13 @@ func TestDockerCurationAudit(t *testing.T) { t.Skip("Skipping Docker curation test - container registry not configured") } - cleanUpJfrogHome, err := coreTests.SetJfrogHome() - assert.NoError(t, err) - defer cleanUpJfrogHome() - - serverDetails := &config.ServerDetails{ - ServerId: "default", - Url: *securityTests.JfrogUrl, - ArtifactoryUrl: *securityTests.JfrogUrl + securityTests.ArtifactoryEndpoint, - XrayUrl: *securityTests.JfrogUrl + securityTests.XrayEndpoint, - AccessToken: *securityTests.JfrogAccessToken, - } - configCmd := commonCommands.NewConfigCommand(commonCommands.AddOrEdit, serverDetails.ServerId). - SetDetails(serverDetails). - SetInteractive(false) - assert.NoError(t, configCmd.Run()) - - loginCmd := exec.Command("docker", "login", *securityTests.ContainerRegistry, "-u", "admin", "--password-stdin") + cleanUp := integration.UseTestHomeWithDefaultXrayConfig(t) + defer cleanUp() + integration.CreateJfrogHomeConfig(t, "", securityTests.XrDetails, true) + loginCmd := exec.Command("docker", "login", *securityTests.ContainerRegistry, "-u", *securityTests.JfrogUser, "--password-stdin") loginCmd.Stdin = strings.NewReader(*securityTests.JfrogAccessToken) - if loginOutput, err := loginCmd.CombinedOutput(); err != nil { - t.Skipf("Skipping Docker curation test - Docker login failed: %s", string(loginOutput)) - } + loginOutput, err := loginCmd.CombinedOutput() + require.NoError(t, err, "Docker login failed: %s", string(loginOutput)) testCli := integration.GetXrayTestCli(cli.GetJfrogCliSecurityApp(), false) @@ -143,10 +129,6 @@ func TestDockerCurationAudit(t *testing.T) { "--image="+testImage, "--format="+string(format.Json)) - if strings.Contains(output, "docker.sock") || strings.Contains(output, "docker daemon") { - t.Skip("Skipping Docker curation test - Docker is not running") - } - bracketIndex := strings.Index(output, "[") require.GreaterOrEqual(t, bracketIndex, 0, "Expected JSON array in output, got: %s", output) From cb6f4e57b553744b8be7b03c7908f341d362ddf1 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Thu, 18 Dec 2025 11:16:16 +0200 Subject: [PATCH 11/21] Use anonymous Docker access for curation test --- curation_test.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/curation_test.go b/curation_test.go index f064eb14e..588c6ea66 100644 --- a/curation_test.go +++ b/curation_test.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "net/http/httptest" - "os/exec" "path/filepath" "strings" "sync" @@ -116,10 +115,6 @@ func TestDockerCurationAudit(t *testing.T) { cleanUp := integration.UseTestHomeWithDefaultXrayConfig(t) defer cleanUp() integration.CreateJfrogHomeConfig(t, "", securityTests.XrDetails, true) - loginCmd := exec.Command("docker", "login", *securityTests.ContainerRegistry, "-u", *securityTests.JfrogUser, "--password-stdin") - loginCmd.Stdin = strings.NewReader(*securityTests.JfrogAccessToken) - loginOutput, err := loginCmd.CombinedOutput() - require.NoError(t, err, "Docker login failed: %s", string(loginOutput)) testCli := integration.GetXrayTestCli(cli.GetJfrogCliSecurityApp(), false) @@ -133,7 +128,7 @@ func TestDockerCurationAudit(t *testing.T) { require.GreaterOrEqual(t, bracketIndex, 0, "Expected JSON array in output, got: %s", output) var results []curation.PackageStatus - err = json.Unmarshal([]byte(output[bracketIndex:]), &results) + err := json.Unmarshal([]byte(output[bracketIndex:]), &results) require.NoError(t, err) require.NotEmpty(t, results, "Expected at least one blocked package") From 2e5ad3d07891d76397f052fc95e692be380cf08e Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Thu, 18 Dec 2025 11:37:20 +0200 Subject: [PATCH 12/21] Skip Docker curation test on macOS CI --- curation_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/curation_test.go b/curation_test.go index 588c6ea66..11e64b3c0 100644 --- a/curation_test.go +++ b/curation_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -108,10 +109,9 @@ func getCurationExpectedResponse(config *config.ServerDetails) []curation.Packag func TestDockerCurationAudit(t *testing.T) { integration.InitCurationTest(t) - if securityTests.ContainerRegistry == nil || *securityTests.ContainerRegistry == "" { + if securityTests.ContainerRegistry == nil || *securityTests.ContainerRegistry == "" || runtime.GOOS == "darwin" { t.Skip("Skipping Docker curation test - container registry not configured") } - cleanUp := integration.UseTestHomeWithDefaultXrayConfig(t) defer cleanUp() integration.CreateJfrogHomeConfig(t, "", securityTests.XrDetails, true) From 59a3a7e549307ee647a582c548cc486ee89dd22f Mon Sep 17 00:00:00 2001 From: Bassel Mbariky <78343797+basel1322@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:03:26 +0200 Subject: [PATCH 13/21] Update commands/curation/curationaudit.go Co-authored-by: Asaf Ambar --- commands/curation/curationaudit.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 571e8d749..1fc64ea7b 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -133,11 +133,7 @@ func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Techno } func (ca *CurationAuditCommand) checkDockerSupport() (bool, error) { - dockerImageName := ca.DockerImageName() - if dockerImageName == "" { - return false, nil - } - return true, nil + return ca.DockerImageName() != "", nil } func (ca *CurationAuditCommand) getRtVersion(tech techutils.Technology) (string, error) { From 490440d4f6f35181d708b7b2a9f7f426fd7eecfd Mon Sep 17 00:00:00 2001 From: Bassel Mbariky <78343797+basel1322@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:34:17 +0200 Subject: [PATCH 14/21] Update curationaudit.go --- commands/curation/curationaudit.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 1fc64ea7b..09b0179bc 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -844,9 +844,6 @@ func (nc *treeAnalyzer) fetchNodeStatus(node xrayUtils.GraphNode, p *sync.Map) e if scope != "" { name = scope + "/" + name } - if nc.tech == techutils.Docker { - nc.httpClientDetails.Headers["Accept"] = "application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json" - } for _, packageUrl := range packageUrls { resp, _, err := nc.rtManager.Client().SendHead(packageUrl, &nc.httpClientDetails) if err != nil { From 06c852a62a4106978405b0079a97cb90a34b0894 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky <78343797+basel1322@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:24:20 +0200 Subject: [PATCH 15/21] Update commands/curation/curationaudit.go Co-authored-by: Asaf Ambar --- commands/curation/curationaudit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 09b0179bc..1121bacb6 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -104,7 +104,7 @@ var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (boo return ca.checkSupportByVersionOrEnv(techutils.Gem, MinArtiGradleGemSupport) }, techutils.Docker: func(ca *CurationAuditCommand) (bool, error) { - return ca.checkDockerSupport() + return ca.DockerImageName() != "", nil }, } From 5337c9cfc824e2e9c7776a04324f67404dabf103 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Sun, 21 Dec 2025 14:52:01 +0200 Subject: [PATCH 16/21] Refactor Docker image parsing and remove redundant checkDockerSupport --- commands/curation/curationaudit.go | 6 +----- sca/bom/buildinfo/technologies/docker/docker.go | 15 +++++++-------- .../buildinfo/technologies/docker/docker_test.go | 7 +++++++ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 1121bacb6..53f7dc771 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -104,7 +104,7 @@ var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (boo return ca.checkSupportByVersionOrEnv(techutils.Gem, MinArtiGradleGemSupport) }, techutils.Docker: func(ca *CurationAuditCommand) (bool, error) { - return ca.DockerImageName() != "", nil + return ca.DockerImageName() != "", nil }, } @@ -132,10 +132,6 @@ func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Techno return true, nil } -func (ca *CurationAuditCommand) checkDockerSupport() (bool, error) { - return ca.DockerImageName() != "", nil -} - func (ca *CurationAuditCommand) getRtVersion(tech techutils.Technology) (string, error) { rtManager, _, err := ca.getRtManagerAndAuth(tech) if err != nil { diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index 1866e7f24..deb3093e8 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -39,13 +39,13 @@ func ParseDockerImage(imageName string) (*DockerImageInfo, error) { } } - parts := strings.Split(imageName, "/") + parts := strings.SplitN(imageName, "/", 2) if len(parts) < 2 { return nil, fmt.Errorf("invalid docker image format: '%s'", imageName) } info.Registry = parts[0] - repo, image := parseRegistryAndExtract(info.Registry, parts[1:]) + repo, image := parseRegistryAndExtract(info.Registry, parts[1]) info.Repo = repo info.Image = image @@ -55,8 +55,8 @@ func ParseDockerImage(imageName string) (*DockerImageInfo, error) { return info, nil } -func parseRegistryAndExtract(registry string, remainingParts []string) (repo, image string) { - image = strings.Join(remainingParts, "/") +func parseRegistryAndExtract(registry string, remaining string) (repo, image string) { + image = remaining // SaaS subdomain: -.jfrog.io/image:tag (repo in subdomain, check first) if matches := jfrogSubdomainPattern.FindStringSubmatch(registry); len(matches) > 2 { @@ -71,10 +71,9 @@ func parseRegistryAndExtract(registry string, remainingParts []string) (repo, im return } - // Repository path: //image:tag (2+ parts means repo in path) - if len(remainingParts) >= 2 { - repo = remainingParts[0] - image = strings.Join(remainingParts[1:], "/") + // Repository path: //image:tag (repo in path if contains /) + if strings.Contains(remaining, "/") { + repo, image, _ = strings.Cut(remaining, "/") return } diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go index 531b0cab1..4a4bcbe03 100644 --- a/sca/bom/buildinfo/technologies/docker/docker_test.go +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -109,6 +109,13 @@ func TestParseDockerImage(t *testing.T) { expectedImg: "nginx", expectedTag: "latest", }, + { + name: "Tag with multiple dots", + imageName: "acme.jfrog.io/docker-local/myapp:1.0.0", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "1.0.0", + }, } for _, tt := range tests { From 5f568e80dc52ada3bda8d555a4cbad3455628112 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Sun, 21 Dec 2025 15:09:05 +0200 Subject: [PATCH 17/21] Fix unparam lint issue --- commands/curation/curationaudit.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index 53f7dc771..099a7c5dc 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -103,9 +103,7 @@ var supportedTech = map[techutils.Technology]func(ca *CurationAuditCommand) (boo techutils.Gem: func(ca *CurationAuditCommand) (bool, error) { return ca.checkSupportByVersionOrEnv(techutils.Gem, MinArtiGradleGemSupport) }, - techutils.Docker: func(ca *CurationAuditCommand) (bool, error) { - return ca.DockerImageName() != "", nil - }, + techutils.Docker: func(ca *CurationAuditCommand) (bool, error) { return true, nil }, } func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Technology, minArtiVersion string) (bool, error) { From 97100ebcce33854b6944c8d9eb26710801081ced Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Mon, 22 Dec 2025 04:22:39 +0200 Subject: [PATCH 18/21] feat: get arch-specific digest for successful docker pulls using buildx --- .../buildinfo/technologies/docker/docker.go | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index deb3093e8..a64feeeaf 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -1,6 +1,7 @@ package docker import ( + "encoding/json" "fmt" "os/exec" "regexp" @@ -22,6 +23,16 @@ type DockerImageInfo struct { Tag string } +type dockerManifestList struct { + Manifests []struct { + Digest string `json:"digest"` + Platform struct { + Architecture string `json:"architecture"` + OS string `json:"os"` + } `json:"platform"` + } `json:"manifests"` +} + var ( jfrogSubdomainPattern = regexp.MustCompile(`^([a-zA-Z0-9]+)-([a-zA-Z0-9-]+)\.jfrog\.io$`) ipAddressPattern = regexp.MustCompile(`^\d+\.`) @@ -116,16 +127,51 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ([]*xr } func getArchDigestUsingDocker(fullImageName string) (string, error) { - log.Debug(fmt.Sprintf("Pulling Docker image: %s", fullImageName)) pullCmd := exec.Command("docker", "pull", fullImageName) - output, err := pullCmd.CombinedOutput() + pullOutput, err := pullCmd.CombinedOutput() if err != nil { - outputStr := string(output) - if strings.Contains(outputStr, "curation service") { - return extractDigestFromBlockedMessage(outputStr), nil + if strings.Contains(string(pullOutput), "curation service") { + return extractDigestFromBlockedMessage(string(pullOutput)), nil } - return "", fmt.Errorf("docker pull failed: %s", strings.TrimSpace(outputStr)) + return "", fmt.Errorf("docker pull failed: %s", strings.TrimSpace(string(pullOutput))) + } + //IF IMAGE EXISTS LOCALLY + inspectCmd := exec.Command("docker", "inspect", fullImageName, "--format", "{{.Os}} {{.Architecture}}") + inspectOutput, err := inspectCmd.CombinedOutput() + if err != nil { + log.Debug(fmt.Sprintf("docker inspect failed: %s", strings.TrimSpace(string(inspectOutput)))) + return "", nil } + parts := strings.Fields(strings.TrimSpace(string(inspectOutput))) + if len(parts) != 2 { + return "", nil + } + localOS := parts[0] + localArch := parts[1] + + log.Debug(fmt.Sprintf("Local platform: %s/%s", localOS, localArch)) + + buildxCmd := exec.Command("docker", "buildx", "imagetools", "inspect", fullImageName, "--raw") + buildxOutput, err := buildxCmd.CombinedOutput() + if err != nil { + log.Debug(fmt.Sprintf("docker buildx imagetools inspect failed: %s", strings.TrimSpace(string(buildxOutput)))) + return "", nil + } + + var manifest dockerManifestList + if err := json.Unmarshal(buildxOutput, &manifest); err != nil { + log.Debug(fmt.Sprintf("Failed to parse manifest JSON: %v", err)) + return "", nil + } + + for _, m := range manifest.Manifests { + if m.Platform.OS == localOS && m.Platform.Architecture == localArch { + log.Debug(fmt.Sprintf("Found arch-specific digest: %s", m.Digest)) + return m.Digest, nil + } + } + + log.Debug(fmt.Sprintf("No matching manifest found for %s/%s", localOS, localArch)) return "", nil } From 207e9b1aaac13fde0ca104fa593c06b4b2179c22 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Mon, 22 Dec 2025 04:31:18 +0200 Subject: [PATCH 19/21] fix: fixed typo in buildx inspect debug logs --- sca/bom/buildinfo/technologies/docker/docker.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index a64feeeaf..569d8fc20 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -135,11 +135,11 @@ func getArchDigestUsingDocker(fullImageName string) (string, error) { } return "", fmt.Errorf("docker pull failed: %s", strings.TrimSpace(string(pullOutput))) } - //IF IMAGE EXISTS LOCALLY + // IF IMAGE EXISTS LOCALLY inspectCmd := exec.Command("docker", "inspect", fullImageName, "--format", "{{.Os}} {{.Architecture}}") - inspectOutput, err := inspectCmd.CombinedOutput() - if err != nil { - log.Debug(fmt.Sprintf("docker inspect failed: %s", strings.TrimSpace(string(inspectOutput)))) + inspectOutput, inspectErr := inspectCmd.CombinedOutput() + if inspectErr != nil { + log.Debug(fmt.Sprintf("docker inspect failed: %v", inspectErr)) return "", nil } parts := strings.Fields(strings.TrimSpace(string(inspectOutput))) @@ -152,9 +152,9 @@ func getArchDigestUsingDocker(fullImageName string) (string, error) { log.Debug(fmt.Sprintf("Local platform: %s/%s", localOS, localArch)) buildxCmd := exec.Command("docker", "buildx", "imagetools", "inspect", fullImageName, "--raw") - buildxOutput, err := buildxCmd.CombinedOutput() - if err != nil { - log.Debug(fmt.Sprintf("docker buildx imagetools inspect failed: %s", strings.TrimSpace(string(buildxOutput)))) + buildxOutput, buildxErr := buildxCmd.CombinedOutput() + if buildxErr != nil { + log.Debug(fmt.Sprintf("docker buildx imagetools inspect failed: %v", buildxErr)) return "", nil } From fe4c43559c0e54d2444cac08f223c69a6d67fc7a Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Mon, 22 Dec 2025 13:45:14 +0200 Subject: [PATCH 20/21] fix: show actual error message for docker buildx imagetools inspect failures --- sca/bom/buildinfo/technologies/docker/docker.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index 569d8fc20..518f5e263 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -139,7 +139,7 @@ func getArchDigestUsingDocker(fullImageName string) (string, error) { inspectCmd := exec.Command("docker", "inspect", fullImageName, "--format", "{{.Os}} {{.Architecture}}") inspectOutput, inspectErr := inspectCmd.CombinedOutput() if inspectErr != nil { - log.Debug(fmt.Sprintf("docker inspect failed: %v", inspectErr)) + log.Error(fmt.Sprintf("docker inspect failed: %v", inspectErr)) return "", nil } parts := strings.Fields(strings.TrimSpace(string(inspectOutput))) @@ -154,13 +154,13 @@ func getArchDigestUsingDocker(fullImageName string) (string, error) { buildxCmd := exec.Command("docker", "buildx", "imagetools", "inspect", fullImageName, "--raw") buildxOutput, buildxErr := buildxCmd.CombinedOutput() if buildxErr != nil { - log.Debug(fmt.Sprintf("docker buildx imagetools inspect failed: %v", buildxErr)) + log.Error(fmt.Sprintf("docker buildx imagetools inspect failed: %s", strings.TrimSpace(string(buildxOutput)))) return "", nil } var manifest dockerManifestList if err := json.Unmarshal(buildxOutput, &manifest); err != nil { - log.Debug(fmt.Sprintf("Failed to parse manifest JSON: %v", err)) + log.Error(fmt.Sprintf("Failed to parse manifest JSON: %v", err)) return "", nil } From ae9c166258942ab9729971cf73cadf45b24255a2 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Mon, 22 Dec 2025 13:54:15 +0200 Subject: [PATCH 21/21] fix: properly return error from docker buildx imagetools inspect --- sca/bom/buildinfo/technologies/docker/docker.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index 518f5e263..46c4bca8f 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -154,8 +154,7 @@ func getArchDigestUsingDocker(fullImageName string) (string, error) { buildxCmd := exec.Command("docker", "buildx", "imagetools", "inspect", fullImageName, "--raw") buildxOutput, buildxErr := buildxCmd.CombinedOutput() if buildxErr != nil { - log.Error(fmt.Sprintf("docker buildx imagetools inspect failed: %s", strings.TrimSpace(string(buildxOutput)))) - return "", nil + return "", fmt.Errorf("docker buildx imagetools inspect failed: %s", strings.TrimSpace(string(buildxOutput))) } var manifest dockerManifestList