Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/infrastructure/scanner/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
120 changes: 99 additions & 21 deletions pkg/infrastructure/scanner/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
Expand All @@ -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{
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -296,24 +356,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")
}
122 changes: 122 additions & 0 deletions pkg/infrastructure/scanner/registry_test.go
Original file line number Diff line number Diff line change
@@ -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", `</v2/repo/tags/list?n=100&last=abc>; rel="next"`, "https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=abc"},
{"next absolute", `<https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=z>; rel="next"`, "https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=z"},
{"prev only", `</v2/repo/tags/list?n=100>; rel="prev"`, ""},
{"mixed", `</v2/repo/tags/list?n=100&last=a>; rel="prev", </v2/repo/tags/list?n=100&last=b>; rel="next"`, "https://mcr.microsoft.com/v2/repo/tags/list?n=100&last=b"},
{"rel without quotes", `</v2/repo/tags/list?n=100&last=c>; 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", `</v2/azurelinux/base/python/tags/list?n=100&last=3.11>; 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")
}