Skip to content

Commit e03cb15

Browse files
bomly-guyclaude
andcommitted
fix(sbom): eight preservation defects review found, and the guard that missed one
Every one reproduced before it was fixed and fails a test after. Identity and sources. Converting SPDX to CycloneDX adopted the source namespace into the model, minted a fresh serial because CycloneDX has no namespace slot, and then suppressed the source link by comparing against the namespace -- so the export named its source neither way. The comparison is now against the identity the format actually writes. A document that asserted nothing about itself is also a source now: dropping it made a merge of two documents look like a conversion of one and publish the merged inventory under the other's identity. A conversion keeps the source's creation time. It adopts the source's identity, and claiming that identity while stating a different creation time is two claims that disagree. The fixed-point test pinned the timestamp, which is exactly what hid this; it pins nothing now. Component data. A homepage vanished on any CycloneDX hop -- the format has no homepage field, so it travels as the website reference CycloneDX offers for the same claim, both ways. A document whose only component is its primary one lost supplier, description, hashes, CPE and references, since the ingest fallback never applied assertions. Configured provenance overwrote a root component's own supplier: one names who supplied the component, the other who produced the project. Two components minting one canonical package URL discarded the second along with everything it asserted. They fold through the SDK's InsertNode now. The guard that exists to catch exactly this spelled the lookup as `.Node(node.ID)`, so it never saw `.Node(packageID)` -- widened, which immediately found a second site in explain. An ingested organization disappeared even on a CycloneDX round trip: the export filled the one manufacturer slot from configured provenance alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c6a6dc5 commit e03cb15

12 files changed

Lines changed: 548 additions & 63 deletions

internal/detectors/guards_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ func walkInternalGo(t *testing.T, visit func(path, body string)) {
4444
func TestNodeInsertionGoesThroughTheSharedHelper(t *testing.T) {
4545
// A lookup on the graph followed by an insert, which is the shape that
4646
// silently discards the duplicate.
47-
lookupThenAdd := regexp.MustCompile(`(?s)\.Node\(node\.ID\).{0,200}?\.AddNode\(`)
47+
// Any receiver and any identifier, not the one variable name this rule was
48+
// first written against. The regex used to spell the lookup as
49+
// `.Node(node.ID)`, so internal/sbom's `.Node(packageID)` walked straight
50+
// past it and silently discarded a duplicate component's assertions for as
51+
// long as the guard has existed. A guard that only catches the shape you
52+
// already fixed is not a guard.
53+
lookupThenAdd := regexp.MustCompile(`(?s)\.Node\([A-Za-z_][\w.]*\).{0,200}?\.AddNode\(`)
4854

4955
var offenders []string
5056
walkInternalGo(t, func(path, body string) {

internal/detectors/sbom/detector.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ func (d Detector) ResolveGraph(_ context.Context, req sdk.DetectionRequest) (sdk
100100
graphs := sdk.SingleGraphContainer(depsGraph, detectorkit.InferManifestMetadata(req, evidencePatterns))
101101
// What the document said about itself rides the entry it became, so a
102102
// later export can restate it instead of crediting only Bomly for a
103-
// document Bomly only converted (ADR-0037).
104-
if assertions := doc.Assertions; !assertions.IsEmpty() && len(graphs.Entries) == 1 {
105-
graphs.Entries[0].Document = &assertions
103+
// document Bomly only converted (ADR-0037). The codec decides what that
104+
// record contains, including for a document that asserted nothing.
105+
if len(graphs.Entries) == 1 {
106+
graphs.Entries[0].Document = sbom.DocumentAssertionsFor(doc)
106107
}
107108

108109
logger.Debug("resolved explicit sbom file", zap.String("path", sbomPath), zap.String("format", string(target)))

internal/engine/explain/why.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,19 @@ func GraphFromPaths(source *sdk.Graph, paths []Path) (*sdk.Graph, error) {
113113
if source == nil {
114114
return focused, nil
115115
}
116+
// Nodes repeat across paths -- a shared dependency is on every path that
117+
// reaches it -- so insertion folds rather than skipping. Every witness here
118+
// is a clone of one source node, so the fold is a no-op in practice; it is
119+
// the shared entry point regardless, because "skipping is fine here" is a
120+
// judgement each site otherwise re-makes on its own.
116121
for _, path := range paths {
117122
for i, ref := range path.Packages {
118123
pkg, ok := source.Node(ref.ID)
119124
if !ok || pkg == nil {
120125
continue
121126
}
122-
if _, exists := focused.Node(pkg.NodeID()); !exists {
123-
if err := focused.AddNode(pkg.CloneNode()); err != nil {
124-
return nil, err
125-
}
127+
if _, err := focused.InsertNode(pkg.CloneNode()); err != nil {
128+
return nil, err
126129
}
127130
if i == 0 {
128131
continue
@@ -132,10 +135,8 @@ func GraphFromPaths(source *sdk.Graph, paths []Path) (*sdk.Graph, error) {
132135
if !ok || parent == nil {
133136
continue
134137
}
135-
if _, exists := focused.Node(parent.NodeID()); !exists {
136-
if err := focused.AddNode(parent.CloneNode()); err != nil {
137-
return nil, err
138-
}
138+
if _, err := focused.InsertNode(parent.CloneNode()); err != nil {
139+
return nil, err
139140
}
140141
if err := focused.AddEdge(parent.NodeID(), pkg.NodeID()); err != nil && !errors.Is(err, sdk.ErrCycleDetected) {
141142
return nil, err

internal/sbom/assertions_round_trip_test.go

Lines changed: 159 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,31 @@ func assertPreserved(t *testing.T, where string, component Component) {
4848
if !strings.Contains(component.Description, "widgeting") {
4949
t.Fatalf("%s: description = %q, want the source's", where, component.Description)
5050
}
51-
if len(component.Digests) == 0 {
52-
t.Fatalf("%s: checksums lost", where)
51+
// Values, not presence. A conversion that kept a checksum entry while
52+
// changing its algorithm or digest, or kept a reference while relabelling
53+
// its type, would satisfy a presence check and still have corrupted the
54+
// claim -- and the algorithm names differ between the two formats, which
55+
// is exactly where such a slip would hide.
56+
var digest Digest
57+
for _, candidate := range component.Digests {
58+
if strings.EqualFold(strings.ReplaceAll(candidate.Algorithm, "-", ""), "sha256") {
59+
digest = candidate
60+
}
61+
}
62+
if digest.Value != "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" {
63+
t.Fatalf("%s: sha-256 checksum = %+v, want the source's", where, component.Digests)
5364
}
54-
if len(component.CPEs) == 0 {
55-
t.Fatalf("%s: CPE lost", where)
65+
if len(component.CPEs) == 0 || component.CPEs[0] != "cpe:2.3:a:widget:widget:1.0.0:*:*:*:*:*:*:*" {
66+
t.Fatalf("%s: CPE = %v, want the source's", where, component.CPEs)
5667
}
5768
var tracker bool
5869
for _, ref := range component.ExternalReferences {
59-
if strings.Contains(ref.Locator, "widgets.example/issues") {
60-
tracker = true
70+
if ref.Locator != "https://widgets.example/issues" {
71+
continue
72+
}
73+
tracker = true
74+
if !strings.EqualFold(ref.Type, "issue-tracker") {
75+
t.Fatalf("%s: the issue-tracker reference was relabelled %q", where, ref.Type)
6176
}
6277
}
6378
if !tracker {
@@ -147,3 +162,141 @@ func TestIngestLeavesComponentsEligibleForEnrichment(t *testing.T) {
147162
}
148163
_ = sdk.EcosystemUnknown
149164
}
165+
166+
// A homepage survives a CycloneDX hop. The format has no homepage field, so it
167+
// travels as the website reference CycloneDX offers for the same claim -- and
168+
// used to travel nowhere at all, disappearing on any SPDX-to-CycloneDX
169+
// conversion.
170+
func TestHomepageSurvivesCycloneDX(t *testing.T) {
171+
const spdxWithHomepage = `{
172+
"spdxVersion": "SPDX-2.3", "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT",
173+
"name": "h", "documentNamespace": "https://h.example/spdx/1",
174+
"creationInfo": {"created": "2026-01-01T00:00:00Z", "creators": ["Tool: t"]},
175+
"packages": [{
176+
"SPDXID": "SPDXRef-w", "name": "widget", "versionInfo": "1.0.0",
177+
"homepage": "https://widget.example/",
178+
"externalRefs": [{"referenceCategory": "PACKAGE-MANAGER", "referenceType": "purl",
179+
"referenceLocator": "pkg:npm/widget@1.0.0"}]
180+
}]
181+
}`
182+
doc, _, err := UnmarshalAutoJSON([]byte(spdxWithHomepage))
183+
if err != nil {
184+
t.Fatalf("ingest: %v", err)
185+
}
186+
if got := componentNamed(t, doc, "widget").Homepage; got != "https://widget.example/" {
187+
t.Fatalf("ingested homepage = %q", got)
188+
}
189+
graph, err := ToGraph(doc)
190+
if err != nil {
191+
t.Fatalf("to graph: %v", err)
192+
}
193+
cyclone, err := MarshalDepGraphJSON(graph, TargetCycloneDX16JSON, BuildOptions{}, EncodeOptions{Pretty: true})
194+
if err != nil {
195+
t.Fatalf("cyclonedx export: %v", err)
196+
}
197+
back, _, err := UnmarshalAutoJSON(cyclone)
198+
if err != nil {
199+
t.Fatalf("re-ingest: %v", err)
200+
}
201+
if got := componentNamed(t, back, "widget").Homepage; got != "https://widget.example/" {
202+
t.Fatalf("homepage after the CycloneDX hop = %q\n%s", got, cyclone)
203+
}
204+
}
205+
206+
// A document whose only component is its primary one keeps that component's
207+
// assertions. This is legal CycloneDX, and the ingest fallback that handles it
208+
// used to read half the fields.
209+
func TestMetadataOnlyComponentKeepsItsAssertions(t *testing.T) {
210+
const metadataOnly = `{
211+
"bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
212+
"metadata": {"component": {
213+
"bom-ref": "pkg:npm/solo@1.0.0", "type": "application", "name": "solo", "version": "1.0.0",
214+
"purl": "pkg:npm/solo@1.0.0", "description": "the only component",
215+
"supplier": {"name": "Solo Supply Co"},
216+
"hashes": [{"alg": "SHA-256", "content": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}]
217+
}}
218+
}`
219+
doc, _, err := UnmarshalAutoJSON([]byte(metadataOnly))
220+
if err != nil {
221+
t.Fatalf("ingest: %v", err)
222+
}
223+
solo := componentNamed(t, doc, "solo")
224+
if solo.Supplier == nil || solo.Supplier.Name != "Solo Supply Co" {
225+
t.Errorf("supplier = %+v", solo.Supplier)
226+
}
227+
if !strings.Contains(solo.Description, "only component") {
228+
t.Errorf("description = %q", solo.Description)
229+
}
230+
if len(solo.Digests) == 0 {
231+
t.Error("the checksum was dropped")
232+
}
233+
}
234+
235+
// Two components that mint one canonical package URL fold into one node whose
236+
// assertions are the union of both, rather than the first one's alone.
237+
func TestDuplicateComponentsFoldTheirAssertions(t *testing.T) {
238+
const duplicated = `{
239+
"bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
240+
"components": [
241+
{"bom-ref": "a", "type": "library", "name": "widget", "version": "1.0.0",
242+
"purl": "pkg:npm/widget@1.0.0",
243+
"externalReferences": [{"type": "issue-tracker", "url": "https://one.example/issues"}]},
244+
{"bom-ref": "b", "type": "library", "name": "widget", "version": "1.0.0",
245+
"purl": "pkg:npm/widget@1.0.0", "description": "the second says more",
246+
"supplier": {"name": "Second Supply Co"},
247+
"externalReferences": [{"type": "chat", "url": "https://two.example/chat"}]}
248+
]
249+
}`
250+
doc, _, err := UnmarshalAutoJSON([]byte(duplicated))
251+
if err != nil {
252+
t.Fatalf("ingest: %v", err)
253+
}
254+
graph, err := ToGraph(doc)
255+
if err != nil {
256+
t.Fatalf("to graph: %v", err)
257+
}
258+
if graph.Size() != 1 {
259+
t.Fatalf("size = %d, want the two components folded into one node", graph.Size())
260+
}
261+
node := graph.DependencyNodes()[0]
262+
if !strings.Contains(node.Description, "second says more") {
263+
t.Errorf("description = %q, want the second component's -- a gap the first left", node.Description)
264+
}
265+
if node.Supplier == nil || node.Supplier.Name != "Second Supply Co" {
266+
t.Errorf("supplier = %+v, want the second component's", node.Supplier)
267+
}
268+
var tracker, chat bool
269+
for _, ref := range node.ExternalReferences {
270+
tracker = tracker || strings.Contains(ref.Locator, "one.example")
271+
chat = chat || strings.Contains(ref.Locator, "two.example")
272+
}
273+
if !tracker || !chat {
274+
t.Errorf("references = %+v, want the union of both components'", node.ExternalReferences)
275+
}
276+
}
277+
278+
// A root component's own supplier is not replaced by configured provenance:
279+
// one names who supplied the component, the other who produced the project.
280+
func TestConfiguredProvenanceDoesNotOverwriteARootSupplier(t *testing.T) {
281+
doc, _, err := UnmarshalAutoJSON([]byte(supplierRichCycloneDX))
282+
if err != nil {
283+
t.Fatalf("ingest: %v", err)
284+
}
285+
// The lone component is the document's root.
286+
raw, err := MarshalJSON(doc, TargetSPDX23JSON, EncodeOptions{Pretty: true})
287+
if err != nil {
288+
t.Fatalf("export: %v", err)
289+
}
290+
if !strings.Contains(string(raw), "Widget Supply Co") {
291+
t.Fatalf("the source supplier is missing:\n%s", raw)
292+
}
293+
294+
doc.Provenance = Provenance{Manufacturer: "Operator Ltd"}
295+
withProvenance, err := MarshalJSON(doc, TargetSPDX23JSON, EncodeOptions{Pretty: true})
296+
if err != nil {
297+
t.Fatalf("export with provenance: %v", err)
298+
}
299+
if !strings.Contains(string(withProvenance), "Widget Supply Co") {
300+
t.Errorf("configured provenance overwrote the component's own supplier:\n%s", withProvenance)
301+
}
302+
}

internal/sbom/cyclonedx.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,7 @@ func (c cycloneDXCodec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, e
6868
}
6969
metadata.Component = &primary
7070
}
71-
if doc.Provenance.Manufacturer != "" {
72-
metadata.Manufacturer = &cdx.OrganizationalEntity{Name: doc.Provenance.Manufacturer}
73-
}
71+
metadata.Manufacturer = cycloneDXDocumentManufacturer(doc)
7472
if authors := cycloneDXDocumentAuthors(doc); len(authors) > 0 {
7573
metadata.Authors = &authors
7674
}
@@ -163,7 +161,7 @@ func (c cycloneDXCodec) decodeJSON(data []byte) (*Document, error) {
163161

164162
if len(componentByID) == 0 && bom.Metadata != nil && bom.Metadata.Component != nil {
165163
root := bom.Metadata.Component
166-
componentByID[root.BOMRef] = Component{
164+
component := Component{
167165
ID: root.BOMRef,
168166
Name: root.Name,
169167
Org: root.Group,
@@ -174,6 +172,12 @@ func (c cycloneDXCodec) decodeJSON(data []byte) (*Document, error) {
174172
Copyright: root.Copyright,
175173
Licenses: parseCycloneDXLicenses(root.Licenses),
176174
}
175+
// The same assertions the inventory loop applies. A document whose
176+
// only component is its primary one is legal, and reading it with
177+
// half the fields was a silent hole: supplier, description, hashes,
178+
// CPE and references all stopped here.
179+
applyCycloneDXAssertions(&component, *root)
180+
componentByID[root.BOMRef] = component
177181
}
178182

179183
components := make([]Component, 0, len(componentByID))
@@ -476,7 +480,7 @@ func cycloneDXComponent(comp Component) cdx.Component {
476480
// carries one list, so they concatenate; the emitted set is deduplicated
477481
// by the SDK's reference identity before it is written.
478482
refs := cycloneDXComponentReferences(comp)
479-
refs = append(refs, cycloneDXEmittedReferences(comp.ExternalReferences)...)
483+
refs = append(refs, cycloneDXEmittedReferences(cycloneDXComponentAssertedReferences(comp))...)
480484
if len(refs) > 0 {
481485
component.ExternalReferences = &refs
482486
}
@@ -488,6 +492,17 @@ func cycloneDXComponent(comp Component) cdx.Component {
488492
return component
489493
}
490494

495+
// cycloneDXComponentAssertedReferences returns the references a source
496+
// document asserted about a component, plus the website reference its homepage
497+
// is carried in.
498+
func cycloneDXComponentAssertedReferences(comp Component) []sdk.ExternalReference {
499+
refs := comp.ExternalReferences
500+
if homepage, ok := cycloneDXHomepageReference(comp); ok {
501+
refs = sdk.MergeExternalReferences(refs, []sdk.ExternalReference{homepage})
502+
}
503+
return refs
504+
}
505+
491506
// cycloneDXLicenses renders a component's licenses into CycloneDX.
492507
//
493508
// The format offers three shapes and scores them differently: `license.id` is

0 commit comments

Comments
 (0)