From 21d200d8281cdcd8e9a752c721b3696fb41686d6 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Fri, 31 Jul 2026 19:22:52 -0700 Subject: [PATCH] feat(sbom): consistent primary component, document identity, and CRA metadata Improve SBOM export quality based on third-party validator findings (sbom-tools v0.1.22 reported grade F / 43.4 overall on our own scan): - Primary component: synthesize a project root (application type, pkg:generic PURL) when the graph has multiple roots, instead of letting CycloneDX metadata.component fall to an arbitrary manifest node (previously a .github workflow file). The root depends on every graph root, so the exported dependency graph is one connected component, and both formats now name the same scanned project (cross-format component identity: 0 added / 0 removed in sbom-tools diff). Single-root graphs keep their natural root. Re-ingestion skips the pseudo root. - Document identity: generate a UUIDv4 per export; CycloneDX gets a urn:uuid serialNumber and the SPDX namespace reuses the same nonce. - Tool version: emit bomly's version in CycloneDX metadata.tools and as the SPDX "bomly-cli-" tool creator. - Component hashes: project detection-time dependency digests (npm SRI integrity, nuget, cocoapods) into SBOM components, normalized to lowercase hex; parse go.sum h1 tree hashes in the gomod detector and expose them as sha256 (cyclonedx-gomod convention). 89% of components in a self-scan now carry hashes (was 0%). - CRA readiness: optional sbom config section (manufacturer, security_contact, vulnerability_disclosure_url, support_end) emitted as CycloneDX metadata.manufacturer, security-contact/advisories external references, and a support-end property; SPDX gets an Organization creator, root package supplier, and creation-info comment. Self-scan score moves 43.4 (F) to 59.4 with no config and to fully recognized manufacturer/contact/support fields with the sbom section set. NTIA minimum elements remain compliant for both formats; both outputs validate clean against the official CycloneDX 1.7 and SPDX 2.3 schemas. The 3 cycles the validator reported are real Go module-graph cycles (otel, grpc/xds, cloud auth), not export artifacts. Co-Authored-By: Claude Fable 5 --- dev-docs/ARCHITECTURE.md | 8 + docs/CONFIG_REFERENCE.md | 18 + docs/SBOM.md | 56 ++- internal/cli/scan_cmd.go | 28 +- internal/config/config.go | 15 + internal/config/load.go | 6 + internal/config/validate_test.go | 13 + internal/detectors/gomod/detector.go | 68 +++- internal/detectors/gomod/detector_test.go | 39 +- internal/detectors/gomod/parser_fuzz_test.go | 2 +- internal/sbom/cyclonedx.go | 75 +++- internal/sbom/export_quality_test.go | 375 +++++++++++++++++++ internal/sbom/graph.go | 24 +- internal/sbom/model.go | 51 +++ internal/sbom/spdx23.go | 44 ++- internal/sbom/transform.go | 140 ++++++- 16 files changed, 911 insertions(+), 51 deletions(-) create mode 100644 internal/sbom/export_quality_test.go diff --git a/dev-docs/ARCHITECTURE.md b/dev-docs/ARCHITECTURE.md index 074bc1c8..a1cf97c3 100644 --- a/dev-docs/ARCHITECTURE.md +++ b/dev-docs/ARCHITECTURE.md @@ -550,6 +550,14 @@ The help-path startup banner (`internal/cli/render/logo.go`) is a frame-based an - **Gating is env-var-only (`NO_COLOR`, `BOMLY_NO_ANIMATION`, `CI`, `BOMLY_QUIET`), deliberately not a config key.** Cobra's `execute()` returns `flag.ErrHelp` right after flag parsing, *before* the `PersistentPreRunE` chain where `options.ResolveConfig` runs — so on `bomly --help` / `bomly --help` (the banner's primary path) resolved config simply does not exist yet. A `logo.animate` config key would silently work only for bare `bomly` and `bomly help `, which is a trap. When animation is gated off but stderr is a TTY, the static final frame prints instead (plain under `NO_COLOR`, colored otherwise); non-TTY stderr prints nothing. - **The animation leaves cursor visibility unchanged.** Hiding the cursor would make the animation slightly cleaner, but a process-level interrupt can bypass deferred cleanup and leave the user's shell cursor hidden. Avoiding that terminal-state mutation keeps interruption safe without introducing signal handling into the render package. +### Decision: SBOM exports carry a synthesized primary component and shared document identity + +A scan that discovers multiple manifests produces a graph with many roots (one per workflow file, one per module). Before this decision the CycloneDX `metadata.component` was simply `Roots[0]` — an arbitrary manifest node such as `.github/workflows/auto-version.yml` — while the SPDX document was named after a static default, so the two exports of one scan disagreed about their own subject and third-party graph analysis saw disconnected islands. + +`sbom.FromDepGraph` now synthesizes a pseudo root when a `ProjectRoot` is supplied and the graph does not already have exactly one root. The pseudo root is named after the scanned project, typed `application`, given a `pkg:generic` PURL for cross-update traceability, and depends on every graph root, which makes the exported dependency graph a single connected component. Its ID carries the `DocumentRoot-` prefix so `ToGraph` excludes it on re-ingestion (the prefix check deliberately overrides the it-has-a-PURL heuristic); the CycloneDX encoder keeps it out of the component inventory (it lives in `metadata.component` plus one `dependencies` entry), while SPDX includes it as the `DESCRIBES` target because SPDX relationships must reference document packages. When the graph has a single natural root — for example a pure Go module scan — that root remains the primary component, since a real package with a real PURL is strictly better identity than a synthesized one. + +Document identity is shared across formats: one generated UUIDv4 becomes both the CycloneDX `serialNumber` (`urn:uuid:`) and the nonce in the SPDX document namespace, so the two files produced by one scan are correlatable. Detection-time dependency digests (npm SRI integrity, `go.sum` `h1:` tree hashes) are projected into component hashes, normalized to lowercase hex because both formats' schemas require hex; the `go.sum` h1 value is exposed as `sha256` following cyclonedx-gomod's convention (it is SHA-256 over the module dirhash manifest, not over a zip artifact). Registry (matching-stage) digests still win when present. Optional producer metadata (manufacturer, security contact, disclosure URL, support end) is config-driven (`sbom:` section) and never invented: per-component supplier/description stay empty rather than being fabricated to satisfy compliance profile checkers. + ## Build Modes Syft and Grype each support two build modes: diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 44e06978..2e3f46dd 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -89,6 +89,15 @@ YAML files use the nested keys documented below. Unknown keys and the former fla | `matchers.scorecard.cache_dir` | `BOMLY_SCORECARD_CACHE_DIR` | `string` | - | Directory for the Scorecard response cache | | `matchers.scorecard.cache_ttl` | `BOMLY_SCORECARD_CACHE_TTL` | `string` | 24h | TTL for cached Scorecard responses (e.g. 24h) | +## SBOM export metadata (optional EU-CRA transparency fields) + +| YAML Key | Environment Variable | Type | Default | Description | +|----------|---------------------|------|---------|-------------| +| `sbom.manufacturer` | `BOMLY_SBOM_MANUFACTURER` | `string` | - | Organization name emitted as the SBOM manufacturer/supplier (EU CRA Art. 13(15)) | +| `sbom.security_contact` | `BOMLY_SBOM_SECURITY_CONTACT` | `string` | - | Security contact URL or email emitted in exported SBOMs (EU CRA Art. 13(6)) | +| `sbom.vulnerability_disclosure_url` | `BOMLY_SBOM_VULNERABILITY_DISCLOSURE_URL` | `string` | - | Coordinated vulnerability disclosure policy URL emitted in exported SBOMs (EU CRA Art. 13(7)) | +| `sbom.support_end` | `BOMLY_SBOM_SUPPORT_END` | `string` | - | Support end date (YYYY-MM-DD) for security updates emitted in exported SBOMs (EU CRA Art. 13(8)) | + ## Flat YAML Migration Flat YAML keys are no longer accepted. Move each existing key to its nested replacement: @@ -268,6 +277,15 @@ Flat YAML keys are no longer accepted. Move each existing key to its nested repl # cache_dir: "" # TTL for cached Scorecard responses (e.g. 24h) # cache_ttl: 24h +# sbom: +# Organization name emitted as the SBOM manufacturer/supplier (EU CRA Art. 13(15)) +# manufacturer: "" +# Security contact URL or email emitted in exported SBOMs (EU CRA Art. 13(6)) +# security_contact: "" +# Coordinated vulnerability disclosure policy URL emitted in exported SBOMs (EU CRA Art. 13(7)) +# vulnerability_disclosure_url: "" +# Support end date (YYYY-MM-DD) for security updates emitted in exported SBOMs (EU CRA Art. 13(8)) +# support_end: "" # Per-plugin configuration keyed by managed plugin ID # plugins: {} ``` diff --git a/docs/SBOM.md b/docs/SBOM.md index 6a2ebe12..595ec63f 100644 --- a/docs/SBOM.md +++ b/docs/SBOM.md @@ -84,6 +84,56 @@ Both formats carry: - Package name, version, PURL. - Dependency relationships from the detector graph. - File-level evidence when the detector provided it. +- Content hashes captured at detection time, when the ecosystem records them: + npm/pnpm/yarn/bun lockfile integrity values and Go module `go.sum` tree + hashes (the `h1:` SHA-256 dirhash, hex-encoded — the same convention + cyclonedx-gomod uses). Values are normalized to lowercase hex so they are + schema-valid in both formats. + +### Document identity + +Every generated document carries a stable identity: + +- A generated `urn:uuid` serial number (CycloneDX `serialNumber`; the same + nonce forms the SPDX document namespace, so the two exports of one scan are + correlatable). +- The producing tool with its version (CycloneDX `metadata.tools[]`; SPDX + `Creator: Tool: bomly-cli-`), plus one tool entry per detector that + contributed to the graph. +- A primary component describing the scanned project. When the dependency + graph has a single root, that root is the primary component. When a scan + discovers multiple manifests (several ecosystems, several workflow files), + Bomly synthesizes a primary component named after the scanned project with a + `pkg:generic` PURL; it depends on every graph root, so the exported + dependency graph is connected and both formats agree on the document's + subject. The synthesized component is not repeated in the CycloneDX + component inventory, and Bomly skips it when re-ingesting its own SBOMs. + +### Provenance metadata (EU CRA readiness) + +The optional `sbom` config section embeds producer metadata that regulated +consumers (for example the EU Cyber Resilience Act's SBOM expectations) ask +for: + +```yaml +sbom: + manufacturer: "Example Org" # CRA Art. 13(15) + security_contact: "security@example.com" # CRA Art. 13(6) + vulnerability_disclosure_url: "https://example.com/security" # Art. 13(7) + support_end: "2030-12-31" # CRA Art. 13(8) +``` + +CycloneDX: `metadata.manufacturer`, `security-contact` / `advisories` +external references on the primary component, and a `bomly:support_end_date` +metadata property. SPDX 2.3 has no first-class fields for most of these, so +Bomly emits an `Organization` creator, the supplier on the primary package, +and the contact fields in the creation-info comment. + +Without these fields Bomly's exports satisfy the NTIA minimum elements +(supplier defaults to the producing tool); third-party CRA profile checks +will flag the missing manufacturer/contact metadata until the `sbom` section +is configured. Per-component supplier and description data is not invented: +those fields stay empty unless a data source actually provides them. When `--enrich` is set, components are enriched from the matching-stage package registry (keyed by PURL): @@ -118,8 +168,10 @@ Some information necessarily becomes less specific during conversion: report data rather than portable SBOM fields. Use JSON when those distinctions must survive export and import. - A CycloneDX document has one metadata component. When an input graph has - multiple roots, every root remains in the dependency graph, but only the first - deterministic root is selected as that metadata component. + multiple roots, every root remains in the dependency graph and the + synthesized primary component (see "Document identity" above) links them; + ingest paths that predate the synthesized root treat the first + deterministic root as the primary component. Before treating a generated file as a release artifact, validate it with the standard validator required by the receiving system. Bomly's tests parse every diff --git a/internal/cli/scan_cmd.go b/internal/cli/scan_cmd.go index 8e19a98f..0840fcb3 100644 --- a/internal/cli/scan_cmd.go +++ b/internal/cli/scan_cmd.go @@ -8,6 +8,7 @@ import ( "github.com/bomly-dev/bomly-cli/internal/cli/exit" "github.com/bomly-dev/bomly-cli/internal/cli/render" + "github.com/bomly-dev/bomly-cli/internal/config" "github.com/bomly-dev/bomly-cli/internal/engine" scanengine "github.com/bomly-dev/bomly-cli/internal/engine/scan" "github.com/bomly-dev/bomly-cli/internal/output" @@ -126,10 +127,11 @@ func newScanCmd() *cobra.Command { return output.WriteSARIF(w, findings, pipeResult.Registry, "bomly", cmd.Root().Version, output.SARIFOptions{IncludeReachability: commandCtx.ResolvedConfig.Analyze, LocationGraphs: []*sdk.Graph{pipeResult.Graph}}) } + sbomBuildOpts := scanSBOMBuildOptions(payload.Project, commandCtx.ResolvedConfig, cmd.Root().Version, resolved, pipeResult.Registry) + if len(outputSpecs) > 0 { prog.Advance("Writing additional output") stdout := streams.reportWriter() - sbomBuildOpts := sbom.BuildOptions{ToolNames: sbomToolNames(resolved), Registry: pipeResult.Registry} for _, spec := range outputSpecs { switch { case spec.IsSBOM(): @@ -157,7 +159,7 @@ func newScanCmd() *cobra.Command { if !ok { return exit.InvalidInputError("output format %q is not supported by scan", graphOutputFormat) } - rawDocument, err := sbom.MarshalDepGraphJSON(selectedGraph, target, sbom.BuildOptions{ToolNames: sbomToolNames(resolved), Registry: pipeResult.Registry}, sbom.EncodeOptions{Pretty: true}) + rawDocument, err := sbom.MarshalDepGraphJSON(selectedGraph, target, sbomBuildOpts, sbom.EncodeOptions{Pretty: true}) if err != nil { return fmt.Errorf("marshal %s sbom: %w", graphOutputFormat, err) } @@ -211,6 +213,28 @@ func scanPolicyExit(auditEnabled bool, findings []sdk.Finding) error { return nil } +// scanSBOMBuildOptions assembles the SBOM projection options for a scan: the +// document is named after the scanned project, the primary component mirrors +// it, and optional provenance metadata comes from configuration. +func scanSBOMBuildOptions(project output.ProjectDescriptor, current config.Resolved, version string, resolved []sdk.DetectionResult, registry *sdk.PackageRegistry) sbom.BuildOptions { + opts := sbom.BuildOptions{ + ToolNames: sbomToolNames(resolved), + ToolVersion: strings.TrimSpace(version), + Registry: registry, + Provenance: sbom.Provenance{ + Manufacturer: strings.TrimSpace(current.SBOMManufacturer), + SecurityContact: strings.TrimSpace(current.SBOMSecurityContact), + VulnerabilityDisclosureURL: strings.TrimSpace(current.SBOMVulnerabilityDisclosureURL), + SupportEnd: strings.TrimSpace(current.SBOMSupportEnd), + }, + } + if name := strings.TrimSpace(project.Name); name != "" { + opts.DocumentName = name + opts.ProjectRoot = &sbom.ProjectRoot{Name: name, Version: strings.TrimSpace(project.TargetRef)} + } + return opts +} + func sbomToolNames(results []sdk.DetectionResult) []string { tools := make([]string, 0, len(results)) seen := make(map[string]struct{}, len(results)) diff --git a/internal/config/config.go b/internal/config/config.go index f8b85224..3a86e5cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,6 +80,12 @@ type Resolved struct { ScorecardAPIBase string `doc:"Base URL for the OpenSSF Scorecard public API" env:"BOMLY_SCORECARD_API_BASE" default:"https://api.scorecard.dev"` ScorecardCacheDir string `doc:"Directory for the Scorecard response cache" env:"BOMLY_SCORECARD_CACHE_DIR"` ScorecardCacheTTL string `doc:"TTL for cached Scorecard responses (e.g. 24h)" env:"BOMLY_SCORECARD_CACHE_TTL" default:"24h"` + + // SBOM export metadata (optional EU-CRA transparency fields) + SBOMManufacturer string `doc:"Organization name emitted as the SBOM manufacturer/supplier (EU CRA Art. 13(15))" env:"BOMLY_SBOM_MANUFACTURER"` + SBOMSecurityContact string `doc:"Security contact URL or email emitted in exported SBOMs (EU CRA Art. 13(6))" env:"BOMLY_SBOM_SECURITY_CONTACT"` + SBOMVulnerabilityDisclosureURL string `doc:"Coordinated vulnerability disclosure policy URL emitted in exported SBOMs (EU CRA Art. 13(7))" env:"BOMLY_SBOM_VULNERABILITY_DISCLOSURE_URL"` + SBOMSupportEnd string `doc:"Support end date (YYYY-MM-DD) for security updates emitted in exported SBOMs (EU CRA Art. 13(8))" env:"BOMLY_SBOM_SUPPORT_END"` } // File is the nested YAML-deserialized shape of a Bomly config file. Leaf @@ -96,9 +102,18 @@ type File struct { Logging LoggingFile `yaml:"logging,omitempty"` Network NetworkFile `yaml:"network,omitempty"` Matchers MatchersFile `yaml:"matchers,omitempty"` + SBOM SBOMFile `yaml:"sbom,omitempty"` Plugins map[string]map[string]any `yaml:"plugins,omitempty" resolved:"Plugins"` } +// SBOMFile configures optional metadata emitted into exported SBOM documents. +type SBOMFile struct { + Manufacturer *string `yaml:"manufacturer,omitempty" resolved:"SBOMManufacturer"` + SecurityContact *string `yaml:"security_contact,omitempty" resolved:"SBOMSecurityContact"` + VulnerabilityDisclosureURL *string `yaml:"vulnerability_disclosure_url,omitempty" resolved:"SBOMVulnerabilityDisclosureURL"` + SupportEnd *string `yaml:"support_end,omitempty" resolved:"SBOMSupportEnd"` +} + // TargetFile configures the execution target selected for a scan. type TargetFile struct { Path *string `yaml:"path,omitempty" resolved:"Path" legacy:"path"` diff --git a/internal/config/load.go b/internal/config/load.go index 4587aa15..bb27e099 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -13,6 +13,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/bomly-dev/bomly-cli/internal/system" "gopkg.in/yaml.v3" @@ -369,6 +370,11 @@ func Validate(cfg Resolved) error { default: return fmt.Errorf("unsupported --typosquat-mode value %q (accepted: warn, fail)", cfg.TyposquatMode) } + if supportEnd := strings.TrimSpace(cfg.SBOMSupportEnd); supportEnd != "" { + if _, err := time.Parse("2006-01-02", supportEnd); err != nil { + return fmt.Errorf("invalid sbom support_end %q: expected an ISO date such as 2030-12-31", cfg.SBOMSupportEnd) + } + } if err := validateProxyURL(cfg.HTTPProxy); err != nil { return err } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 745de549..793aa064 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -169,3 +169,16 @@ func TestApplyDefaultsSetsMaxDepth(t *testing.T) { t.Fatalf("expected explicit MaxDepth 7 to survive defaults, got %d", explicit.MaxDepth) } } + +func TestValidateSBOMSupportEnd(t *testing.T) { + if err := Validate(Resolved{SBOMSupportEnd: "2030-12-31"}); err != nil { + t.Fatalf("Validate rejected a valid sbom support_end date: %v", err) + } + err := Validate(Resolved{SBOMSupportEnd: "31/12/2030"}) + if err == nil { + t.Fatal("Validate returned nil for a malformed sbom support_end; want error") + } + if !strings.Contains(err.Error(), "support_end") { + t.Errorf("error message = %q, want it to mention support_end", err.Error()) + } +} diff --git a/internal/detectors/gomod/detector.go b/internal/detectors/gomod/detector.go index 886267d7..04a35646 100644 --- a/internal/detectors/gomod/detector.go +++ b/internal/detectors/gomod/detector.go @@ -4,6 +4,9 @@ import ( "bufio" "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -144,6 +147,14 @@ func (d Detector) resolveGraph(stderr io.Writer, projectPath string, verbose boo return nil, err } + sumDigests, err := parseGoSumDigests(filepath.Join(workingDir, "go.sum")) + if err != nil { + // go.sum is integrity metadata, not graph input: a missing or + // malformed file must never fail resolution. + logger.Debug("go.sum digests unavailable", zap.Error(err)) + sumDigests = nil + } + goPath, err := goExecLookPath("go") if err != nil { return nil, fmt.Errorf("resolve go executable: %w", err) @@ -168,7 +179,7 @@ func (d Detector) resolveGraph(stderr io.Writer, projectPath string, verbose boo return nil, fmt.Errorf("run go list -deps -json all: %w", err) } - depsGraph, err := depGraphFromGoListWithScope(raw, modulePath, directRequires, scopeFilter) + depsGraph, err := depGraphFromGoListWithScope(raw, modulePath, directRequires, scopeFilter, sumDigests) if err != nil { logger.Warn(fmt.Sprintf("Failed to map Go module output to a dependency graph: %v", err)) logger.Debug("go module output mapping failed", zap.Error(err)) @@ -192,10 +203,10 @@ func buildGoListArgs() []string { } func depGraphFromGoList(raw []byte, rootModule string, directRequires []moduleRef) (*sdk.Graph, error) { - return depGraphFromGoListWithScope(raw, rootModule, directRequires, sdk.ScopeUnknown) + return depGraphFromGoListWithScope(raw, rootModule, directRequires, sdk.ScopeUnknown, nil) } -func depGraphFromGoListWithScope(raw []byte, rootModule string, directRequires []moduleRef, scopeFilter sdk.Scope) (*sdk.Graph, error) { +func depGraphFromGoListWithScope(raw []byte, rootModule string, directRequires []moduleRef, scopeFilter sdk.Scope, sumDigests map[string]sdk.Digest) (*sdk.Graph, error) { if strings.TrimSpace(rootModule) == "" { return nil, errors.New("go module path is empty") } @@ -272,22 +283,22 @@ func depGraphFromGoListWithScope(raw []byte, rootModule string, directRequires [ continue } if !currentModule.Main { - currentNode := packageFromModuleNode(currentModule, mergedScope, directLines) + currentNode := packageFromModuleNode(currentModule, mergedScope, directLines, sumDigests) if err := addOrMergeModuleNode(depsGraph, currentNode, mergedScope); err != nil { return nil, err } } if scopeFilter != sdk.ScopeDevelopment || mergedScope == sdk.ScopeDevelopment { - if err := enqueueImportedPackages(depsGraph, rootNode.ID, currentModule, mergedScope, current.pkg.Imports, packageRecords, packageModules, directLines, &queue); err != nil { + if err := enqueueImportedPackages(depsGraph, rootNode.ID, currentModule, mergedScope, current.pkg.Imports, packageRecords, packageModules, directLines, sumDigests, &queue); err != nil { return nil, err } } if scopeFilter != sdk.ScopeRuntime { - if err := enqueueImportedPackages(depsGraph, rootNode.ID, currentModule, sdk.ScopeDevelopment, current.pkg.TestImports, packageRecords, packageModules, directLines, &queue); err != nil { + if err := enqueueImportedPackages(depsGraph, rootNode.ID, currentModule, sdk.ScopeDevelopment, current.pkg.TestImports, packageRecords, packageModules, directLines, sumDigests, &queue); err != nil { return nil, err } - if err := enqueueImportedPackages(depsGraph, rootNode.ID, currentModule, sdk.ScopeDevelopment, current.pkg.XTestImports, packageRecords, packageModules, directLines, &queue); err != nil { + if err := enqueueImportedPackages(depsGraph, rootNode.ID, currentModule, sdk.ScopeDevelopment, current.pkg.XTestImports, packageRecords, packageModules, directLines, sumDigests, &queue); err != nil { return nil, err } } @@ -327,7 +338,7 @@ func moduleNodeFromPackage(pkg goListPackage, rootModule string) (moduleNode, bo }, true } -func enqueueImportedPackages(depsGraph *sdk.Graph, rootID string, from moduleNode, scope sdk.Scope, imports []string, packageRecords map[string]goListPackage, packageModules map[string]moduleNode, directLines map[string]int, queue *[]queuedPackage) error { +func enqueueImportedPackages(depsGraph *sdk.Graph, rootID string, from moduleNode, scope sdk.Scope, imports []string, packageRecords map[string]goListPackage, packageModules map[string]moduleNode, directLines map[string]int, sumDigests map[string]sdk.Digest, queue *[]queuedPackage) error { fromID := rootID if !from.Main { fromID = moduleNodeID(from) @@ -343,7 +354,7 @@ func enqueueImportedPackages(depsGraph *sdk.Graph, rootID string, from moduleNod continue } if !to.Main { - pkg := packageFromModuleNode(to, scope, directLines) + pkg := packageFromModuleNode(to, scope, directLines, sumDigests) if err := addOrMergeModuleNode(depsGraph, pkg, scope); err != nil { return err } @@ -358,7 +369,7 @@ func enqueueImportedPackages(depsGraph *sdk.Graph, rootID string, from moduleNod return nil } -func packageFromModuleNode(node moduleNode, scope sdk.Scope, directLines map[string]int) *sdk.Dependency { +func packageFromModuleNode(node moduleNode, scope sdk.Scope, directLines map[string]int, sumDigests map[string]sdk.Digest) *sdk.Dependency { dep := sdk.Dependency{Coordinates: sdk.Coordinates{Ecosystem: sdk.EcosystemGo, Name: node.Path, Version: node.Version}, @@ -366,6 +377,9 @@ func packageFromModuleNode(node moduleNode, scope sdk.Scope, directLines map[str if scope != sdk.ScopeUnknown { dep.Scopes = []sdk.Scope{scope} } + if digest, ok := sumDigests[node.Path+"@"+node.Version]; ok { + dep.Digests = []sdk.Digest{digest} + } if line, ok := directLines[node.Path]; ok && line > 0 { dep.Locations = []sdk.PackageLocation{ { @@ -385,6 +399,40 @@ func moduleNodeID(node moduleNode) string { }).ID } +// parseGoSumDigests reads go.sum and returns a "path@version" → digest map for +// module tree hashes ("h1:" lines, excluding the "/go.mod" entries). The h1 +// hash is SHA-256 over the Go module dirhash manifest; it is exposed as a +// sha256 digest in hex, matching the convention CycloneDX's cyclonedx-gomod +// uses for Go module component hashes. +func parseGoSumDigests(path string) (map[string]sdk.Digest, error) { + data, err := system.ReadRepositoryFile(path) + if err != nil { + return nil, fmt.Errorf("read %q: %w", path, err) + } + + digests := make(map[string]sdk.Digest) + scanner := bufio.NewScanner(strings.NewReader(strings.ReplaceAll(string(data), "\r\n", "\n"))) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) != 3 { + continue + } + modulePath, version, hash := fields[0], fields[1], fields[2] + if strings.HasSuffix(version, "/go.mod") || !strings.HasPrefix(hash, "h1:") { + continue + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(hash, "h1:")) + if err != nil || len(raw) != sha256.Size { + continue + } + digests[modulePath+"@"+version] = sdk.Digest{Algorithm: sdk.DigestAlgorithmSHA256, Value: hex.EncodeToString(raw)} + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan %q: %w", path, err) + } + return digests, nil +} + func parseGoModFile(path string) (string, []moduleRef, error) { data, err := system.ReadRepositoryFile(path) if err != nil { diff --git a/internal/detectors/gomod/detector_test.go b/internal/detectors/gomod/detector_test.go index 7bbf400d..1918ddcd 100644 --- a/internal/detectors/gomod/detector_test.go +++ b/internal/detectors/gomod/detector_test.go @@ -166,7 +166,7 @@ func TestDepGraphFromGoList_RuntimeScopeSkipsTestImports(t *testing.T) { {"ImportPath":"github.com/davecgh/go-spew/spew","Module":{"Path":"github.com/davecgh/go-spew","Version":"v1.1.1"}} `) - g, err := depGraphFromGoListWithScope(raw, "example.com/demo", nil, sdk.ScopeRuntime) + g, err := depGraphFromGoListWithScope(raw, "example.com/demo", nil, sdk.ScopeRuntime, nil) if err != nil { t.Fatalf("depGraphFromGoListWithScope() error = %v", err) } @@ -189,7 +189,7 @@ func TestDepGraphFromGoList_DevelopmentScopeFiltersRuntimeImports(t *testing.T) {"ImportPath":"github.com/davecgh/go-spew/spew","Module":{"Path":"github.com/davecgh/go-spew","Version":"v1.1.1"}} `) - g, err := depGraphFromGoListWithScope(raw, "example.com/demo", nil, sdk.ScopeDevelopment) + g, err := depGraphFromGoListWithScope(raw, "example.com/demo", nil, sdk.ScopeDevelopment, nil) if err != nil { t.Fatalf("depGraphFromGoListWithScope() error = %v", err) } @@ -341,3 +341,38 @@ func TestDepGraphFromGoList_AttachesPositionToDirectDeps(t *testing.T) { t.Errorf("transitive dep should have no Locations (not in go.mod); got %+v", trans.Locations) } } + +func TestParseGoSumDigests(t *testing.T) { + projectDir := t.TempDir() + sumPath := filepath.Join(projectDir, "go.sum") + if err := os.WriteFile(sumPath, []byte(`github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +rsc.io/quote v1.5.2 h2:unsupported-hash-algorithm +malformed line +`), 0o644); err != nil { + t.Fatalf("write go.sum: %v", err) + } + + digests, err := parseGoSumDigests(sumPath) + if err != nil { + t.Fatalf("parseGoSumDigests() error = %v", err) + } + if len(digests) != 1 { + t.Fatalf("expected exactly one module digest, got %#v", digests) + } + digest, ok := digests["github.com/google/uuid@v1.6.0"] + if !ok { + t.Fatalf("missing digest for github.com/google/uuid@v1.6.0: %#v", digests) + } + if digest.Algorithm != sdk.DigestAlgorithmSHA256 { + t.Fatalf("expected sha256 digest, got %q", digest.Algorithm) + } + // hex of base64 "NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=" + if digest.Value != "348bda24330eb231c0f27d630212d2833ac0cf2d4782bfa136b6f9edefbde05d" { + t.Fatalf("unexpected digest value %q", digest.Value) + } + + if _, err := parseGoSumDigests(filepath.Join(projectDir, "missing", "go.sum")); err == nil { + t.Fatal("expected an error for a missing go.sum") + } +} diff --git a/internal/detectors/gomod/parser_fuzz_test.go b/internal/detectors/gomod/parser_fuzz_test.go index 7fb6e11f..d518d7fc 100644 --- a/internal/detectors/gomod/parser_fuzz_test.go +++ b/internal/detectors/gomod/parser_fuzz_test.go @@ -14,7 +14,7 @@ func FuzzDepGraphFromGoList(f *testing.F) { if len(data) > testutil.MaxFuzzInputSize { return } - graph, err := depGraphFromGoListWithScope(data, "example.com/root", nil, sdk.Scope("")) + graph, err := depGraphFromGoListWithScope(data, "example.com/root", nil, sdk.Scope(""), nil) if err == nil { testutil.RequireFuzzGraphValid(t, graph) } diff --git a/internal/sbom/cyclonedx.go b/internal/sbom/cyclonedx.go index f8e2a701..77aa5a91 100644 --- a/internal/sbom/cyclonedx.go +++ b/internal/sbom/cyclonedx.go @@ -20,6 +20,12 @@ func (c cycloneDXCodec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, e components := make([]cdx.Component, 0, len(doc.Components)) for _, comp := range doc.Components { + if IsProjectRootComponent(comp) { + // The synthesized project root lives in metadata.component (and + // keeps its entry in the dependencies section); repeating it in + // the component inventory would double-count it. + continue + } component := cdx.Component{ BOMRef: comp.ID, Type: cycloneDXComponentType(comp.Type), @@ -63,17 +69,27 @@ func (c cycloneDXCodec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, e metadata := &cdx.Metadata{ Timestamp: doc.CreatedOrNow().Format(time.RFC3339), - Tools: cycloneDXTools(doc.ToolNamesOrDefault()), + Tools: cycloneDXTools(doc.ToolNamesOrDefault(), doc.ToolOrDefault(), doc.ToolVersion), } if root := chooseRoot(doc); root != nil { metadata.Component = &cdx.Component{ - BOMRef: root.ID, - Type: cycloneDXComponentType(firstNonEmpty(root.Type, "application")), - Name: root.NameOrID(), - Scope: cycloneDXScope(root.Scope), - Version: root.Version, + BOMRef: root.ID, + Type: cycloneDXComponentType(firstNonEmpty(root.Type, "application")), + Name: root.NameOrID(), + Scope: cycloneDXScope(root.Scope), + Version: root.Version, + PackageURL: root.PURL, + } + if refs := cycloneDXSecurityReferences(doc.Provenance); len(refs) > 0 { + metadata.Component.ExternalReferences = &refs } } + if doc.Provenance.Manufacturer != "" { + metadata.Manufacturer = &cdx.OrganizationalEntity{Name: doc.Provenance.Manufacturer} + } + if props := cycloneDXMetadataProperties(doc.Provenance); len(props) > 0 { + metadata.Properties = &props + } bom.Metadata = metadata var out bytes.Buffer @@ -107,10 +123,21 @@ func (c cycloneDXCodec) decodeJSON(data []byte) (*Document, error) { } } + primaryRef := "" + if bom.Metadata != nil && bom.Metadata.Component != nil { + primaryRef = bom.Metadata.Component.BOMRef + } + dependencies := make([]Dependency, 0, len(componentByID)) inDegree := make(map[string]int, len(componentByID)) if bom.Dependencies != nil { for _, dep := range *bom.Dependencies { + if _, known := componentByID[dep.Ref]; !known && (isProjectRootID(dep.Ref) || dep.Ref == primaryRef) { + // The primary component lives only in metadata.component; its + // dependency entry links the document root to the real graph + // roots and must not demote those roots on re-ingestion. + continue + } ds := make([]string, 0) if dep.Dependencies != nil { ds = append(ds, *dep.Dependencies...) @@ -183,7 +210,7 @@ func (c cycloneDXCodec) decodeJSON(data []byte) (*Document, error) { }, nil } -func cycloneDXTools(names []string) *cdx.ToolsChoice { +func cycloneDXTools(names []string, primaryTool, toolVersion string) *cdx.ToolsChoice { if len(names) == 0 { return nil } @@ -192,10 +219,14 @@ func cycloneDXTools(names []string) *cdx.ToolsChoice { if strings.TrimSpace(name) == "" { continue } - components = append(components, cdx.Component{ + component := cdx.Component{ Type: cdx.ComponentTypeApplication, Name: name, - }) + } + if name == primaryTool { + component.Version = toolVersion + } + components = append(components, component) } if len(components) == 0 { return nil @@ -203,6 +234,32 @@ func cycloneDXTools(names []string) *cdx.ToolsChoice { return &cdx.ToolsChoice{Components: &components} } +// cycloneDXSecurityReferences maps provenance contact fields onto external +// references attached to the primary component. +func cycloneDXSecurityReferences(p Provenance) []cdx.ExternalReference { + refs := make([]cdx.ExternalReference, 0, 2) + if contact := strings.TrimSpace(p.SecurityContact); contact != "" { + if !strings.Contains(contact, ":") && strings.Contains(contact, "@") { + contact = "mailto:" + contact + } + refs = append(refs, cdx.ExternalReference{Type: cdx.ERTypeSecurityContact, URL: contact}) + } + if disclosure := strings.TrimSpace(p.VulnerabilityDisclosureURL); disclosure != "" { + refs = append(refs, cdx.ExternalReference{Type: cdx.ERTypeAdvisories, URL: disclosure, Comment: "Coordinated vulnerability disclosure policy"}) + } + if len(refs) == 0 { + return nil + } + return refs +} + +func cycloneDXMetadataProperties(p Provenance) []cdx.Property { + if strings.TrimSpace(p.SupportEnd) == "" { + return nil + } + return []cdx.Property{{Name: "bomly:support_end_date", Value: strings.TrimSpace(p.SupportEnd)}} +} + func cycloneDXToolNames(metadata *cdx.Metadata) []string { if metadata == nil || metadata.Tools == nil { return nil diff --git a/internal/sbom/export_quality_test.go b/internal/sbom/export_quality_test.go new file mode 100644 index 00000000..a3698c35 --- /dev/null +++ b/internal/sbom/export_quality_test.go @@ -0,0 +1,375 @@ +package sbom + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/bomly-dev/bomly-cli/sdk" + v23 "github.com/spdx/tools-golang/spdx/v2/v2_3" +) + +// mustMultiRootGraph builds a graph with two disconnected roots, mirroring a +// scan that discovered manifests from more than one ecosystem. +func mustMultiRootGraph(t *testing.T) *sdk.Graph { + t.Helper() + + g := sdk.New() + workflow := sdk.NewDependencyRef("ci.yml", "local") + action := sdk.NewDependencyRef("actions/checkout", "4.0.0") + app := sdk.NewDependencyRef("app", "1.0.0") + react := sdk.NewDependencyRef("react", "18.2.0") + + for _, n := range []*sdk.Dependency{workflow, action, app, react} { + if err := g.AddNode(n); err != nil { + t.Fatalf("add package %s: %v", n.ID, err) + } + } + if err := g.AddEdge(workflow.ID, action.ID); err != nil { + t.Fatalf("add edge workflow->action: %v", err) + } + if err := g.AddEdge(app.ID, react.ID); err != nil { + t.Fatalf("add edge app->react: %v", err) + } + return g +} + +func TestFromDepGraph_SynthesizesProjectRootForMultiRootGraphs(t *testing.T) { + doc, err := FromDepGraph(mustMultiRootGraph(t), BuildOptions{ + DocumentName: "demo-project", + ProjectRoot: &ProjectRoot{Name: "demo-project", Version: "v1.2.3"}, + }) + if err != nil { + t.Fatalf("from depgraph: %v", err) + } + + if len(doc.Roots) != 1 { + t.Fatalf("expected a single synthesized root, got %v", doc.Roots) + } + rootID := doc.Roots[0] + if !strings.HasPrefix(rootID, projectRootIDPrefix) { + t.Fatalf("expected pseudo root prefix on %q", rootID) + } + + var root *Component + for i := range doc.Components { + if doc.Components[i].ID == rootID { + root = &doc.Components[i] + } + } + if root == nil { + t.Fatalf("synthesized root %q missing from components", rootID) + } + if root.Name != "demo-project" || root.Version != "v1.2.3" || root.Type != "application" { + t.Fatalf("unexpected root identity: %+v", root) + } + if root.PURL != "pkg:generic/demo-project@v1.2.3" { + t.Fatalf("unexpected root purl: %q", root.PURL) + } + if !IsProjectRootComponent(*root) { + t.Fatalf("expected IsProjectRootComponent to detect %q", rootID) + } + + var rootDeps []string + for _, dep := range doc.Dependencies { + if dep.Ref == rootID { + rootDeps = dep.DependsOn + } + } + if len(rootDeps) != 2 { + t.Fatalf("expected the pseudo root to depend on both graph roots, got %v", rootDeps) + } +} + +func TestFromDepGraph_KeepsSingleRootWithoutSynthesis(t *testing.T) { + doc, err := FromDepGraph(mustGraph(t), BuildOptions{ + DocumentName: "demo-project", + ProjectRoot: &ProjectRoot{Name: "demo-project"}, + }) + if err != nil { + t.Fatalf("from depgraph: %v", err) + } + if len(doc.Roots) != 1 || strings.HasPrefix(doc.Roots[0], projectRootIDPrefix) { + t.Fatalf("expected the natural single root, got %v", doc.Roots) + } + for _, c := range doc.Components { + if IsProjectRootComponent(c) { + t.Fatalf("unexpected synthesized root %q", c.ID) + } + } +} + +var uuidURNPattern = regexp.MustCompile(`^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +func TestFromDepGraph_GeneratesSerialNumberAndAlignedNamespace(t *testing.T) { + doc, err := FromDepGraph(mustGraph(t), BuildOptions{}) + if err != nil { + t.Fatalf("from depgraph: %v", err) + } + if !uuidURNPattern.MatchString(doc.SerialNumber) { + t.Fatalf("expected an urn:uuid serial number, got %q", doc.SerialNumber) + } + nonce := strings.TrimPrefix(doc.SerialNumber, "urn:uuid:") + if doc.Namespace != "https://bomly.dev/spdx/"+nonce { + t.Fatalf("expected namespace to reuse the serial nonce, got %q vs serial %q", doc.Namespace, doc.SerialNumber) + } + + explicit, err := FromDepGraph(mustGraph(t), BuildOptions{SerialNumber: "urn:uuid:00000000-0000-4000-8000-000000000000"}) + if err != nil { + t.Fatalf("from depgraph: %v", err) + } + if explicit.SerialNumber != "urn:uuid:00000000-0000-4000-8000-000000000000" { + t.Fatalf("explicit serial number was not preserved: %q", explicit.SerialNumber) + } +} + +func TestFromDepGraph_ProjectsDetectionTimeDigests(t *testing.T) { + g := sdk.New() + dep := sdk.NewDependency(sdk.Dependency{ + Coordinates: sdk.Coordinates{Name: "left-pad", Version: "1.3.0", Ecosystem: sdk.EcosystemNPM}, + // npm SRI integrity values are base64; expect hex in the SBOM model. + Digests: []sdk.Digest{ + {Algorithm: "sha512", Value: "pkJf8Ni4YWlKDgODlNGxi/z1Wd0/hkJH8N4Rq+Cd1lTv7ZZKPXm8mTzcp2xEVSlHoQlUwjzUKh2nGSHTMEUUpg=="}, + {Algorithm: "nuget-content-hash", Value: "abc123"}, + }, + }) + if err := g.AddNode(dep); err != nil { + t.Fatalf("add node: %v", err) + } + + doc, err := FromDepGraph(g, BuildOptions{}) + if err != nil { + t.Fatalf("from depgraph: %v", err) + } + if len(doc.Components) != 1 { + t.Fatalf("expected one component, got %d", len(doc.Components)) + } + digests := doc.Components[0].Digests + if len(digests) != 2 { + t.Fatalf("expected both digests projected, got %+v", digests) + } + if digests[0].Algorithm != "sha512" || len(digests[0].Value) != 128 { + t.Fatalf("expected the sha512 digest re-encoded as 128 hex chars, got %+v", digests[0]) + } + if strings.ToLower(digests[0].Value) != digests[0].Value { + t.Fatalf("expected lowercase hex, got %q", digests[0].Value) + } + if digests[1].Algorithm != "nuget-content-hash" || digests[1].Value != "abc123" { + t.Fatalf("expected unknown-algorithm digest kept verbatim, got %+v", digests[1]) + } +} + +func TestMarshalDepGraphJSON_CycloneDXDocumentIdentityAndProvenance(t *testing.T) { + out, err := MarshalDepGraphJSON(mustMultiRootGraph(t), TargetCycloneDX17JSON, BuildOptions{ + DocumentName: "demo-project", + ProjectRoot: &ProjectRoot{Name: "demo-project", Version: "v1.2.3"}, + ToolVersion: "9.9.9", + ToolNames: []string{"bomly-detector:demo"}, + Provenance: Provenance{ + Manufacturer: "Example Org", + SecurityContact: "security@example.com", + VulnerabilityDisclosureURL: "https://example.com/security", + SupportEnd: "2030-12-31", + }, + }, EncodeOptions{}) + if err != nil { + t.Fatalf("marshal cyclonedx: %v", err) + } + + var bom cdx.BOM + if err := json.Unmarshal(out, &bom); err != nil { + t.Fatalf("unmarshal cyclonedx: %v", err) + } + + if !uuidURNPattern.MatchString(bom.SerialNumber) { + t.Fatalf("expected generated serial number, got %q", bom.SerialNumber) + } + if bom.Metadata == nil || bom.Metadata.Component == nil { + t.Fatalf("expected metadata.component") + } + root := bom.Metadata.Component + if root.Name != "demo-project" || root.Version != "v1.2.3" || root.PackageURL != "pkg:generic/demo-project@v1.2.3" { + t.Fatalf("unexpected primary component: %+v", root) + } + if root.ExternalReferences == nil || len(*root.ExternalReferences) != 2 { + t.Fatalf("expected security external references, got %+v", root.ExternalReferences) + } + refs := *root.ExternalReferences + if refs[0].Type != cdx.ERTypeSecurityContact || refs[0].URL != "mailto:security@example.com" { + t.Fatalf("unexpected security contact reference: %+v", refs[0]) + } + if refs[1].Type != cdx.ERTypeAdvisories || refs[1].URL != "https://example.com/security" { + t.Fatalf("unexpected disclosure reference: %+v", refs[1]) + } + if bom.Metadata.Manufacturer == nil || bom.Metadata.Manufacturer.Name != "Example Org" { + t.Fatalf("expected metadata.manufacturer, got %+v", bom.Metadata.Manufacturer) + } + if bom.Metadata.Properties == nil || len(*bom.Metadata.Properties) != 1 || (*bom.Metadata.Properties)[0].Name != "bomly:support_end_date" || (*bom.Metadata.Properties)[0].Value != "2030-12-31" { + t.Fatalf("expected support end property, got %+v", bom.Metadata.Properties) + } + + if bom.Metadata.Tools == nil || bom.Metadata.Tools.Components == nil { + t.Fatalf("expected tools components") + } + toolVersions := map[string]string{} + for _, tool := range *bom.Metadata.Tools.Components { + toolVersions[tool.Name] = tool.Version + } + if toolVersions["bomly-cli"] != "9.9.9" { + t.Fatalf("expected primary tool version, got %+v", toolVersions) + } + if toolVersions["bomly-detector:demo"] != "" { + t.Fatalf("expected detector tools to stay unversioned, got %+v", toolVersions) + } + + // The pseudo root must not be double-counted in the component inventory, + // but its dependency entry must link the document root to the graph roots. + if bom.Components == nil { + t.Fatalf("expected components") + } + for _, comp := range *bom.Components { + if comp.BOMRef == root.BOMRef { + t.Fatalf("pseudo root %q duplicated in components", root.BOMRef) + } + } + if bom.Dependencies == nil { + t.Fatalf("expected dependencies") + } + foundRootEntry := false + for _, dep := range *bom.Dependencies { + if dep.Ref == root.BOMRef { + foundRootEntry = true + if dep.Dependencies == nil || len(*dep.Dependencies) != 2 { + t.Fatalf("expected pseudo root dependency entry to list both graph roots, got %+v", dep.Dependencies) + } + } + } + if !foundRootEntry { + t.Fatalf("missing dependency entry for pseudo root %q", root.BOMRef) + } +} + +func TestUnmarshalJSON_CycloneDXPseudoRootRoundTrip(t *testing.T) { + out, err := MarshalDepGraphJSON(mustMultiRootGraph(t), TargetCycloneDX17JSON, BuildOptions{ + DocumentName: "demo-project", + ProjectRoot: &ProjectRoot{Name: "demo-project"}, + }, EncodeOptions{}) + if err != nil { + t.Fatalf("marshal cyclonedx: %v", err) + } + + doc, err := UnmarshalJSON(out, TargetCycloneDX17JSON) + if err != nil { + t.Fatalf("unmarshal cyclonedx: %v", err) + } + if len(doc.Components) != 4 { + t.Fatalf("expected the four real components, got %d", len(doc.Components)) + } + if len(doc.Roots) != 2 { + t.Fatalf("expected both real graph roots after decode, got %v", doc.Roots) + } + + g, err := ToGraph(doc) + if err != nil { + t.Fatalf("to graph: %v", err) + } + if g.Size() != 4 { + t.Fatalf("expected 4 graph nodes, got %d", g.Size()) + } +} + +func TestToGraph_SkipsSynthesizedProjectRootWithPURL(t *testing.T) { + doc := &Document{ + Components: []Component{ + {ID: projectRootIDPrefix + "demo", Name: "demo", Type: "application", PURL: "pkg:generic/demo"}, + {ID: "pkg:npm/react@18.2.0", Name: "react", Version: "18.2.0", PURL: "pkg:npm/react@18.2.0"}, + }, + Dependencies: []Dependency{ + {Ref: projectRootIDPrefix + "demo", DependsOn: []string{"pkg:npm/react@18.2.0"}}, + {Ref: "pkg:npm/react@18.2.0"}, + }, + } + g, err := ToGraph(doc) + if err != nil { + t.Fatalf("to graph: %v", err) + } + if g.Size() != 1 { + t.Fatalf("expected pseudo root to be skipped, got %d nodes", g.Size()) + } +} + +func TestMarshalDepGraphJSON_SPDX23ProvenanceAndToolVersion(t *testing.T) { + out, err := MarshalDepGraphJSON(mustMultiRootGraph(t), TargetSPDX23JSON, BuildOptions{ + DocumentName: "demo-project", + ProjectRoot: &ProjectRoot{Name: "demo-project", Version: "v1.2.3"}, + ToolVersion: "9.9.9", + Provenance: Provenance{ + Manufacturer: "Example Org", + SecurityContact: "security@example.com", + VulnerabilityDisclosureURL: "https://example.com/security", + SupportEnd: "2030-12-31", + }, + }, EncodeOptions{}) + if err != nil { + t.Fatalf("marshal spdx: %v", err) + } + + var d v23.Document + if err := json.Unmarshal(out, &d); err != nil { + t.Fatalf("unmarshal spdx: %v", err) + } + if d.DocumentName != "demo-project" { + t.Fatalf("unexpected document name %q", d.DocumentName) + } + + toolCreator, orgCreator := "", "" + for _, c := range d.CreationInfo.Creators { + switch c.CreatorType { + case "Tool": + if toolCreator == "" { + toolCreator = c.Creator + } + case "Organization": + orgCreator = c.Creator + } + } + if toolCreator != "bomly-cli-9.9.9" { + t.Fatalf("expected SPDX tool creator with version, got %q", toolCreator) + } + if orgCreator != "Example Org" { + t.Fatalf("expected organization creator, got %q", orgCreator) + } + if !strings.Contains(d.CreationInfo.CreatorComment, "SecurityContact: security@example.com") || + !strings.Contains(d.CreationInfo.CreatorComment, "VulnerabilityDisclosure: https://example.com/security") || + !strings.Contains(d.CreationInfo.CreatorComment, "SupportEnd: 2030-12-31") { + t.Fatalf("unexpected creator comment %q", d.CreationInfo.CreatorComment) + } + + var root *v23.Package + for _, p := range d.Packages { + if strings.HasPrefix(string(p.PackageSPDXIdentifier), projectRootIDPrefix) { + root = p + } + } + if root == nil { + t.Fatalf("expected synthesized root package in SPDX document") + } + if root.PrimaryPackagePurpose != "APPLICATION" { + t.Fatalf("expected APPLICATION purpose on root, got %q", root.PrimaryPackagePurpose) + } + if root.PackageSupplier == nil || root.PackageSupplier.Supplier != "Example Org" { + t.Fatalf("expected supplier on root, got %+v", root.PackageSupplier) + } + + describesRoot := false + for _, rel := range d.Relationships { + if rel != nil && rel.Relationship == "DESCRIBES" && rel.RefB.ElementRefID == root.PackageSPDXIdentifier { + describesRoot = true + } + } + if !describesRoot { + t.Fatalf("expected the document to describe the synthesized root") + } +} diff --git a/internal/sbom/graph.go b/internal/sbom/graph.go index 6dd86983..7353f9b7 100644 --- a/internal/sbom/graph.go +++ b/internal/sbom/graph.go @@ -62,17 +62,20 @@ func ToGraph(doc *Document) (*sdk.Graph, error) { if _, ok := skipped[dependency.Ref]; ok { continue } - fromID := dependency.Ref - if mapped := idMap[fromID]; mapped != "" { - fromID = mapped + fromID, ok := idMap[dependency.Ref] + if !ok { + // Dependency entries may reference the synthesized document root + // (present only in CycloneDX metadata.component) or otherwise + // dangling refs; neither has a graph node to anchor an edge. + continue } for _, child := range dependency.DependsOn { if _, ok := skipped[child]; ok { continue } - toID := child - if mapped := idMap[toID]; mapped != "" { - toID = mapped + toID, ok := idMap[child] + if !ok { + continue } if fromID == toID { continue @@ -87,13 +90,14 @@ func ToGraph(doc *Document) (*sdk.Graph, error) { } func isDocumentRootPseudoPackage(component Component) bool { + // Bomly's synthesized project root carries a pkg:generic PURL but is + // still a stand-in for the scanned tree, not a resolved package. + if IsProjectRootComponent(component) { + return true + } if strings.TrimSpace(component.PURL) != "" { return false } - id := strings.TrimSpace(component.ID) - if strings.HasPrefix(id, "SPDXRef-DocumentRoot-") { - return true - } if strings.EqualFold(strings.TrimSpace(component.Type), "file") && strings.TrimSpace(component.Version) == "" { return true } diff --git a/internal/sbom/model.go b/internal/sbom/model.go index 97061212..13bb564c 100644 --- a/internal/sbom/model.go +++ b/internal/sbom/model.go @@ -1,6 +1,7 @@ package sbom import ( + "strings" "time" "github.com/bomly-dev/bomly-cli/sdk" @@ -18,18 +19,55 @@ const ( TargetSyftJSON Target = "syft+json" defaultDocumentName = "bomly-dependencies" defaultToolName = "bomly-cli" + + // projectRootIDPrefix marks the synthesized primary component that + // represents the scanned project itself rather than a resolved package. + // SPDX encoding prefixes IDs with "SPDXRef-", so consumers must treat + // both "DocumentRoot-" and "SPDXRef-DocumentRoot-" as pseudo roots. + projectRootIDPrefix = "DocumentRoot-" ) +// ProjectRoot describes the scanned project so the projection can synthesize +// a primary component when the graph itself has no single root (multiple +// manifests, multiple ecosystems). Name is required; Version is optional. +type ProjectRoot struct { + Name string + Version string +} + +// Provenance carries optional producer metadata emitted into SBOM documents. +// The fields map onto EU CRA transparency expectations (manufacturer +// identification, security contact, coordinated disclosure, support period) +// but are format-agnostic and always optional. +type Provenance struct { + Manufacturer string + SecurityContact string + VulnerabilityDisclosureURL string + SupportEnd string +} + +// Empty reports whether no provenance field is set. +func (p Provenance) Empty() bool { + return p.Manufacturer == "" && p.SecurityContact == "" && p.VulnerabilityDisclosureURL == "" && p.SupportEnd == "" +} + // BuildOptions controls how a depgraph is projected into the intermediate SBOM model. type BuildOptions struct { DocumentName string DocumentNS string ToolName string ToolNames []string + ToolVersion string Created time.Time RootComponentID string SerialNumber string + // ProjectRoot, when non-nil, lets the projection synthesize a primary + // component for the scanned project when the graph has no single root. + ProjectRoot *ProjectRoot + + Provenance Provenance + // Registry, when non-nil, supplies matching-stage enrichment (licenses, // vulnerabilities, CPEs, digests, EOL) resolved by PURL and folded onto // each component during projection. @@ -47,14 +85,27 @@ type Document struct { Namespace string Tool string Tools []string + ToolVersion string Created time.Time SerialNumber string + Provenance Provenance Components []Component Dependencies []Dependency Roots []string } +// IsProjectRootComponent reports whether a component is a synthesized pseudo +// root that stands in for the scanned project rather than a resolved package. +func IsProjectRootComponent(c Component) bool { + return isProjectRootID(c.ID) +} + +func isProjectRootID(id string) bool { + id = strings.TrimSpace(id) + return strings.HasPrefix(id, projectRootIDPrefix) || strings.HasPrefix(id, "SPDXRef-"+projectRootIDPrefix) +} + // Component describes one package surfaced in the intermediate SBOM model. type Component struct { ID string diff --git a/internal/sbom/spdx23.go b/internal/sbom/spdx23.go index c95ab641..02c63ef1 100644 --- a/internal/sbom/spdx23.go +++ b/internal/sbom/spdx23.go @@ -31,7 +31,7 @@ func (spdx23Codec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, error) spdxID := common.ElementID(base) idByComponent[c.ID] = spdxID - packages = append(packages, &v23.Package{ + pkg := &v23.Package{ PackageName: c.NameOrID(), PackageSPDXIdentifier: spdxID, PackageVersion: c.Version, @@ -43,7 +43,14 @@ func (spdx23Codec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, error) PackageCopyrightText: spdxCopyrightValue(c.Copyright), PackageChecksums: spdxChecksums(c.Digests), PackageExternalReferences: spdxExternalReferences(c), - }) + } + if IsProjectRootComponent(c) { + pkg.PrimaryPackagePurpose = "APPLICATION" + if doc.Provenance.Manufacturer != "" { + pkg.PackageSupplier = &common.Supplier{SupplierType: "Organization", Supplier: doc.Provenance.Manufacturer} + } + } + packages = append(packages, pkg) } relationships := make([]*v23.Relationship, 0, len(doc.Dependencies)+len(doc.Roots)) @@ -78,16 +85,27 @@ func (spdx23Codec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, error) } } - creators := make([]common.Creator, 0, len(doc.ToolNamesOrDefault())) + creators := make([]common.Creator, 0, len(doc.ToolNamesOrDefault())+1) for _, tool := range doc.ToolNamesOrDefault() { + // SPDX creator convention appends the tool version as "name-version". + if tool == doc.ToolOrDefault() && doc.ToolVersion != "" { + tool += "-" + doc.ToolVersion + } creators = append(creators, common.Creator{ CreatorType: "Tool", Creator: tool, }) } + if doc.Provenance.Manufacturer != "" { + creators = append(creators, common.Creator{ + CreatorType: "Organization", + Creator: doc.Provenance.Manufacturer, + }) + } creation := &v23.CreationInfo{ - Creators: creators, - Created: doc.CreatedOrNow().Format("2006-01-02T15:04:05Z"), + Creators: creators, + Created: doc.CreatedOrNow().Format("2006-01-02T15:04:05Z"), + CreatorComment: spdxCreatorComment(doc.Provenance), } spdxDoc := &v23.Document{ @@ -246,6 +264,22 @@ func parseSPDXCreated(ci *v23.CreationInfo) time.Time { return t.UTC() } +// spdxCreatorComment folds provenance contact metadata into the SPDX creation +// comment; SPDX 2.3 has no first-class fields for these. +func spdxCreatorComment(p Provenance) string { + fields := make([]string, 0, 3) + if contact := strings.TrimSpace(p.SecurityContact); contact != "" { + fields = append(fields, "SecurityContact: "+contact) + } + if disclosure := strings.TrimSpace(p.VulnerabilityDisclosureURL); disclosure != "" { + fields = append(fields, "VulnerabilityDisclosure: "+disclosure) + } + if supportEnd := strings.TrimSpace(p.SupportEnd); supportEnd != "" { + fields = append(fields, "SupportEnd: "+supportEnd) + } + return strings.Join(fields, "; ") +} + func spdxPackageComment(component Component) string { fields := make([]string, 0, 4) if scope := strings.TrimSpace(component.Scope); scope != "" { diff --git a/internal/sbom/transform.go b/internal/sbom/transform.go index 1d4b87ce..0d349f89 100644 --- a/internal/sbom/transform.go +++ b/internal/sbom/transform.go @@ -1,8 +1,12 @@ package sbom import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" "errors" "fmt" + "net/url" "sort" "strconv" "strings" @@ -35,6 +39,7 @@ func FromDepGraph(g *sdk.Graph, opts BuildOptions) (*Document, error) { Type: string(pkg.Type), Copyright: pkg.Copyright, Licenses: componentLicenses(sdk.DetectionLicenses(pkg)), + Digests: componentDigests(pkg.Digests), } enrichComponentFromRegistry(&component, opts.Registry, pkg.PURL) components = append(components, component) @@ -86,9 +91,38 @@ func FromDepGraph(g *sdk.Graph, opts BuildOptions) (*Document, error) { if documentName == "" { documentName = defaultDocumentName } + + // When the graph has no single root (multiple manifests, multiple + // ecosystems) the primary component would otherwise be an arbitrary + // manifest node. Synthesize a pseudo root that represents the scanned + // project and depends on every graph root so both formats agree on the + // document's primary identity and the export forms one connected graph. + if opts.ProjectRoot != nil && strings.TrimSpace(opts.ProjectRoot.Name) != "" && opts.RootComponentID == "" && len(rootIDs) != 1 { + root := projectRootComponent(*opts.ProjectRoot) + sort.Strings(rootIDs) + components = append(components, root) + sort.Slice(components, func(i, j int) bool { + return components[i].ID < components[j].ID + }) + dependencies = append(dependencies, Dependency{Ref: root.ID, DependsOn: rootIDs}) + sort.Slice(dependencies, func(i, j int) bool { + return dependencies[i].Ref < dependencies[j].Ref + }) + rootIDs = []string{root.ID} + } + + serialNumber := strings.TrimSpace(opts.SerialNumber) + nonce := "" + if serialNumber == "" { + nonce = newUUIDv4() + serialNumber = "urn:uuid:" + nonce + } documentNS := opts.DocumentNS if documentNS == "" { - documentNS = fmt.Sprintf("https://bomly.dev/spdx/%d", created.UnixNano()) + if nonce == "" { + nonce = newUUIDv4() + } + documentNS = "https://bomly.dev/spdx/" + nonce } toolName := opts.ToolName if toolName == "" { @@ -101,14 +135,107 @@ func FromDepGraph(g *sdk.Graph, opts BuildOptions) (*Document, error) { Namespace: documentNS, Tool: toolName, Tools: toolNames, + ToolVersion: strings.TrimSpace(opts.ToolVersion), Created: created, - SerialNumber: opts.SerialNumber, + SerialNumber: serialNumber, + Provenance: opts.Provenance, Components: components, Dependencies: dependencies, Roots: rootIDs, }, nil } +// projectRootComponent synthesizes the pseudo component representing the +// scanned project. It carries a pkg:generic PURL so downstream consumers have +// a stable identifier for the primary component across updates. +func projectRootComponent(spec ProjectRoot) Component { + name := strings.TrimSpace(spec.Name) + version := strings.TrimSpace(spec.Version) + purl := "pkg:generic/" + url.PathEscape(strings.ToLower(name)) + if version != "" { + purl += "@" + url.PathEscape(version) + } + return Component{ + ID: projectRootIDPrefix + sanitizeSPDXID(name), + Name: name, + Version: version, + Type: "application", + PURL: purl, + } +} + +// newUUIDv4 returns a random RFC 4122 version-4 UUID string. +func newUUIDv4() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand never fails on supported platforms; fall back to a + // time-derived nonce rather than emitting an empty identifier. + return fmt.Sprintf("00000000-0000-4000-8000-%012x", time.Now().UnixNano()&0xffffffffffff) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// componentDigests projects detection-time dependency digests into the SBOM +// component model, normalizing values to lowercase hex so encoders can emit +// them as schema-valid CycloneDX hashes / SPDX checksums. Digests whose value +// cannot be normalized for a known algorithm are kept verbatim; encoders drop +// entries with unsupported algorithms. +func componentDigests(digests []sdk.Digest) []Digest { + if len(digests) == 0 { + return nil + } + out := make([]Digest, 0, len(digests)) + for _, d := range digests { + value := strings.TrimSpace(d.Value) + if value == "" { + continue + } + out = append(out, Digest{Algorithm: string(d.Algorithm), Value: normalizeDigestValue(string(d.Algorithm), value)}) + } + if len(out) == 0 { + return nil + } + return out +} + +// digestHexSizes maps digest algorithms onto their raw byte lengths, used to +// validate base64-encoded values (npm SRI integrity) before hex re-encoding. +var digestHexSizes = map[string]int{ + "md5": 16, + "sha1": 20, + "sha-1": 20, + "sha224": 28, + "sha-224": 28, + "sha256": 32, + "sha-256": 32, + "sha384": 48, + "sha-384": 48, + "sha512": 64, + "sha-512": 64, + "sha3-256": 32, + "sha3-384": 48, + "sha3-512": 64, +} + +func normalizeDigestValue(algorithm, value string) string { + size, ok := digestHexSizes[strings.ToLower(strings.TrimSpace(algorithm))] + if !ok { + return value + } + if len(value) == size*2 { + if _, err := hex.DecodeString(value); err == nil { + return strings.ToLower(value) + } + } + // npm-style SRI integrity values are standard base64 of the raw digest. + if raw, err := base64.StdEncoding.DecodeString(value); err == nil && len(raw) == size { + return hex.EncodeToString(raw) + } + return value +} + func uniqueToolNames(values []string) []string { out := make([]string, 0, len(values)) seen := make(map[string]struct{}, len(values)) @@ -142,14 +269,7 @@ func enrichComponentFromRegistry(component *Component, registry *sdk.PackageRegi if len(pkg.CPEs) > 0 { component.CPEs = append([]string(nil), pkg.CPEs...) } - if len(pkg.Digests) > 0 { - digests := make([]Digest, 0, len(pkg.Digests)) - for _, d := range pkg.Digests { - if d.Value == "" { - continue - } - digests = append(digests, Digest{Algorithm: string(d.Algorithm), Value: d.Value}) - } + if digests := componentDigests(pkg.Digests); len(digests) > 0 { component.Digests = digests } if len(pkg.Vulnerabilities) > 0 {