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
33 changes: 31 additions & 2 deletions enterprise/spdx/builder/spdx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions enterprise/spdx/builder/spdx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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@<ver>` 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/<name>@<version> 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 "<source>-", so the
// per-source root is "<sourceID>-DocumentRoot-Directory-<sourceName>".
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
}
}
Comment thread
frezbo marked this conversation as resolved.
Comment thread
frezbo marked this conversation as resolved.

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-<name> 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()
}
18 changes: 10 additions & 8 deletions pkg/constants/name_ent_on.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)