Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions dev-docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions docs/CONFIG_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: ""
```
77 changes: 75 additions & 2 deletions docs/SBOM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <package> to <version>"). 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-<version>`), 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):
Expand Down Expand Up @@ -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
Expand Down
92 changes: 90 additions & 2 deletions internal/cli/scan_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
Comment thread
bomly-guy marked this conversation as resolved.
}

func sbomToolNames(results []sdk.DetectionResult) []string {
tools := make([]string, 0, len(results))
seen := make(map[string]struct{}, len(results))
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/scan_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Loading
Loading