diff --git a/README.md b/README.md index 98d69b9..cec64ba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # sbom -Go library for reading and writing Software Bill of Materials documents. Reads CycloneDX and SPDX JSON into a single `Document` / `Package` / `Relationship` model, and writes that model back out as CycloneDX (JSON or XML) or SPDX JSON. +Go library for reading and writing Software Bill of Materials documents. Reads CycloneDX JSON/XML and SPDX JSON/tag-value into a single `Document` / `Package` / `Relationship` model, and writes that model back out as CycloneDX (JSON or XML) or SPDX JSON. Ported from [github.com/andrew/sbom](https://github.com/andrew/sbom). @@ -30,7 +30,9 @@ for _, p := range doc.Packages { } ``` -`Parse` auto-detects the format. `Detect` returns just the type without a full parse and runs in roughly constant time regardless of document size, since it only token-scans top-level keys until it hits a discriminator. +`Parse` auto-detects the format. `Detect` returns just the type without a full parse, scanning JSON keys, the XML root, or the tag-value document header. + +Packages with different document IDs or PURLs remain separate, even when their names and versions match. `AddPackage` replaces an entry only when its name, version, document ID, and set of PURLs match. `Package.PURL()` returns the first PURL; `Package.ExternalRefs` retains all declared references. ```go switch sbom.Detect(data) { @@ -94,10 +96,11 @@ for _, p := range doc.Packages { | Format | Parse | Encode | Notes | | --- | --- | --- | --- | | CycloneDX JSON | yes | yes | nested components flattened, `license.id` / `license.name` / `expression` all read | -| CycloneDX XML | no | yes | | +| CycloneDX XML | yes | yes | nested components, dependency references, and package metadata | | SPDX JSON | yes | yes | `{"sbom": ...}` and in-toto `{"predicate": ...}` envelopes unwrapped on parse | +| SPDX tag-value | yes | no | document and package fields, external references, relationships, and multiline text | -XML/YAML/tag-value parsing is not handled yet. +SPDX file, snippet, annotation, and extracted-license records are not included in the parsed model. YAML parsing is not supported. ## Why not protobom? diff --git a/cyclonedx.go b/cyclonedx.go index 8be2755..bdb7fbc 100644 --- a/cyclonedx.go +++ b/cyclonedx.go @@ -34,10 +34,11 @@ type cdxBOMXML struct { } type cdxMetadataXML struct { - Timestamp string `xml:"timestamp,omitempty"` - Tools *cdxToolsXML `xml:"tools,omitempty"` - Component *cdxComponentXML `xml:"component,omitempty"` - Supplier *cdxOrgEntity `xml:"supplier,omitempty"` + Timestamp string `xml:"timestamp,omitempty"` + Tools *cdxToolsXML `xml:"tools,omitempty"` + Component *cdxComponentXML `xml:"component,omitempty"` + Supplier *cdxOrgEntity `xml:"supplier,omitempty"` + Manufacture *cdxOrgEntity `xml:"manufacture,omitempty"` } type cdxToolsXML struct { @@ -245,10 +246,13 @@ func parseCycloneDX(data []byte) (*SBOM, error) { if err := json.Unmarshal(data, &bom); err != nil { return nil, wrapErr("cyclonedx json", err) } - if bom.BOMFormat != cdxBOMFormat { + if bom.BOMFormat != cdxBOMFormat && (bom.BOMFormat != "" || bom.Components == nil) { return nil, ErrUnrecognized } + return normalizeCycloneDX(&bom), nil +} +func normalizeCycloneDX(bom *cdxBOM) *SBOM { packageCount := len(bom.Components) nestedRelationshipCount := 0 for i := range bom.Components { @@ -298,7 +302,7 @@ func parseCycloneDX(data []byte) (*SBOM, error) { } } - return s, nil + return s } func cdxComponentStats(components []cdxComponent) (packages, relationships int) { diff --git a/cyclonedx_xml.go b/cyclonedx_xml.go new file mode 100644 index 0000000..2cbecca --- /dev/null +++ b/cyclonedx_xml.go @@ -0,0 +1,140 @@ +package sbom + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "strings" +) + +const cdxXMLNamespacePrefix = "http://cyclonedx.org/schema/bom/" + +type cdxDependencyXML struct { + Ref string `xml:"ref,attr"` + DependsOn []cdxDependencyXML `xml:"dependency"` +} + +// The dependency targets are ref attributes, unlike the JSON string array. +type cdxInputXML struct { + XMLName xml.Name `xml:"bom"` + Version int `xml:"version,attr"` + SerialNumber string `xml:"serialNumber,attr"` + Metadata *cdxMetadataXML `xml:"metadata"` + Components *cdxComponentsXML `xml:"components"` + Dependencies []cdxDependencyXML `xml:"dependencies>dependency"` +} + +func detectCycloneDXXML(data []byte) detectedFormat { + decoder := xml.NewDecoder(bytes.NewReader(data)) + for { + token, err := decoder.Token() + if err != nil { + return detectedFormat{} + } + if start, ok := token.(xml.StartElement); ok { + if start.Name.Local == "bom" && (start.Name.Space == "" || cycloneDXXMLVersion(start.Name.Space) != "") { + return detectedFormat{typ: TypeCycloneDX, xml: true} + } + return detectedFormat{} + } + } +} + +func cycloneDXXMLVersion(namespace string) string { + version, ok := strings.CutPrefix(namespace, cdxXMLNamespacePrefix) + if !ok { + return "" + } + return version +} + +func parseCycloneDXXML(data []byte) (*SBOM, error) { + var input cdxInputXML + decoder := xml.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&input); err != nil { + return nil, wrapErr("cyclonedx xml", err) + } + if err := checkXMLEnd(decoder); err != nil { + return nil, wrapErr("cyclonedx xml", err) + } + bom := cdxBOM{ + BOMFormat: cdxBOMFormat, SpecVersion: cycloneDXXMLVersion(input.XMLName.Space), + BOMVersion: input.Version, SerialNumber: input.SerialNumber, + Components: componentsFromXML(input.Components), + } + if input.Metadata != nil { + m := input.Metadata + bom.Metadata = &cdxMetadata{Timestamp: m.Timestamp, Supplier: m.Supplier, Manufacture: m.Manufacture} + if m.Component != nil { + component := componentFromXML(m.Component) + bom.Metadata.Component = &component + } + } + for _, dependency := range input.Dependencies { + d := cdxDependency{Ref: dependency.Ref} + for _, target := range dependency.DependsOn { + d.DependsOn = append(d.DependsOn, target.Ref) + } + bom.Dependencies = append(bom.Dependencies, d) + } + return normalizeCycloneDX(&bom), nil +} + +func checkXMLEnd(decoder *xml.Decoder) error { + for { + token, err := decoder.Token() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + switch token := token.(type) { + case xml.Comment, xml.ProcInst: + continue + case xml.CharData: + if len(bytes.TrimSpace(token)) == 0 { + continue + } + } + return fmt.Errorf("unexpected content after bom element") + } +} + +func componentsFromXML(input *cdxComponentsXML) []cdxComponent { + if input == nil || len(input.Components) == 0 { + return nil + } + components := make([]cdxComponent, len(input.Components)) + for i := range input.Components { + components[i] = componentFromXML(&input.Components[i]) + } + return components +} + +func componentFromXML(input *cdxComponentXML) cdxComponent { + c := cdxComponent{ + BOMRef: input.BOMRef, Type: input.Type, Name: input.Name, Version: input.Version, + Description: input.Description, Copyright: input.Copyright, Author: input.Author, + PURL: input.PURL, Supplier: input.Supplier, Components: componentsFromXML(input.Components), + } + if input.Hashes != nil { + c.Hashes = input.Hashes.Hashes + } + if input.ExternalReferences != nil { + c.ExternalReferences = input.ExternalReferences.References + } + if input.Properties != nil { + c.Properties = input.Properties.Properties + } + if input.Licenses != nil { + for _, license := range input.Licenses.Licenses { + c.Licenses = append(c.Licenses, cdxLicense{License: &cdxLicenseID{ID: license.ID, Name: license.Name}}) + } + if input.Licenses.Expression != "" { + c.Licenses = append(c.Licenses, cdxLicense{Expression: input.Licenses.Expression}) + } + } + return c +} diff --git a/parse_formats_test.go b/parse_formats_test.go new file mode 100644 index 0000000..ea7eae2 --- /dev/null +++ b/parse_formats_test.go @@ -0,0 +1,259 @@ +package sbom + +import ( + "bytes" + "errors" + "reflect" + "strings" + "testing" +) + +func TestParseCycloneDXXML(t *testing.T) { + data := readFixture(t, "cyclonedx/packages.cdx.xml") + if got := Detect(data); got != TypeCycloneDX { + t.Fatalf("Detect = %q", got) + } + doc, err := Parse(data) + if err != nil { + t.Fatal(err) + } + if doc.Type != TypeCycloneDX || doc.SpecVersion != "1.5" || doc.Document.Name != "example" || doc.Document.Supplier != "Acme" || doc.Document.Created != "2026-01-01T00:00:00Z" { + t.Fatalf("document = %+v", doc.Document) + } + if len(doc.Packages) != 3 { + t.Fatalf("packages = %+v", doc.Packages) + } + want := Package{ + ID: "alpha", Name: "shared", Version: "1.0.0", Type: "LIBRARY", + Description: "Shared & useful", Copyright: "Copyright Acme", + Supplier: "Acme", SupplierType: SupplierOrganization, + Originator: "Jane Doe", OriginatorType: SupplierPerson, + LicenseConcluded: "MIT", LicenseDeclared: "MIT", + Checksums: []Checksum{{Algorithm: "SHA256", Value: "abc"}}, + ExternalRefs: []ExternalRef{ + {Category: "PACKAGE_MANAGER", Type: "purl", Locator: "pkg:npm/%40alpha/shared@1.0.0"}, + {Category: "website", Type: "website", Locator: "https://example.com"}, + }, + Properties: []Property{{Name: "scope", Value: "runtime"}}, + } + if !reflect.DeepEqual(doc.Packages[0], want) { + t.Errorf("package = %#v, want %#v", doc.Packages[0], want) + } + if doc.Packages[2].PURL() != "pkg:npm/%40beta/shared@1.0.0" || doc.Packages[2].LicenseDeclared != "MIT OR Apache-2.0" { + t.Errorf("second scoped package = %+v", doc.Packages[2]) + } + wantRelationships := []Relationship{ + {SourceID: "alpha", TargetID: "child", Type: RelDependsOn}, + {SourceID: "alpha", TargetID: "beta", Type: RelDependsOn}, + } + if !reflect.DeepEqual(doc.Relationships, wantRelationships) { + t.Errorf("relationships = %+v", doc.Relationships) + } +} + +func TestParseSPDXTagValue(t *testing.T) { + data := readFixture(t, "spdx/packages.spdx") + if got := Detect(data); got != TypeSPDX { + t.Fatalf("Detect = %q", got) + } + doc, err := Parse(data) + if err != nil { + t.Fatal(err) + } + wantDocument := Document{ + Name: "example", ID: "SPDXRef-DOCUMENT", Type: TypeSPDX, SpecVersion: "SPDX-2.3", + DataLicense: "CC0-1.0", Namespace: "https://example.com/sbom/example", + Created: "2026-01-01T00:00:00Z", Supplier: "Acme", + Creators: []Creator{{Type: "Tool", Name: "example-generator"}}, + } + if !reflect.DeepEqual(doc.Document, wantDocument) { + t.Errorf("document = %#v", doc.Document) + } + if len(doc.Packages) != 2 { + t.Fatalf("packages = %+v", doc.Packages) + } + want := Package{ + ID: "SPDXRef-alpha", Name: "shared", Version: "1.0.0", Type: "LIBRARY", + Filename: "alpha.tgz", DownloadLocation: "https://example.com/alpha.tgz", Homepage: "https://example.com", + Description: "Shared & useful\n# This is part of the description.\n Indented text.", Copyright: "Copyright Acme", + Supplier: "Acme", SupplierType: SupplierOrganization, Originator: "Jane Doe", OriginatorType: SupplierPerson, + LicenseDeclared: "MIT", LicenseConcluded: "MIT", + Checksums: []Checksum{{Algorithm: "SHA256", Value: "abc"}}, + ExternalRefs: []ExternalRef{ + {Category: "PACKAGE-MANAGER", Type: "purl", Locator: "pkg:npm/%40alpha/shared@1.0.0"}, + {Category: "PACKAGE-MANAGER", Type: "purl", Locator: "pkg:generic/alpha@1.0.0"}, + {Category: "SECURITY", Type: "cpe23Type", Locator: "cpe:2.3:a:acme:shared:1.0.0"}, + }, + } + if !reflect.DeepEqual(doc.Packages[0], want) { + t.Errorf("package = %#v, want %#v", doc.Packages[0], want) + } + if doc.Packages[1].PURL() != "pkg:npm/%40beta/shared@1.0.0" { + t.Errorf("second scoped package = %+v", doc.Packages[1]) + } + wantRelationships := []Relationship{ + {SourceID: "SPDXRef-DOCUMENT", Source: "example", TargetID: "SPDXRef-alpha", Target: "shared", Type: RelDescribes}, + {SourceID: "SPDXRef-alpha", Source: "shared", TargetID: "SPDXRef-beta", Target: "shared", Type: RelDependsOn}, + } + if !reflect.DeepEqual(doc.Relationships, wantRelationships) { + t.Errorf("relationships = %+v", doc.Relationships) + } +} + +func TestParsePreservesPackageIdentities(t *testing.T) { + for name, data := range map[string]string{ + "cyclonedx without marker": `{"components":[ + {"name":"shared","version":"1","purl":"pkg:npm/%40alpha/shared@1"}, + {"name":"shared","version":"1","purl":"pkg:npm/%40beta/shared@1"}]}`, + "XML without namespace": `shared1pkg:npm/%40alpha/shared@1shared1pkg:npm/%40beta/shared@1`, + "cyclonedx": `{"bomFormat":"CycloneDX","components":[ + {"name":"shared","version":"1","purl":"pkg:npm/%40alpha/shared@1"}, + {"name":"shared","version":"1","purl":"pkg:npm/%40beta/shared@1"}]}`, + "spdx": `{"spdxVersion":"SPDX-2.3","packages":[ + {"name":"shared","versionInfo":"1","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:npm/%40alpha/shared@1"}]}, + {"name":"shared","versionInfo":"1","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:npm/%40beta/shared@1"}]}]}`, + "different IDs": `{"bomFormat":"CycloneDX","components":[{"bom-ref":"a","name":"shared","version":"1"},{"bom-ref":"b","name":"shared","version":"1"}],"dependencies":[{"ref":"a","dependsOn":["b"]}]}`, + "multiple PURLs": `{"spdxVersion":"SPDX-2.3","packages":[ + {"name":"shared","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:generic/shared"},{"referenceType":"purl","referenceLocator":"pkg:npm/%40alpha/shared"}]}, + {"name":"shared","externalRefs":[{"referenceType":"purl","referenceLocator":"pkg:generic/shared"},{"referenceType":"purl","referenceLocator":"pkg:npm/%40beta/shared"}]}]}`, + } { + t.Run(name, func(t *testing.T) { + doc, err := Parse([]byte(data)) + if err != nil { + t.Fatal(err) + } + if len(doc.Packages) != 2 { + t.Fatalf("packages = %+v; want both identities", doc.Packages) + } + }) + } +} + +func TestParseAdditionalFormatErrors(t *testing.T) { + for name, data := range map[string]string{ + "truncated XML": ``, + "extra XML root": ``, + "trailing XML text": `oops`, + "unclosed text": "SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: unfinished", + "text suffix": "SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: textoops", + "missing colon": "SPDXVersion: SPDX-2.3\nPackageName a", + "invalid reference": "SPDXVersion: SPDX-2.3\nPackageName: a\nExternalRef: PACKAGE-MANAGER purl", + "invalid relationship": "SPDXVersion: SPDX-2.3\nRelationship: SPDXRef-a DEPENDS_ON", + "invalid checksum": "SPDXVersion: SPDX-2.3\nPackageName: a\nPackageChecksum: SHA256", + } { + t.Run(name, func(t *testing.T) { + if doc, err := Parse([]byte(data)); err == nil { + t.Fatalf("Parse accepted invalid input: %+v", doc) + } + }) + } + for _, data := range []string{``, ``, "PackageName: a", "DocumentComment: \nSPDXVersion: SPDX-2.3\n"} { + if got := Detect([]byte(data)); got != TypeUnknown { + t.Errorf("Detect(%q) = %q", data, got) + } + if _, err := Parse([]byte(data)); !errors.Is(err, ErrUnrecognized) { + t.Errorf("Parse(%q) = %v", data, err) + } + } +} + +func TestParseSPDXHeaderOrder(t *testing.T) { + data := []byte("DataLicense: CC0-1.0\nDocumentComment: Generated inventory\nSPDXVersion: SPDX-2.3\nSPDXID: SPDXRef-DOCUMENT\nPackageName: shared\nSPDXID: SPDXRef-shared\nExternalRef: PACKAGE-MANAGER purl pkg:generic/shared@1") + if got := Detect(data); got != TypeSPDX { + t.Fatalf("Detect = %q", got) + } + doc, err := Parse(data) + if err != nil { + t.Fatal(err) + } + if doc.Document.DataLicense != "CC0-1.0" || len(doc.Packages) != 1 || doc.Packages[0].PURL() != "pkg:generic/shared@1" { + t.Fatalf("document = %+v", doc) + } +} + +func TestParseAdditionalFormatWhitespace(t *testing.T) { + for _, fixture := range []string{"cyclonedx/packages.cdx.xml", "spdx/packages.spdx"} { + data := readFixture(t, fixture) + want, err := Parse(data) + if err != nil { + t.Fatal(err) + } + data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n")) + data = append([]byte("\xef\xbb\xbf \r\n"), data...) + got, err := Parse(data) + if err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("Parse %s with BOM and CRLF = %+v, %v", fixture, got, err) + } + } +} + +func TestParseSPDXLongText(t *testing.T) { + text := strings.Repeat("x", 100_000) + doc, err := Parse([]byte("SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: " + text + "")) + if err != nil { + t.Fatal(err) + } + if len(doc.Packages) != 1 || doc.Packages[0].Description != text { + t.Fatal("long description was not preserved") + } +} + +func TestParseSPDXTextWhitespace(t *testing.T) { + text := " first line \n\n second line \n" + doc, err := Parse([]byte("SPDXVersion: SPDX-2.3\nPackageName: a\nPackageDescription: " + text + "")) + if err != nil { + t.Fatal(err) + } + if len(doc.Packages) != 1 || doc.Packages[0].Description != text { + t.Fatalf("multiline whitespace was not preserved: %+v", doc.Packages) + } +} + +func TestAddPackagePURLSet(t *testing.T) { + s := New(TypeSPDX) + alpha := ExternalRef{Type: "purl", Locator: "pkg:npm/%40alpha/shared@1"} + generic := ExternalRef{Type: "purl", Locator: "pkg:generic/shared@1"} + s.AddPackage(Package{Name: "shared", Version: "1", ExternalRefs: []ExternalRef{alpha, generic}}) + s.AddPackage(Package{Name: "shared", Version: "1", Description: "updated", ExternalRefs: []ExternalRef{generic, alpha, alpha}}) + if len(s.Packages) != 1 || s.Packages[0].Description != "updated" { + t.Fatalf("reordered PURLs should replace the same identity: %+v", s.Packages) + } + s.AddPackage(Package{Name: "shared", Version: "1"}) + if len(s.Packages) != 2 { + t.Fatalf("an entry without PURLs must not replace a declared identity: %+v", s.Packages) + } +} + +func TestParseEmptyAdditionalFormats(t *testing.T) { + for _, data := range []string{``, ``, "SPDXVersion: SPDX-2.3\nSPDXID: SPDXRef-DOCUMENT"} { + doc, err := Parse([]byte(data)) + if err != nil { + t.Fatal(err) + } + if doc.Packages != nil || doc.Relationships != nil { + t.Errorf("empty document = %+v", doc) + } + } +} + +func TestParsePrefixedCycloneDXXML(t *testing.T) { + data := `sharedpkg:generic/shared@1Custom license` + doc, err := Parse([]byte(data)) + if err != nil { + t.Fatal(err) + } + if doc.SpecVersion != "1.5" || len(doc.Packages) != 1 || doc.Packages[0].PURL() != "pkg:generic/shared@1" || doc.Packages[0].LicenseDeclared != "Custom license" { + t.Fatalf("prefixed document = %+v", doc) + } +} + +func TestParseCycloneDXXMLManufacture(t *testing.T) { + data := `Acme` + doc, err := Parse([]byte(data)) + if err != nil { + t.Fatal(err) + } + if doc.Document.Supplier != "Acme" { + t.Fatalf("supplier = %q, want manufacture name", doc.Document.Supplier) + } +} diff --git a/sbom.go b/sbom.go index da3e063..dd7fc7e 100644 --- a/sbom.go +++ b/sbom.go @@ -1,5 +1,5 @@ // Package sbom parses Software Bill of Materials documents into a unified -// data model. It reads CycloneDX and SPDX (JSON serialisations) and +// data model. It reads CycloneDX JSON/XML and SPDX JSON/tag-value and // normalises both into the same Document/Package/Relationship shape so // callers don't need to care which format they were handed. // @@ -16,6 +16,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" ) @@ -180,23 +181,23 @@ type SBOM struct { Packages []Package Relationships []Relationship - // pkgIndex keys packages by (name, version) so duplicates collapse the - // same way the Ruby implementation's @packages[[name, version]] does. - pkgIndex map[[2]string]int + pkgIndex map[packageKey]int +} + +type packageKey struct { + name, version, id, purls string } // New returns an empty SBOM ready for AddPackage calls. Use this when // building a document for Encode rather than parsing one. func New(t Type) *SBOM { - return &SBOM{Type: t, pkgIndex: map[[2]string]int{}} + return &SBOM{Type: t, pkgIndex: map[packageKey]int{}} } -func newSBOM(t Type) *SBOM { return New(t) } - func newSizedSBOM(t Type, packages, relationships int) *SBOM { s := &SBOM{ Type: t, - pkgIndex: make(map[[2]string]int, packages), + pkgIndex: make(map[packageKey]int, packages), } if packages > 0 { s.Packages = make([]Package, 0, packages) @@ -208,7 +209,7 @@ func newSizedSBOM(t Type, packages, relationships int) *SBOM { } // AddPackage appends p, replacing any existing package with the same -// (Name, Version) pair. +// name, version, document ID, and set of PURLs. func (s *SBOM) AddPackage(p Package) { s.addPackage(p) } // FilterProperties walks every package and removes properties whose @@ -290,9 +291,9 @@ func (s *SBOM) ClassifyScope() map[string]string { func (s *SBOM) addPackage(p Package) { if s.pkgIndex == nil { - s.pkgIndex = map[[2]string]int{} + s.pkgIndex = map[packageKey]int{} } - key := [2]string{p.Name, p.Version} + key := packageKey{p.Name, p.Version, p.ID, packagePURLKey(p.ExternalRefs)} if i, ok := s.pkgIndex[key]; ok { s.Packages[i] = p return @@ -301,10 +302,27 @@ func (s *SBOM) addPackage(p Package) { s.Packages = append(s.Packages, p) } -// Parse sniffs the SBOM format from content and parses it. Only JSON -// serialisations are supported. +func packagePURLKey(refs []ExternalRef) string { + var purls []string + for _, ref := range refs { + if ref.Type == purlExternalReferenceType && ref.Locator != "" { + purls = append(purls, ref.Locator) + } + } + slices.Sort(purls) + return strings.Join(slices.Compact(purls), "\x00") +} + +// Parse reads CycloneDX JSON/XML or SPDX JSON/tag-value, detecting the format. func Parse(data []byte) (*SBOM, error) { + data = trimDocument(data) detected := detect(data) + if detected.xml { + return parseCycloneDXXML(data) + } + if detected.tagValue { + return parseSPDXTagValue(data) + } switch detected.typ { case TypeCycloneDX: return parseCycloneDX(data) @@ -324,9 +342,7 @@ func Parse(data []byte) (*SBOM, error) { // Detect inspects content and returns the SBOM Type without fully parsing // it. Returns TypeUnknown if the format can't be determined. // -// Only the top-level object keys are scanned; nested arrays and objects are -// skipped without allocation so detection cost is independent of document -// size once a discriminator is found. +// It scans JSON keys, the XML root, or the tag-value document header. func Detect(data []byte) Type { return detect(data).typ } @@ -334,13 +350,25 @@ func Detect(data []byte) Type { type detectedFormat struct { typ Type spdxEnvelope bool + xml bool + tagValue bool +} + +func trimDocument(data []byte) []byte { + return bytes.TrimSpace(bytes.TrimPrefix(bytes.TrimSpace(data), []byte("\xef\xbb\xbf"))) } func detect(data []byte) detectedFormat { - data = bytes.TrimSpace(data) - if len(data) == 0 || data[0] != '{' { + data = trimDocument(data) + if len(data) == 0 { return detectedFormat{} } + if data[0] == '<' { + return detectCycloneDXXML(data) + } + if data[0] != '{' { + return detectSPDXTagValue(data) + } dec := json.NewDecoder(bytes.NewReader(data)) if _, err := dec.Token(); err != nil { // opening '{' return detectedFormat{} diff --git a/sbom_test.go b/sbom_test.go index 5357b89..8bc92be 100644 --- a/sbom_test.go +++ b/sbom_test.go @@ -26,7 +26,7 @@ func TestDetect(t *testing.T) { {"github envelope", `{"sbom":{"spdxVersion":"SPDX-2.3"}}`, TypeSPDX}, {"intoto envelope", `{"predicateType":"https://spdx.dev/Document","predicate":{}}`, TypeSPDX}, {"unknown json", `{"foo":1}`, TypeUnknown}, - {"not json", ``, TypeUnknown}, + {"bare xml bom", ``, TypeCycloneDX}, {"empty", ``, TypeUnknown}, {"whitespace", " \n\t", TypeUnknown}, } @@ -121,10 +121,10 @@ func TestParseSPDXEnvelope(t *testing.T) { } func TestPackageDedup(t *testing.T) { - s := newSBOM(TypeCycloneDX) - s.addPackage(Package{Name: "a", Version: "1"}) - s.addPackage(Package{Name: "a", Version: "1", Description: "second"}) - s.addPackage(Package{Name: "a", Version: "2"}) + s := New(TypeCycloneDX) + s.AddPackage(Package{Name: "a", Version: "1"}) + s.AddPackage(Package{Name: "a", Version: "1", Description: "second"}) + s.AddPackage(Package{Name: "a", Version: "2"}) if len(s.Packages) != 2 { t.Fatalf("Packages = %d, want 2", len(s.Packages)) } diff --git a/spdx.go b/spdx.go index 9c279d2..8de7f92 100644 --- a/spdx.go +++ b/spdx.go @@ -86,7 +86,10 @@ func parseSPDX(data []byte, envelope bool) (*SBOM, error) { if doc.SPDXVersion == "" && doc.SPDXID == "" { return nil, ErrUnrecognized } + return normalizeSPDX(&doc), nil +} +func normalizeSPDX(doc *spdxDoc) *SBOM { s := newSizedSBOM(TypeSPDX, len(doc.Packages), len(doc.Relationships)) s.SpecVersion = doc.SPDXVersion s.Document = Document{ @@ -154,7 +157,7 @@ func parseSPDX(data []byte, envelope bool) (*SBOM, error) { }) } - return s, nil + return s } func applySPDXCreationInfo(s *SBOM, creationInfo *spdxCreationInfo) { diff --git a/spdx_tagvalue.go b/spdx_tagvalue.go new file mode 100644 index 0000000..b848338 --- /dev/null +++ b/spdx_tagvalue.go @@ -0,0 +1,193 @@ +package sbom + +import ( + "bufio" + "bytes" + "fmt" + "strings" + "unicode" +) + +const spdxTripleFields = 3 + +func detectSPDXTagValue(data []byte) detectedFormat { + scanner := newSPDXScanner(data) + for scanner.Scan() { + line := strings.TrimLeftFunc(scanner.Text(), unicode.IsSpace) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + tag, value, ok := strings.Cut(line, ":") + if !ok || strings.TrimSpace(tag) == "PackageName" { + break + } + value, err := readSPDXText(scanner, value) + if err != nil { + break + } + if strings.TrimSpace(tag) == "SPDXVersion" && strings.HasPrefix(value, "SPDX-") { + return detectedFormat{typ: TypeSPDX, tagValue: true} + } + } + return detectedFormat{} +} + +func newSPDXScanner(data []byte) *bufio.Scanner { + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(nil, len(data)+1) + return scanner +} + +type spdxTagParser struct { + doc spdxDoc + pkg *spdxPackage + inDocument bool +} + +func parseSPDXTagValue(data []byte) (*SBOM, error) { + p := spdxTagParser{inDocument: true} + scanner := newSPDXScanner(data) + for scanner.Scan() { + line := strings.TrimLeftFunc(scanner.Text(), unicode.IsSpace) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + tag, value, ok := strings.Cut(line, ":") + if !ok || strings.TrimSpace(tag) == "" { + return nil, wrapErr("spdx tag-value", fmt.Errorf("expected tag: value, got %q", line)) + } + value, err := readSPDXText(scanner, value) + if err != nil { + return nil, wrapErr("spdx tag-value", err) + } + if err := p.apply(strings.TrimSpace(tag), value); err != nil { + return nil, wrapErr("spdx tag-value", err) + } + } + if err := scanner.Err(); err != nil { + return nil, wrapErr("spdx tag-value", err) + } + return normalizeSPDX(&p.doc), nil +} + +func readSPDXText(scanner *bufio.Scanner, value string) (string, error) { + text, multiline := strings.CutPrefix(strings.TrimLeftFunc(value, unicode.IsSpace), "") + if !multiline { + return strings.TrimSpace(value), nil + } + var b strings.Builder + for { + before, after, closed := strings.Cut(text, "") + b.WriteString(before) + if closed { + if strings.TrimSpace(after) != "" { + return "", fmt.Errorf("unexpected content after ") + } + return b.String(), nil + } + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("unterminated value") + } + b.WriteByte('\n') + text = scanner.Text() + } +} + +func (p *spdxTagParser) apply(tag, value string) error { + switch tag { + case "SPDXVersion": + p.doc.SPDXVersion = value + case "DataLicense": + p.doc.DataLicense = value + case "DocumentName": + p.doc.Name = value + case "DocumentNamespace": + p.doc.DocumentNamespace = value + case "Creator", "Created", "LicenseListVersion": + p.creationInfo(tag, value) + case "PackageName": + p.inDocument = false + p.doc.Packages = append(p.doc.Packages, spdxPackage{Name: value}) + p.pkg = &p.doc.Packages[len(p.doc.Packages)-1] + case "FileName", "SnippetSPDXID", "LicenseID", "Annotator", "Reviewer": + p.inDocument = false + p.pkg = nil + case "SPDXID": + if p.pkg != nil { + p.pkg.SPDXID = value + } else if p.inDocument { + p.doc.SPDXID = value + } + case "Relationship": + fields := strings.Fields(value) + if len(fields) != spdxTripleFields { + return fmt.Errorf("invalid Relationship %q", value) + } + p.doc.Relationships = append(p.doc.Relationships, spdxRelationship{ + SPDXElementID: fields[0], RelationshipType: fields[1], RelatedSPDXElement: fields[2], + }) + default: + if p.pkg != nil { + return p.packageField(tag, value) + } + } + return nil +} + +func (p *spdxTagParser) creationInfo(tag, value string) { + if p.doc.CreationInfo == nil { + p.doc.CreationInfo = &spdxCreationInfo{} + } + switch tag { + case "Creator": + p.doc.CreationInfo.Creators = append(p.doc.CreationInfo.Creators, value) + case "Created": + p.doc.CreationInfo.Created = value + case "LicenseListVersion": + p.doc.CreationInfo.LicenseListVersion = value + } +} + +func (p *spdxTagParser) packageField(tag, value string) error { + switch tag { + case "PackageVersion": + p.pkg.VersionInfo = value + case "PackageFileName": + p.pkg.PackageFileName = value + case "PackageSupplier": + p.pkg.Supplier = value + case "PackageOriginator": + p.pkg.Originator = value + case "PackageDownloadLocation": + p.pkg.DownloadLocation = value + case "PackageHomePage": + p.pkg.Homepage = value + case "PackageLicenseConcluded": + p.pkg.LicenseConcluded = value + case "PackageLicenseDeclared": + p.pkg.LicenseDeclared = value + case "PackageCopyrightText": + p.pkg.CopyrightText = value + case "PackageDescription": + p.pkg.Description = value + case "PrimaryPackagePurpose": + p.pkg.PrimaryPackagePurpose = value + case "PackageChecksum": + algorithm, checksum, ok := strings.Cut(value, ":") + algorithm, checksum = strings.TrimSpace(algorithm), strings.TrimSpace(checksum) + if !ok || algorithm == "" || checksum == "" { + return fmt.Errorf("invalid PackageChecksum %q", value) + } + p.pkg.Checksums = append(p.pkg.Checksums, spdxChecksum{Algorithm: algorithm, Value: checksum}) + case "ExternalRef": + fields := strings.Fields(value) + if len(fields) != spdxTripleFields { + return fmt.Errorf("invalid ExternalRef %q", value) + } + p.pkg.ExternalRefs = append(p.pkg.ExternalRefs, spdxExtRef{Category: fields[0], Type: fields[1], Locator: fields[2]}) + } + return nil +} diff --git a/testdata/cyclonedx/packages.cdx.xml b/testdata/cyclonedx/packages.cdx.xml new file mode 100644 index 0000000..ad3b570 --- /dev/null +++ b/testdata/cyclonedx/packages.cdx.xml @@ -0,0 +1,29 @@ + + + + 2026-01-01T00:00:00Z + example1.0.0 + Acme + + + + Acme + Jane Doe + @alphashared1.0.0 + Shared & useful + abc + MIT + Copyright Acme + pkg:npm/%40alpha/shared@1.0.0 + https://example.com + runtime + childpkg:npm/child@1.0.0 + + + @betashared1.0.0 + MIT OR Apache-2.0 + pkg:npm/%40beta/shared@1.0.0 + + + + diff --git a/testdata/spdx/packages.spdx b/testdata/spdx/packages.spdx new file mode 100644 index 0000000..e272c4c --- /dev/null +++ b/testdata/spdx/packages.spdx @@ -0,0 +1,57 @@ +# SPDX package inventory +SPDXVersion: SPDX-2.3 +DataLicense: CC0-1.0 +SPDXID: SPDXRef-DOCUMENT +DocumentName: example +DocumentNamespace: https://example.com/sbom/example +Creator: Organization: Acme +Creator: Tool: example-generator +Created: 2026-01-01T00:00:00Z +DocumentComment: A document comment. +PackageName: this is text, not a package + + +PackageName: shared +SPDXID: SPDXRef-alpha +PackageVersion: 1.0.0 +PackageFileName: alpha.tgz +PackageSupplier: Organization: Acme +PackageOriginator: Person: Jane Doe +PackageDownloadLocation: https://example.com/alpha.tgz +FilesAnalyzed: false +PackageChecksum: SHA256: abc +PackageHomePage: https://example.com +PackageLicenseConcluded: MIT +PackageLicenseDeclared: MIT +PackageCopyrightText: Copyright Acme +PackageDescription: Shared & useful +# This is part of the description. + Indented text. +PrimaryPackagePurpose: LIBRARY +ExternalRef: PACKAGE-MANAGER purl pkg:npm/%40alpha/shared@1.0.0 +ExternalRef: PACKAGE-MANAGER purl pkg:generic/alpha@1.0.0 +ExternalRef: SECURITY cpe23Type cpe:2.3:a:acme:shared:1.0.0 + +FileName: ./README.md +SPDXID: SPDXRef-file +FileCopyrightText: Copyright File Author +SnippetSPDXID: SPDXRef-snippet +SnippetFromFileSPDXID: SPDXRef-file +SnippetByteRange: 1:20 + +PackageName: shared +SPDXID: SPDXRef-beta +PackageVersion: 1.0.0 +PackageDownloadLocation: NOASSERTION +FilesAnalyzed: false +ExternalRef: PACKAGE-MANAGER purl pkg:npm/%40beta/shared@1.0.0 + +Relationship: SPDXRef-DOCUMENT DESCRIBES SPDXRef-alpha +Relationship: SPDXRef-alpha DEPENDS_ON SPDXRef-beta +LicenseID: LicenseRef-example +ExtractedText: PackageName: not a package +Annotator: Person: Jane Doe +AnnotationDate: 2026-01-01T00:00:00Z +AnnotationType: REVIEW +SPDXREF: SPDXRef-beta +AnnotationComment: Reviewed