diff --git a/cli/docs/flags.go b/cli/docs/flags.go index 9760a394..02824c47 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -149,8 +149,9 @@ const ( AnalyzerManagerCustomPath = "analyzer-manager-path" // Unique curation flags - CurationOutput = "curation-format" - SolutionPath = "solution-path" + CurationOutput = "curation-format" + DockerImageName = "image" + SolutionPath = "solution-path" // Unique git flags InputFile = "input-file" @@ -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. 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.."), 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 0e134abd..9464be02 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 7ea2c072..bff1a24b 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 cc0d28c8..85fd326e 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 bc13212f..099a7c5d 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,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 true, nil }, } func (ca *CurationAuditCommand) checkSupportByVersionOrEnv(tech techutils.Technology, minArtiVersion string) (bool, error) { @@ -350,6 +352,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 { @@ -426,6 +431,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 @@ -718,6 +725,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 @@ -950,6 +967,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 +1153,32 @@ 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://") + + if idx := strings.Index(id, ":sha256:"); idx > 0 { + name = id[:idx] + version = id[idx+1:] + } else if idx := strings.LastIndex(id, ":"); idx > 0 { + name = id[:idx] + version = id[idx+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 downloadUrls, name, scope, version +} + 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 2c27c788..4c630a5d 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -1187,6 +1187,68 @@ 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", + }, + } + 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/curation_test.go b/curation_test.go index f27e8fce..11e64b3c 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" @@ -13,6 +14,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 +107,38 @@ func getCurationExpectedResponse(config *config.ServerDetails) []curation.Packag return expectedResp } +func TestDockerCurationAudit(t *testing.T) { + integration.InitCurationTest(t) + 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) + + 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)) + + 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) + + 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) { diff --git a/sca/bom/buildinfo/buildinfobom.go b/sca/bom/buildinfo/buildinfobom.go index fe767126..a95627b6 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 18df8c65..10aa1c27 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 00000000..46c4bca8 --- /dev/null +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -0,0 +1,198 @@ +package docker + +import ( + "encoding/json" + "fmt" + "os/exec" + "regexp" + "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+\.`) + hexDigestPattern = regexp.MustCompile(`[a-fA-F0-9]{64}`) +) + +func ParseDockerImage(imageName string) (*DockerImageInfo, error) { + imageName = strings.TrimSpace(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.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]) + 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)) + + return info, nil +} + +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 { + 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 (repo in path if contains /) + if strings.Contains(remaining, "/") { + repo, image, _ = strings.Cut(remaining, "/") + 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)) + + rootId := imageInfo.Image + ":" + imageInfo.Tag + return []*xrayUtils.GraphNode{{Id: rootId, Nodes: []*xrayUtils.GraphNode{{Id: imageRef}}}}, + []string{imageRef}, nil +} + +func getArchDigestUsingDocker(fullImageName string) (string, error) { + pullCmd := exec.Command("docker", "pull", fullImageName) + pullOutput, err := pullCmd.CombinedOutput() + if err != nil { + if strings.Contains(string(pullOutput), "curation service") { + return extractDigestFromBlockedMessage(string(pullOutput)), nil + } + 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, inspectErr := inspectCmd.CombinedOutput() + if inspectErr != nil { + log.Error(fmt.Sprintf("docker inspect failed: %v", inspectErr)) + 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, buildxErr := buildxCmd.CombinedOutput() + if buildxErr != nil { + return "", fmt.Errorf("docker buildx imagetools inspect failed: %s", strings.TrimSpace(string(buildxOutput))) + } + + var manifest dockerManifestList + if err := json.Unmarshal(buildxOutput, &manifest); err != nil { + log.Error(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 +} + +func extractDigestFromBlockedMessage(output string) string { + if match := hexDigestPattern.FindString(output); match != "" { + return "sha256:" + match + } + return "" +} + +func GetDockerRepositoryConfig(imageName string) (*project.RepositoryConfig, error) { + imageInfo, err := ParseDockerImage(imageName) + if err != nil { + return nil, err + } + + serverDetails, err := config.GetDefaultServerConf() + if err != nil { + return nil, err + } + + 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 00000000..4a4bcbe0 --- /dev/null +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -0,0 +1,155 @@ +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", + }, + { + 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 { + 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) + } + }) + } +}