diff --git a/dev-docs/ARCHITECTURE.md b/dev-docs/ARCHITECTURE.md index 7d0c4928..b556d462 100644 --- a/dev-docs/ARCHITECTURE.md +++ b/dev-docs/ARCHITECTURE.md @@ -584,6 +584,15 @@ Two nested-module designs were evaluated and abandoned before this: a committed Syft's proprietary JSON SBOM format is no longer an accepted `--sbom` ingest input. It had exactly one consumer in the codebase — the SBOM ingest detector — while the syft detector itself always shells out with `-o spdx-json`. The lite build (`bomly_external_syft`) never actually ingested it either: its fallback re-ran the generic decoder, which returned a nil document for the syft target, so `ToGraph(nil)` hard-failed with an unhelpful `sbom document is nil` error. The change therefore unifies full and lite behavior on one explicit, actionable rejection; the compatibility impact is on full builds only, which previously decoded the format. Removing the decode path made `internal/detectors/sbom` build-tag-free and dropped its `anchore/syft` dependency. Follow-up simplification (same decision, second pass): the format-specific sniffing and the `syft convert` migration error were removed too. There is nothing special about syft-JSON — an unsupported format is an unsupported format, and the generic `ErrUnsupportedFormat` rejection covers it. This also deleted the last root-module import of `github.com/anchore/syft` (the `syftjson` decoder used only for identification), so the anchore tree now reaches the binary exclusively through the syft/grype component modules. Supported ingest formats are SPDX 2.3 JSON and CycloneDX 1.4–1.7 JSON. +### 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, GitHub Actions manifest-file SHA-256s and SHA-pinned action commit IDs) 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. + +Further identity and claim rules follow the same only-say-what-we-know principle. The project version comes from `--ref` or `git describe` and is stamped onto the primary component and first-party (main-module) components only — third-party versions are never touched, and no version is emitted when Git has nothing to say. The CycloneDX composition declaration is `complete` only for an unfiltered scan with no detector warnings; a `--scope` filter downgrades it to `incomplete` and degraded resolution to `unknown`. Vulnerability `recommendation` text is rendered only from enrichment-known fixed versions. Deprecated SPDX license identifiers are normalized to their current names token-wise inside expressions (`GPL-2.0` → `GPL-2.0-only`), leaving free-text license values untouched. Every SPDX package carries a `PrimaryPackagePurpose`; decode still prefers the `bomly:type=` comment so round-trips keep the richer domain types (workflow, action) that SPDX's vocabulary lacks. ## Build Modes diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index ac4267a9..8e561401 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,4 +277,13 @@ 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: "" ``` diff --git a/docs/SBOM.md b/docs/SBOM.md index 845c29f6..7afca480 100644 --- a/docs/SBOM.md +++ b/docs/SBOM.md @@ -84,6 +84,77 @@ 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, Go module `go.sum` tree + hashes (the `h1:` SHA-256 dirhash, hex-encoded — the same convention + cyclonedx-gomod uses), SHA-256 digests of GitHub Actions workflow and + action manifests, and the pinned commit ID of SHA-pinned actions. Values + are normalized to lowercase hex so they are schema-valid in both formats. +- License identifiers normalized to the current SPDX license list: deprecated + ids such as `GPL-2.0` are rewritten to their replacements (`GPL-2.0-only`) + inside expressions, in both formats. +- An SPDX `primaryPackagePurpose` for every package (LIBRARY for registry + packages, APPLICATION for the primary component, and so on). +- Remediation guidance on CycloneDX vulnerability entries: when enrichment + knows fixed versions, each vulnerability carries a `recommendation` + ("Upgrade to "). No guidance is invented when no fix is + known. SPDX 2.3 has no equivalent field. + +### 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 project version on the primary component and the project's own + (first-party) modules: the `--ref` value for remote scans, or `git + describe --tags --always --dirty` for local checkouts. When neither is + available the version is omitted rather than invented. +- A CycloneDX lifecycle phase (`pre-build` for source scans, `post-build` + for container images) and a composition completeness declaration: + `complete` for unfiltered, warning-free scans, `incomplete` when a + `--scope` filter dropped part of the graph, `unknown` when resolution was + degraded. SPDX 2.3 has no equivalent fields. +- 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. + +When `manufacturer` is set, it becomes the supplier of the primary component +in both formats (CycloneDX `metadata.manufacturer`, SPDX `PackageSupplier` on +the package the document DESCRIBES). Supplier is not defaulted to anything +when the field is unset, and per-component supplier and description data is +never invented: those fields stay absent unless a data source actually +provides them. Third-party CRA profile checks will flag the missing +manufacturer/contact metadata until the `sbom` section is configured. When `--enrich` is set, components are enriched from the matching-stage package registry (keyed by PURL): @@ -118,8 +189,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 c43c20b4..a4062b26 100644 --- a/internal/cli/scan_cmd.go +++ b/internal/cli/scan_cmd.go @@ -8,13 +8,17 @@ 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" "github.com/bomly-dev/bomly-cli/internal/sbom" "github.com/bomly-dev/bomly-cli/internal/tui" "github.com/bomly-dev/bomly-sdk" + "github.com/bomly-dev/bomly-sdk/logkit" + "github.com/bomly-dev/bomly-sdk/system" "github.com/spf13/cobra" + "go.uber.org/zap" ) func newScanCmd() *cobra.Command { @@ -126,10 +130,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(logger, payload.Project, commandCtx.ResolvedConfig, cmd.Root().Version, resolved, pipeResult.Registry, selectedScope, len(pipeResult.DetectorWarnings) > 0) + 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 +162,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 +216,89 @@ 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(logger *zap.Logger, project output.ProjectDescriptor, current config.Resolved, version string, resolved []sdk.DetectionResult, registry *sdk.PackageRegistry, selectedScope sdk.Scope, degraded bool) sbom.BuildOptions { + opts := sbom.BuildOptions{ + ToolNames: sbomToolNames(resolved), + ToolVersion: strings.TrimSpace(version), + Registry: registry, + Lifecycle: sbomLifecyclePhase(project.TargetType), + Aggregate: sbomCompositionAggregate(selectedScope, degraded), + 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 != "" { + projectVersion := strings.TrimSpace(project.TargetRef) + if projectVersion == "" { + projectVersion = gitDescribeVersion(logger, project.Path) + } + opts.DocumentName = name + opts.ProjectRoot = &sbom.ProjectRoot{Name: name, Version: projectVersion} + } + return opts +} + +// sbomLifecyclePhase maps the execution target type onto a CycloneDX +// lifecycle phase: source trees are pre-build inventories, container images +// describe a built artifact. Other targets (for example re-exported SBOMs) +// carry no phase claim. +func sbomLifecyclePhase(targetType string) string { + switch targetType { + case "filesystem", "git repository": + return "pre-build" + case "container image": + return "post-build" + default: + return "" + } +} + +// sbomCompositionAggregate declares dependency-graph completeness. A scope +// filter deliberately drops part of the graph, and degraded resolution means +// completeness is unknown; only an unfiltered, warning-free scan may claim +// "complete". +func sbomCompositionAggregate(selectedScope sdk.Scope, degraded bool) string { + if degraded { + return "unknown" + } + if selectedScope != sdk.ScopeUnknown && selectedScope != "" { + return "incomplete" + } + return "complete" +} + +// gitDescribeVersion derives a project version from Git history when the scan +// target is a checkout with no explicit ref (local path scans). Returns "" +// when Git or history is unavailable — the version is then simply omitted. +func gitDescribeVersion(logger *zap.Logger, path string) string { + if logger == nil { + logger = zap.NewNop() + } + if strings.TrimSpace(path) == "" { + return "" + } + gitPath, err := system.LookPath("git") + if err != nil { + logger.Debug("sbom: git unavailable for project version", zap.Error(err)) + return "" + } + args := []string{"-C", path, "describe", "--tags", "--always", "--dirty"} + logger.Debug("sbom: resolving project version", logkit.CommandFields(gitPath, args, path)...) + cmd := system.Command(gitPath, args...) + out, err := cmd.Output() + if err != nil { + logger.Debug("sbom: git describe failed; omitting project version", zap.Error(err)) + return "" + } + return strings.TrimSpace(string(out)) +} + func sbomToolNames(results []sdk.DetectionResult) []string { tools := make([]string, 0, len(results)) seen := make(map[string]struct{}, len(results)) diff --git a/internal/cli/scan_cmd_test.go b/internal/cli/scan_cmd_test.go index 1e2558c5..3cfe1381 100644 --- a/internal/cli/scan_cmd_test.go +++ b/internal/cli/scan_cmd_test.go @@ -7,6 +7,7 @@ import ( "github.com/bomly-dev/bomly-cli/internal/cli/render" "github.com/bomly-dev/bomly-cli/internal/output" "github.com/bomly-dev/bomly-sdk" + "go.uber.org/zap" ) func TestRenderScanReportShowsPackageCountAndDirectDeps(t *testing.T) { @@ -195,3 +196,39 @@ func TestRenderScanReportTopLevelDepsCoverAllModules(t *testing.T) { } } } + +func TestSBOMLifecyclePhase(t *testing.T) { + cases := map[string]string{ + "filesystem": "pre-build", + "git repository": "pre-build", + "container image": "post-build", + "sbom": "", + "": "", + } + for in, want := range cases { + if got := sbomLifecyclePhase(in); got != want { + t.Errorf("sbomLifecyclePhase(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSBOMCompositionAggregate(t *testing.T) { + if got := sbomCompositionAggregate(sdk.ScopeUnknown, false); got != "complete" { + t.Fatalf("unfiltered clean scan should claim complete, got %q", got) + } + if got := sbomCompositionAggregate(sdk.ScopeRuntime, false); got != "incomplete" { + t.Fatalf("scope-filtered scan must not claim complete, got %q", got) + } + if got := sbomCompositionAggregate(sdk.ScopeUnknown, true); got != "unknown" { + t.Fatalf("degraded resolution must declare unknown completeness, got %q", got) + } +} + +func TestGitDescribeVersion(t *testing.T) { + if got := gitDescribeVersion(zap.NewNop(), ""); got != "" { + t.Fatalf("empty path must yield no version, got %q", got) + } + if got := gitDescribeVersion(zap.NewNop(), t.TempDir()); got != "" { + t.Fatalf("non-git directory must yield no version, got %q", got) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 1be6852c..e72915ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -85,6 +85,12 @@ type Resolved struct { // startup from the build's version string; it is not user configuration // and has no flag, environment variable, or YAML key. CoreVersion string + + // 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 @@ -101,9 +107,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 PluginsFile `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 51cc157a..96b7748d 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-sdk/system" "gopkg.in/yaml.v3" @@ -364,6 +365,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: %w", cfg.SBOMSupportEnd, err) + } + } if err := validateProxyURL(cfg.HTTPProxy); err != nil { return err } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index e918ad58..1dec56ef 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -276,3 +276,16 @@ func TestValidatePluginConfigsWarnsOnSchemaUnknownKeys(t *testing.T) { t.Fatalf("open schema produced warnings: %#v", warnings) } } + +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/githubactions/detector.go b/internal/detectors/githubactions/detector.go index 2f40bf95..9e58baa5 100644 --- a/internal/detectors/githubactions/detector.go +++ b/internal/detectors/githubactions/detector.go @@ -2,6 +2,8 @@ package githubactions import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "io/fs" "os" @@ -113,6 +115,7 @@ func depGraphContainerFromRepository(projectPath string) (*sdk.GraphContainer, e for _, relPath := range workflowFiles { node := localWorkflowNode(relPath) + node.Digests = manifestFileDigests(projectPath, relPath) workflowNodes[relPath] = node if err := addNodeIfMissing(depsGraph, node); err != nil { return nil, err @@ -121,6 +124,7 @@ func depGraphContainerFromRepository(projectPath string) (*sdk.GraphContainer, e for _, relManifestPath := range actionFiles { relActionPath := filepath.ToSlash(filepath.Dir(relManifestPath)) node := localActionNode(relActionPath) + node.Digests = manifestFileDigests(projectPath, relManifestPath) actionNodes[relActionPath] = node if err := addNodeIfMissing(depsGraph, node); err != nil { return nil, err @@ -359,17 +363,34 @@ func resolveReference(ref, callerRelPath string, workflowNodes map[string]*sdk.D if strings.Contains(name, ".github/workflows/") { typeName = "workflow" } - return sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{Ecosystem: sdk.EcosystemGitHub, - Org: org, - Name: packageName, - Version: version, + node := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{Ecosystem: sdk.EcosystemGitHub, + Org: org, + Name: packageName, + Version: version, - PackageManager: sdk.PackageManagerGitHubActions, - Type: sdk.ParsePackageType(typeName), - Language: "yaml"}, Scopes: sdk.ScopesOf(sdk.ScopeRuntime), - }), + PackageManager: sdk.PackageManagerGitHubActions, + Type: sdk.ParsePackageType(typeName), + Language: "yaml"}, Scopes: sdk.ScopesOf(sdk.ScopeRuntime), + }) + // A SHA-pinned ref is the content-addressed identity of the action's + // source tree; record it so SBOM consumers can verify the pin. + if isGitCommitSHA(version) { + node.Digests = []sdk.Digest{{Algorithm: sdk.DigestAlgorithmSHA1, Value: strings.ToLower(version)}} + } + return node, nil +} - nil +// isGitCommitSHA reports whether value is a full 40-hex-character Git object ID. +func isGitCommitSHA(value string) bool { + if len(value) != 40 { + return false + } + for _, r := range value { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { + return false + } + } + return true } func localReferenceCandidates(ref, callerRelPath string) []string { @@ -421,6 +442,18 @@ func localActionNode(relPath string) *sdk.Dependency { } +// manifestFileDigests hashes a workflow or action manifest file so local +// manifest components carry verifiable integrity data. Returns nil on any +// read failure — digests are metadata, never a resolution requirement. +func manifestFileDigests(projectPath, relPath string) []sdk.Digest { + data, err := system.ReadRepositoryFile(filepath.Join(projectPath, filepath.FromSlash(relPath))) + if err != nil { + return nil + } + sum := sha256.Sum256(data) + return []sdk.Digest{{Algorithm: sdk.DigestAlgorithmSHA256, Value: hex.EncodeToString(sum[:])}} +} + func addNodeIfMissing(depsGraph *sdk.Graph, node *sdk.Dependency) error { if _, ok := depsGraph.Node(node.ID); ok { return nil diff --git a/internal/detectors/githubactions/detector_test.go b/internal/detectors/githubactions/detector_test.go index af3c9d81..ecff76d6 100644 --- a/internal/detectors/githubactions/detector_test.go +++ b/internal/detectors/githubactions/detector_test.go @@ -188,3 +188,50 @@ func TestDetectorResolveGraphPreservesDuplicateUsesLocations(t *testing.T) { t.Fatalf("checkout locations = %#v, want ci line 4 and guard line 5", checkout.Locations) } } + +func TestDepGraphDigests(t *testing.T) { + projectDir := t.TempDir() + workflowDir := filepath.Join(projectDir, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("create workflow dir: %v", err) + } + pinned := "11bd71901bbe5b1630ceea73d27597364c9af683" + workflow := []byte("jobs:\n build:\n steps:\n - uses: actions/checkout@" + pinned + "\n - uses: actions/cache@v4\n") + if err := os.WriteFile(filepath.Join(workflowDir, "ci.yml"), workflow, 0o644); err != nil { + t.Fatalf("write workflow: %v", err) + } + + g, err := depGraphFromRepository(projectDir) + if err != nil { + t.Fatalf("depGraphFromRepository() error = %v", err) + } + + found := map[string][]sdk.Digest{} + g.WalkNodes(func(node *sdk.Dependency) bool { + found[node.Name] = node.Digests + return true + }) + + checkout := found["checkout"] + if len(checkout) != 1 || checkout[0].Algorithm != sdk.DigestAlgorithmSHA1 || checkout[0].Value != pinned { + t.Fatalf("expected pinned commit digest on actions/checkout, got %#v", checkout) + } + if len(found["cache"]) != 0 { + t.Fatalf("tag-pinned action must not carry a digest, got %#v", found["cache"]) + } + wf := found[".github/workflows/ci.yml"] + if len(wf) != 1 || wf[0].Algorithm != sdk.DigestAlgorithmSHA256 || len(wf[0].Value) != 64 { + t.Fatalf("expected sha256 file digest on the workflow manifest, got %#v", wf) + } +} + +func TestIsGitCommitSHA(t *testing.T) { + if !isGitCommitSHA("11bd71901bbe5b1630ceea73d27597364c9af683") { + t.Fatal("expected 40-hex value to be recognized as a commit SHA") + } + for _, value := range []string{"v4", "main", "11bd719", "11bd71901bbe5b1630ceea73d27597364c9afzzz"} { + if isGitCommitSHA(value) { + t.Fatalf("value %q must not be treated as a commit SHA", value) + } + } +} diff --git a/internal/detectors/gomod/detector.go b/internal/detectors/gomod/detector.go index 2d3653f6..ab917d22 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" @@ -146,6 +149,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) @@ -170,7 +181,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)) @@ -194,10 +205,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") } @@ -274,22 +285,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 } } @@ -329,7 +340,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) @@ -345,7 +356,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 } @@ -360,7 +371,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}, @@ -368,6 +379,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{ { @@ -387,6 +401,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 6205f4b9..3095e09f 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 349b87f9..127c0bc0 100644 --- a/internal/detectors/gomod/parser_fuzz_test.go +++ b/internal/detectors/gomod/parser_fuzz_test.go @@ -1,6 +1,8 @@ package gomod import ( + "os" + "path/filepath" "testing" "github.com/bomly-dev/bomly-sdk" @@ -14,9 +16,47 @@ 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) } }) } + +func FuzzParseGoSumDigests(f *testing.F) { + f.Add([]byte("github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\n")) + f.Add([]byte("example.com/mod v1.0.0 h1:not-base64!!\n")) + f.Add([]byte("example.com/mod v1.0.0")) + f.Add([]byte("\n\n \n")) + f.Add([]byte("")) + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > testutil.MaxFuzzInputSize { + return + } + path := filepath.Join(t.TempDir(), "go.sum") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Skipf("write go.sum: %v", err) + } + + first, firstErr := parseGoSumDigests(path) + second, secondErr := parseGoSumDigests(path) + if (firstErr == nil) != (secondErr == nil) { + t.Fatalf("nondeterministic parse outcome: %v vs %v", firstErr, secondErr) + } + if firstErr != nil { + return + } + if len(first) != len(second) { + t.Fatalf("nondeterministic digest count: %d vs %d", len(first), len(second)) + } + for key, digest := range first { + other, ok := second[key] + if !ok || other != digest { + t.Fatalf("nondeterministic digest for %q: %#v vs %#v", key, digest, other) + } + if digest.Algorithm != sdk.DigestAlgorithmSHA256 || len(digest.Value) != 64 { + t.Fatalf("unexpected digest shape for %q: %#v", key, digest) + } + } + }) +} diff --git a/internal/detectors/gomod/testdata/demo/go.sum b/internal/detectors/gomod/testdata/demo/go.sum new file mode 100644 index 00000000..59508753 --- /dev/null +++ b/internal/detectors/gomod/testdata/demo/go.sum @@ -0,0 +1,3 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/internal/sbom/codec_fuzz_test.go b/internal/sbom/codec_fuzz_test.go index 38c42be4..5423ece1 100644 --- a/internal/sbom/codec_fuzz_test.go +++ b/internal/sbom/codec_fuzz_test.go @@ -2,6 +2,7 @@ package sbom import ( "errors" + "strings" "testing" testkit "github.com/bomly-dev/bomly-sdk/testkit" @@ -68,3 +69,44 @@ func FuzzUnmarshalAutoJSON(f *testing.F) { } }) } + +// FuzzNormalizeSPDXLicenseExpression exercises the license-expression rewriter +// that runs over every component license (detector- and registry-supplied, +// both of which originate in untrusted repository or API data). +func FuzzNormalizeSPDXLicenseExpression(f *testing.F) { + for _, seed := range []string{ + "MIT", + "GPL-2.0", + "GPL-3.0+", + "(MIT OR GPL-2.0)", + "LGPL-2.1 WITH Classpath-exception-2.0", + "Apache-2.0 AND (MIT OR BSD-3-Clause)", + "", + " ", + "(((", + ")", + "GPL-2.0-with-classpath-exception", + "\x00�", + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, expression string) { + if len(expression) > testkit.MaxFuzzInputSize { + return + } + first := normalizeSPDXLicenseExpression(expression) + if second := normalizeSPDXLicenseExpression(expression); first != second { + t.Fatalf("nondeterministic normalization: %q vs %q", first, second) + } + // Normalization only substitutes identifier tokens; it must never + // drop expression structure. + for _, r := range []rune{'(', ')'} { + if strings.Count(first, string(r)) != strings.Count(expression, string(r)) { + t.Fatalf("normalization changed %q grouping: %q -> %q", string(r), expression, first) + } + } + if strings.TrimSpace(expression) == "" && first != expression { + t.Fatalf("blank expression must pass through unchanged: %q -> %q", expression, first) + } + }) +} diff --git a/internal/sbom/cyclonedx.go b/internal/sbom/cyclonedx.go index f8e2a701..27fbe393 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,19 +69,41 @@ 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} + author := cdx.OrganizationalContact{Name: doc.Provenance.Manufacturer} + if email := bareEmail(doc.Provenance.SecurityContact); email != "" { + author.Email = email + } + metadata.Authors = &[]cdx.OrganizationalContact{author} + } + if props := cycloneDXMetadataProperties(doc.Provenance); len(props) > 0 { + metadata.Properties = &props + } + if phase := cycloneDXLifecyclePhase(doc.Lifecycle); phase != "" { + metadata.Lifecycles = &[]cdx.Lifecycle{{Phase: phase}} + } bom.Metadata = metadata + if aggregate := cycloneDXAggregate(doc.Aggregate); aggregate != "" { + bom.Compositions = &[]cdx.Composition{{Aggregate: aggregate}} + } + var out bytes.Buffer enc := cdx.NewBOMEncoder(&out, cdx.BOMFileFormatJSON).SetPretty(opts.Pretty) if err := enc.EncodeVersion(bom, toCycloneDXVersion(c.version)); err != nil { @@ -107,10 +135,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 +222,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 +231,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 +246,70 @@ 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 +} + +// bareEmail returns value when it looks like a plain email address (no URI +// scheme), otherwise "". +func bareEmail(value string) string { + value = strings.TrimSpace(value) + if strings.Contains(value, "@") && !strings.Contains(value, ":") && !strings.Contains(value, "/") { + return value + } + return "" +} + +// cycloneDXLifecyclePhase validates a lifecycle phase against the CycloneDX +// vocabulary, returning "" for unknown values so an invalid phase can never +// make the document non-conformant. +func cycloneDXLifecyclePhase(value string) cdx.LifecyclePhase { + switch cdx.LifecyclePhase(strings.ToLower(strings.TrimSpace(value))) { + case cdx.LifecyclePhaseDesign, cdx.LifecyclePhasePreBuild, cdx.LifecyclePhaseBuild, + cdx.LifecyclePhasePostBuild, cdx.LifecyclePhaseOperations, cdx.LifecyclePhaseDiscovery, + cdx.LifecyclePhaseDecommission: + return cdx.LifecyclePhase(strings.ToLower(strings.TrimSpace(value))) + default: + return "" + } +} + +// cycloneDXAggregate validates a composition aggregate value, returning "" for +// unknown values. +func cycloneDXAggregate(value string) cdx.CompositionAggregate { + switch cdx.CompositionAggregate(strings.ToLower(strings.TrimSpace(value))) { + case cdx.CompositionAggregateComplete, cdx.CompositionAggregateIncomplete, + cdx.CompositionAggregateIncompleteFirstPartyOnly, cdx.CompositionAggregateIncompleteFirstPartyOpenSourceOnly, + cdx.CompositionAggregateIncompleteThirdPartyOnly, cdx.CompositionAggregateIncompleteThirdPartyOpenSourceOnly, + cdx.CompositionAggregateNotSpecified, cdx.CompositionAggregateUnknown: + return cdx.CompositionAggregate(strings.ToLower(strings.TrimSpace(value))) + default: + return "" + } +} + +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 @@ -414,8 +521,9 @@ func cycloneDXVulnerabilities(components []Component) []cdx.Vulnerability { func cycloneDXVulnerability(v Vulnerability, refs []string) cdx.Vulnerability { vuln := cdx.Vulnerability{ - ID: v.ID, - Description: v.Description, + ID: v.ID, + Description: v.Description, + Recommendation: v.Recommendation, } if v.Source != "" { vuln.Source = &cdx.Source{Name: v.Source} diff --git a/internal/sbom/export_quality_test.go b/internal/sbom/export_quality_test.go new file mode 100644 index 00000000..c53adfb4 --- /dev/null +++ b/internal/sbom/export_quality_test.go @@ -0,0 +1,558 @@ +package sbom + +import ( + "encoding/json" + "regexp" + "strings" + "testing" + + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/bomly-dev/bomly-sdk" + "github.com/spdx/tools-golang/spdx/v2/common" + 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") + } +} + +func TestFromDepGraph_StampsFirstPartyVersionFromProjectRoot(t *testing.T) { + g := sdk.New() + main := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{ + Name: "example.com/app", Ecosystem: sdk.EcosystemGo, FirstParty: true, + }}) + dep := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{ + Name: "example.com/lib", Version: "v1.0.0", Ecosystem: sdk.EcosystemGo, + }}) + for _, n := range []*sdk.Dependency{main, dep} { + if err := g.AddNode(n); err != nil { + t.Fatalf("add node: %v", err) + } + } + if err := g.AddEdge(main.ID, dep.ID); err != nil { + t.Fatalf("add edge: %v", err) + } + + doc, err := FromDepGraph(g, BuildOptions{ProjectRoot: &ProjectRoot{Name: "app", Version: "v2.3.4"}}) + if err != nil { + t.Fatalf("from depgraph: %v", err) + } + for _, c := range doc.Components { + if c.Name == "example.com/app" && c.Version != "v2.3.4" { + t.Fatalf("expected first-party component to carry the project version, got %q", c.Version) + } + if c.Name == "example.com/lib" && c.Version != "v1.0.0" { + t.Fatalf("third-party version must not be overwritten, got %q", c.Version) + } + } +} + +func TestNormalizeSPDXLicenseExpression(t *testing.T) { + cases := map[string]string{ + "GPL-2.0": "GPL-2.0-only", + "GPL-3.0+": "GPL-3.0-or-later", + "(MIT OR GPL-2.0)": "(MIT OR GPL-2.0-only)", + "MIT": "MIT", + "Custom License Text": "Custom License Text", + "LGPL-2.1 WITH addon": "LGPL-2.1-only WITH addon", + "": "", + } + for in, want := range cases { + if got := normalizeSPDXLicenseExpression(in); got != want { + t.Errorf("normalizeSPDXLicenseExpression(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMarshalDepGraphJSON_CycloneDXLifecycleCompositionAndAuthors(t *testing.T) { + out, err := MarshalDepGraphJSON(mustMultiRootGraph(t), TargetCycloneDX17JSON, BuildOptions{ + ProjectRoot: &ProjectRoot{Name: "demo"}, + Lifecycle: "pre-build", + Aggregate: "complete", + Provenance: Provenance{ + Manufacturer: "Example Org", + SecurityContact: "security@example.com", + }, + }, 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 bom.Metadata.Lifecycles == nil || len(*bom.Metadata.Lifecycles) != 1 || (*bom.Metadata.Lifecycles)[0].Phase != cdx.LifecyclePhasePreBuild { + t.Fatalf("expected pre-build lifecycle, got %+v", bom.Metadata.Lifecycles) + } + if bom.Compositions == nil || len(*bom.Compositions) != 1 || (*bom.Compositions)[0].Aggregate != cdx.CompositionAggregateComplete { + t.Fatalf("expected complete composition, got %+v", bom.Compositions) + } + if bom.Metadata.Authors == nil || len(*bom.Metadata.Authors) != 1 { + t.Fatalf("expected one author, got %+v", bom.Metadata.Authors) + } + author := (*bom.Metadata.Authors)[0] + if author.Name != "Example Org" || author.Email != "security@example.com" { + t.Fatalf("unexpected author: %+v", author) + } +} + +func TestMarshalDepGraphJSON_CycloneDXOmitsInvalidLifecycleAndAggregate(t *testing.T) { + out, err := MarshalDepGraphJSON(mustGraph(t), TargetCycloneDX17JSON, BuildOptions{ + Lifecycle: "sometime", + Aggregate: "mostly-done", + }, 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 bom.Metadata.Lifecycles != nil { + t.Fatalf("invalid lifecycle should be omitted, got %+v", bom.Metadata.Lifecycles) + } + if bom.Compositions != nil { + t.Fatalf("invalid aggregate should be omitted, got %+v", bom.Compositions) + } +} + +func TestMarshalDepGraphJSON_SPDX23PackagePurposes(t *testing.T) { + g := sdk.New() + workflow := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{ + Name: "ci.yml", Version: "local", Ecosystem: sdk.EcosystemGitHub, Type: sdk.ParsePackageType("workflow"), + }}) + lib := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{ + Name: "react", Version: "18.2.0", Ecosystem: sdk.EcosystemNPM, + }}) + for _, n := range []*sdk.Dependency{workflow, lib} { + if err := g.AddNode(n); err != nil { + t.Fatalf("add node: %v", err) + } + } + + out, err := MarshalDepGraphJSON(g, TargetSPDX23JSON, BuildOptions{ProjectRoot: &ProjectRoot{Name: "demo"}}, 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) + } + purposes := map[string]string{} + for _, p := range d.Packages { + purposes[p.PackageName] = p.PrimaryPackagePurpose + } + if purposes["react"] != "LIBRARY" { + t.Fatalf("expected LIBRARY purpose for react, got %q", purposes["react"]) + } + if purposes["ci.yml"] != "OTHER" { + t.Fatalf("expected OTHER purpose for workflow, got %q", purposes["ci.yml"]) + } + if purposes["demo"] != "APPLICATION" { + t.Fatalf("expected APPLICATION purpose for project root, got %q", purposes["demo"]) + } +} + +func TestMarshalDepGraphJSON_SPDX23SupplierOnNaturalPrimaryPackage(t *testing.T) { + // A single-root graph keeps its natural root as the primary component, so + // no pseudo root is synthesized. Provenance must still land on it, the + // way CycloneDX attaches it to metadata.component in both forms. + out, err := MarshalDepGraphJSON(mustGraph(t), TargetSPDX23JSON, BuildOptions{ + ProjectRoot: &ProjectRoot{Name: "demo-project"}, + Provenance: Provenance{Manufacturer: "Example Org"}, + }, 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) + } + + var describes common.ElementID + for _, rel := range d.Relationships { + if rel != nil && rel.Relationship == "DESCRIBES" { + describes = rel.RefB.ElementRefID + } + } + if describes == "" { + t.Fatal("expected a DESCRIBES relationship") + } + + suppliers := 0 + for _, p := range d.Packages { + if p.PackageSPDXIdentifier != describes { + if p.PackageSupplier != nil { + t.Fatalf("supplier leaked onto non-root package %q", p.PackageName) + } + continue + } + if p.PackageSupplier == nil || p.PackageSupplier.Supplier != "Example Org" { + t.Fatalf("expected supplier on the natural primary package, got %+v", p.PackageSupplier) + } + suppliers++ + } + if suppliers != 1 { + t.Fatalf("expected exactly one supplied package, got %d", suppliers) + } +} diff --git a/internal/sbom/graph.go b/internal/sbom/graph.go index bb791613..3944f3c2 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 d22654ec..31607005 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-sdk" @@ -17,18 +18,65 @@ const ( TargetCycloneDX17JSON Target = "cyclonedx-1.7+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 + + // Lifecycle is the CycloneDX lifecycle phase the document describes + // (for example "pre-build" for source scans, "post-build" for container + // images). Empty omits lifecycle metadata. + Lifecycle string + + // Aggregate is the CycloneDX composition completeness declaration + // ("complete", "incomplete", ...). Empty omits the declaration; callers + // must only claim "complete" when nothing filtered or degraded the graph. + Aggregate string + // Registry, when non-nil, supplies matching-stage enrichment (licenses, // vulnerabilities, CPEs, digests, EOL) resolved by PURL and folded onto // each component during projection. @@ -46,14 +94,29 @@ type Document struct { Namespace string Tool string Tools []string + ToolVersion string Created time.Time SerialNumber string + Provenance Provenance + Lifecycle string + Aggregate string 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 @@ -107,6 +170,10 @@ type Vulnerability struct { FixedVersions []string Advisories []string Description string + + // Recommendation is remediation guidance derived from known fixed + // versions (CycloneDX `recommendation`; SPDX 2.3 has no equivalent). + Recommendation string } // EOL is a format-agnostic projection of end-of-life enrichment for a component. diff --git a/internal/sbom/spdx23.go b/internal/sbom/spdx23.go index 1cbb1e57..cff750e4 100644 --- a/internal/sbom/spdx23.go +++ b/internal/sbom/spdx23.go @@ -21,6 +21,15 @@ func (spdx23Codec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, error) usedIDs := make(map[string]int, len(doc.Components)) packages := make([]*v23.Package, 0, len(doc.Components)) + // Document roots are the packages SPDX DESCRIBES, i.e. the primary + // component in either form: the synthesized project root, or the graph's + // own single root when no pseudo root was needed. Provenance attaches to + // both so the SPDX export matches CycloneDX metadata.component. + rootComponents := make(map[string]struct{}, len(doc.Roots)) + for _, root := range doc.Roots { + rootComponents[root] = struct{}{} + } + for _, c := range doc.Components { base := sanitizeSPDXID(c.ID) seq := usedIDs[base] @@ -31,7 +40,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 +52,14 @@ func (spdx23Codec) encodeJSON(doc *Document, opts EncodeOptions) ([]byte, error) PackageCopyrightText: spdxCopyrightValue(c.Copyright), PackageChecksums: spdxChecksums(c.Digests), PackageExternalReferences: spdxExternalReferences(c), - }) + PrimaryPackagePurpose: spdxPrimaryPackagePurpose(c.Type), + } + if _, isRoot := rootComponents[c.ID]; isRoot || IsProjectRootComponent(c) { + 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 +94,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 +273,48 @@ func parseSPDXCreated(ci *v23.CreationInfo) time.Time { return t.UTC() } +// spdxPrimaryPackagePurpose maps Bomly's component type onto the SPDX 2.3 +// PrimaryPackagePurpose vocabulary. Ordinary registry packages default to +// LIBRARY; unmapped domain types (for example workflows) return OTHER. +func spdxPrimaryPackagePurpose(componentType string) string { + switch strings.ToLower(strings.TrimSpace(componentType)) { + case "", "package", "library": + return "LIBRARY" + case "application": + return "APPLICATION" + case "framework": + return "FRAMEWORK" + case "container": + return "CONTAINER" + case "operating-system": + return "OPERATING-SYSTEM" + case "device": + return "DEVICE" + case "firmware": + return "FIRMWARE" + case "file": + return "FILE" + default: + return "OTHER" + } +} + +// 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 96c511c9..891d4f4a 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" @@ -24,10 +28,16 @@ func FromDepGraph(g *sdk.Graph, opts BuildOptions) (*Document, error) { depsByRef := make(map[string][]string, componentCount) g.WalkNodes(func(pkg *sdk.Dependency) bool { + version := pkg.Version + if version == "" && pkg.FirstParty && opts.ProjectRoot != nil { + // First-party nodes (the scanned project's own modules) have no + // registry version; the project version is theirs. + version = strings.TrimSpace(opts.ProjectRoot.Version) + } component := Component{ ID: pkg.ID, Name: pkg.EcosystemName(), - Version: pkg.Version, + Version: version, Scope: string(pkg.PrimaryScope()), PURL: pkg.PURL, Ecosystem: string(pkg.Ecosystem), @@ -35,6 +45,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 +97,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 +141,109 @@ 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, + Lifecycle: strings.TrimSpace(opts.Lifecycle), + Aggregate: strings.TrimSpace(opts.Aggregate), 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,18 +277,11 @@ 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 { - component.Vulnerabilities = vulnerabilitiesFromPackage(pkg.Vulnerabilities) + component.Vulnerabilities = vulnerabilitiesFromPackage(pkg.EcosystemName(), pkg.Vulnerabilities) } if pkg.EOL != nil { component.EOL = &EOL{ @@ -168,15 +296,16 @@ func enrichComponentFromRegistry(component *Component, registry *sdk.PackageRegi // vulnerabilitiesFromPackage projects matching-stage advisories into the // format-agnostic SBOM vulnerability model. Severity/score/vector come from the // first CVSS entry when present, falling back to the parsed severity band. -func vulnerabilitiesFromPackage(vulns []sdk.Vulnerability) []Vulnerability { +func vulnerabilitiesFromPackage(packageName string, vulns []sdk.Vulnerability) []Vulnerability { out := make([]Vulnerability, 0, len(vulns)) for _, v := range vulns { vuln := Vulnerability{ - ID: v.ID, - Source: v.Source, - Severity: string(v.ParsedSeverity), - FixedVersions: append([]string(nil), v.FixedVersions...), - Description: v.Details, + ID: v.ID, + Source: v.Source, + Severity: string(v.ParsedSeverity), + FixedVersions: append([]string(nil), v.FixedVersions...), + Description: v.Details, + Recommendation: vulnerabilityRecommendation(packageName, v.FixedVersions), } if vuln.Source == "" { vuln.Source = v.DataSource @@ -201,6 +330,26 @@ func vulnerabilitiesFromPackage(vulns []sdk.Vulnerability) []Vulnerability { return out } +// vulnerabilityRecommendation renders remediation guidance from known fixed +// versions. Returns "" when no fix is known so consumers never see fabricated +// advice. +func vulnerabilityRecommendation(packageName string, fixedVersions []string) string { + versions := make([]string, 0, len(fixedVersions)) + for _, v := range fixedVersions { + if v = strings.TrimSpace(v); v != "" { + versions = append(versions, v) + } + } + if len(versions) == 0 { + return "" + } + subject := strings.TrimSpace(packageName) + if subject == "" { + subject = "the affected package" + } + return "Upgrade " + subject + " to " + strings.Join(versions, " or ") +} + // cweNumber extracts the integer portion of a CWE identifier such as // "CWE-79" → 79. Returns 0 when no number is present. func cweNumber(id string) int { @@ -241,10 +390,68 @@ func componentLicenses(licenses []sdk.PackageLicense) []License { out := make([]License, 0, len(licenses)) for _, license := range licenses { out = append(out, License{ - Value: license.Value, - SPDXExpression: license.SPDXExpression, + Value: normalizeSPDXLicenseExpression(license.Value), + SPDXExpression: normalizeSPDXLicenseExpression(license.SPDXExpression), Type: string(license.Type), }) } return out } + +// deprecatedSPDXLicenseIDs maps SPDX license identifiers that the SPDX license +// list has deprecated onto their current replacements. Only unambiguous +// renames are listed; anything else passes through untouched. +var deprecatedSPDXLicenseIDs = map[string]string{ + "AGPL-1.0": "AGPL-1.0-only", + "AGPL-3.0": "AGPL-3.0-only", + "GFDL-1.1": "GFDL-1.1-only", + "GFDL-1.2": "GFDL-1.2-only", + "GFDL-1.3": "GFDL-1.3-only", + "GPL-1.0": "GPL-1.0-only", + "GPL-1.0+": "GPL-1.0-or-later", + "GPL-2.0": "GPL-2.0-only", + "GPL-2.0+": "GPL-2.0-or-later", + "GPL-3.0": "GPL-3.0-only", + "GPL-3.0+": "GPL-3.0-or-later", + "LGPL-2.0": "LGPL-2.0-only", + "LGPL-2.0+": "LGPL-2.0-or-later", + "LGPL-2.1": "LGPL-2.1-only", + "LGPL-2.1+": "LGPL-2.1-or-later", + "LGPL-3.0": "LGPL-3.0-only", + "LGPL-3.0+": "LGPL-3.0-or-later", + "GPL-2.0-with-classpath-exception": "GPL-2.0-only WITH Classpath-exception-2.0", +} + +// normalizeSPDXLicenseExpression replaces deprecated SPDX identifiers inside a +// license expression with their current names, preserving expression +// structure. Non-SPDX free-text values pass through unchanged. +func normalizeSPDXLicenseExpression(expression string) string { + if strings.TrimSpace(expression) == "" { + return expression + } + var b strings.Builder + b.Grow(len(expression)) + token := strings.Builder{} + flush := func() { + if token.Len() == 0 { + return + } + t := token.String() + if replacement, ok := deprecatedSPDXLicenseIDs[t]; ok { + b.WriteString(replacement) + } else { + b.WriteString(t) + } + token.Reset() + } + for _, r := range expression { + if r == ' ' || r == '(' || r == ')' { + flush() + b.WriteRune(r) + continue + } + token.WriteRune(r) + } + flush() + return b.String() +} diff --git a/scripts/run-fuzz.sh b/scripts/run-fuzz.sh index 1064efca..36bf6147 100755 --- a/scripts/run-fuzz.sh +++ b/scripts/run-fuzz.sh @@ -14,6 +14,7 @@ targets=( "github.com/bomly-dev/bomly-cli/internal/detectors/conan FuzzDepGraphFromConanJSON" "github.com/bomly-dev/bomly-cli/internal/detectors/githubactions FuzzParseWorkflowRefs" "github.com/bomly-dev/bomly-cli/internal/detectors/gomod FuzzDepGraphFromGoList" + "github.com/bomly-dev/bomly-cli/internal/detectors/gomod FuzzParseGoSumDigests" "github.com/bomly-dev/bomly-cli/internal/detectors/mix FuzzDepGraphFromMixLock" "github.com/bomly-dev/bomly-cli/internal/detectors/node FuzzPackageManagerWarnings" "github.com/bomly-dev/bomly-cli/internal/detectors/node/npm FuzzDepGraphFromNPMLockfile" @@ -29,6 +30,7 @@ targets=( "github.com/bomly-dev/bomly-cli/internal/detectors/ruby FuzzDepGraphFromBundlerLock" "github.com/bomly-dev/bomly-cli/internal/detectors/swiftpm FuzzDepGraphFromSwiftResolved" "github.com/bomly-dev/bomly-cli/internal/sbom FuzzUnmarshalAutoJSON" + "github.com/bomly-dev/bomly-cli/internal/sbom FuzzNormalizeSPDXLicenseExpression" "github.com/bomly-dev/bomly-cli/internal/baseline FuzzLoad" "github.com/bomly-dev/bomly-cli/internal/engine FuzzConsolidateVulnerabilities" "github.com/bomly-dev/bomly-cli/internal/plugin FuzzPluginPathSanitizers"