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
8 changes: 8 additions & 0 deletions dev-docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,14 @@ The help-path startup banner (`internal/cli/render/logo.go`) is a frame-based an
- **Gating is env-var-only (`NO_COLOR`, `BOMLY_NO_ANIMATION`, `CI`, `BOMLY_QUIET`), deliberately not a config key.** Cobra's `execute()` returns `flag.ErrHelp` right after flag parsing, *before* the `PersistentPreRunE` chain where `options.ResolveConfig` runs — so on `bomly --help` / `bomly <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) are projected into component hashes, normalized to lowercase hex because both formats' schemas require hex; the `go.sum` h1 value is exposed as `sha256` following cyclonedx-gomod's convention (it is SHA-256 over the module dirhash manifest, not over a zip artifact). Registry (matching-stage) digests still win when present. Optional producer metadata (manufacturer, security contact, disclosure URL, support end) is config-driven (`sbom:` section) and never invented: per-component supplier/description stay empty rather than being fabricated to satisfy compliance profile checkers.

## Build Modes

Syft and Grype each support two build modes:
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,6 +277,15 @@ Flat YAML keys are no longer accepted. Move each existing key to its nested repl
# cache_dir: ""
# TTL for cached Scorecard responses (e.g. 24h)
# cache_ttl: 24h
# sbom:
# Organization name emitted as the SBOM manufacturer/supplier (EU CRA Art. 13(15))
# manufacturer: ""
# Security contact URL or email emitted in exported SBOMs (EU CRA Art. 13(6))
# security_contact: ""
# Coordinated vulnerability disclosure policy URL emitted in exported SBOMs (EU CRA Art. 13(7))
# vulnerability_disclosure_url: ""
# Support end date (YYYY-MM-DD) for security updates emitted in exported SBOMs (EU CRA Art. 13(8))
# support_end: ""
# Per-plugin configuration keyed by managed plugin ID
# plugins: {}
```
56 changes: 54 additions & 2 deletions docs/SBOM.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,56 @@ Both formats carry:
- Package name, version, PURL.
- Dependency relationships from the detector graph.
- File-level evidence when the detector provided it.
- Content hashes captured at detection time, when the ecosystem records them:
npm/pnpm/yarn/bun lockfile integrity values and Go module `go.sum` tree
hashes (the `h1:` SHA-256 dirhash, hex-encoded — the same convention
cyclonedx-gomod uses). Values are normalized to lowercase hex so they are
schema-valid in both formats.

### Document identity

Every generated document carries a stable identity:

- A generated `urn:uuid` serial number (CycloneDX `serialNumber`; the same
nonce forms the SPDX document namespace, so the two exports of one scan are
correlatable).
- The producing tool with its version (CycloneDX `metadata.tools[]`; SPDX
`Creator: Tool: bomly-cli-<version>`), plus one tool entry per detector that
contributed to the graph.
- A primary component describing the scanned project. When the dependency
graph has a single root, that root is the primary component. When a scan
discovers multiple manifests (several ecosystems, several workflow files),
Bomly synthesizes a primary component named after the scanned project with a
`pkg:generic` PURL; it depends on every graph root, so the exported
dependency graph is connected and both formats agree on the document's
subject. The synthesized component is not repeated in the CycloneDX
component inventory, and Bomly skips it when re-ingesting its own SBOMs.

### Provenance metadata (EU CRA readiness)

The optional `sbom` config section embeds producer metadata that regulated
consumers (for example the EU Cyber Resilience Act's SBOM expectations) ask
for:

```yaml
sbom:
manufacturer: "Example Org" # CRA Art. 13(15)
security_contact: "security@example.com" # CRA Art. 13(6)
vulnerability_disclosure_url: "https://example.com/security" # Art. 13(7)
support_end: "2030-12-31" # CRA Art. 13(8)
```

CycloneDX: `metadata.manufacturer`, `security-contact` / `advisories`
external references on the primary component, and a `bomly:support_end_date`
metadata property. SPDX 2.3 has no first-class fields for most of these, so
Bomly emits an `Organization` creator, the supplier on the primary package,
and the contact fields in the creation-info comment.

Without these fields Bomly's exports satisfy the NTIA minimum elements
(supplier defaults to the producing tool); third-party CRA profile checks
will flag the missing manufacturer/contact metadata until the `sbom` section
is configured. Per-component supplier and description data is not invented:
those fields stay empty unless a data source actually provides them.

When `--enrich` is set, components are enriched from the matching-stage package
registry (keyed by PURL):
Expand Down Expand Up @@ -118,8 +168,10 @@ Some information necessarily becomes less specific during conversion:
report data rather than portable SBOM fields. Use JSON when those distinctions
must survive export and import.
- A CycloneDX document has one metadata component. When an input graph has
multiple roots, every root remains in the dependency graph, but only the first
deterministic root is selected as that metadata component.
multiple roots, every root remains in the dependency graph and the
synthesized primary component (see "Document identity" above) links them;
ingest paths that predate the synthesized root treat the first
deterministic root as the primary component.

Before treating a generated file as a release artifact, validate it with the
standard validator required by the receiving system. Bomly's tests parse every
Expand Down
28 changes: 26 additions & 2 deletions internal/cli/scan_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/bomly-dev/bomly-cli/internal/cli/exit"
"github.com/bomly-dev/bomly-cli/internal/cli/render"
"github.com/bomly-dev/bomly-cli/internal/config"
"github.com/bomly-dev/bomly-cli/internal/engine"
scanengine "github.com/bomly-dev/bomly-cli/internal/engine/scan"
"github.com/bomly-dev/bomly-cli/internal/output"
Expand Down Expand Up @@ -126,10 +127,11 @@ func newScanCmd() *cobra.Command {
return output.WriteSARIF(w, findings, pipeResult.Registry, "bomly", cmd.Root().Version, output.SARIFOptions{IncludeReachability: commandCtx.ResolvedConfig.Analyze, LocationGraphs: []*sdk.Graph{pipeResult.Graph}})
}

sbomBuildOpts := scanSBOMBuildOptions(payload.Project, commandCtx.ResolvedConfig, cmd.Root().Version, resolved, pipeResult.Registry)

if len(outputSpecs) > 0 {
prog.Advance("Writing additional output")
stdout := streams.reportWriter()
sbomBuildOpts := sbom.BuildOptions{ToolNames: sbomToolNames(resolved), Registry: pipeResult.Registry}
for _, spec := range outputSpecs {
switch {
case spec.IsSBOM():
Expand Down Expand Up @@ -157,7 +159,7 @@ func newScanCmd() *cobra.Command {
if !ok {
return exit.InvalidInputError("output format %q is not supported by scan", graphOutputFormat)
}
rawDocument, err := sbom.MarshalDepGraphJSON(selectedGraph, target, sbom.BuildOptions{ToolNames: sbomToolNames(resolved), Registry: pipeResult.Registry}, sbom.EncodeOptions{Pretty: true})
rawDocument, err := sbom.MarshalDepGraphJSON(selectedGraph, target, sbomBuildOpts, sbom.EncodeOptions{Pretty: true})
if err != nil {
return fmt.Errorf("marshal %s sbom: %w", graphOutputFormat, err)
}
Expand Down Expand Up @@ -211,6 +213,28 @@ func scanPolicyExit(auditEnabled bool, findings []sdk.Finding) error {
return nil
}

// scanSBOMBuildOptions assembles the SBOM projection options for a scan: the
// document is named after the scanned project, the primary component mirrors
// it, and optional provenance metadata comes from configuration.
func scanSBOMBuildOptions(project output.ProjectDescriptor, current config.Resolved, version string, resolved []sdk.DetectionResult, registry *sdk.PackageRegistry) sbom.BuildOptions {
opts := sbom.BuildOptions{
ToolNames: sbomToolNames(resolved),
ToolVersion: strings.TrimSpace(version),
Registry: registry,
Provenance: sbom.Provenance{
Manufacturer: strings.TrimSpace(current.SBOMManufacturer),
SecurityContact: strings.TrimSpace(current.SBOMSecurityContact),
VulnerabilityDisclosureURL: strings.TrimSpace(current.SBOMVulnerabilityDisclosureURL),
SupportEnd: strings.TrimSpace(current.SBOMSupportEnd),
},
}
if name := strings.TrimSpace(project.Name); name != "" {
opts.DocumentName = name
opts.ProjectRoot = &sbom.ProjectRoot{Name: name, Version: strings.TrimSpace(project.TargetRef)}
}
return opts
}

func sbomToolNames(results []sdk.DetectionResult) []string {
tools := make([]string, 0, len(results))
seen := make(map[string]struct{}, len(results))
Expand Down
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ type Resolved struct {
ScorecardAPIBase string `doc:"Base URL for the OpenSSF Scorecard public API" env:"BOMLY_SCORECARD_API_BASE" default:"https://api.scorecard.dev"`
ScorecardCacheDir string `doc:"Directory for the Scorecard response cache" env:"BOMLY_SCORECARD_CACHE_DIR"`
ScorecardCacheTTL string `doc:"TTL for cached Scorecard responses (e.g. 24h)" env:"BOMLY_SCORECARD_CACHE_TTL" default:"24h"`

// SBOM export metadata (optional EU-CRA transparency fields)
SBOMManufacturer string `doc:"Organization name emitted as the SBOM manufacturer/supplier (EU CRA Art. 13(15))" env:"BOMLY_SBOM_MANUFACTURER"`
SBOMSecurityContact string `doc:"Security contact URL or email emitted in exported SBOMs (EU CRA Art. 13(6))" env:"BOMLY_SBOM_SECURITY_CONTACT"`
SBOMVulnerabilityDisclosureURL string `doc:"Coordinated vulnerability disclosure policy URL emitted in exported SBOMs (EU CRA Art. 13(7))" env:"BOMLY_SBOM_VULNERABILITY_DISCLOSURE_URL"`
SBOMSupportEnd string `doc:"Support end date (YYYY-MM-DD) for security updates emitted in exported SBOMs (EU CRA Art. 13(8))" env:"BOMLY_SBOM_SUPPORT_END"`
}

// File is the nested YAML-deserialized shape of a Bomly config file. Leaf
Expand All @@ -96,9 +102,18 @@ type File struct {
Logging LoggingFile `yaml:"logging,omitempty"`
Network NetworkFile `yaml:"network,omitempty"`
Matchers MatchersFile `yaml:"matchers,omitempty"`
SBOM SBOMFile `yaml:"sbom,omitempty"`
Plugins map[string]map[string]any `yaml:"plugins,omitempty" resolved:"Plugins"`
}

// SBOMFile configures optional metadata emitted into exported SBOM documents.
type SBOMFile struct {
Manufacturer *string `yaml:"manufacturer,omitempty" resolved:"SBOMManufacturer"`
SecurityContact *string `yaml:"security_contact,omitempty" resolved:"SBOMSecurityContact"`
VulnerabilityDisclosureURL *string `yaml:"vulnerability_disclosure_url,omitempty" resolved:"SBOMVulnerabilityDisclosureURL"`
SupportEnd *string `yaml:"support_end,omitempty" resolved:"SBOMSupportEnd"`
}

// TargetFile configures the execution target selected for a scan.
type TargetFile struct {
Path *string `yaml:"path,omitempty" resolved:"Path" legacy:"path"`
Expand Down
6 changes: 6 additions & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"reflect"
"strconv"
"strings"
"time"

"github.com/bomly-dev/bomly-cli/internal/system"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -369,6 +370,11 @@ func Validate(cfg Resolved) error {
default:
return fmt.Errorf("unsupported --typosquat-mode value %q (accepted: warn, fail)", cfg.TyposquatMode)
}
if supportEnd := strings.TrimSpace(cfg.SBOMSupportEnd); supportEnd != "" {
if _, err := time.Parse("2006-01-02", supportEnd); err != nil {
return fmt.Errorf("invalid sbom support_end %q: expected an ISO date such as 2030-12-31", cfg.SBOMSupportEnd)
}
}
if err := validateProxyURL(cfg.HTTPProxy); err != nil {
return err
}
Expand Down
13 changes: 13 additions & 0 deletions internal/config/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,16 @@ func TestApplyDefaultsSetsMaxDepth(t *testing.T) {
t.Fatalf("expected explicit MaxDepth 7 to survive defaults, got %d", explicit.MaxDepth)
}
}

func TestValidateSBOMSupportEnd(t *testing.T) {
if err := Validate(Resolved{SBOMSupportEnd: "2030-12-31"}); err != nil {
t.Fatalf("Validate rejected a valid sbom support_end date: %v", err)
}
err := Validate(Resolved{SBOMSupportEnd: "31/12/2030"})
if err == nil {
t.Fatal("Validate returned nil for a malformed sbom support_end; want error")
}
if !strings.Contains(err.Error(), "support_end") {
t.Errorf("error message = %q, want it to mention support_end", err.Error())
}
}
Loading
Loading