From 263f93d113273976491de347a6d76e8f84f99b2b Mon Sep 17 00:00:00 2001 From: Abhineshhh Date: Fri, 17 Jul 2026 01:23:36 +0530 Subject: [PATCH 1/2] fix: parse digest-pinned refs and host:port registries correctly ExtractRegistryAndRepo previously split on the first :, which mangled digest suffixes (@sha256:...) and registries with ports (localhost:5000). Strip digests first and use the last : after the last / as the tag separator, matching Docker reference parsing. Fixes #74 --- pkg/infrastructure/scanner/analyzer_test.go | 5 +++ pkg/infrastructure/scanner/registry.go | 34 ++++++++++++++++----- 2 files changed, 31 insertions(+), 8 deletions(-) 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") +} From 14e5e90cf78231e4c6128e840ec823f50b0c73b9 Mon Sep 17 00:00:00 2001 From: Abhineshhh Date: Fri, 17 Jul 2026 01:24:06 +0530 Subject: [PATCH 2/2] fix: follow Registry API Link pagination when listing tags GetTags issued a single tags/list request and ignored the Link rel=next header, so large repositories could silently drop tags before filtering and --max-tags selection. Page with n=100 and follow next links until exhausted; add unit tests with a multi-page httptest server. Fixes #76 --- pkg/infrastructure/scanner/registry.go | 86 +++++++++++--- pkg/infrastructure/scanner/registry_test.go | 122 ++++++++++++++++++++ 2 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 pkg/infrastructure/scanner/registry_test.go diff --git a/pkg/infrastructure/scanner/registry.go b/pkg/infrastructure/scanner/registry.go index 80af626..968fa6d 100644 --- a/pkg/infrastructure/scanner/registry.go +++ b/pkg/infrastructure/scanner/registry.go @@ -26,6 +26,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "regexp" "sort" "strings" @@ -35,6 +36,9 @@ import ( log "github.com/sirupsen/logrus" ) +// tagsPageSize is the per-request page size for registry tags/list pagination. +const tagsPageSize = 100 + // DefaultTagFilter returns the default tag filter configuration. func DefaultTagFilter() domain.TagFilterConfig { return domain.TagFilterConfig{ @@ -63,27 +67,83 @@ func NewRegistryScanner(defaultRegistry string) *RegistryScanner { } } -// GetTags fetches available tags for an MCR repository. +// GetTags fetches available tags for a repository, following Registry API +// pagination via the Link response header (rel="next"). func (s *RegistryScanner) GetTags(repo string) ([]string, error) { - url := fmt.Sprintf("%s/v2/%s/tags/list", s.registryURL, repo) - log.Debugf("Fetching tags from: %s", url) + next := fmt.Sprintf("%s/v2/%s/tags/list?n=%d", s.registryURL, repo, tagsPageSize) + var all []string + + for next != "" { + log.Debugf("Fetching tags from: %s", next) + + req, err := http.NewRequest(http.MethodGet, next, nil) + if err != nil { + return nil, fmt.Errorf("creating tags request for %s: %w", repo, err) + } + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching tags for %s: %w", repo, err) + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, repo) + } + + var tagsResp domain.TagsResponse + if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil { + _ = resp.Body.Close() + return nil, fmt.Errorf("decoding tags response: %w", err) + } + _ = resp.Body.Close() + + all = append(all, tagsResp.Tags...) - resp, err := s.client.Get(url) - if err != nil { - return nil, fmt.Errorf("fetching tags for %s: %w", repo, err) + base, err := url.Parse(next) + if err != nil { + return nil, fmt.Errorf("parsing tags URL for %s: %w", repo, err) + } + next = nextPageFromLink(base, resp.Header.Get("Link")) } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status %d for %s", resp.StatusCode, repo) + return all, nil +} + +// nextPageFromLink returns the absolute URL for rel="next" from an RFC 5988 +// Link header, or "" when there is no next page. +func nextPageFromLink(base *url.URL, linkHeader string) string { + if linkHeader == "" || base == nil { + return "" } - var tagsResp domain.TagsResponse - if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil { - return nil, fmt.Errorf("decoding tags response: %w", err) + for _, part := range strings.Split(linkHeader, ",") { + part = strings.TrimSpace(part) + lower := strings.ToLower(part) + // Accept rel="next" / rel=next / rel='next' + if !strings.Contains(lower, `rel="next"`) && + !strings.Contains(lower, `rel='next'`) && + !strings.Contains(lower, `rel=next`) { + continue + } + + start := strings.Index(part, "<") + end := strings.Index(part, ">") + if start < 0 || end <= start { + continue + } + + ref := strings.TrimSpace(part[start+1 : end]) + resolved, err := base.Parse(ref) + if err != nil { + log.Warnf("Invalid pagination Link URL %q: %v", ref, err) + return "" + } + + return resolved.String() } - return tagsResp.Tags, nil + return "" } // FilterTags removes pre-release, arch-specific, and unwanted tags based on the provided config. diff --git a/pkg/infrastructure/scanner/registry_test.go b/pkg/infrastructure/scanner/registry_test.go new file mode 100644 index 0000000..d9aeff1 --- /dev/null +++ b/pkg/infrastructure/scanner/registry_test.go @@ -0,0 +1,122 @@ +// MIT License +// +// Copyright (c) Microsoft Corporation. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE + +package scanner + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNextPageFromLink(t *testing.T) { + base, err := url.Parse("https://mcr.microsoft.com/v2/repo/tags/list?n=100") + require.NoError(t, err) + + tests := []struct { + name string + header string + want string + }{ + {"empty", "", ""}, + {"next relative", `; rel="next"`, "https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=abc"}, + {"next absolute", `; rel="next"`, "https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=z"}, + {"prev only", `; rel="prev"`, ""}, + {"mixed", `; rel="prev", ; rel="next"`, "https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=b"}, + {"rel without quotes", `; rel=next`, "https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=c"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, nextPageFromLink(base, tt.header)) + }) + } +} + +func TestGetTags_FollowsPagination(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits++ + assert.Equal(t, "/v2/azurelinux/base/python/tags/list", r.URL.Path) + + switch r.URL.Query().Get("last") { + case "": + assert.Equal(t, "100", r.URL.Query().Get("n")) + w.Header().Set("Link", `; rel="next"`) + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "azurelinux/base/python", + "tags": []string{"3.12", "3.11"}, + }) + case "3.11": + // Final page — no Link header + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "azurelinux/base/python", + "tags": []string{"3.10", "3.9"}, + }) + default: + t.Fatalf("unexpected last=%q", r.URL.Query().Get("last")) + } + })) + defer srv.Close() + + s := &RegistryScanner{ + client: srv.Client(), + registryURL: srv.URL, + } + + tags, err := s.GetTags("azurelinux/base/python") + require.NoError(t, err) + assert.Equal(t, 2, hits, "should request both pages") + assert.Equal(t, []string{"3.12", "3.11", "3.10", "3.9"}, tags) +} + +func TestGetTags_SinglePage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "repo", + "tags": []string{"1.0", "2.0"}, + }) + })) + defer srv.Close() + + s := &RegistryScanner{client: srv.Client(), registryURL: srv.URL} + tags, err := s.GetTags("repo") + require.NoError(t, err) + assert.Equal(t, []string{"1.0", "2.0"}, tags) +} + +func TestGetTags_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + s := &RegistryScanner{client: srv.Client(), registryURL: srv.URL} + _, err := s.GetTags("missing") + require.Error(t, err) + assert.Contains(t, err.Error(), "404") +}