From 61825df3c1140844d72a8d1af48650f18f7fe0f5 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Fri, 2 Jan 2026 12:30:20 +0200 Subject: [PATCH 1/5] repo extraction fix --- .../buildinfo/technologies/docker/docker.go | 117 ++- .../technologies/docker/docker_test.go | 690 +++++++++++++++--- 2 files changed, 691 insertions(+), 116 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index a0052c2d..4f0ad4ba 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -35,12 +35,28 @@ type dockerManifestList struct { } 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}`) + hexDigestPattern = regexp.MustCompile(`[a-fA-F0-9]{64}`) ) -func ParseDockerImage(imageName string) (*DockerImageInfo, error) { +// getArtifactoryBaseDomain extracts the base domain from the configured Artifactory URL. +// e.g., "https://myinstance.jfrog.io/artifactory" -> "myinstance.jfrog.io" +// e.g., "https://artifactory.company.com:8081/artifactory" -> "artifactory.company.com" +func getArtifactoryBaseDomain(url string) string { + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + if idx := strings.Index(url, "/"); idx > 0 { + url = url[:idx] + } + if idx := strings.LastIndex(url, ":"); idx > 0 { + if !strings.Contains(url[idx:], "]") { + url = url[:idx] + } + } + return url +} + +// ParseDockerImageWithArtifactoryUrl parses a Docker image name and extracts repo based on Artifactory URL. +func ParseDockerImageWithArtifactoryUrl(imageName, url string) (*DockerImageInfo, error) { imageName = strings.TrimSpace(imageName) info := &DockerImageInfo{Tag: "latest"} if idx := strings.LastIndex(imageName, ":"); idx > 0 { @@ -57,7 +73,9 @@ func ParseDockerImage(imageName string) (*DockerImageInfo, error) { } info.Registry = parts[0] - info.Repo, info.Image = parseRegistryAndExtract(info.Registry, parts[1]) + remaining := parts[1] + + info.Repo, info.Image = parseWithArtifactoryUrl(info.Registry, remaining, url) log.Debug(fmt.Sprintf("Parsed Docker image - Registry: %s, Repo: %s, Image: %s, Tag: %s", info.Registry, info.Repo, info.Image, info.Tag)) @@ -65,35 +83,74 @@ func ParseDockerImage(imageName string) (*DockerImageInfo, error) { return info, nil } -func parseRegistryAndExtract(registry string, remaining string) (repo, image string) { +// parseWithArtifactoryUrl determines Docker access method by comparing registry with Artifactory URL. +func parseWithArtifactoryUrl(registry, remaining, url string) (repo, image string) { image = remaining + baseDomain := getArtifactoryBaseDomain(url) - // SaaS subdomain: -.jfrog.io/image:tag (repo in subdomain, check first) - if matches := jfrogSubdomainPattern.FindStringSubmatch(registry); len(matches) > 2 { - repo = matches[2] - return + registryHost, registryPort := splitHostPort(registry) + + isSaaS := strings.HasSuffix(baseDomain, ".jfrog.io") || strings.HasSuffix(baseDomain, ".jfrogdev.org") + + if repo = extractSubdomainRepo(registryHost, baseDomain, isSaaS); repo != "" { + log.Debug(fmt.Sprintf("Subdomain method detected (repo=%s)", repo)) + return repo, remaining } - // 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 + if registryPort != "" && registryHost == baseDomain { + log.Debug(fmt.Sprintf("Port method detected (repo=%s)", registryPort)) + return registryPort, remaining } + if registryHost == baseDomain && registryPort == "" { + log.Debug("Repository Path method detected") + return extractRepoFromPath(remaining) + } + log.Debug("Fallback: using Repository Path extraction") + return extractRepoFromPath(remaining) +} - // Repository path: //image:tag (repo in path if contains /) - if strings.Contains(remaining, "/") { - repo, image, _ = strings.Cut(remaining, "/") - return +func splitHostPort(registry string) (host, port string) { + if idx := strings.LastIndex(registry, ":"); idx > 0 { + return registry[:idx], registry[idx+1:] } + return registry, "" +} - // Port method: :/image:tag (port IS the repo, single part only) - if strings.Contains(registry, ":") { - _, repo, _ = strings.Cut(registry, ":") - return +// extractSubdomainRepo extracts repo from subdomain based on platform type. +// SaaS: -. → repo is after hyphen +// Self-hosted: . → repo is prepended subdomain +func extractSubdomainRepo(registryHost, baseDomain string, isSaaS bool) string { + if isSaaS { + // SaaS pattern: -. + baseParts := strings.SplitN(baseDomain, ".", 2) + if len(baseParts) != 2 { + return "" + } + instance, domainSuffix := baseParts[0], baseParts[1] + expectedSuffix := "." + domainSuffix + + if strings.HasSuffix(registryHost, expectedSuffix) { + prefix := strings.TrimSuffix(registryHost, expectedSuffix) + if strings.HasPrefix(prefix, instance+"-") && prefix != instance { + return strings.TrimPrefix(prefix, instance+"-") + } + } + } else { + // Self-hosted pattern: . + if strings.HasSuffix(registryHost, "."+baseDomain) { + return strings.TrimSuffix(registryHost, "."+baseDomain) + } } + return "" +} - return "", "" +// extractRepoFromPath extracts repo as first segment if path contains "/" +func extractRepoFromPath(path string) (repo, image string) { + if strings.Contains(path, "/") { + repo, image, _ = strings.Cut(path, "/") + return + } + return "", path } func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ([]*xrayUtils.GraphNode, []string, error) { @@ -101,7 +158,12 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ([]*xr return nil, nil, fmt.Errorf("docker image name is required") } - imageInfo, err := ParseDockerImage(params.DockerImageName) + serverDetails, err := config.GetDefaultServerConf() + if err != nil { + return nil, nil, err + } + + imageInfo, err := ParseDockerImageWithArtifactoryUrl(params.DockerImageName, serverDetails.Url) if err != nil { return nil, nil, err } @@ -194,12 +256,11 @@ func extractDigestFromBlockedMessage(output string) string { } func GetDockerRepositoryConfig(imageName string) (*project.RepositoryConfig, error) { - imageInfo, err := ParseDockerImage(imageName) + serverDetails, err := config.GetDefaultServerConf() if err != nil { return nil, err } - - serverDetails, err := config.GetDefaultServerConf() + imageInfo, err := ParseDockerImageWithArtifactoryUrl(imageName, serverDetails.Url) if err != nil { return nil, err } diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go index f0d3a2c8..1d460ea8 100644 --- a/sca/bom/buildinfo/technologies/docker/docker_test.go +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -8,127 +8,641 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseDockerImage(t *testing.T) { +// TestParseDockerImageWithArtifactoryUrl tests parsing WITH Artifactory URL +func TestParseDockerImageWithArtifactoryUrl(t *testing.T) { tests := []struct { - name string - imageName string - expectedRepo string - expectedImg string - expectedTag string - expectError bool + name string + imageName string + artifactoryUrl string + expectedRepo string + expectedImg string + expectedTag string }{ - // 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 repo path - simple image", + imageName: "acme.jfrog.io/docker-local/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + 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", + name: "SaaS repo path - nested image", + imageName: "acme.jfrog.io/docker-local/bitnami/kubectl:latest", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + 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 repo path - deeply nested image", + imageName: "acme.jfrog.io/docker-remote/library/nginx/stable:1.25", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "library/nginx/stable", + expectedTag: "1.25", }, { - 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", + name: "SaaS repo path - version tag with dots", + imageName: "mycompany.jfrog.io/docker-prod/myapp:1.2.3", + artifactoryUrl: "https://mycompany.jfrog.io/artifactory", + expectedRepo: "docker-prod", + expectedImg: "myapp", + expectedTag: "1.2.3", }, - // Subdomain CNAME { - name: "Subdomain CNAME format", - imageName: "docker-local.acme.com/nginx:alpine", - expectedRepo: "docker-local", - expectedImg: "nginx", - expectedTag: "alpine", + name: "SaaS repo path - short sha tag", + imageName: "acme.jfrog.io/docker-local/nginx:abc123def456", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "abc123def456", }, - // 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", + name: "SaaS repo path - no tag defaults to latest", + imageName: "acme.jfrog.io/docker-local/nginx", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "latest", }, - // Self-Managed: Subdomain { - name: "Self-managed subdomain", - imageName: "docker-virtual.myartifactory.com/alpine:3.18", - expectedRepo: "docker-virtual", - expectedImg: "alpine", - expectedTag: "3.18", + name: "SaaS repo path - repo with hyphen", + imageName: "acme.jfrog.io/docker-virtual-prod/redis:7.0", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-virtual-prod", + expectedImg: "redis", + expectedTag: "7.0", }, - // 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", + name: "SaaS repo path - image with uppercase", + imageName: "acme.jfrog.io/docker-local/MyApp:v1", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "MyApp", + expectedTag: "v1", }, - // 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: "SaaS subdomain - simple image", + imageName: "acme-docker-local.jfrog.io/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + 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: "SaaS subdomain - nested image", + imageName: "acme-docker-remote.jfrog.io/bitnami/redis:7.0", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "bitnami/redis", + expectedTag: "7.0", }, { - 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", + name: "SaaS subdomain - deeply nested image", + imageName: "acme-docker-virtual.jfrog.io/library/nginx/stable:1.25", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-virtual", + expectedImg: "library/nginx/stable", + expectedTag: "1.25", }, - // Default tag { - name: "No tag defaults to latest", - imageName: "acme.jfrog.io/docker-local/nginx", - expectedRepo: "docker-local", - expectedImg: "nginx", - expectedTag: "latest", + name: "SaaS subdomain - repo with multiple hyphens", + imageName: "acme-docker-prod-release.jfrog.io/myapp:v2", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-prod-release", + expectedImg: "myapp", + expectedTag: "v2", }, { - name: "Tag with multiple dots", - imageName: "acme.jfrog.io/docker-local/myapp:1.0.0", - expectedRepo: "docker-local", - expectedImg: "myapp", - expectedTag: "1.0.0", + name: "SaaS subdomain - no tag", + imageName: "acme-docker-local.jfrog.io/alpine", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "alpine", + expectedTag: "latest", + }, + { + name: "SaaS subdomain - complex nested path", + imageName: "mycompany-docker-remote.jfrog.io/gcr.io/google-containers/pause:3.2", + artifactoryUrl: "https://mycompany.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "gcr.io/google-containers/pause", + expectedTag: "3.2", + }, + { + name: "SaaS subdomain - numeric instance", + imageName: "company123-docker-local.jfrog.io/app:1.0", + artifactoryUrl: "https://company123.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "app", + expectedTag: "1.0", + }, + + // ========================================== + // JFrog SaaS (.jfrog.io) - PORT METHOD + // ========================================== + { + name: "SaaS port - simple image", + imageName: "acme.jfrog.io:8081/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "8081", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "SaaS port - nested image", + imageName: "acme.jfrog.io:8082/bitnami/redis:7.0", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "8082", + expectedImg: "bitnami/redis", + expectedTag: "7.0", + }, + { + name: "SaaS port - high port number", + imageName: "acme.jfrog.io:54321/myapp:latest", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "54321", + expectedImg: "myapp", + expectedTag: "latest", + }, + { + name: "Dev repo path - simple image", + imageName: "z0curation211355112.jfrogdev.org/curation-test/hello-app:1.0", + artifactoryUrl: "https://z0curation211355112.jfrogdev.org/artifactory", + expectedRepo: "curation-test", + expectedImg: "hello-app", + expectedTag: "1.0", + }, + { + name: "Dev repo path - nested image", + imageName: "z0curation211355112.jfrogdev.org/curation-test/google-samples/hello-app:1.0", + artifactoryUrl: "https://z0curation211355112.jfrogdev.org/artifactory", + expectedRepo: "curation-test", + expectedImg: "google-samples/hello-app", + expectedTag: "1.0", + }, + { + name: "Dev repo path - deeply nested", + imageName: "testinstance.jfrogdev.org/docker-local/org/team/service:v1.2.3", + artifactoryUrl: "https://testinstance.jfrogdev.org/artifactory", + expectedRepo: "docker-local", + expectedImg: "org/team/service", + expectedTag: "v1.2.3", + }, + { + name: "Dev repo path - numeric instance name", + imageName: "dev12345.jfrogdev.org/test-repo/app:beta", + artifactoryUrl: "https://dev12345.jfrogdev.org/artifactory", + expectedRepo: "test-repo", + expectedImg: "app", + expectedTag: "beta", + }, + { + name: "Dev subdomain - simple image", + imageName: "myinstance-docker-local.jfrogdev.org/nginx:latest", + artifactoryUrl: "https://myinstance.jfrogdev.org/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "latest", + }, + { + name: "Dev subdomain - nested image", + imageName: "myinstance-docker-local.jfrogdev.org/bitnami/nginx:alpine", + artifactoryUrl: "https://myinstance.jfrogdev.org/artifactory", + expectedRepo: "docker-local", + expectedImg: "bitnami/nginx", + expectedTag: "alpine", + }, + { + name: "Dev subdomain - deeply nested", + imageName: "testdev-docker-remote.jfrogdev.org/gcr.io/distroless/static:latest", + artifactoryUrl: "https://testdev.jfrogdev.org/artifactory", + expectedRepo: "docker-remote", + expectedImg: "gcr.io/distroless/static", + expectedTag: "latest", + }, + { + name: "Dev subdomain - repo with hyphens", + imageName: "instance1-docker-prod-release.jfrogdev.org/myapp:v3", + artifactoryUrl: "https://instance1.jfrogdev.org/artifactory", + expectedRepo: "docker-prod-release", + expectedImg: "myapp", + expectedTag: "v3", + }, + { + name: "Self-hosted repo path - simple domain", + imageName: "artifactory.company.com/docker-local/nginx:1.21", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Self-hosted repo path - nested image", + imageName: "artifactory.company.com/docker-remote/bitnami/redis:7.0", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-remote", + expectedImg: "bitnami/redis", + expectedTag: "7.0", + }, + { + name: "Self-hosted repo path - multi-part domain", + imageName: "artifactory.packages.dev.rsint.net/curation-test/google-samples/hello-app:1.0", + artifactoryUrl: "https://artifactory.packages.dev.rsint.net/artifactory", + expectedRepo: "curation-test", + expectedImg: "google-samples/hello-app", + expectedTag: "1.0", + }, + { + name: "Self-hosted repo path - 4-part domain", + imageName: "docker.artifacts.internal.net/prod-repo/myservice:v2.1.0", + artifactoryUrl: "https://docker.artifacts.internal.net/artifactory", + expectedRepo: "prod-repo", + expectedImg: "myservice", + expectedTag: "v2.1.0", + }, + { + name: "Self-hosted repo path - 5-part domain", + imageName: "registry.docker.internal.corp.net/docker-virtual/org/service:v1.2.3", + artifactoryUrl: "https://registry.docker.internal.corp.net/artifactory", + expectedRepo: "docker-virtual", + expectedImg: "org/service", + expectedTag: "v1.2.3", + }, + { + name: "Self-hosted repo path - simple 2-part domain", + imageName: "myartifactory.com/docker-local/alpine:3.18", + artifactoryUrl: "https://myartifactory.com/artifactory", + expectedRepo: "docker-local", + expectedImg: "alpine", + expectedTag: "3.18", + }, + { + name: "Self-hosted repo path - no tag", + imageName: "artifactory.company.com/docker-local/ubuntu", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-local", + expectedImg: "ubuntu", + expectedTag: "latest", + }, + { + name: "Self-hosted repo path - deeply nested image", + imageName: "artifactory.company.com/docker-remote/quay.io/prometheus/prometheus:v2.45.0", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-remote", + expectedImg: "quay.io/prometheus/prometheus", + expectedTag: "v2.45.0", + }, + { + name: "Self-hosted subdomain - simple", + imageName: "docker-local.artifactory.company.com/nginx:1.21", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Self-hosted subdomain - nested image", + imageName: "docker-local.artifactory.company.com/bitnami/nginx:alpine", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-local", + expectedImg: "bitnami/nginx", + expectedTag: "alpine", + }, + { + name: "Self-hosted subdomain - deeply nested", + imageName: "docker-remote.myartifactory.com/library/nginx/stable:1.25", + artifactoryUrl: "https://myartifactory.com/artifactory", + expectedRepo: "docker-remote", + expectedImg: "library/nginx/stable", + expectedTag: "1.25", + }, + { + name: "Self-hosted subdomain - repo with hyphens", + imageName: "docker-prod-release.artifactory.company.com/myapp:v1.0.0", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-prod-release", + expectedImg: "myapp", + expectedTag: "v1.0.0", + }, + { + name: "Self-hosted subdomain - multi-part base domain", + imageName: "docker-virtual.registry.internal.net/app:latest", + artifactoryUrl: "https://registry.internal.net/artifactory", + expectedRepo: "docker-virtual", + expectedImg: "app", + expectedTag: "latest", + }, + { + name: "Self-hosted subdomain - no tag", + imageName: "docker-local.myartifactory.com/busybox", + artifactoryUrl: "https://myartifactory.com/artifactory", + expectedRepo: "docker-local", + expectedImg: "busybox", + expectedTag: "latest", + }, + { + name: "Self-hosted subdomain - complex nested path", + imageName: "docker-remote.artifactory.company.com/gcr.io/google-containers/pause:3.2", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "docker-remote", + expectedImg: "gcr.io/google-containers/pause", + expectedTag: "3.2", + }, + { + name: "Self-hosted port - simple", + imageName: "artifactory.company.com:8081/nginx:1.21", + artifactoryUrl: "https://artifactory.company.com/artifactory", + expectedRepo: "8081", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Self-hosted port - nested image", + imageName: "myartifactory.com:8082/bitnami/redis:7.0", + artifactoryUrl: "https://myartifactory.com/artifactory", + expectedRepo: "8082", + expectedImg: "bitnami/redis", + expectedTag: "7.0", + }, + { + name: "Self-hosted port - multi-part domain", + imageName: "registry.internal.net:9000/myapp:v1", + artifactoryUrl: "https://registry.internal.net/artifactory", + expectedRepo: "9000", + expectedImg: "myapp", + expectedTag: "v1", + }, + { + name: "Tag with v prefix", + imageName: "acme.jfrog.io/docker-local/myapp:v1.2.3", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "v1.2.3", + }, + { + name: "Tag with build number", + imageName: "acme.jfrog.io/docker-local/myapp:1.0.0-build.123", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "1.0.0-build.123", + }, + { + name: "Tag with git sha", + imageName: "acme.jfrog.io/docker-local/myapp:abc123def", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "abc123def", + }, + { + name: "Tag with date", + imageName: "acme.jfrog.io/docker-local/myapp:2024-01-15", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "2024-01-15", + }, + { + name: "Tag alpha", + imageName: "acme.jfrog.io/docker-local/myapp:alpha", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "alpha", + }, + { + name: "Tag beta", + imageName: "acme.jfrog.io/docker-local/myapp:beta", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "beta", + }, + { + name: "Tag rc", + imageName: "acme.jfrog.io/docker-local/myapp:1.0.0-rc1", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "1.0.0-rc1", + }, + { + name: "Repo name docker-local", + imageName: "acme.jfrog.io/docker-local/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Repo name docker-remote", + imageName: "acme.jfrog.io/docker-remote/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Repo name docker-virtual", + imageName: "acme.jfrog.io/docker-virtual/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-virtual", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Repo name with env - dev", + imageName: "acme.jfrog.io/docker-dev/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-dev", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Repo name with env - prod", + imageName: "acme.jfrog.io/docker-prod/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-prod", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Repo name with env - staging", + imageName: "acme.jfrog.io/docker-staging/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-staging", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Custom repo name", + imageName: "acme.jfrog.io/my-custom-repo/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "my-custom-repo", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Short repo name", + imageName: "acme.jfrog.io/repo/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "repo", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Official image nginx", + imageName: "acme.jfrog.io/docker-remote/nginx:latest", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "nginx", + expectedTag: "latest", + }, + { + name: "Official image redis", + imageName: "acme.jfrog.io/docker-remote/redis:7.0", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "redis", + expectedTag: "7.0", + }, + { + name: "Official image postgres", + imageName: "acme.jfrog.io/docker-remote/postgres:15", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "postgres", + expectedTag: "15", + }, + { + name: "Bitnami image", + imageName: "acme.jfrog.io/docker-remote/bitnami/postgresql:15.3.0", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "bitnami/postgresql", + expectedTag: "15.3.0", + }, + { + name: "Google container", + imageName: "acme.jfrog.io/docker-remote/gcr.io/google-containers/pause:3.9", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "gcr.io/google-containers/pause", + expectedTag: "3.9", + }, + { + name: "AWS ECR image", + imageName: "acme.jfrog.io/docker-remote/public.ecr.aws/lambda/python:3.11", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "public.ecr.aws/lambda/python", + expectedTag: "3.11", + }, + { + name: "Quay.io image", + imageName: "acme.jfrog.io/docker-remote/quay.io/prometheus/prometheus:v2.45.0", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "quay.io/prometheus/prometheus", + expectedTag: "v2.45.0", + }, + { + name: "GitHub container registry", + imageName: "acme.jfrog.io/docker-remote/ghcr.io/actions/runner:latest", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "ghcr.io/actions/runner", + expectedTag: "latest", + }, + + // ========================================== + // Edge cases - Artifactory URL variations + // ========================================== + { + name: "Artifactory URL without trailing slash", + imageName: "acme.jfrog.io/docker-local/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Artifactory URL with trailing slash", + imageName: "acme.jfrog.io/docker-local/nginx:1.21", + artifactoryUrl: "https://acme.jfrog.io/artifactory/", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Artifactory URL http", + imageName: "acme.jfrog.io/docker-local/nginx:1.21", + artifactoryUrl: "http://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Artifactory URL with port", + imageName: "myartifactory.com/docker-local/nginx:1.21", + artifactoryUrl: "https://myartifactory.com:8443/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "Kubernetes deployment image", + imageName: "acme.jfrog.io/docker-prod/mycompany/backend-service:v2.3.1", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-prod", + expectedImg: "mycompany/backend-service", + expectedTag: "v2.3.1", + }, + { + name: "CI/CD built image", + imageName: "acme-docker-local.jfrog.io/builds/myapp:build-1234", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "builds/myapp", + expectedTag: "build-1234", + }, + { + name: "Helm chart container", + imageName: "acme.jfrog.io/docker-virtual/charts/mychart:0.1.0", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-virtual", + expectedImg: "charts/mychart", + expectedTag: "0.1.0", + }, + { + name: "Multi-arch image", + imageName: "acme.jfrog.io/docker-local/myapp:1.0.0-amd64", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-local", + expectedImg: "myapp", + expectedTag: "1.0.0-amd64", + }, + { + name: "Distroless image", + imageName: "acme.jfrog.io/docker-remote/gcr.io/distroless/static-debian11:nonroot", + artifactoryUrl: "https://acme.jfrog.io/artifactory", + expectedRepo: "docker-remote", + expectedImg: "gcr.io/distroless/static-debian11", + expectedTag: "nonroot", }, } 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 - } + info, err := ParseDockerImageWithArtifactoryUrl(tt.imageName, tt.artifactoryUrl) 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) + assert.Equal(t, tt.expectedRepo, info.Repo, "repo mismatch") + assert.Equal(t, tt.expectedImg, info.Image, "image mismatch") + assert.Equal(t, tt.expectedTag, info.Tag, "tag mismatch") }) } } From 8cda26a443f7da8931ee50d0c84d0435338ca20a Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Fri, 2 Jan 2026 12:41:42 +0200 Subject: [PATCH 2/5] fix lint errors --- sca/bom/buildinfo/technologies/docker/docker.go | 7 ++----- sca/bom/buildinfo/technologies/docker/docker_test.go | 4 ---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index 4f0ad4ba..14901306 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -85,7 +85,6 @@ func ParseDockerImageWithArtifactoryUrl(imageName, url string) (*DockerImageInfo // parseWithArtifactoryUrl determines Docker access method by comparing registry with Artifactory URL. func parseWithArtifactoryUrl(registry, remaining, url string) (repo, image string) { - image = remaining baseDomain := getArtifactoryBaseDomain(url) registryHost, registryPort := splitHostPort(registry) @@ -135,11 +134,9 @@ func extractSubdomainRepo(registryHost, baseDomain string, isSaaS bool) string { return strings.TrimPrefix(prefix, instance+"-") } } - } else { + } else if strings.HasSuffix(registryHost, "."+baseDomain) { // Self-hosted pattern: . - if strings.HasSuffix(registryHost, "."+baseDomain) { - return strings.TrimSuffix(registryHost, "."+baseDomain) - } + return strings.TrimSuffix(registryHost, "."+baseDomain) } return "" } diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go index 1d460ea8..9aa3d4a7 100644 --- a/sca/bom/buildinfo/technologies/docker/docker_test.go +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -558,10 +558,6 @@ func TestParseDockerImageWithArtifactoryUrl(t *testing.T) { expectedImg: "ghcr.io/actions/runner", expectedTag: "latest", }, - - // ========================================== - // Edge cases - Artifactory URL variations - // ========================================== { name: "Artifactory URL without trailing slash", imageName: "acme.jfrog.io/docker-local/nginx:1.21", From b4164f51ada27ee3f387c688f5bd13d04767e8db Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Sun, 4 Jan 2026 11:12:19 +0200 Subject: [PATCH 3/5] added unit test for ip:port --- .../buildinfo/technologies/docker/docker.go | 37 ++--- .../technologies/docker/docker_test.go | 140 ++++++++++++++++++ 2 files changed, 156 insertions(+), 21 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index 14901306..35336427 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -3,6 +3,7 @@ package docker import ( "encoding/json" "fmt" + "net/url" "os/exec" "regexp" "strings" @@ -38,21 +39,15 @@ var ( hexDigestPattern = regexp.MustCompile(`[a-fA-F0-9]{64}`) ) -// getArtifactoryBaseDomain extracts the base domain from the configured Artifactory URL. -// e.g., "https://myinstance.jfrog.io/artifactory" -> "myinstance.jfrog.io" -// e.g., "https://artifactory.company.com:8081/artifactory" -> "artifactory.company.com" -func getArtifactoryBaseDomain(url string) string { - url = strings.TrimPrefix(url, "https://") - url = strings.TrimPrefix(url, "http://") - if idx := strings.Index(url, "/"); idx > 0 { - url = url[:idx] +// getArtifactoryHostPort extracts the host and port from the configured Artifactory URL. +// e.g., "https://myinstance.jfrog.io/artifactory" -> "myinstance.jfrog.io", "" +// e.g., "http://177.111.1.100:8082/artifactory" -> "192.168.1.100", "8082" +func getArtifactoryHostPort(artifactoryUrl string) (host, port string) { + parsed, err := url.Parse(artifactoryUrl) + if err != nil || parsed.Host == "" { + return "", "" } - if idx := strings.LastIndex(url, ":"); idx > 0 { - if !strings.Contains(url[idx:], "]") { - url = url[:idx] - } - } - return url + return splitHostPort(parsed.Host) } // ParseDockerImageWithArtifactoryUrl parses a Docker image name and extracts repo based on Artifactory URL. @@ -85,26 +80,26 @@ func ParseDockerImageWithArtifactoryUrl(imageName, url string) (*DockerImageInfo // parseWithArtifactoryUrl determines Docker access method by comparing registry with Artifactory URL. func parseWithArtifactoryUrl(registry, remaining, url string) (repo, image string) { - baseDomain := getArtifactoryBaseDomain(url) + artifactoryHost, artifactoryPort := getArtifactoryHostPort(url) registryHost, registryPort := splitHostPort(registry) - isSaaS := strings.HasSuffix(baseDomain, ".jfrog.io") || strings.HasSuffix(baseDomain, ".jfrogdev.org") + isSaaS := strings.HasSuffix(artifactoryHost, ".jfrog.io") || strings.HasSuffix(artifactoryHost, ".jfrogdev.org") - if repo = extractSubdomainRepo(registryHost, baseDomain, isSaaS); repo != "" { + if repo = extractSubdomainRepo(registryHost, artifactoryHost, isSaaS); repo != "" { log.Debug(fmt.Sprintf("Subdomain method detected (repo=%s)", repo)) return repo, remaining } - - if registryPort != "" && registryHost == baseDomain { + if registryPort != "" && registryHost == artifactoryHost && registryPort != artifactoryPort { log.Debug(fmt.Sprintf("Port method detected (repo=%s)", registryPort)) return registryPort, remaining } - if registryHost == baseDomain && registryPort == "" { + + if registryHost == artifactoryHost { log.Debug("Repository Path method detected") return extractRepoFromPath(remaining) } - log.Debug("Fallback: using Repository Path extraction") + log.Debug("Using Repository Path extraction") return extractRepoFromPath(remaining) } diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go index 9aa3d4a7..abd1ec19 100644 --- a/sca/bom/buildinfo/technologies/docker/docker_test.go +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -630,6 +630,146 @@ func TestParseDockerImageWithArtifactoryUrl(t *testing.T) { expectedImg: "gcr.io/distroless/static-debian11", expectedTag: "nonroot", }, + { + name: "IP with port - simple image", + imageName: "192.168.1.100:8081/nginx:1.21", + artifactoryUrl: "https://192.168.1.100/artifactory", + expectedRepo: "8081", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "IP with port - nested image", + imageName: "192.168.1.100:8082/bitnami/redis:7.0", + artifactoryUrl: "https://192.168.1.100/artifactory", + expectedRepo: "8082", + expectedImg: "bitnami/redis", + expectedTag: "7.0", + }, + { + name: "IP with port - deeply nested image", + imageName: "10.0.0.50:9000/org/team/myservice:v2.1.0", + artifactoryUrl: "https://10.0.0.50/artifactory", + expectedRepo: "9000", + expectedImg: "org/team/myservice", + expectedTag: "v2.1.0", + }, + { + name: "IP with port - no tag", + imageName: "172.16.0.1:8081/alpine", + artifactoryUrl: "https://172.16.0.1/artifactory", + expectedRepo: "8081", + expectedImg: "alpine", + expectedTag: "latest", + }, + { + name: "IP with port - high port number", + imageName: "192.168.1.100:54321/myapp:latest", + artifactoryUrl: "https://192.168.1.100/artifactory", + expectedRepo: "54321", + expectedImg: "myapp", + expectedTag: "latest", + }, + + // ========================================== + // IP ADDRESS WITH REPO PATH (no port) + // ========================================== + { + name: "IP repo path - simple image", + imageName: "192.168.1.100/docker-local/nginx:1.21", + artifactoryUrl: "https://192.168.1.100/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "IP repo path - nested image", + imageName: "10.0.0.50/docker-remote/bitnami/nginx:alpine", + artifactoryUrl: "https://10.0.0.50/artifactory", + expectedRepo: "docker-remote", + expectedImg: "bitnami/nginx", + expectedTag: "alpine", + }, + { + name: "IP repo path - deeply nested image", + imageName: "172.16.0.1/docker-virtual/gcr.io/google-containers/pause:3.2", + artifactoryUrl: "https://172.16.0.1/artifactory", + expectedRepo: "docker-virtual", + expectedImg: "gcr.io/google-containers/pause", + expectedTag: "3.2", + }, + + // ========================================== + // IP ADDRESS - Artifactory URL also has port + // ========================================== + { + name: "IP with port - Artifactory URL has port too", + imageName: "192.168.1.100:8081/nginx:1.21", + artifactoryUrl: "http://192.168.1.100:8082/artifactory", + expectedRepo: "8081", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "IP with port - nested image, Artifactory URL has port", + imageName: "10.0.0.50:9000/bitnami/redis:7.0", + artifactoryUrl: "http://10.0.0.50:8082/artifactory", + expectedRepo: "9000", + expectedImg: "bitnami/redis", + expectedTag: "7.0", + }, + { + name: "IP repo path - Artifactory URL has port", + imageName: "192.168.1.100/docker-local/nginx:1.21", + artifactoryUrl: "http://192.168.1.100:8082/artifactory", + expectedRepo: "docker-local", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "IP repo path - nested image, Artifactory URL has port", + imageName: "10.0.0.50/docker-remote/gcr.io/distroless/static:latest", + artifactoryUrl: "http://10.0.0.50:8082/artifactory", + expectedRepo: "docker-remote", + expectedImg: "gcr.io/distroless/static", + expectedTag: "latest", + }, + + // ========================================== + // IP:PORT with REPO PATH method (same port as Artifactory) + // ========================================== + { + name: "IP:port repo path - simple image", + imageName: "192.168.1.100:8082/docker-test/nginx:1.21", + artifactoryUrl: "http://192.168.1.100:8082/artifactory", + expectedRepo: "docker-test", + expectedImg: "nginx", + expectedTag: "1.21", + }, + { + name: "IP:port repo path - nested image path", + imageName: "192.168.1.100:8082/docker-test/image/path:test", + artifactoryUrl: "http://192.168.1.100:8082/artifactory", + expectedRepo: "docker-test", + expectedImg: "image/path", + expectedTag: "test", + }, + { + name: "IP:port repo path - deeply nested", + imageName: "10.0.0.50:8082/my-repo/org/team/service:v2.0", + artifactoryUrl: "http://10.0.0.50:8082/artifactory", + expectedRepo: "my-repo", + expectedImg: "org/team/service", + expectedTag: "v2.0", + }, + { + name: "IP:port repo path - no tag", + imageName: "172.16.0.1:8082/docker-local/alpine", + artifactoryUrl: "http://172.16.0.1:8082/artifactory", + expectedRepo: "docker-local", + expectedImg: "alpine", + expectedTag: "latest", + }, } for _, tt := range tests { From e105190b5c2b6473667d65d227198559708c85f3 Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Sun, 4 Jan 2026 12:00:06 +0200 Subject: [PATCH 4/5] added nil check for server config --- sca/bom/buildinfo/technologies/docker/docker.go | 6 ++++++ sca/bom/buildinfo/technologies/docker/docker_test.go | 4 ---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker.go b/sca/bom/buildinfo/technologies/docker/docker.go index 35336427..181c9512 100644 --- a/sca/bom/buildinfo/technologies/docker/docker.go +++ b/sca/bom/buildinfo/technologies/docker/docker.go @@ -154,6 +154,9 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) ([]*xr if err != nil { return nil, nil, err } + if serverDetails == nil { + return nil, nil, fmt.Errorf("no Artifactory server configured. Use 'jf c add' to configure a server") + } imageInfo, err := ParseDockerImageWithArtifactoryUrl(params.DockerImageName, serverDetails.Url) if err != nil { @@ -252,6 +255,9 @@ func GetDockerRepositoryConfig(imageName string) (*project.RepositoryConfig, err if err != nil { return nil, err } + if serverDetails == nil { + return nil, fmt.Errorf("no Artifactory server configured. Use 'jf c add' to configure a server") + } imageInfo, err := ParseDockerImageWithArtifactoryUrl(imageName, serverDetails.Url) if err != nil { return nil, err diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go index abd1ec19..37812e8f 100644 --- a/sca/bom/buildinfo/technologies/docker/docker_test.go +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -734,10 +734,6 @@ func TestParseDockerImageWithArtifactoryUrl(t *testing.T) { expectedImg: "gcr.io/distroless/static", expectedTag: "latest", }, - - // ========================================== - // IP:PORT with REPO PATH method (same port as Artifactory) - // ========================================== { name: "IP:port repo path - simple image", imageName: "192.168.1.100:8082/docker-test/nginx:1.21", From e4b45aa8161b9e31c63b9f670becf150c918279c Mon Sep 17 00:00:00 2001 From: Bassel Mbariky Date: Sun, 4 Jan 2026 12:26:31 +0200 Subject: [PATCH 5/5] fixed unit test --- .../technologies/docker/docker_test.go | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/sca/bom/buildinfo/technologies/docker/docker_test.go b/sca/bom/buildinfo/technologies/docker/docker_test.go index 37812e8f..744fa8b5 100644 --- a/sca/bom/buildinfo/technologies/docker/docker_test.go +++ b/sca/bom/buildinfo/technologies/docker/docker_test.go @@ -1,6 +1,7 @@ package docker import ( + "strings" "testing" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" @@ -781,34 +782,34 @@ func TestParseDockerImageWithArtifactoryUrl(t *testing.T) { func TestBuildDependencyTree(t *testing.T) { tests := []struct { - name string - dockerImageName string - expectError bool - errorContains string + name string + dockerImageName string + expectError bool + errorContainsAny []string }{ { - name: "Empty image name", - dockerImageName: "", - expectError: true, - errorContains: "docker image name is required", + name: "Empty image name", + dockerImageName: "", + expectError: true, + errorContainsAny: []string{"docker image name is required"}, }, { - name: "No registry - single part image", - dockerImageName: "nginx", - expectError: true, - errorContains: "invalid docker image format", + name: "No registry - single part image", + dockerImageName: "nginx", + expectError: true, + errorContainsAny: []string{"no Artifactory server configured", "invalid docker image format"}, }, { - name: "No registry - image with tag only", - dockerImageName: "nginx:1.21", - expectError: true, - errorContains: "invalid docker image format", + name: "No registry - image with tag only", + dockerImageName: "nginx:1.21", + expectError: true, + errorContainsAny: []string{"no Artifactory server configured", "invalid docker image format"}, }, { - name: "Whitespace only", - dockerImageName: " ", - expectError: true, - errorContains: "invalid docker image format", + name: "Whitespace only", + dockerImageName: " ", + expectError: true, + errorContainsAny: []string{"no Artifactory server configured", "invalid docker image format"}, }, } @@ -818,8 +819,16 @@ func TestBuildDependencyTree(t *testing.T) { _, _, err := BuildDependencyTree(params) if tt.expectError { assert.Error(t, err) - if tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) + if len(tt.errorContainsAny) > 0 { + errMsg := err.Error() + matched := false + for _, expected := range tt.errorContainsAny { + if strings.Contains(errMsg, expected) { + matched = true + break + } + } + assert.True(t, matched, "error %q should contain one of %v", errMsg, tt.errorContainsAny) } } else { assert.NoError(t, err)