diff --git a/cli/docs/flags.go b/cli/docs/flags.go index c4d2c7e30..f56595886 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -151,8 +151,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" @@ -211,7 +212,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, @@ -336,6 +337,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, "Specifies the Docker image name to audit. Uses the same format as the Docker CLI, including Artifactory-hosted images."), + // 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 b32f45175..62f476b48 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -697,6 +697,7 @@ func getCurationCommand(c *components.Context) (*curation.CurationAuditCommand, SetNpmScope(c.GetStringFlagValue(flags.DepType)). SetPipRequirementsFile(c.GetStringFlagValue(flags.RequirementsFile)). SetSolutionFilePath(c.GetStringFlagValue(flags.SolutionPath)) + curationAuditCommand.SetDockerImageName(c.GetStringFlagValue(flags.DockerImageName)) return curationAuditCommand, nil } diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index bc13212fa..4a00738c7 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) { @@ -217,6 +219,7 @@ type CurationAuditCommand struct { workingDirs []string OriginPath string parallelRequests int + dockerImageName string audit.AuditParamsInterface } @@ -253,6 +256,15 @@ func (ca *CurationAuditCommand) SetParallelRequests(threads int) *CurationAuditC return ca } +func (ca *CurationAuditCommand) DockerImageName() string { + return ca.dockerImageName +} + +func (ca *CurationAuditCommand) SetDockerImageName(dockerImageName string) *CurationAuditCommand { + ca.dockerImageName = dockerImageName + return ca +} + func (ca *CurationAuditCommand) Run() (err error) { rootDir, err := os.Getwd() if err != nil { @@ -350,6 +362,10 @@ func getPolicyAndConditionId(policy, condition string) string { func (ca *CurationAuditCommand) doCurateAudit(results map[string]*CurationReport) error { techs := techutils.DetectedTechnologiesList() + if ca.DockerImageName() != "" { + log.Debug(fmt.Sprintf("Docker image name '%s' was provided, running Docker curation audit.", ca.DockerImageName())) + techs = []string{techutils.Docker.String()} + } for _, tech := range techs { supportedFunc, ok := supportedTech[techutils.Technology(tech)] if !ok { @@ -426,6 +442,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 +736,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 +978,9 @@ func getUrlNameAndVersionByTech(tech techutils.Technology, node *xrayUtils.Graph case techutils.Nuget: downloadUrls, name, version = getNugetNameScopeAndVersion(node.Id, artiUrl, repo) return + case techutils.Docker: + downloadUrls, name, version = getDockerNameAndVersion(node.Id, artiUrl, repo) + return } return } @@ -1134,6 +1165,39 @@ func buildNpmDownloadUrl(url, repo, name, scope, version string) []string { return []string{packageUrl} } +func getDockerNameAndVersion(id, artiUrl, repo string) (downloadUrls []string, name, version string) { + if id == "" { + return + } + + id = strings.TrimPrefix(id, "docker://") + + sha256Idx := strings.Index(id, ":sha256:") + tagIdx := strings.LastIndex(id, ":") + + switch { + // Example: docker://nginx:sha256:abc123def456 + case sha256Idx > 0: + name = id[:sha256Idx] + version = id[sha256Idx+1:] + // Example: docker://nginx:1.21 + case tagIdx > 0: + name = id[:tagIdx] + version = id[tagIdx+1:] + // Example: docker://nginx (no tag specified, defaults to "latest") + default: + 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..c4617e358 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -1187,6 +1187,62 @@ func Test_getGemNameScopeAndVersion(t *testing.T) { } } +func Test_getDockerNameAndVersion(t *testing.T) { + tests := []struct { + name string + id string + artiUrl string + repo string + wantDownloadUrls []string + wantName 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", + 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", + 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", + 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", + wantVersion: "latest", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDownloadUrls, gotName, gotVersion := getDockerNameAndVersion(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.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 f27e8fcef..79f9e84e4 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" @@ -105,6 +106,34 @@ 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() + + testImage := fmt.Sprintf("%s/%s/%s", *securityTests.ContainerRegistry, "docker-curation", "ganodndentcom/drupal") + + output := securityTests.PlatformCli.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 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..a0052c2d5 --- /dev/null +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -0,0 +1,217 @@ +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-cli-security/utils/artifactory" + "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] + info.Repo, info.Image = parseRegistryAndExtract(info.Registry, parts[1]) + + 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)) + + // Note: It does NOT extract the actual dependencies/packages inside the Docker image layers. + // The graph contains just the image reference to verify if it's blocked by curation policies. + 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, we need to get the digest of the image for the specific OS/architecture + localOS, localArch, err := getLocalPlatform(fullImageName) + if err != nil { + return "", err + } + + return findDigestForPlatform(fullImageName, localOS, localArch) +} + +// Retrieves the OS and architecture of a locally pulled Docker image. +func getLocalPlatform(imageName string) (os, arch string, err error) { + inspectCmd := exec.Command("docker", "inspect", imageName, "--format", "{{.Os}} {{.Architecture}}") + inspectOutput, inspectErr := inspectCmd.CombinedOutput() + if inspectErr != nil { + log.Error(fmt.Sprintf("docker inspect failed: %v", inspectErr)) + return "", "", inspectErr + } + parts := strings.Fields(strings.TrimSpace(string(inspectOutput))) + if len(parts) != 2 { + return "", "", fmt.Errorf("unexpected inspect output format") + } + log.Debug(fmt.Sprintf("Local platform: %s/%s", parts[0], parts[1])) + return parts[0], parts[1], nil +} + +// Retrieves the digest for a specific OS/architecture from the image manifest. +func findDigestForPlatform(imageName, targetOS, targetArch string) (string, error) { + buildxCmd := exec.Command("docker", "buildx", "imagetools", "inspect", imageName, "--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 == targetOS && m.Platform.Architecture == targetArch { + 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", targetOS, targetArch)) + 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 + } + exists, err := artifactory.IsRepoExists(imageInfo.Repo, serverDetails) + if err != nil { + return nil, fmt.Errorf("failed to check if repository '%s' exists on Artifactory '%s': %w", imageInfo.Repo, serverDetails.Url, err) + } + if !exists { + return nil, fmt.Errorf("repository '%s' was not found on Artifactory (%s), ensure the repository exists", imageInfo.Repo, serverDetails.Url) + } + + 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..f0d3a2c8c --- /dev/null +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -0,0 +1,183 @@ +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 + errorContains string + }{ + { + name: "Empty image name", + dockerImageName: "", + expectError: true, + errorContains: "docker image name is required", + }, + { + name: "No registry - single part image", + dockerImageName: "nginx", + expectError: true, + errorContains: "invalid docker image format", + }, + { + name: "No registry - image with tag only", + dockerImageName: "nginx:1.21", + expectError: true, + errorContains: "invalid docker image format", + }, + { + name: "Whitespace only", + dockerImageName: " ", + expectError: true, + errorContains: "invalid docker image format", + }, + } + + 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) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +}