# ADR-0012: Phase 7d — npm supply chain audit

**Status:** Accepted
**Date:** 2026-05-09
**Related:** ADR-0010 (Phase 7f Docker audit), ADR-0011 (Phase 7e.2 firewall)

## Context

Phase 7e/7e.1/7e.2/7f built host-level audit Checks (sysctl, sshd,
sudoers, SUID, world-writable, auditd, systemd, firewall, Docker).
None of those audit OpenClaw's *dependency tree* — `version_patch`
matches OpenClaw's own version against curated TI-OPENCLAW-CVE-* but
doesn't look at npm transitive deps. Phase 7d introduces the first
real supply-chain audit Check.

Spec: `docs/superpowers/specs/2026-05-09-phase-7d-supply-chain-design.md`.

## Decisions

### D1. Scope = npm only (defer pip / go / rust to 7d.x)

OpenClaw is an npm package; npm is the highest-leverage ecosystem to
cover first. Other ecosystems will use the same template Check + OSV
ecosystem subset model and land as 7d.1 / 7d.2 with minimal new design
work.

### D2. Lookup = offline DB refreshed via `agentsec ioc-update`

Mirrors the existing `cve_database.json` pattern (offline file, refreshed
by `ioc-update`, CI byte-diff via `--check`). Online OSV queries at
audit time would add a runtime network dependency to the evaluator and
break the "evaluator-runs-sealed" architectural invariant.

### D3. DB distribution = bundled in wheel

`src/agentsec/audit/ioc/osv_npm.json` ships in the wheel like
`cve_database.json`. Trade-off: ~5–10 MB bump (wheel goes from 277 KB
to 5–10 MB). Hard cap of 15 MB enforced by `scripts/check_osv_npm.py`.
Rejected alternatives: separate `agentsec-eval[supply-chain]` extra
package (doubles release burden), download-on-first-run (adds operator
friction).

### D4. TI umbrella entry `TI-OSV-NPM-CATALOG`

Every supply_chain finding sets `threat_refs=["TI-OSV-NPM-CATALOG"]`.
Per-GHSA TI entries (one per advisory) would explode `threat_intel.yaml`
to 5000+ rows and break the bidirectional `check_threat_refs.py`
invariant. The actual advisory id (GHSA-xxxx) lives in
`evidence.advisory_id` and is rendered prominently in the report.

### D5. Strict path scope: `~/.npm-global/lib/node_modules/openclaw/`

`allowed_paths` adds a prefix only for the OpenClaw install dir, NOT
bare `~/.npm-global/`. Operator-installed third-party globals stay
out of scope. Lesson learned from 7e.1 where `/usr/bin/`,
`/usr/sbin/`, `/etc/systemd/system/` ended up overly broad and remain
on the dormant-risk list.

### D6. `OsvNpmFetcher` runs as a side pipeline, not inside `_run_fetchers`

The existing per-WatchlistEntry fetcher pattern (NVD/KEV/GHSA) is
designed for incremental records that get normalized + merged into
`threat_intel.yaml`. OSV-npm is a single full-ecosystem dump that
becomes a standalone DB file. Forcing it into `_run_fetchers` would
require fake watchlist entries and a forked merge path. Cleaner to
add `_run_osv_npm` as a parallel pipeline within
`agentsec ioc-update`.

### D7. Five-axis bundle-size filter (post-implementation finding)

The original spec assumed 5–10 MB DB. First real OSV-npm pull was
**49 MB / 208,896 advisories** — 3× over the 15 MB hard cap from §7.2.
Investigation showed 98.6% of entries (≈206K) were "medium" purely
because OSV did not ship a CVSS score and our `_extract_severity`
defaulted to `medium`. A first-attempt `modified`-date filter (commit
`797f471`) only shaved ~5 MB because OSV refreshes `modified`
timestamps during routine maintenance.

The accepted filter chain (commit `7e4ffbf`):

1. drop `withdrawn`
2. drop entries with no `affected` row in npm ecosystem
3. drop entries with no usable SEMVER/ECOSYSTEM range
4. **drop entries with no CVSS_V3 score at all** — operators can't
   triage what OSV didn't grade; strips the 190K-noise floor
5. for scored medium/low, drop if `published` is older than 2 years
   — `published` is stable; `modified` is not
6. critical/high are always kept regardless of age

Result: 49 MB → 0.9 MB (3437 advisories), comfortably under cap.
Severity distribution: 1620 high + 1028 critical + 688 medium + 101 low.

Implication for future ecosystems (7d.1 pip / 7d.2 go / 7d.x): expect
the unscored bucket to dominate; the no-CVSS drop is likely
load-bearing across ecosystems, not npm-specific.

## Consequences

- 1 new Check + 1 new fetcher + 1 new DB file + 1 new TI umbrella +
  2 new dependencies (`semver`, `cvss`); server-audit Check count
  rises from 24 (v0.11.0) to 25 (v0.12.0).
- Wheel size grows from 277 KB to ~1 MB after filter chain (D7); well
  under the 15 MB CI cap, much better than the 5–10 MB initial
  estimate.
- Lightsail real-machine smoke expected to surface 0–10 supply-chain
  findings depending on OpenClaw's current transitive deps and how
  many of them carry actively scored CVSS_V3 advisories.
- Future 7d.x ecosystems (pip, go, rust, cargo) can adopt the same
  Check shape + OSV fetcher template; expect the no-CVSS drop (D7
  step 4) to do most of the bundle-size work in any ecosystem.
