Skip to content
Open
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
10 changes: 10 additions & 0 deletions dev-docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,16 @@ The help-path startup banner (`internal/cli/render/logo.go`) is a frame-based an
- **Gating is env-var-only (`BOMLY_LOGO` to opt in; `NO_COLOR`, `BOMLY_NO_ANIMATION`, `CI`, `BOMLY_QUIET` to force static), deliberately not a config key.** Cobra's `execute()` returns `flag.ErrHelp` right after flag parsing, *before* the `PersistentPreRunE` chain where `options.ResolveConfig` runs — so on `bomly --help` / `bomly <cmd> --help` (the banner's primary path) resolved config simply does not exist yet. A `logo.animate` config key would silently work only for bare `bomly` and `bomly help <cmd>`, which is a trap. When animation is gated off but stderr is a TTY, the static final frame prints instead (plain under `NO_COLOR`, colored otherwise); non-TTY stderr prints nothing.
- **The animation leaves cursor visibility unchanged.** Hiding the cursor would make the animation slightly cleaner, but a process-level interrupt can bypass deferred cleanup and leave the user's shell cursor hidden. Avoiding that terminal-state mutation keeps interruption safe without introducing signal handling into the render package.

### Decision: SBOM exports carry a synthesized primary component and shared document identity

A scan that discovers multiple manifests produces a graph with many roots (one per workflow file, one per module). Before this decision the CycloneDX `metadata.component` was simply `Roots[0]` — an arbitrary manifest node such as `.github/workflows/auto-version.yml` — while the SPDX document was named after a static default, so the two exports of one scan disagreed about their own subject and third-party graph analysis saw disconnected islands.

`sbom.FromDepGraph` now synthesizes a pseudo root when a `ProjectRoot` is supplied and the graph does not already have exactly one root. The pseudo root is named after the scanned project, typed `application`, given a `pkg:generic` PURL for cross-update traceability, and depends on every graph root, which makes the exported dependency graph a single connected component. Its ID carries the `DocumentRoot-` prefix so `ToGraph` excludes it on re-ingestion (the prefix check deliberately overrides the it-has-a-PURL heuristic); the CycloneDX encoder keeps it out of the component inventory (it lives in `metadata.component` plus one `dependencies` entry), while SPDX includes it as the `DESCRIBES` target because SPDX relationships must reference document packages. When the graph has a single natural root — for example a pure Go module scan — that root remains the primary component, since a real package with a real PURL is strictly better identity than a synthesized one.

Document identity is shared across formats: one generated UUIDv4 becomes both the CycloneDX `serialNumber` (`urn:uuid:`) and the nonce in the SPDX document namespace, so the two files produced by one scan are correlatable. Detection-time dependency digests (npm SRI integrity, `go.sum` `h1:` tree hashes, 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

Syft and Grype each support two 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: ""
```
75 changes: 73 additions & 2 deletions docs/SBOM.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,75 @@ 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.

Without these fields Bomly's exports satisfy the NTIA minimum elements
(supplier defaults to the producing tool); third-party CRA profile checks
will flag the missing manufacturer/contact metadata until the `sbom` section
is configured. Per-component supplier and description data is not invented:
Comment on lines +151 to +154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the supplier fallback statement.

The exporter does not default a package supplier to the producing tool. SPDX sets PackageSupplier only when manufacturer is configured. State that supplier metadata remains absent until a data source provides it, or implement the documented fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/SBOM.md` around lines 151 - 154, Update the supplier fallback statement
in the SBOM documentation to accurately state that SPDX PackageSupplier remains
unset unless manufacturer metadata is configured or another data source supplies
it; do not claim it defaults to the producing tool unless that fallback is
implemented.

those fields stay empty unless a data source actually provides them.

When `--enrich` is set, components are enriched from the matching-stage package
registry (keyed by PURL):
Expand Down Expand Up @@ -118,8 +187,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
83 changes: 81 additions & 2 deletions internal/cli/scan_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ 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/system"
"github.com/bomly-dev/bomly-cli/internal/tui"
"github.com/bomly-dev/bomly-sdk"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -126,10 +128,11 @@ func newScanCmd() *cobra.Command {
return output.WriteSARIF(w, findings, pipeResult.Registry, "bomly", cmd.Root().Version, output.SARIFOptions{IncludeReachability: commandCtx.ResolvedConfig.Analyze, LocationGraphs: []*sdk.Graph{pipeResult.Graph}})
}

sbomBuildOpts := scanSBOMBuildOptions(payload.Project, commandCtx.ResolvedConfig, cmd.Root().Version, resolved, pipeResult.Registry, 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 +160,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 +214,82 @@ func scanPolicyExit(auditEnabled bool, findings []sdk.Finding) error {
return nil
}

// scanSBOMBuildOptions assembles the SBOM projection options for a scan: the
// document is named after the scanned project, the primary component mirrors
// it, and optional provenance metadata comes from configuration.
func scanSBOMBuildOptions(project output.ProjectDescriptor, current config.Resolved, version string, resolved []sdk.DetectionResult, registry *sdk.PackageRegistry, 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(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(path string) string {
if strings.TrimSpace(path) == "" {
return ""
}
gitPath, err := system.LookPath("git")
if err != nil {
return ""
}
cmd := system.Command(gitPath, "-C", path, "describe", "--tags", "--always", "--dirty")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
Comment on lines +274 to +290

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Log the Git command before execution.

Line 285 starts a subprocess. It does not log the executable path, arguments, and working directory at DEBUG level. Emit the required DEBUG log before cmd.Output().

As per coding guidelines, “When invoking subprocesses, DEBUG logs must include the binary path, arguments, and working directory so the command can be reproduced.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/scan_cmd.go` around lines 274 - 290, Add a DEBUG log in
gitDescribeVersion immediately before cmd.Output(), including the git executable
path, arguments, and working directory path so the subprocess can be reproduced.
Keep the existing command execution and error handling unchanged.

Source: Coding guidelines

}

func sbomToolNames(results []sdk.DetectionResult) []string {
tools := make([]string, 0, len(results))
seen := make(map[string]struct{}, len(results))
Expand Down
36 changes: 36 additions & 0 deletions internal/cli/scan_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,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(""); got != "" {
t.Fatalf("empty path must yield no version, got %q", got)
}
if got := gitDescribeVersion(t.TempDir()); got != "" {
t.Fatalf("non-git directory must yield no version, got %q", got)
}
}
Loading
Loading