diff --git a/enterprise/spdx/builder/spdx.go b/enterprise/spdx/builder/spdx.go index 30b312a0..1bea5a22 100644 --- a/enterprise/spdx/builder/spdx.go +++ b/enterprise/spdx/builder/spdx.go @@ -18,8 +18,14 @@ import ( spdxjson "github.com/spdx/tools-golang/json" "github.com/spdx/tools-golang/spdx" "github.com/spdx/tools-golang/spdx/v2/common" + + ifconstants "github.com/siderolabs/image-factory/pkg/constants" ) +// talosRootSPDXID is the SPDXIdentifier of the synthetic root package added +// to every merged bundle. +const talosRootSPDXID = common.ElementID("DocumentRoot-Directory-talos") + // File represents an extracted SPDX file. type File struct { // Filename is the original filename (e.g., "extension-name.spdx.json"). @@ -76,6 +82,25 @@ func BundleToJSON(bundle *Bundle) (io.Reader, int64, error) { {CreatorType: "Tool", Creator: "image-factory"}, }, }, + Packages: []*spdx.Package{ + { + PackageSPDXIdentifier: talosRootSPDXID, + PackageName: ifconstants.TalosPackageName, + PackageVersion: bundle.TalosVersion, + PrimaryPackagePurpose: "FILE", + PackageDownloadLocation: "NOASSERTION", + // PackageSupplier must be non-nil — syft's fileSource + // dereferences it unconditionally when classifying the root. + PackageSupplier: &common.Supplier{Supplier: "NOASSERTION"}, + }, + }, + Relationships: []*spdx.Relationship{ + { + RefA: common.DocElementID{ElementRefID: common.ElementID("DOCUMENT")}, + RefB: common.DocElementID{ElementRefID: talosRootSPDXID}, + Relationship: common.TypeRelationshipDescribe, + }, + }, } // Sort files for deterministic merge output. Without this, map-derived @@ -143,9 +168,13 @@ func mergeDocument(merged, source *spdx.Document, sourceID string) { rel.Relationship == common.TypeRelationshipDescribe if isDocDescribes { - // Point DESCRIBES from the merged document to the prefixed target. + // Re-parent each source's root package under the synthetic talos + // root via CONTAINS so the merged document has exactly one + // DOCUMENT-DESCRIBES root — required by syft's source detection + // (see talosRootSPDXID). + newRel.Relationship = common.TypeRelationshipContains newRel.RefA = common.DocElementID{ - ElementRefID: merged.SPDXIdentifier, + ElementRefID: talosRootSPDXID, } newRel.RefB = prefixDocElementID(prefix, rel.RefB) } else { diff --git a/enterprise/spdx/builder/spdx_test.go b/enterprise/spdx/builder/spdx_test.go index a3709dba..9b6eae36 100644 --- a/enterprise/spdx/builder/spdx_test.go +++ b/enterprise/spdx/builder/spdx_test.go @@ -8,15 +8,19 @@ package builder_test import ( + "bytes" "io" "strings" "testing" spdxjson "github.com/spdx/tools-golang/json" + "github.com/spdx/tools-golang/spdx" + "github.com/spdx/tools-golang/spdx/v2/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/siderolabs/image-factory/enterprise/spdx/builder" + ifconstants "github.com/siderolabs/image-factory/pkg/constants" ) func TestCacheTag(t *testing.T) { @@ -100,3 +104,150 @@ func TestBundleToJSON_DocumentNamespace(t *testing.T) { }) } } + +// TestBundleToJSON_SingleTalosRoot guards the source-name/version pathway +// that grype's OpenVEX matcher relies on. Two invariants the test enforces: +// +// 1. Exactly one package is the target of a DOCUMENT-DESCRIBES relationship. +// syft's SPDX importer returns its derived source only when there is a +// single root (see findRootPackages + extractSource in syft). +// 2. That root package is named after constants.TalosPackageName (always +// "talos-enterprise" in this enterprise-only package), carries the +// bundle's TalosVersion, and is marked with PrimaryPackagePurpose=FILE +// plus the "DocumentRoot-Directory-..." SPDXIdentifier prefix syft uses +// to map the root onto a syft Source.Description with Name and Version set. +// +// Without both, grype's productIdentifiersFromContext returns an empty list, +// the VEX product `pkg:generic/talos-enterprise@` never matches any +// artifact, and the talos-vex statements don't suppress matches. +func TestBundleToJSON_SingleTalosRoot(t *testing.T) { + t.Parallel() + + const ( + sourceID = "talos-amd64" + sourceName = "talos" + ) + + sourceDoc := minimalSourceSPDX(t, sourceID, sourceName, "v1.13.3") + + bundle := &builder.Bundle{ + SchematicID: "sch", + TalosVersion: "v1.13.3", + Arch: "amd64", + ExternalURL: "https://factory.sidero.dev", + Files: []builder.File{ + { + Filename: sourceID + ".spdx.json", + Source: sourceID, + Content: sourceDoc, + }, + }, + } + + r, _, err := builder.BundleToJSON(bundle) + require.NoError(t, err) + + data, err := io.ReadAll(r) + require.NoError(t, err) + + doc, err := spdxjson.Read(bytes.NewReader(data)) + require.NoError(t, err) + + var roots []common.ElementID + + for _, rel := range doc.Relationships { + if rel.RefA.ElementRefID == common.ElementID("DOCUMENT") && + rel.Relationship == common.TypeRelationshipDescribe { + roots = append(roots, rel.RefB.ElementRefID) + } + } + + require.Len(t, roots, 1, "merged document must have exactly one DOCUMENT-DESCRIBES root") + + var rootPkg *spdx.Package + + for _, p := range doc.Packages { + if p.PackageSPDXIdentifier == roots[0] { + rootPkg = p + + break + } + } + + require.NotNil(t, rootPkg, "DESCRIBES target %q must exist in doc.Packages", roots[0]) + + assert.Equal(t, ifconstants.TalosPackageName, rootPkg.PackageName, + "root PackageName must equal constants.TalosPackageName so grype's derived pkg:generic/@ equals the VEX product") + assert.Equal(t, "v1.13.3", rootPkg.PackageVersion, "root package version feeds syft's Source.Version") + assert.Equal(t, "FILE", rootPkg.PrimaryPackagePurpose, "FILE purpose routes syft through fileSource()") + assert.True(t, + strings.HasPrefix(string(rootPkg.PackageSPDXIdentifier), "DocumentRoot-"), + "SPDXIdentifier prefix is what triggers syft's directory/file source classification") + + // Per-source root must be re-parented under the new talos root via + // CONTAINS, not via another DOCUMENT-DESCRIBES (which would create a + // second root and break syft's single-root detection). + // The merge prefixes every source element ID with "-", so the + // per-source root is "-DocumentRoot-Directory-". + perSourceRoot := common.ElementID(sourceID + "-DocumentRoot-Directory-" + sourceName) + + var perSourceContained bool + + for _, rel := range doc.Relationships { + if rel.RefA.ElementRefID == roots[0] && + rel.Relationship == common.TypeRelationshipContains && + rel.RefB.ElementRefID == perSourceRoot { + perSourceContained = true + + break + } + } + + assert.True(t, perSourceContained, + "per-source root must be re-parented under the talos root via CONTAINS") +} + +// minimalSourceSPDX produces a syft-shaped SPDX 2.3 JSON document with one +// DocumentRoot-Directory- root, mirroring what hack/sbom.sh embeds +// into the Talos initramfs. +func minimalSourceSPDX(t *testing.T, sourceID, name, version string) []byte { + t.Helper() + + rootID := common.ElementID("DocumentRoot-Directory-" + name) + + doc := &spdx.Document{ + SPDXVersion: spdx.Version, + DataLicense: spdx.DataLicense, + SPDXIdentifier: common.ElementID("DOCUMENT"), + DocumentName: sourceID, + DocumentNamespace: "https://anchore.com/syft/dir/" + sourceID, + CreationInfo: &spdx.CreationInfo{ + Created: "2026-05-30T00:00:00Z", + Creators: []common.Creator{ + {CreatorType: "Tool", Creator: "syft-test"}, + }, + }, + Packages: []*spdx.Package{ + { + PackageSPDXIdentifier: rootID, + PackageName: name, + PackageVersion: version, + PrimaryPackagePurpose: "FILE", + PackageDownloadLocation: "NOASSERTION", + }, + }, + Relationships: []*spdx.Relationship{ + { + RefA: common.DocElementID{ElementRefID: common.ElementID("DOCUMENT")}, + RefB: common.DocElementID{ElementRefID: rootID}, + Relationship: common.TypeRelationshipDescribe, + }, + }, + } + + var buf bytes.Buffer + + require.NoError(t, spdxjson.Write(doc, &buf)) + + return buf.Bytes() +} diff --git a/pkg/constants/name_ent_on.go b/pkg/constants/name_ent_on.go index 3bd99eaf..ff4299b2 100644 --- a/pkg/constants/name_ent_on.go +++ b/pkg/constants/name_ent_on.go @@ -6,11 +6,13 @@ package constants -// TalosName is the name in the profile. -const TalosName = "Talos Enterprise" - -// ImageFactoryName is the name of the image factory. -const ImageFactoryName = "Image Factory Enterprise" - -// TalosPURL is the purl for Talos Enterprise. -const TalosPURL = "pkg:generic/talos-enterprise" +const ( + // TalosName is the name in the profile. + TalosName = "Talos Enterprise" + // ImageFactoryName is the name of the image factory. + ImageFactoryName = "Image Factory Enterprise" + // TalosPackageName is the name of the Talos package in SPDX documents. + TalosPackageName = "talos-enterprise" + // TalosPURL is the purl for Talos Enterprise. + TalosPURL = "pkg:generic/" + TalosPackageName +)