Skip to content
Merged
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
40 changes: 30 additions & 10 deletions pkg/sbom/sbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,12 +302,17 @@ func bundleSBOM(ctx context.Context, image, platform string) (map[string]string,
// bundleType is the media type of a Sigstore bundle manifest and its layer.
const bundleType = "application/vnd.dev.sigstore.bundle"

// envelopeSBOM reads the first SBOM layer that parses: each a DSSE envelope,
// bare (cosign 2) or wrapped in a Sigstore bundle (cosign 3), whose payload is
// an in-toto statement.
// envelopeSBOM reads the image's SBOM layers -- each a DSSE envelope, bare
// (cosign 2) or wrapped in a Sigstore bundle (cosign 3), whose payload is an
// in-toto statement -- and returns the CycloneDX one when there is one, else
// the first that parses. Always the same kind for every image, so two images
// compare like for like.
func envelopeSBOM(ctx context.Context, image string, layers []sbomLayer) (map[string]string, string) {
// CycloneDX first: it names the app itself as well as its packages.
// Known CycloneDX first. A cosign 3 bundle's annotation often names no
// kind at all, so those are read until one turns out to be CycloneDX.
sort.SliceStable(layers, func(i, j int) bool { return layers[i].kind == "cyclonedx" && layers[j].kind != "cyclonedx" })
var first map[string]string
firstKind := ""
for _, l := range layers {
raw, err := blob(ctx, image, l.digest, sbomLimit)
if err != nil {
Expand Down Expand Up @@ -337,11 +342,18 @@ func envelopeSBOM(ctx context.Context, image string, layers []sbomLayer) (map[st
if kind == "" {
continue
}
if pkgs := parseStatement(stmt, kind); pkgs != nil {
pkgs := parseStatement(stmt, kind)
if pkgs == nil {
continue
}
if kind == "cyclonedx" {
return pkgs, kind
}
if first == nil {
first, firstKind = pkgs, kind
}
}
return nil, ""
return first, firstKind
}

// statementKind is the SBOM kind an in-toto statement names for itself.
Expand Down Expand Up @@ -407,16 +419,24 @@ func parseStatement(raw []byte, kind string) map[string]string {
}

func parseCycloneDX(raw []byte) map[string]string {
type component struct {
Name string `json:"name"`
Version string `json:"version"`
}
var bom struct {
Components []struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"components"`
Metadata struct {
Component component `json:"component"`
} `json:"metadata"`
Components []component `json:"components"`
}
if json.Unmarshal(raw, &bom) != nil {
return nil
}
out := map[string]string{}
// The app itself is the document's subject, not one of its components;
// SPDX lists it as a package. Without it an update from an image read as
// one kind to one read as the other showed the app as added or removed.
add(out, bom.Metadata.Component.Name, bom.Metadata.Component.Version)
for _, c := range bom.Components {
add(out, c.Name, c.Version)
}
Expand Down
45 changes: 45 additions & 0 deletions pkg/sbom/sbom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,48 @@ func TestCosignBundleAsGhcrServesIt(t *testing.T) {
t.Errorf("doc = %+v", d)
}
}

// ghcr lists an image's SPDX and CycloneDX bundles in either order, both
// annotated cosign/sign/v1. The CycloneDX one is read whichever comes first,
// and its subject -- the app -- is a package, as SPDX has it: an update
// between two images read as different kinds showed pdfcraft as removed.
func TestUnannotatedBundlesPickCycloneDX(t *testing.T) {
m, cfg := image("0.5.0", "2026-10-10T06:58:35Z")
const bt = "application/vnd.dev.sigstore.bundle.v0.3+json"
bundle := func(st any) map[string]any {
p, _ := json.Marshal(st)
return map[string]any{"mediaType": bt, "dsseEnvelope": map[string]any{"payload": base64.StdEncoding.EncodeToString(p)}}
}
ref := func(layer string) map[string]any {
return map[string]any{
"artifactType": bt,
"annotations": map[string]string{"dev.sigstore.bundle.predicateType": "https://sigstore.dev/cosign/sign/v1"},
"layers": []map[string]any{{"digest": layer, "mediaType": bt}},
}
}
fakeRegistry(t,
map[string]any{
"sha256:amd64": m,
"sha256-amd64": map[string]any{"manifests": []map[string]any{
{"digest": "sha256:ref-spdx"}, {"digest": "sha256:ref-cdx"},
}},
"sha256:ref-spdx": ref("sha256:spdx"),
"sha256:ref-cdx": ref("sha256:cdx"),
},
map[string]any{
"sha256:cfg-0.5.0": cfg,
"sha256:spdx": bundle(map[string]any{"predicateType": "https://spdx.dev/Document", "predicate": map[string]any{
"packages": []map[string]string{{"name": "pdfcraft", "versionInfo": "0.5.0"}, {"name": "nginx", "versionInfo": "1.28.0"}}}}),
"sha256:cdx": bundle(map[string]any{"predicateType": "https://cyclonedx.org/bom", "predicate": map[string]any{
"metadata": map[string]any{"component": map[string]string{"name": "pdfcraft", "version": "0.5.0", "type": "container"}},
"components": []map[string]string{{"name": "nginx", "version": "1.28.0"}}}}),
})
d, err := For(context.Background(), "ghcr.io/x/pdfcraft:latest", "sha256:index", "sha256:amd64")
if err != nil {
t.Fatal(err)
}
want := map[string]string{"pdfcraft": "0.5.0", "nginx": "1.28.0"}
if d.Source != "cyclonedx" || !reflect.DeepEqual(d.Packages, want) {
t.Errorf("doc = %+v, packages %v", d, d.Packages)
}
}
Loading