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
64 changes: 39 additions & 25 deletions docs/src/enrichers/epss.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,51 @@ bomdrift queries the [Exploit Prediction Scoring System (EPSS)] from
FIRST.org for every CVE-aliased advisory and surfaces the per-CVE score
(0.0 – 1.0) in markdown / terminal / SARIF output.

## Why this signal

EPSS estimates the probability that a given CVE will be exploited in the
next 30 days. Combined with severity it gives reviewers a sharper signal
than CVSS alone a Critical CVE with EPSS 0.01 is far less urgent than a
than CVSS alone: a Critical CVE with EPSS 0.01 is far less urgent than a
Medium CVE with EPSS 0.85.

## Output
## Algorithm

- **Markdown**: per-advisory badge `EPSS 0.87` after the severity label.
- **Terminal**: same badge, no markup.
- **JSON**: `enrichment.vulns[purl][i].epss_score` numeric field.
- **SARIF**: `properties.epssScore` on `bomdrift.cve` results.
For every CVE-aliased advisory surfaced by the [OSV.dev CVE lookup](./osv-cve.md),
bomdrift fetches the EPSS score from FIRST.org and attaches it to the
finding. When an advisory is keyed by GHSA but has CVE aliases, the score
is the **max across all CVE aliases**, so a GHSA covering two CVEs
surfaces the worse of the two.

When an advisory is keyed by GHSA but has CVE aliases, the score is the
**max across all CVE aliases** so a GHSA covering two CVEs surfaces the
worse of the two.
## Threshold

## Threshold gating
EPSS has no surfacing threshold: every scored advisory shows its score.
The optional gate is `--fail-on-epss <FLOAT>`, which exits 2 when any
advisory has a score at or above the value:

```bash
bomdrift diff before.json after.json --fail-on-epss 0.5
```

Exits 2 when any advisory has score ≥ 0.5. 0.5 is roughly the top decile
of actively-exploited CVEs; tune for your team's risk appetite.
0.5 is roughly the top decile of actively-exploited CVEs; tune for your
team's risk appetite.

## Calibration
## Output

- `--cache-ttl-hours <N>` (v0.9.6+) — overrides the default 24h disk
cache TTL for the EPSS scores cache.
- `--fail-on-epss <FLOAT>` — threshold gate; see [Threshold gating](#threshold-gating).
- **Markdown**: per-advisory badge `EPSS 0.87` after the severity label.
- **Terminal**: same badge, no markup.
- **JSON**: `enrichment.vulns[purl][i].epss_score` numeric field.
- **SARIF**: `properties.epssScore` on `bomdrift.cve` results.

## Network

- **Source**: FIRST.org `/api/v1/epss`.
- **Caching**: 24h TTL at `<XDG_CACHE>/bomdrift/epss/<cve>.json`. Negative
results (CVEs FIRST.org returned no score for) are cached to avoid
re-querying recently-published CVEs that haven't been scored yet.
- **Best-effort**: like every bomdrift enricher, EPSS is best-effort. A
network failure or a malformed response surfaces a `BOMDRIFT_DEBUG=1`
stderr note and the diff renders with empty `epss_score` fields. EPSS
being unreachable is never a reason to block a PR review.

## Disabling

Expand All @@ -50,17 +65,16 @@ no_epss = true

Both forms skip the FIRST.org HTTP call AND the disk cache lookup.

## Caching
## Calibration

24h TTL at `<XDG_CACHE>/bomdrift/epss/<cve>.json`. Negative results
(CVEs FIRST.org returned no score for) are cached to avoid re-querying
recently-published CVEs that haven't been scored yet.
- `--cache-ttl-hours <N>` (v0.9.6+) overrides the default 24h disk cache
TTL for the EPSS scores cache.
- `--fail-on-epss <FLOAT>` is the threshold gate; see [Threshold](#threshold).

## Best-effort
## See also

Like every bomdrift enricher, EPSS is best-effort: a network failure or
a malformed response surfaces a `BOMDRIFT_DEBUG=1` stderr note and the
diff renders with empty `epss_score` fields. EPSS being unreachable is
never a reason to block a PR review.
- [OSV.dev CVE lookup](./osv-cve.md)
- [CISA KEV](./kev.md)
- [Enrichers overview](./overview.md)

[Exploit Prediction Scoring System (EPSS)]: https://www.first.org/epss/
59 changes: 36 additions & 23 deletions docs/src/enrichers/kev.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,47 @@ bomdrift downloads the [CISA Known Exploited Vulnerabilities catalog] and
flips a `KEV` flag on every advisory whose primary id or aliases include a
CVE listed in the catalog.

## Why this signal

CISA KEV is the highest-confidence "actively exploited in the wild" signal
available CISA only adds CVEs to the catalog after observing real-world
available: CISA only adds CVEs to the catalog after observing real-world
exploitation. It's a tighter filter than `--fail-on critical-cve` (which
fires on CVSS High or above regardless of exploitation evidence).

## Output
## Algorithm

- **Markdown**: bold `**KEV**` badge after the severity / EPSS label.
- **Terminal**: plain `KEV` token.
- **JSON**: `enrichment.vulns[purl][i].kev` boolean field.
- **SARIF**: `properties.kev: true` on `bomdrift.cve` results when set.
bomdrift fetches the bulk catalog once, then flips a boolean `KEV` flag on
every advisory (from the [OSV.dev CVE lookup](./osv-cve.md)) whose primary
id or CVE aliases appear in the catalog.

## Threshold gating
## Threshold

KEV is a boolean flag, not a scored threshold: an advisory either is in the
catalog or it is not. The optional gate is `--fail-on kev`, which exits 2
when any advisory has its KEV flag set:

```bash
bomdrift diff before.json after.json --fail-on kev
```

Exits 2 when any advisory has its KEV flag set. `--fail-on any` also
includes KEV.
`--fail-on any` also includes KEV.

## Calibration
## Output

### `--cache-ttl-hours <N>` (v0.9.6+)
- **Markdown**: bold `**KEV**` badge after the severity / EPSS label.
- **Terminal**: plain `KEV` token.
- **JSON**: `enrichment.vulns[purl][i].kev` boolean field.
- **SARIF**: `properties.kev: true` on `bomdrift.cve` results when set.

## Network

The 24h TTL for the catalog file is now configurable via the unified
cache-TTL knob. Lower for faster CISA-update propagation in
long-running self-hosted runners; raise when running offline or against
archived SBOMs.
- **Source**: CISA known-exploited catalog (one bulk JSON download).
- **Caching**: 24h TTL on the bulk catalog JSON at
`<XDG_CACHE>/bomdrift/kev/catalog.json`. Once-daily refresh matches
CISA's publication cadence.
- **Best-effort**: a network failure logs at `BOMDRIFT_DEBUG=1` and the
diff renders with KEV flags absent. A stale catalog (within the 24h
window) is preferred over re-fetching on every run.

## Disabling

Expand All @@ -47,16 +59,17 @@ or in `.bomdrift.toml`:
no_kev = true
```

## Caching
## Calibration

24h TTL on the bulk catalog JSON at
`<XDG_CACHE>/bomdrift/kev/catalog.json`. Once-daily refresh matches CISA's
publication cadence.
- `--cache-ttl-hours <N>` (v0.9.6+) overrides the default 24h TTL for the
catalog file via the unified cache-TTL knob. Lower it for faster
CISA-update propagation in long-running self-hosted runners; raise it
when running offline or against archived SBOMs.

## Best-effort
## See also

Network failure logs at `BOMDRIFT_DEBUG=1` and the diff renders with KEV
flags absent. A stale catalog (within the 24h window) is preferred over
re-fetching on every run.
- [OSV.dev CVE lookup](./osv-cve.md)
- [EPSS](./epss.md)
- [Enrichers overview](./overview.md)

[CISA Known Exploited Vulnerabilities catalog]: https://www.cisa.gov/known-exploited-vulnerabilities-catalog
153 changes: 81 additions & 72 deletions docs/src/enrichers/maintainer-age.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,86 +3,113 @@
Flag newly added dependencies (GitHub, GitLab, Codeberg) whose top
contributor's first commit is suspiciously recent. **The xz/Jia Tan pattern.**

## Why it matters
## Why this signal

The xz-utils backdoor (CVE-2024-3094, Mar 2024) was the work of "Jia Tan",
a GitHub identity that started contributing roughly two years before
landing the malicious payload. The pattern — a brand-new account
becoming the de facto sole maintainer of a low-traffic but
widely-depended-upon package — is a leading indicator of long-game
supply-chain takeovers.
landing the malicious payload. The pattern, a brand-new account becoming
the de facto sole maintainer of a low-traffic but widely-depended-upon
package, is a leading indicator of long-game supply-chain takeovers.

We can't catch Jia Tan in retrospect, but we can flag the *next* one
earlier in their arc by surfacing "this package's top contributor opened
their first PR less than 90 days ago" at the moment a new dep is added.

## Threshold

**90 days** by default. Intentionally aggressive: most legitimate new
packages will trip this on initial introduction. That's fine — a human
reviewer can dismiss "the package is brand-new and the author is its
only maintainer" trivially.

The expensive miss is the **silent takeover** of an existing package by
a recently-arrived contributor, which is what the 90-day window
captures. Tune for your environment via `--young-maintainer-days <N>`
or `[diff] young_maintainer_days = <N>` (v0.9.6+); see
[Calibration](#calibration) below.

## How it works
## Algorithm

For each `cs.added` component with a `source_url` on a supported host:

### GitHub

1. **GET `/repos/{owner}/{repo}/contributors?per_page=1`** top
1. **GET `/repos/{owner}/{repo}/contributors?per_page=1`** for the top
contributor login.
2. **GET `/repos/{owner}/{repo}/contributors?per_page=1&anon=true`** —
contributor count from Link `rel="last"` page number. **Skip if > 50.**
3. **GET `/repos/{owner}/{repo}/commits?author=<login>&per_page=1`** —
paginate to last page for the author's oldest commit (`commit.author.date`).
2. **GET `/repos/{owner}/{repo}/contributors?per_page=1&anon=true`** for
the contributor count from Link `rel="last"` page number. **Skip if > 50.**
3. **GET `/repos/{owner}/{repo}/commits?author=<login>&per_page=1`** and
paginate to the last page for the author's oldest commit
(`commit.author.date`).

### GitLab

1. **GET `/api/v4/projects/{url-encoded}/repository/contributors?order_by=commits&sort=desc&per_page=1`**
top contributor name (GitLab identifies contributors by author name, not
login) and total count via `X-Total` header. **Skip if > 50.**
2. **GET `.../commits?author=<name>&per_page=1`** paginate to last page
via Link header for the author's oldest commit (`authored_date`).
1. **GET `/api/v4/projects/{url-encoded}/repository/contributors?order_by=commits&sort=desc&per_page=1`**
for the top contributor name (GitLab identifies contributors by author
name, not login) and total count via `X-Total` header. **Skip if > 50.**
2. **GET `.../commits?author=<name>&per_page=1`** and paginate to the last
page via Link header for the author's oldest commit (`authored_date`).

### Codeberg

URL parsing and dispatch are implemented. The per-author first-commit
lookup is stubbed pending verification of the Forgejo v1.20+ API shape;
Codeberg components produce no finding in this release.

## Skipped cases
### Skipped cases

- Components without a `source_url` (CycloneDX `externalReferences`
with no `vcs` entry, etc.) -- silently skipped.
- Source URLs not from `github.com`, `gitlab.com`, or `codeberg.org` --
with no `vcs` entry, etc.) are silently skipped.
- Source URLs not from `github.com`, `gitlab.com`, or `codeberg.org` are
silently skipped.
- Repositories with **> 50 contributors** -- skipped because the "top
- Repositories with **> 50 contributors** are skipped because the "top
contributor's first commit" loses meaning on monorepos and
multi-vendor projects.
- Repositories returning 404, 401, or 403 -- skipped silently (private
- Repositories returning 404, 401, or 403 are skipped silently (private
repo or missing token).

Per-repo results are cached within a single bomdrift run so repeated
`cs.added` entries from the same project don't re-issue the same three
requests.

## Network behavior
## Threshold

**90 days** by default. Intentionally aggressive: most legitimate new
packages will trip this on initial introduction. That's fine, a human
reviewer can dismiss "the package is brand-new and the author is its
only maintainer" trivially. The expensive miss is the **silent takeover**
of an existing package by a recently-arrived contributor, which is what
the 90-day window captures.

The signal is always informational. It **never** trips `--fail-on
critical-cve`; it surfaces only under `--fail-on any`. The intent is human
review, not gating: many legitimate packages have brand-new authors, and
the threshold is calibrated to surface the xz-style pattern, not to fail
the build automatically.

## Output

The finding surfaces in the rendered diff alongside the other enrichers,
naming the package, its top contributor, and the days-since-first-commit.
A `--debug-calibration` row is also emitted (see [Calibration](#calibration)).

## Network

- **Source**: GitHub REST `/repos/.../contributors` + `/commits` (GitLab
and Codeberg equivalents per [Algorithm](#algorithm)).
- **Per-request timeout**: 15 seconds.
- **Token env vars**: `GITHUB_TOKEN` (Bearer), `GITLAB_TOKEN`
(PRIVATE-TOKEN), `CODEBERG_TOKEN` (Authorization: token). All optional;
missing token means unauthenticated requests (fine for low volume).
(PRIVATE-TOKEN), `CODEBERG_TOKEN` (Authorization: Bearer). All optional;
a missing token means unauthenticated requests (fine for low volume).
`GITHUB_TOKEN` bumps the unauthenticated 60/hr cap to 5000/hr.
- **No `octocrab`**: the `octocrab` crate would pull in tokio + ~70
transitive crates. Hand-rolled `ureq` GETs + a 25-line ISO-8601
parser keep the bomdrift binary under our 5 MB target. Same
constraint applies to the GitLab and Codeberg paths.
transitive crates. Hand-rolled `ureq` GETs + a 25-line ISO-8601 parser
keep the bomdrift binary under our 5 MB target. The same constraint
applies to the GitLab and Codeberg paths.
- **Per-component caching** within a single run (see [Algorithm](#algorithm)).

## Disabling

`--no-maintainer-age` skips the entire enricher (no GitHub, GitLab, or
Codeberg API calls):

```bash
bomdrift diff before.json after.json --no-maintainer-age
```

Required for:

- Offline runs and tests.
- CI environments where tokens are unset and unauthenticated rate
limits are too low for the diff being analyzed.
- Smoke tests of the deterministic offline signals.

## Calibration

Expand All @@ -105,43 +132,25 @@ samples can guide tuning:
maintainer-age|<purl>|<days_since_first_commit>|90
```

## Disabling

`--no-maintainer-age` skips the entire enricher (no GitHub, GitLab, or
Codeberg API calls). Required for:

- Offline runs and tests.
- CI environments where tokens are unset and unauthenticated rate
limits are too low for the diff being analyzed.
- Smoke tests of the deterministic offline signals.

```bash
bomdrift diff before.json after.json --no-maintainer-age
```

## Severity

Always informational. The maintainer-age signal **never** trips
`--fail-on critical-cve`; it surfaces only under `--fail-on any`. The
intent is for human review, not gating: many legitimate packages have
brand-new authors, and the threshold is calibrated to surface the
xz-style pattern, not to fail the build automatically.

## Calibration roadmap (v0.9.6+ status)
### Calibration roadmap (v0.9.6+ status)

Past calibration backlog and how each item resolved:

- **Tunable threshold flag** *shipped in v0.9.6* as
`--young-maintainer-days <N>`. See [Calibration](#calibration) above.
- **Multi-signal fusion** combine maintainer-age with the registry
- **Tunable threshold flag** is *shipped in v0.9.6* as
`--young-maintainer-days <N>` (above).
- **Multi-signal fusion**: combine maintainer-age with the registry
enricher's "recently-published" or "maintainer-set-changed" findings
to narrow the false-positive rate. The signals all surface in the
same diff today; explicit fusion in a single composite finding is a
v1.0 follow-up.
- **GraphQL pagination** — *decided not to pursue.* Adds a token
requirement (the GraphQL endpoint always wants auth) for one
saved round-trip per component. The `last-page` REST trick is
documented as the canonical approach; see the module doc-comment in
`src/enrich/maintainer.rs` for the rationale.
- **GraphQL pagination** is *decided not to pursue*. It adds a token
requirement (the GraphQL endpoint always wants auth) for one saved
round-trip per component. The `last-page` REST trick is documented as
the canonical approach; see the module doc-comment in
`src/enrich/maintainer/mod.rs` for the rationale.

## See also

See [Roadmap](../roadmap.md) for the current backlog.
- [Registry metadata (npm/PyPI/crates.io)](./registry.md)
- [Roadmap](../roadmap.md)
- [Enrichers overview](./overview.md)
Loading
Loading