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
2 changes: 1 addition & 1 deletion .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
# matrices in sync when adding smoke tests.
slice:
- name: go
run: 'TestScan$/scan-go$|TestDiff/diff-go|TestExplain/explain-go|TestAuditScan/(scan-go-enrich|scan-go-audit)|TestAuditDiffAndExplain/(diff-go-audit|explain-go-enrich)|TestLiteScan/lite-scan-go|TestLiteDiff/lite-diff-go|TestLiteExplain/lite-explain-go'
run: 'TestScan$/scan-go$|TestDiff/diff-go|TestExplain/explain-go|TestAuditScan/(scan-go-enrich|scan-go-audit)|TestAuditDiffAndExplain/(diff-go-audit|explain-go-enrich)|TestFindingBaselineWorkflow$|TestLiteScan/lite-scan-go|TestLiteDiff/lite-diff-go|TestLiteExplain/lite-explain-go'
- name: go-reachability
run: 'TestScan$/scan-go-reachability'
- name: node
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/update-smoke-goldens.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ jobs:
# cases, and isolating them keeps a stall from blocking the rest of
# an ecosystem's goldens.
- name: go
run: 'TestScan$/scan-go$|TestDiff/diff-go|TestExplain/explain-go|TestAuditScan/(scan-go-enrich|scan-go-audit)|TestAuditDiffAndExplain/(diff-go-audit|explain-go-enrich)|TestLiteScan/lite-scan-go|TestLiteDiff/lite-diff-go|TestLiteExplain/lite-explain-go'
run: 'TestScan$/scan-go$|TestDiff/diff-go|TestExplain/explain-go|TestAuditScan/(scan-go-enrich|scan-go-audit)|TestAuditDiffAndExplain/(diff-go-audit|explain-go-enrich)|TestFindingBaselineWorkflow$|TestLiteScan/lite-scan-go|TestLiteDiff/lite-diff-go|TestLiteExplain/lite-explain-go'
Comment thread
bomly-guy marked this conversation as resolved.
- name: go-reachability
run: 'TestScan$/scan-go-reachability'
- name: node
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ See [`dev-docs/ARCHITECTURE.md`](dev-docs/ARCHITECTURE.md) for full detail (the
| `internal/detectors/*` | Concrete dependency resolution per ecosystem (gomod, gradle, maven, node, python, sbom, syft) |
| `internal/matchers/*` | External enrichment matchers and shared matcher cache (osv, grype, deps.dev, scorecard; ClearlyDefined and eol run as external matcher plugins) |
| `internal/auditors/*` | Policy evaluators and audit-only logic (policy, noop) |
| `internal/baseline` | Portable package-finding baseline codec and audit-integrated policy-status resolver |
| `internal/sbom` | SBOM codec (SPDX 2.3, CycloneDX) |
| `internal/benchmark` | Hidden local dependency-graph benchmark, baseline comparison, scoring, and embedded presets |
| `internal/output` | Output rendering plus structured command payloads and schema generation for `scan`, `diff`, `explain`, JSON, and SARIF 2.1.0 |
Expand All @@ -53,7 +54,7 @@ See [`dev-docs/ARCHITECTURE.md`](dev-docs/ARCHITECTURE.md) for full detail (the
| `internal/testutil` | Test helpers (fake binary builder) |
| `internal/system` | OS-level helpers |

Scan pipeline: `runtimePreparation → subprojectDiscovery (root-only by default; --recursive walks nested dirs) → detect (per-package-manager chains; resolve + consolidate into one graph) → scopeFilter → match (license enrichment on the consolidated graph) → audit → format`. Consolidation is the tail of the detect stage, not a separate stage.
Scan pipeline: `runtimePreparation → subprojectDiscovery (root-only by default; --recursive walks nested dirs) → detect (per-package-manager chains; resolve + consolidate into one graph) → scopeFilter → match (license enrichment on the consolidated graph) → analyze (reachability, when --analyze is set) → audit (including finding policy-status resolution) → format`. Consolidation is the tail of the detect stage, not a separate stage.

Runtime preparation is owned by `internal/engine`: build the filtered registry once, index the execution target with that same registry, and reuse the prepared runtime for `scan`, `diff`, `explain`, license enrichment, and auditing. The CLI resolves raw execution targets and flags, but it must not discover subprojects with a separate registry.

Expand All @@ -64,6 +65,7 @@ Runtime preparation is owned by `internal/engine`: build the filtered registry o
- `internal/detectors/*` must not import `internal/engine` or `internal/registry`. Concrete detectors depend on `internal/detectors`, `sdk`, and local helpers only.
- `internal/detectors` owns detector-facing contracts such as `Detector`, `DetectorDescriptor`, `ResolveGraphRequest`, and detector helper functions.
- `sdk` owns neutral shared identifiers and support metadata that would otherwise create package cycles, including ecosystems, package managers, detector types, and support-matrix data.
- `internal/baseline` owns the baseline document and matching implementation. It depends on `sdk` policy contracts and must not be imported by `sdk` or `internal/engine`.
- `internal/registry` owns package-manager discovery, support lookups, and built-in registry wiring in `internal/registry/builder.go`. Do not create or reintroduce a separate `registrybuilder` package.
- `internal/engine` may import `internal/detectors` and `internal/registry`, but detector packages must not point back into `internal/engine`. Runtime planning, prepared subprojects, and detector-chain reuse belong in `internal/engine`.

Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ internal/analyzers/* Reachability analyzers (govulncheck — Go;
registry packages, and never abort the
pipeline on failure
internal/auditors/* Policy evaluators (policy, noop)
internal/baseline Portable package-finding baseline and audit policy-status resolver
internal/sbom/ SPDX 2.3 / CycloneDX codec
internal/benchmark/ Hidden local dependency-graph benchmark, baseline scoring,
and embedded smoke/benchmark repository presets
Expand All @@ -88,14 +89,15 @@ internal/testutil/ Test helpers (fake binary builder)

**`bomly explain`** is implemented by `newExplainCmd` in `internal/cli/explain_cmd.go`.

**Scan pipeline order**: `runtimePreparation → subprojectDiscovery (root-only by default; --recursive walks nested dirs) → detect (per-package-manager chains; resolve + consolidate into one graph) → scopeFilter → match (license enrichment) → analyze (reachability, when --analyze is set) → audit → format`. Consolidation is the tail of the detect stage (`runDetect` = `runResolve` + `runConsolidate`), not a separate stage.
**Scan pipeline order**: `runtimePreparation → subprojectDiscovery (root-only by default; --recursive walks nested dirs) → detect (per-package-manager chains; resolve + consolidate into one graph) → scopeFilter → match (license enrichment) → analyze (reachability, when --analyze is set) → audit (including finding policy-status resolution) → format`. Consolidation is the tail of the detect stage (`runDetect` = `runResolve` + `runConsolidate`), not a separate stage.

Runtime preparation is owned by `internal/engine` and is reached through CLI option helpers before pipeline execution. The CLI resolves raw targets and flags but must not discover subprojects with a separate registry.

### Package Boundaries

- `internal/detectors/*` and `internal/analyzers/*` must not import `internal/engine`, `internal/engine/*`, or `internal/registry`. Analyzers depend only on `sdk` and the vendored library that backs their runner.
- `sdk` owns neutral identifiers that would otherwise create import cycles.
- `internal/baseline` owns the baseline document and matching implementation. It depends on `sdk` policy contracts and must not be imported by `sdk` or `internal/engine`.
- `internal/registry` owns package-manager discovery, support lookups, and built-in wiring in `builder.go`. Do not create a separate `registrybuilder` package.
- `internal/engine` (pipeline core) may import `internal/engine/consolidation`, `internal/engine/explain`, `internal/detectors`, and `internal/registry`.
- `internal/engine` subpackages (`consolidation`, `diff`, `explain`, `scan`) must not import `internal/cli`.
Expand Down
45 changes: 43 additions & 2 deletions dev-docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Stage summary:
3. Detection resolves a dependency graph per package manager and then consolidates the per-subproject graphs into the single graph and package registry the rest of the pipeline uses. When `--scope` is set, the requested scope is part of the detector request so build-tool detectors can narrow command execution where the package manager supports it; all detector results pass through the shared SDK scope filter, and consolidation is the tail of this stage rather than a separate step.
4. Matchers enrich packages with additional metadata such as licenses, EOL status, and vulnerability records.
5. Analyzers run when `--analyze` is set. They consume the matched graph and annotate `sdk.Vulnerability.Reachability` (on the PURL-keyed registry package) with status (reachable/unreachable/unknown), tier (symbol/module/package/none), and call paths. Failures degrade to `Status=unknown` rather than aborting the pipeline. See [`../docs/REACHABILITY.md`](../docs/REACHABILITY.md) for ecosystem coverage and tier semantics.
6. Auditors evaluate policy against the enriched graph + registry pair and create reference-style findings (`PackageRef` + `VulnerabilityID`) when `--audit` is enabled. The built-in `vulnerability`, `license`, and `package` auditors cover advisory thresholds, SPDX policy, and denied or suspicious packages respectively.
6. Auditors evaluate policy against the enriched graph + registry pair and create reference-style findings (`PackageRef` + `VulnerabilityID`) when `--audit` is enabled. As the final part of that same audit stage, neutral policy-status resolvers may change only `Finding.PolicyStatus`; they never remove or rewrite finding evidence. The built-in `vulnerability`, `license`, and `package` auditors cover advisory thresholds, SPDX policy, and denied or suspicious packages respectively.
7. Users combine `--enrich --audit` when they want external matcher data to feed policy evaluation in the same run.
8. Output rendering emits text, JSON, SARIF, or SBOM documents.

Expand Down Expand Up @@ -114,12 +114,52 @@ Reachability data lives on `sdk.Vulnerability.Reachability` rather than on `Find

1. **`sdk.Dependency`** (`sdk/dependency.go`) is a detection-time graph node. It carries identity (`ID`, `Name`, `Version`, `PURL`), detection metadata (`Scopes`, `Locations`, `FoundBy`), an optional direct/transitive/unknown `Relationship`, occurrence `Source`, edges through the `Graph`, and a `PackageRef` (PURL) that links to a matching artifact. It does **not** carry licenses, vulnerabilities, or scorecard data.
2. **`sdk.Package`** (`sdk/package.go`) is a matching artifact keyed by PURL on a `sdk.PackageRegistry`. It carries `Licenses`, `Vulnerabilities` (OSV-aligned `sdk.Vulnerability`), `Scorecard`, `EOL`, and similar enrichment. There is one entry per unique PURL across the whole pipeline, so 50 dependencies referencing the same package share one set of CVEs and one license decision.
3. **`sdk.Finding`** (`sdk/vulnerability.go`) is a reference-style audit result. It carries policy fields (`Severity`, `Disposition`, `Reasons`, `Auditor`) plus the references `PackageRef` (PURL) and, for vulnerability findings, `VulnerabilityID`. It does **not** copy CVSS / EPSS / KEV / CWE — consumers resolve those by following the references back into the registry.
3. **`sdk.Finding`** (`sdk/vulnerability.go`) is a reference-style audit result. It carries policy fields (`Severity`, `PolicyStatus`, `Reasons`, `Auditor`, stable `RuleID`) plus the references `PackageRef` (PURL) and, for vulnerability findings, `VulnerabilityID`. It does **not** copy CVSS / EPSS / KEV / CWE — consumers resolve those by following the references back into the registry.

`sdk.Vulnerability` is OSV-aligned (id, aliases, summary, details, severity, affected, references, database_specific) and extended with Bomly's matching-stage fields (CVSS, EPSS, KEV, CWE, FixedVersions, AffectedSymbols, `Reachability`). The OSV matcher maps `internal/matchers/osv/response.go` directly to this shape; grype / depsdev / eol / scorecard and enabled external matchers write the equivalent records.

### Decision: enrichment consolidates alias-equivalent vulnerabilities

Matchers may describe the same package vulnerability under different primary
advisory IDs, even within a single matcher database. After all selected
matchers run, the engine consolidates vulnerability records per PURL using the
transitive closure of `ID` and `Aliases`. The canonical record unions advisory
IDs and evidence, uses the richest input record for scalar metadata, and
retains the highest severity and conservative fix/reachability state. OSV
`Related` IDs never trigger consolidation because
they may identify distinct vulnerabilities. This policy belongs at the central
enrichment boundary so built-in and protocol-v1 external matchers receive the
same behavior without owning global identity policy. Baseline construction
repeats the identity normalization defensively for legacy or independently
constructed registries.

Pipeline plumbing: `engine.PipelineResult` exposes `Graph`, `Registry`, `Findings`, and `RiskScores`. The registry is built right after consolidation (`consolidation.BuildPackageRegistry`) and threaded through match/analyze/audit requests; output helpers (`BuildScanResponse`, `WriteSARIF`, `FindingsFromScan`, `PackagesFromGraph`) all accept `*sdk.PackageRegistry` and re-enrich their projections by resolving `PackageRef` and `VulnerabilityID`. See [`MODELS.md`](MODELS.md) for the full schema reference.

### Decision: finding policy-status resolution belongs inside audit

Auditors remain responsible for creating complete reference-style findings.
After deduplication and `warn-only` handling, the audit stage may run neutral
`sdk.FindingPolicyResolver` implementations. A resolver receives the finding
and package registry, may return a replacement policy status, and cannot remove
or mutate evidence. When multiple resolvers participate, the least suppressive
decision wins.

The first resolver is the package-specific finding baseline under
`internal/baseline`. Its versioned document keys entries by full PURL, finding
kind, auditor, and advisory aliases or stable rule ID. It intentionally contains
no dependency occurrence or project identity, so a baseline is portable across
projects. Discovery happens during normal target preparation: scan and explain
read the materialized project tree, including repositories cloned through
`--url`, while Git diff independently reads the base and head trees. A detected
baseline is logged with its path, entry count, selection mode, and target kind;
each evaluation logs findings evaluated and accepted. Output receives ordinary
findings whose policy status may be `suppressed` through
`Finding.PolicyStatus` / `policy_status`, and no baseline-specific output model
or pipeline stage exists. Renaming the earlier finding field is an intentional
breaking output-contract change while the CLI output schema identifier remains
`1.0` and the compact MCP schema remains `mcp/1`. Protocol-v1 decoding still
accepts the earlier wire field from existing external auditor plugins.

### Decision: registry matching eligibility is an occurrence-level engine boundary

Detection keeps every dependency occurrence and every PURL-backed package artifact, including application roots, workspace members, local sources, and unknown relationships. Immediately before matcher selection and execution, `engine.registryMatchRequest` clones only occurrences for which `Dependency.RegistryMatchEligible()` is true and preserves edges whose endpoints are both eligible. Every built-in and external matcher therefore receives the same filtered graph, while the full `PackageRegistry` remains shared so enrichment is still deduplicated by PURL. Analysis and auditors continue with the complete original graph.
Expand Down Expand Up @@ -309,6 +349,7 @@ Cache failures are non-fatal. The command should warn and continue rather than f
| `internal/registry` | Support metadata, package-manager discovery, and built-in detector, matcher, and auditor wiring |
| `internal/detectors` | Detector contracts and ecosystem implementations |
| `internal/auditors` | Policy evaluators and finding creation |
| `internal/baseline` | Portable package-finding baseline codec and audit policy-status resolver |
| `internal/analyzers` | Reachability analyzers (govulncheck for Go, jsreach for JS/TS, pyreach for Python, jvmreach for JVM languages) that annotate `sdk.Vulnerability.Reachability` on registry packages |
| `internal/matchers` | Matcher contracts plus shared enrichment helpers used by built-in matchers |
| `internal/engine/diff` | Diff pipeline orchestration and audit delta classification |
Expand Down
17 changes: 15 additions & 2 deletions dev-docs/MODELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,15 @@ type Vulnerability struct {
}
```

Matchers (OSV, grype, depsdev, eol, scorecard, and enabled external matcher plugins) write these records onto registry packages by PURL. Reachability is the only field analyzers touch; they annotate it in place.
Matchers (OSV, grype, depsdev, eol, scorecard, and enabled external matcher
plugins) write these records onto registry packages by PURL. At the end of
matching, the engine consolidates records whose `ID` and `Aliases` form one
transitively connected identity set within a package. The record with the
broadest populated metadata becomes the base, the remaining evidence is
unioned, the highest severity and conservative fix state are retained, and
every non-canonical primary ID becomes an alias. `Related` IDs are not identity
evidence because OSV uses them for associated but distinct vulnerabilities.
Reachability is the only field analyzers touch; they annotate it in place.

First-party packages — `application`-typed nodes such as workspace members, reactor modules, and the project's own package — appear in the packages collection **unenriched by design**: `sdk.NodeIsEnrichable` excludes them from every matcher's work list because they are absent from public sources and a coincidental name match would attach someone else's advisories. They keep their PURLs and stay visible in `packages` output and generated SBOMs.

Expand All @@ -196,7 +204,8 @@ type Finding struct {
Reasons []string
Source string // osv | grype | license | package | ...
Auditor string // which auditor emitted it
Disposition FindingDisposition // pass | fail | warn
RuleID string // stable auditor rule, independent of project occurrence
PolicyStatus FindingPolicyStatus // fail | warn | suppressed; empty defaults to fail

// References (the whole point of "reference-style")
PackageRef string // PURL → resolve via registry.Get
Expand All @@ -208,6 +217,10 @@ type Finding struct {
}
```

`PolicyStatus` is the SDK field name and `policy_status` is its structured-output
key. User interfaces describe the values as fail, warning, or accepted
(`suppressed`). An omitted value retains the historical failing behavior.

Findings carry **no** CVSS/EPSS/KEV/CWE/fix-state/reachability fields. Consumers (JSON output, SARIF, render, TUI) resolve those by following `PackageRef` and `VulnerabilityID` into the registry. This eliminates the ~25-field duplication the old `Finding` shape had.

`engine.DeduplicateFindings(findings)` keys on `(PackageRef, VulnerabilityID, Kind)` with `(grype > osv > other)` source-rank tiebreaks.
Expand Down
Loading