diff --git a/pkg/infrastructure/scanner/analyzer_test.go b/pkg/infrastructure/scanner/analyzer_test.go index 2f82481..9291136 100644 --- a/pkg/infrastructure/scanner/analyzer_test.go +++ b/pkg/infrastructure/scanner/analyzer_test.go @@ -195,6 +195,11 @@ func TestExtractRegistryAndRepo(t *testing.T) { {"Docker Hub", "docker.io/library/python:3.12-slim", "docker.io", "library/python", "3.12-slim"}, {"Short MCR", "azurelinux/base/python:3.12", "mcr.microsoft.com", "azurelinux/base/python", "3.12"}, {"No tag", "mcr.microsoft.com/azurelinux/base/python", "mcr.microsoft.com", "azurelinux/base/python", ""}, + {"Tag with digest", "mcr.microsoft.com/dotnet/aspnet:8.0@sha256:abcdef", "mcr.microsoft.com", "dotnet/aspnet", "8.0"}, + {"Digest only", "mcr.microsoft.com/dotnet/aspnet@sha256:abcdef", "mcr.microsoft.com", "dotnet/aspnet", ""}, + {"Registry with port", "localhost:5000/myrepo:1.0", "localhost:5000", "myrepo", "1.0"}, + {"Localhost no port", "localhost/myrepo:1.0", "localhost", "myrepo", "1.0"}, + {"Nested path with digest", "mcr.microsoft.com/azurelinux/base/python:3.12-nonroot@sha256:deadbeef", "mcr.microsoft.com", "azurelinux/base/python", "3.12-nonroot"}, } for _, tt := range tests { diff --git a/pkg/infrastructure/scanner/registry.go b/pkg/infrastructure/scanner/registry.go index d1ecd83..80af626 100644 --- a/pkg/infrastructure/scanner/registry.go +++ b/pkg/infrastructure/scanner/registry.go @@ -296,24 +296,42 @@ func BuildFullImageName(defaultRegistry, repo, tag string) string { } // ExtractRegistryAndRepo splits a full image name into registry, repository, and tag. +// Digest suffixes (@sha256:...) are stripped before parsing. Tags are taken from the +// last ":" after the last "/", so host:port registries (e.g. localhost:5000) work. func ExtractRegistryAndRepo(imageName string) (registry, repository, tag string) { - // Split off the tag - parts := strings.SplitN(imageName, ":", 2) - nameWithoutTag := parts[0] - if len(parts) == 2 { - tag = parts[1] + name := imageName + + // Strip @digest first — digests contain ":" (e.g. sha256:abc) and must not + // be mistaken for tags. + if at := strings.Index(name, "@"); at >= 0 { + name = name[:at] + } + + // Tag separator is the last ":" after the last "/". + lastSlash := strings.LastIndex(name, "/") + lastColon := strings.LastIndex(name, ":") + nameWithoutTag := name + if lastColon > lastSlash { + tag = name[lastColon+1:] + nameWithoutTag = name[:lastColon] } - // Split into registry and repository + // Registry is the first path segment when it looks like a host. segments := strings.SplitN(nameWithoutTag, "/", 2) - if len(segments) == 2 && strings.Contains(segments[0], ".") { + if len(segments) == 2 && looksLikeRegistryHost(segments[0]) { registry = segments[0] repository = segments[1] } else { - // Default to MCR + // Default to MCR for short names like "azurelinux/base/python:3.12" registry = "mcr.microsoft.com" repository = nameWithoutTag } return registry, repository, tag } + +// looksLikeRegistryHost reports whether s is a registry host (domain, host:port, +// or localhost) rather than a repository path segment. +func looksLikeRegistryHost(s string) bool { + return strings.Contains(s, ".") || strings.Contains(s, ":") || strings.EqualFold(s, "localhost") +}