diff --git a/cmd/proxy/mirror_test.go b/cmd/proxy/mirror_test.go index e0f7e6af..bd39de9b 100644 --- a/cmd/proxy/mirror_test.go +++ b/cmd/proxy/mirror_test.go @@ -113,6 +113,22 @@ func TestMirrorCommandDryRunWhileScanning(t *testing.T) { } } +func TestMirrorCommandSBOMComponentVersion(t *testing.T) { + dir := t.TempDir() + configPath := writeScanningMirrorConfig(t, dir) + sbomPath := filepath.Join(dir, "bom.json") + data := []byte(`{"components":[{"name":"app","version":"1.0","purl":"pkg:docker/ghcr.io/team/app"}]}`) + if err := os.WriteFile(sbomPath, data, 0600); err != nil { + t.Fatal(err) + } + output := runMirrorProcess(t, 0, "-config", configPath, "-dry-run", "-sbom", sbomPath) + for _, want := range []string{"Would mirror 1 package versions", "pkg:docker/ghcr.io/team/app@1.0"} { + if !strings.Contains(output, want) { + t.Errorf("dry run missing %q:\n%s", want, output) + } + } +} + func TestRefuseUnscannedMirroring(t *testing.T) { m := mirror.New(nil, nil, nil, nil, 1) refuseUnscannedMirroring(m) diff --git a/internal/mirror/image.go b/internal/mirror/image.go index bc219372..8b168937 100644 --- a/internal/mirror/image.go +++ b/internal/mirror/image.go @@ -64,6 +64,13 @@ func imageRef(pv PackageVersion) (handler.ImageRef, []handler.Platform, error) { ref.References = []string{pv.Version} } repositoryURL := pv.Qualifiers["repository_url"] + if pv.Ecosystem == "docker" && repositoryURL == "" { + host, repository, found := strings.Cut(pv.Name, "/") + if found && (strings.ContainsAny(host, ".:") || host == "localhost") { + ref.Registry = host + ref.Name = repository + } + } if pv.Ecosystem == "oci" { if repositoryURL == "" { return ref, nil, errors.New("pkg:oci needs a repository_url qualifier") @@ -96,8 +103,6 @@ func (m *Mirror) mirrorImage(ctx context.Context, pv PackageVersion, tracker *pr tracker.addError(pv.Ecosystem, pv.Name, pv.Version, err.Error()) m.logger.Warn("mirror failed", "ecosystem", pv.Ecosystem, "name", pv.Name, "version", pv.Version, "error", err) } - // pv.String keeps the namespace separator, which MakeString escapes for - // docker names. if m.proxy != nil && m.proxy.Denylist.Denied(pv.String()) { fail(fmt.Errorf("%w: %s", handler.ErrVersionDenied, pv)) return diff --git a/internal/mirror/image_test.go b/internal/mirror/image_test.go index 339e86c2..c38ddf7c 100644 --- a/internal/mirror/image_test.go +++ b/internal/mirror/image_test.go @@ -25,6 +25,22 @@ func TestImageRefFromPURL(t *testing.T) { purl: "pkg:docker/library/alpine@3.20", wantRef: handler.ImageRef{Name: "library/alpine", References: []string{"3.20"}}, }, + { + purl: "pkg:docker/ghcr.io/team/app@1.0", + wantRef: handler.ImageRef{Registry: "ghcr.io", Name: "team/app", References: []string{"1.0"}}, + }, + { + purl: "pkg:docker/registry.example:5000/team/app@" + digest, + wantRef: handler.ImageRef{Registry: "registry.example:5000", Name: "team/app", References: []string{digest}}, + }, + { + purl: "pkg:docker/localhost/app@1.0", + wantRef: handler.ImageRef{Registry: "localhost", Name: "app", References: []string{"1.0"}}, + }, + { + purl: "pkg:docker/localhost:5000/team/app@1.0", + wantRef: handler.ImageRef{Registry: "localhost:5000", Name: "team/app", References: []string{"1.0"}}, + }, { purl: "pkg:docker/alpine@3.20?repository_url=ghcr.io", platforms: []string{"linux/arm64/v8"}, diff --git a/internal/mirror/sbom_version_test.go b/internal/mirror/sbom_version_test.go new file mode 100644 index 00000000..190cc343 --- /dev/null +++ b/internal/mirror/sbom_version_test.go @@ -0,0 +1,73 @@ +package mirror + +import ( + "context" + "net/http" + "reflect" + "testing" + + "github.com/git-pkgs/proxy/internal/handler" + "github.com/git-pkgs/registries" +) + +func TestSBOMSourceUsesComponentVersion(t *testing.T) { + for name, data := range map[string]string{ + "CycloneDX JSON": `{"components":[{"name":"app","version":"1.0","purl":"pkg:docker/ghcr.io/team/app"}]}`, + "CycloneDX XML": `app1.0pkg:docker/ghcr.io/team/app`, + "SPDX JSON": `{"spdxVersion":"SPDX-2.3","packages":[{"name":"app","versionInfo":"1.0","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:docker/ghcr.io/team/app"}]}]}`, + "SPDX tag-value": "DataLicense: CC0-1.0\nSPDXVersion: SPDX-2.3\nSPDXID: SPDXRef-DOCUMENT\nDocumentName: example\nDocumentNamespace: https://example.com/sbom\nPackageName: app\nSPDXID: SPDXRef-app\nPackageVersion: 1.0\nExternalRef: PACKAGE-MANAGER purl pkg:docker/ghcr.io/team/app\n", + } { + t.Run(name, func(t *testing.T) { + source := &SBOMSource{Data: []byte(data)} + var got []PackageVersion + if err := source.Enumerate(context.Background(), func(pv PackageVersion) error { got = append(got, pv); return nil }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Version != "1.0" { + t.Fatalf("Enumerate = %+v, want one image at 1.0", got) + } + }) + } +} + +func TestSBOMSourceVersionPrecedence(t *testing.T) { + for _, test := range []struct{ name, componentVersion, imagePURL, wantVersion string }{ + {"PURL version wins", "2.0", "pkg:docker/team/app@1.0", "1.0"}, + {"digest from component", "sha256:abc123", "pkg:docker/team/app", "sha256:abc123"}, + {"unknown version", "", "pkg:docker/team/app", ""}, + {"package version", "1.0", "pkg:npm/example", "1.0"}, + } { + t.Run(test.name, func(t *testing.T) { + data := marshalJSON(t, map[string]any{"components": []map[string]any{{"name": "app", "version": test.componentVersion, "purl": test.imagePURL}}}) + var got []PackageVersion + source := &SBOMSource{Data: data, RegClient: registries.NewClient(registries.WithHTTPClient(&http.Client{Transport: rejectingMirrorTransport{}}))} + if err := source.Enumerate(context.Background(), func(pv PackageVersion) error { got = append(got, pv); return nil }); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Version != test.wantVersion { + t.Fatalf("Enumerate = %+v, want version %q", got, test.wantVersion) + } + }) + } +} + +func TestMirrorRunSBOMDockerReference(t *testing.T) { + m := setupTestMirror(t, 1) + images := &fakeImages{result: handler.ImageResult{Fetched: 1}} + m.Images = images + source := &SBOMSource{ + Data: []byte(`{"components":[{"name":"app","version":"1.0","purl":"pkg:docker/registry.example:5000/team/app?arch=arm64"}]}`), + Platforms: []string{"linux/arm64"}, + } + progress, err := m.Run(context.Background(), source) + if err != nil { + t.Fatal(err) + } + if progress.Completed != 1 || progress.Failed != 0 { + t.Fatalf("progress = %+v", progress) + } + want := []handler.ImageRef{{Registry: "registry.example:5000", Name: "team/app", References: []string{"1.0"}}} + if !reflect.DeepEqual(images.refs, want) { + t.Errorf("image refs = %+v, want %+v", images.refs, want) + } +} diff --git a/internal/mirror/source.go b/internal/mirror/source.go index 8a4100f4..c4792892 100644 --- a/internal/mirror/source.go +++ b/internal/mirror/source.go @@ -144,9 +144,20 @@ func (s *SBOMSource) extractPURLs() ([]string, error) { var purls []string for _, p := range doc.Packages { for _, ref := range p.ExternalRefs { - if ref.Type == "purl" && ref.Locator != "" { - purls = append(purls, ref.Locator) + if ref.Type != "purl" || ref.Locator == "" { + continue } + locator := ref.Locator + if p.Version != "" { + parsed, err := purl.Parse(locator) + if err != nil { + return nil, fmt.Errorf("parsing PURL %q: %w", locator, err) + } + if parsed.Version == "" { + locator = parsed.WithVersion(p.Version).String() + } + } + purls = append(purls, locator) } } if len(purls) == 0 {