# Phase 7d npm Supply Chain Audit Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `supply_chain_audit` server-audit Check that reads OpenClaw's npm `package-lock.json` on the audited host, matches each (name, version) against a bundled OSV-npm advisory subset, and emits one Finding per (pkg, version, advisory).

**Architecture:** Single new Check + companion `OsvNpmFetcher` in the `agentsec ioc-update` pipeline + bundled compact JSON DB (`osv_npm.json`, 5–10 MB) in the wheel. The Check is offline (DB ships in wheel); freshness comes from operator running `agentsec ioc-update` and committing the regenerated DB. TI umbrella entry `TI-OSV-NPM-CATALOG` gives every supply_chain finding a valid `threat_refs` target without requiring 5000 per-GHSA TI entries.

**Tech Stack:** Python 3.11+ async Check ABC, new `semver>=3,<4` dependency for npm semver comparison, OSV.dev npm export (`https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip`), httpx for fetcher, existing `_RecordingExecutor` dict-stub test pattern.

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

---

## Files to be Created or Modified

**Create:**
- `src/agentsec/audit/checks/supply_chain_audit.py` — `SupplyChainAuditCheck` + parsers + matcher
- `src/agentsec/audit/ioc/osv_npm.json` — bundled DB, written empty `[]` in Task 2, populated by Task 10
- `src/agentsec/audit/ioc_update/fetchers/osv_npm.py` — `OsvNpmFetcher`
- `scripts/check_osv_npm.py` — CI byte-diff + ≤15 MB size guard
- `tests/test_supply_chain_audit.py`
- `tests/test_osv_npm_fetcher.py`
- `tests/test_check_osv_npm_script.py`
- `tests/fixtures/supply_chain/package-lock-clean.json`
- `tests/fixtures/supply_chain/package-lock-vulnerable.json`
- `tests/fixtures/supply_chain/osv_npm_subset.json`
- `tests/fixtures/osv/npm/normal-cvss.json`
- `tests/fixtures/osv/npm/withdrawn.json`
- `tests/fixtures/osv/npm/cross-ecosystem.json`
- `tests/fixtures/osv/npm/no-npm-affected.json`
- `tests/fixtures/osv/npm/git-range-no-semver.json`
- `tests/fixtures/osv/npm/cvss-missing.json`
- `docs/adr/0012-phase-7d-supply-chain.md`

**Modify:**
- `src/agentsec/audit/platform_profile.py` — add `npm_root` to `LINUX.paths` and `LINUX_USER_MODE.paths`
- `src/agentsec/audit/ssh_policy.yaml` — append 1 `allowed_paths` prefix
- `src/agentsec/audit/ioc/threat_intel.yaml` — append `TI-OSV-NPM-CATALOG`
- `src/agentsec/audit/ioc/cve_database.json` — regenerated by `scripts/build_cve_db.py` (no payload change for the new TI; just hash check)
- `pyproject.toml` — add `semver>=3,<4`; bump version to `0.12.0`
- `src/agentsec/audit/ioc_update/cli.py` — add `_run_osv_npm` invoked alongside existing fetchers
- `src/agentsec/cli.py` — register `SupplyChainAuditCheck` in 2 sites + 1 import
- `tests/integration/test_openclaw_mock.py` — stub the new `cat` call
- `tests/integration/test_evaluate_smoke.py` — same
- `tests/audit/test_cli_server_audit.py` — same (dict-based mock)
- `docs/samples/report-2026-04-28/*` — refresh via `AGENTSEC_REFRESH_SAMPLE=1`
- `ROADMAP.md`, `CHANGELOG.md`, `README.md`, `CLAUDE.md`

---

## Task 1: Data Infrastructure (PlatformProfile + ssh_policy + TI + dependency)

**Files:**
- Modify: `src/agentsec/audit/platform_profile.py`
- Modify: `src/agentsec/audit/ssh_policy.yaml`
- Modify: `src/agentsec/audit/ioc/threat_intel.yaml`
- Modify: `src/agentsec/audit/ioc/cve_database.json`
- Modify: `pyproject.toml`

Pure infra task — sets up everything subsequent tasks depend on. No new Python tests of our own; `tests/test_command_policy.py` and `tests/test_platform_profile.py` provide automatic regression coverage.

- [ ] **Step 1: Add `npm_root` to LINUX and LINUX_USER_MODE profiles**

Open `src/agentsec/audit/platform_profile.py`. Locate the `LINUX = PlatformProfile(...)` constructor. In its `paths={...}` dict, append:

```python
        "npm_root": "~/.npm-global/lib/node_modules/openclaw",
```

Apply the same addition to `LINUX_USER_MODE.paths`. Confirm with `grep -n "npm_root\|openclaw_home" src/agentsec/audit/platform_profile.py` — should show the two new lines next to the existing `openclaw_home` entries.

- [ ] **Step 2: Append the firewall-style `allowed_paths` prefix to ssh_policy.yaml**

Open `src/agentsec/audit/ssh_policy.yaml`. Find the `allowed_paths:` section. After the existing `~/.openclaw/` entry block (the OpenClaw-related prefixes), insert:

```yaml
  # Phase 7d — npm supply chain audit; reads package-lock.json plus the
  # transitive node_modules tree. Scope deliberately narrowed to the
  # OpenClaw install dir (NOT bare ~/.npm-global/) to avoid pulling in
  # the operator's other globally-installed npm packages.
  - {kind: prefix, path: "~/.npm-global/lib/node_modules/openclaw/"}
```

- [ ] **Step 3: Append `TI-OSV-NPM-CATALOG` to threat_intel.yaml**

Append at the very end of `src/agentsec/audit/ioc/threat_intel.yaml`:

```yaml
- id: TI-OSV-NPM-CATALOG
  source: osv
  url: https://osv.dev/list?ecosystem=npm
  observed_at: '2026-05-09'
  claim: OSV.dev npm ecosystem advisory catalog — supply_chain_audit Check 引用根
  confidence: high
  mapped_tests: []
  auto_refresh: false
  note: 真实 advisory 索引在 src/agentsec/audit/ioc/osv_npm.json；本 TI 仅作为 supply_chain finding 的 threat_refs 目标，避免破坏 strict TI 校验
```

- [ ] **Step 4: Regenerate cve_database.json**

Run: `.venv/bin/python scripts/build_cve_db.py`
Expected: Exit 0. The new TI has no `TI-*-CVE-*` shape so payload is unchanged; the script regenerates the file deterministically.

Then verify: `.venv/bin/python scripts/build_cve_db.py --check`
Expected: Exit 0.

- [ ] **Step 5: Add `semver` to pyproject.toml dependencies + bump version**

Open `pyproject.toml`. Find the `[project]` table and locate `dependencies = [...]`. Add:

```toml
    "semver>=3,<4",
```

(In alphabetical position relative to existing entries.)

Same file, find `version = "0.11.0"` and change to `version = "0.12.0"`.

- [ ] **Step 6: Install the new dependency into .venv**

Run: `.venv/bin/pip install -e ".[dev]"`
Expected: `semver` package installed; no other unexpected upgrades. Confirm: `.venv/bin/python -c "import semver; print(semver.__version__)"` shows a 3.x version.

- [ ] **Step 7: Run regression gate**

Run all in parallel:
- `.venv/bin/pytest tests/ -q` — Expected: 1136 passed, 0 failed (no new tests yet; just confirming infra changes don't break anything).
- `.venv/bin/python scripts/check_threat_refs.py` — Expected: Exit 0.
- `.venv/bin/ruff check src/ tests/ scripts/` — Expected: clean.

- [ ] **Step 8: Commit**

```bash
git add src/agentsec/audit/platform_profile.py \
        src/agentsec/audit/ssh_policy.yaml \
        src/agentsec/audit/ioc/threat_intel.yaml \
        src/agentsec/audit/ioc/cve_database.json \
        pyproject.toml
git commit -m "$(cat <<'EOF'
data(7d): npm_root profile path + TI-OSV-NPM-CATALOG + semver dep

Reserve allowed_paths prefix ~/.npm-global/lib/node_modules/openclaw/
and inject npm_root into LINUX/LINUX_USER_MODE profiles. Seed TI
umbrella entry TI-OSV-NPM-CATALOG so supply_chain findings have a
valid threat_refs target. Add semver>=3,<4 to dependencies for npm
semver comparison (Python's packaging.Version semantics differ on
prereleases). Bump pyproject version to 0.12.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Bundled DB Stub + CI Guard Script

**Files:**
- Create: `src/agentsec/audit/ioc/osv_npm.json` (initially `[]`)
- Create: `scripts/check_osv_npm.py`
- Create: `tests/test_check_osv_npm_script.py`

The DB ships empty in this commit; Task 10 populates it via the real OSV fetcher. The check script + size guard go in now so CI is protected from day one.

- [ ] **Step 1: Create the empty bundled DB**

Write `src/agentsec/audit/ioc/osv_npm.json`:

```
[]
```

(Single line, no trailing newline. The fetcher's writer in Task 5 will produce the same compact form when it populates the file.)

- [ ] **Step 2: Write the failing test for the guard script**

Create `tests/test_check_osv_npm_script.py`:

```python
"""scripts/check_osv_npm.py CI guard tests (Phase 7d)."""

from __future__ import annotations

import json
import subprocess
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "scripts" / "check_osv_npm.py"


def _run(args: list[str]) -> subprocess.CompletedProcess:
    return subprocess.run(
        [".venv/bin/python", str(SCRIPT), *args],
        cwd=REPO_ROOT, capture_output=True, text=True, check=False,
    )


def test_check_passes_on_committed_db():
    """Default --check against the in-tree osv_npm.json must pass."""
    proc = _run(["--check"])
    assert proc.returncode == 0, (
        f"check_osv_npm.py --check failed:\nstdout={proc.stdout!r}\nstderr={proc.stderr!r}"
    )


def test_check_fails_when_db_too_large(tmp_path: Path):
    """A 16 MB synthetic DB exceeds the 15 MB hard cap → exit 1."""
    big = tmp_path / "osv_npm.json"
    # 16 MB of dummy entries
    payload = [{"id": f"GHSA-fake-{i:08d}", "package": "x", "ranges": [], "severity": "low",
                "summary": "x" * 200, "url": "", "aliases": []} for i in range(50_000)]
    big.write_text(json.dumps(payload, separators=(",", ":")))
    assert big.stat().st_size > 15 * 1024 * 1024  # sanity
    proc = _run(["--check", "--db-path", str(big)])
    assert proc.returncode != 0
    assert "exceeds" in proc.stderr.lower() or "too large" in proc.stderr.lower()


def test_check_fails_on_invalid_json(tmp_path: Path):
    bad = tmp_path / "osv_npm.json"
    bad.write_text("not-json{{{")
    proc = _run(["--check", "--db-path", str(bad)])
    assert proc.returncode != 0
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/test_check_osv_npm_script.py -q`
Expected: All 3 tests fail with "No such file or directory" or similar (script doesn't exist yet).

- [ ] **Step 4: Implement the guard script**

Create `scripts/check_osv_npm.py`:

```python
#!/usr/bin/env python3
"""CI guard for src/agentsec/audit/ioc/osv_npm.json (Phase 7d).

Validates: file is valid JSON, is a list, total size ≤ 15 MB. Mirrors
scripts/build_cve_db.py --check semantics: exit 0 = OK, exit 1 = fail.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DB_PATH = REPO_ROOT / "src" / "agentsec" / "audit" / "ioc" / "osv_npm.json"
SIZE_LIMIT = 15 * 1024 * 1024  # 15 MB


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--check", action="store_true",
                   help="validate the committed file (CI mode)")
    p.add_argument("--db-path", type=Path, default=DEFAULT_DB_PATH,
                   help="alternate DB path (testing only)")
    args = p.parse_args()

    if not args.check:
        # No-op write mode reserved; for now require --check explicitly.
        print("usage: check_osv_npm.py --check [--db-path PATH]", file=sys.stderr)
        return 2

    db_path: Path = args.db_path
    if not db_path.exists():
        print(f"osv_npm.json not found at {db_path}", file=sys.stderr)
        return 1

    size = db_path.stat().st_size
    if size > SIZE_LIMIT:
        print(
            f"osv_npm.json size {size} exceeds {SIZE_LIMIT} hard cap (15 MB). "
            "Tighten fetcher filters or split into a separate package.",
            file=sys.stderr,
        )
        return 1

    try:
        data = json.loads(db_path.read_text())
    except json.JSONDecodeError as e:
        print(f"osv_npm.json invalid JSON: {e}", file=sys.stderr)
        return 1

    if not isinstance(data, list):
        print(
            f"osv_npm.json top level must be a list, got {type(data).__name__}",
            file=sys.stderr,
        )
        return 1

    print(f"OK: {len(data)} advisories, {size} bytes")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

Make it executable: `chmod +x scripts/check_osv_npm.py`

- [ ] **Step 5: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_check_osv_npm_script.py -q`
Expected: All 3 tests pass.

Then run the guard against the committed DB once manually:
Run: `.venv/bin/python scripts/check_osv_npm.py --check`
Expected: `OK: 0 advisories, 2 bytes` (the empty `[]`).

- [ ] **Step 6: Run full regression gate**

Run: `.venv/bin/pytest tests/ -q`
Expected: 1139 passed (1136 + 3 new).

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/ioc/osv_npm.json scripts/check_osv_npm.py tests/test_check_osv_npm_script.py
git commit -m "$(cat <<'EOF'
infra(7d): empty osv_npm.json stub + 15 MB CI guard

Bundled DB starts empty []; populated for real in Task 10 via
agentsec ioc-update. scripts/check_osv_npm.py enforces:
- valid JSON
- top-level is a list
- size ≤ 15 MB hard cap (so growth doesn't silently bloat the wheel)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Lock-File Parser + Semver Range Helpers (TDD)

**Files:**
- Create: `src/agentsec/audit/checks/supply_chain_audit.py` (parsers + helpers only; Check class lands in Task 4)
- Test: `tests/test_supply_chain_audit.py` (parser tests only; Check tests in Task 4)

Build the pure helpers first via TDD; the Check itself in Task 4 will compose them.

- [ ] **Step 1: Write failing tests for the parser + matcher**

Create `tests/test_supply_chain_audit.py`:

```python
"""SupplyChainAuditCheck unit tests (Phase 7d)."""

from __future__ import annotations

from agentsec.audit.checks.supply_chain_audit import (
    parse_npm_lock,
    severity_from_cvss_score,
    version_in_any_range,
)


# ---- parse_npm_lock ----

def test_parse_lock_v3_basic():
    lock = """
    {
      "name": "openclaw", "lockfileVersion": 3,
      "packages": {
        "": {"name": "openclaw", "version": "1.0.0"},
        "node_modules/lodash": {"version": "4.17.21"},
        "node_modules/express": {"version": "4.18.2"}
      }
    }
    """
    pkgs = parse_npm_lock(lock)
    assert ("lodash", "4.17.21") in pkgs
    assert ("express", "4.18.2") in pkgs
    assert ("openclaw", "1.0.0") not in pkgs   # root entry skipped


def test_parse_lock_skips_link_entries():
    lock = """
    {"lockfileVersion": 3, "packages": {
      "node_modules/local-pkg": {"link": true, "resolved": "../local"}
    }}
    """
    assert parse_npm_lock(lock) == []


def test_parse_lock_scoped_packages():
    lock = """
    {"lockfileVersion": 3, "packages": {
      "node_modules/@scope/pkg": {"version": "2.0.0"}
    }}
    """
    pkgs = parse_npm_lock(lock)
    assert pkgs == [("@scope/pkg", "2.0.0")]


def test_parse_lock_nested_node_modules():
    """A deeply nested transitive dep at node_modules/express/node_modules/qs."""
    lock = """
    {"lockfileVersion": 3, "packages": {
      "node_modules/express/node_modules/qs": {"version": "6.11.0"}
    }}
    """
    pkgs = parse_npm_lock(lock)
    assert pkgs == [("qs", "6.11.0")]


def test_parse_lock_v1_returns_empty():
    """Lockfile v1 puts deps under top-level 'dependencies' key, not 'packages'.
    We don't support v1; should silently return []."""
    lock = """
    {"lockfileVersion": 1, "dependencies": {"lodash": {"version": "4.17.15"}}}
    """
    assert parse_npm_lock(lock) == []


def test_parse_lock_entry_without_version():
    """Workspace placeholder or partial entry → skip cleanly."""
    lock = """
    {"lockfileVersion": 3, "packages": {
      "node_modules/no-version": {}
    }}
    """
    assert parse_npm_lock(lock) == []


# ---- version_in_any_range ----

def test_version_in_range_lower_bound():
    """introduced=4.17.0, fixed=4.17.21 → 4.17.15 is vulnerable, 3.99 is not."""
    ranges = [{"introduced": "4.17.0", "fixed": "4.17.21"}]
    assert version_in_any_range("4.17.15", ranges) is True
    assert version_in_any_range("3.99.0", ranges) is False


def test_version_in_range_upper_bound_fixed():
    """version >= fixed is safe."""
    ranges = [{"introduced": "0", "fixed": "4.17.21"}]
    assert version_in_any_range("4.17.20", ranges) is True
    assert version_in_any_range("4.17.21", ranges) is False
    assert version_in_any_range("5.0.0", ranges) is False


def test_version_in_range_last_affected():
    """OSV uses last_affected (inclusive) for some entries."""
    ranges = [{"introduced": "1.0.0", "last_affected": "1.2.5"}]
    assert version_in_any_range("1.2.5", ranges) is True
    assert version_in_any_range("1.2.6", ranges) is False


def test_version_in_range_multiple_disjoint():
    """If any range matches, return True."""
    ranges = [
        {"introduced": "1.0.0", "fixed": "1.0.5"},
        {"introduced": "2.0.0", "fixed": "2.0.3"},
    ]
    assert version_in_any_range("1.0.4", ranges) is True
    assert version_in_any_range("2.0.2", ranges) is True
    assert version_in_any_range("1.5.0", ranges) is False


def test_version_in_range_invalid_semver_returns_false():
    """Non-semver version (e.g., 'file:../x') silently returns False."""
    ranges = [{"introduced": "0", "fixed": "4.17.21"}]
    assert version_in_any_range("file:../x", ranges) is False


# ---- severity_from_cvss_score ----

def test_severity_critical():
    assert severity_from_cvss_score(9.8) == "critical"
    assert severity_from_cvss_score(9.0) == "critical"


def test_severity_high():
    assert severity_from_cvss_score(8.5) == "high"
    assert severity_from_cvss_score(7.0) == "high"


def test_severity_medium():
    assert severity_from_cvss_score(6.5) == "medium"
    assert severity_from_cvss_score(4.0) == "medium"


def test_severity_low():
    assert severity_from_cvss_score(3.9) == "low"
    assert severity_from_cvss_score(0.1) == "low"


def test_severity_none_defaults_medium():
    """No CVSS available → default to medium (conservative; not low)."""
    assert severity_from_cvss_score(None) == "medium"
```

- [ ] **Step 2: Run tests to verify they fail with import error**

Run: `.venv/bin/pytest tests/test_supply_chain_audit.py -q`
Expected: All tests fail with `ModuleNotFoundError: agentsec.audit.checks.supply_chain_audit`.

- [ ] **Step 3: Implement parser + helpers (Check class is Task 4)**

Create `src/agentsec/audit/checks/supply_chain_audit.py`:

```python
"""npm 供应链漏洞审计（Phase 7d）。

Reads OpenClaw's npm package-lock.json on the audited host, walks the
transitive dependency tree, and matches each (name, version) against a
bundled OSV-npm advisory subset. Emits one Finding per matching
(pkg, version, advisory) tuple.

DB schema (src/agentsec/audit/ioc/osv_npm.json):
  [{"id": "GHSA-xxxx", "package": "lodash",
    "ranges": [{"introduced": "0", "fixed": "4.17.21"}],
    "severity": "critical|high|medium|low", "summary": "...",
    "url": "...", "aliases": ["CVE-..."]}, ...]
"""

from __future__ import annotations

import json
from typing import Literal

import semver

Severity = Literal["critical", "high", "medium", "low"]


def parse_npm_lock(stdout: str) -> list[tuple[str, str]]:
    """Return [(pkg_name, version)] from package-lock.json v2/v3.

    Skips the root entry (path key=='') and 'link' entries (workspace
    symlinks have no real version). lockfileVersion 1 silently returns
    [] (deps live under a different key shape we don't support).
    """
    try:
        data = json.loads(stdout)
    except json.JSONDecodeError:
        return []
    pkgs: list[tuple[str, str]] = []
    for path_key, entry in data.get("packages", {}).items():
        if path_key == "":
            continue
        if not isinstance(entry, dict):
            continue
        if entry.get("link"):
            continue
        version = entry.get("version")
        if not version:
            continue
        # path_key 'node_modules/foo/node_modules/bar' → bar
        # 'node_modules/@scope/pkg' → @scope/pkg
        parts = path_key.split("node_modules/")
        name = parts[-1]
        if not name:
            continue
        pkgs.append((name, version))
    return pkgs


def _safe_parse_semver(s: str):
    """Return semver.Version or None on parse failure."""
    try:
        return semver.Version.parse(s)
    except (ValueError, TypeError):
        return None


def version_in_any_range(version: str, ranges: list[dict]) -> bool:
    """True iff `version` falls into at least one OSV range entry.

    Range entry shape (npm subset):
      {"introduced": "0", "fixed": "4.17.21"}
      {"introduced": "1.0.0", "last_affected": "1.2.5"}

    Non-semver versions return False (caller treats as 'cannot match').
    """
    v = _safe_parse_semver(version)
    if v is None:
        return False
    for r in ranges:
        intro = r.get("introduced", "0")
        fixed = r.get("fixed")
        last = r.get("last_affected")

        intro_v = _safe_parse_semver(intro) if intro != "0" else None
        if intro_v is not None and v.compare(intro_v) < 0:
            continue   # version < introduced

        if fixed is not None:
            fixed_v = _safe_parse_semver(fixed)
            if fixed_v is not None and v.compare(fixed_v) >= 0:
                continue   # version >= fixed → safe

        if last is not None:
            last_v = _safe_parse_semver(last)
            if last_v is not None and v.compare(last_v) > 0:
                continue   # version > last_affected → outside

        return True
    return False


def severity_from_cvss_score(score: float | None) -> Severity:
    """CVSS v3 base-score → AgentSec severity label.

    None → 'medium' (conservative default; not 'low' so unscored
    vulnerabilities aren't underreported).
    """
    if score is None:
        return "medium"
    if score >= 9.0:
        return "critical"
    if score >= 7.0:
        return "high"
    if score >= 4.0:
        return "medium"
    return "low"
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_supply_chain_audit.py -q`
Expected: All 16 tests pass.

Then full suite:
Run: `.venv/bin/pytest tests/ -q`
Expected: 1155 passed (1139 + 16).

- [ ] **Step 5: Run lint + types**

Run in parallel:
- `.venv/bin/ruff check src/agentsec/audit/checks/supply_chain_audit.py tests/test_supply_chain_audit.py`
- `.venv/bin/pyright src/agentsec/audit/checks/supply_chain_audit.py`

Expected: clean / 0 errors.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/supply_chain_audit.py tests/test_supply_chain_audit.py
git commit -m "$(cat <<'EOF'
feat(7d): supply_chain parser + semver matcher (TDD)

parse_npm_lock walks package-lock.json v2/v3 'packages' map, skipping
root entry and workspace symlinks. version_in_any_range uses python
semver lib for range membership (npm semver semantics differ from PEP
440 prereleases). severity_from_cvss_score maps CVSS v3 score to
critical/high/medium/low; None → medium (conservative).

17 unit tests covering parser edge cases (scoped pkgs, nested
transitive deps, lockfileVersion=1, link entries) and matcher edge
cases (introduced/fixed/last_affected combinations, non-semver inputs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: SupplyChainAuditCheck (TDD)

**Files:**
- Modify: `src/agentsec/audit/checks/supply_chain_audit.py` (append the Check class)
- Modify: `tests/test_supply_chain_audit.py` (append Check tests)
- Create: `tests/fixtures/supply_chain/package-lock-clean.json`
- Create: `tests/fixtures/supply_chain/package-lock-vulnerable.json`
- Create: `tests/fixtures/supply_chain/osv_npm_subset.json`

- [ ] **Step 1: Create the 3 fixtures**

Create `tests/fixtures/supply_chain/package-lock-clean.json`:

```json
{
  "name": "openclaw",
  "lockfileVersion": 3,
  "packages": {
    "": {"name": "openclaw", "version": "1.0.0"},
    "node_modules/safe-pkg-a": {"version": "2.0.0"},
    "node_modules/safe-pkg-b": {"version": "3.5.1"},
    "node_modules/lodash": {"version": "4.17.21"}
  }
}
```

Create `tests/fixtures/supply_chain/package-lock-vulnerable.json`:

```json
{
  "name": "openclaw",
  "lockfileVersion": 3,
  "packages": {
    "": {"name": "openclaw", "version": "1.0.0"},
    "node_modules/lodash": {"version": "4.17.15"},
    "node_modules/minimist": {"version": "1.2.0"},
    "node_modules/@scope/foo": {"version": "1.0.0"},
    "node_modules/safe-pkg": {"version": "5.0.0"},
    "node_modules/local-link": {"link": true, "resolved": "../local"},
    "node_modules/weird-version": {"version": "file:../local"}
  }
}
```

Create `tests/fixtures/supply_chain/osv_npm_subset.json` (3 real-shape advisories matching the vulnerable lock + 1 control entry that won't match):

```json
[
  {"id": "GHSA-jf85-cpcp-j695", "package": "lodash",
   "ranges": [{"introduced": "0", "fixed": "4.17.21"}],
   "severity": "critical", "summary": "Prototype Pollution in lodash",
   "url": "https://github.com/advisories/GHSA-jf85-cpcp-j695",
   "aliases": ["CVE-2019-10744"]},
  {"id": "GHSA-vh95-rmgr-6w4m", "package": "minimist",
   "ranges": [{"introduced": "0", "fixed": "1.2.6"}],
   "severity": "medium", "summary": "Prototype Pollution in minimist",
   "url": "https://github.com/advisories/GHSA-vh95-rmgr-6w4m",
   "aliases": ["CVE-2021-44906"]},
  {"id": "GHSA-test-multi-1", "package": "lodash",
   "ranges": [{"introduced": "4.0.0", "fixed": "4.17.21"}],
   "severity": "high", "summary": "Second lodash advisory for multi-hit test",
   "url": "https://github.com/advisories/GHSA-test-multi-1",
   "aliases": []},
  {"id": "GHSA-control-noop", "package": "never-installed-pkg",
   "ranges": [{"introduced": "0", "fixed": "99.0.0"}],
   "severity": "low", "summary": "Control entry; should never match",
   "url": "https://github.com/advisories/GHSA-control-noop",
   "aliases": []}
]
```

- [ ] **Step 2: Append the failing Check tests to tests/test_supply_chain_audit.py**

Append to `tests/test_supply_chain_audit.py`:

```python
# ---- SupplyChainAuditCheck (integration via stub executor) ----

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.supply_chain_audit import SupplyChainAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


_FIXTURES = Path(__file__).parent / "fixtures" / "supply_chain"
_STUB_DB = _FIXTURES / "osv_npm_subset.json"
_LOCK_PATH = "/home/u/.npm-global/lib/node_modules/openclaw/package-lock.json"
_CAT_KEY = ("cat", _LOCK_PATH)


class _RecordingExecutor:
    def __init__(self, responses: dict[tuple, RunResult]):
        self._responses = responses
        self.calls: list[list[str]] = []

    def run(self, argv, *, role=None):
        argv = list(argv)
        self.calls.append(argv)
        return self._responses.get(
            tuple(argv),
            RunResult(
                stdout="", stderr="", exit_code=1,
                rejected=False, reject_reason=None, argv=argv,
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
        )


def _runresult(stdout="", stderr="", exit_code=0, rejected=False) -> RunResult:
    return RunResult(
        stdout=stdout, stderr=stderr, exit_code=exit_code,
        rejected=rejected, reject_reason="policy" if rejected else None,
        argv=[], command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, *, profile_name="linux", with_npm_root=True):
    profile = materialize_paths(LINUX, "/home/u")
    if not with_npm_root:
        # Strip npm_root to test missing-path branch
        new_paths = {k: v for k, v in profile.paths.items() if k != "npm_root"}
        profile = profile.model_copy(update={"paths": new_paths})
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(
        executor=executor, redactor=None, ioc_dir=Path("/tmp"),
        profile=profile, log_review_config=None,
    )


def _check() -> SupplyChainAuditCheck:
    return SupplyChainAuditCheck(db_path=_STUB_DB)


def _run(check, ctx):
    return asyncio.run(check.run(ctx))


def test_check_non_linux_profile_not_applicable():
    ctx = _ctx(_RecordingExecutor({}), profile_name="macos")
    assert _run(_check(), ctx) == []
    assert ctx.status.is_not_applicable is True


def test_check_npm_root_missing_not_applicable():
    ctx = _ctx(_RecordingExecutor({}), with_npm_root=False)
    assert _run(_check(), ctx) == []
    assert ctx.status.is_not_applicable is True


def test_check_lock_file_missing_not_applicable():
    """cat exit_code != 0 with no perm-denied stderr → not_applicable
    (NOT skipped — equivalent to 'this host doesn't have OpenClaw npm-installed')."""
    responses = {_CAT_KEY: _runresult(stderr="No such file", exit_code=1)}
    ctx = _ctx(_RecordingExecutor(responses))
    assert _run(_check(), ctx) == []
    assert ctx.status.is_not_applicable is True


def test_check_command_rejected_skipped():
    responses = {_CAT_KEY: _runresult(rejected=True)}
    ctx = _ctx(_RecordingExecutor(responses))
    assert _run(_check(), ctx) == []
    assert ctx.status.is_skipped is True


def test_check_empty_stdout_skipped():
    responses = {_CAT_KEY: _runresult(stdout="")}
    ctx = _ctx(_RecordingExecutor(responses))
    assert _run(_check(), ctx) == []
    assert ctx.status.is_skipped is True


def test_check_invalid_json_skipped():
    responses = {_CAT_KEY: _runresult(stdout="not-json{{{")}
    ctx = _ctx(_RecordingExecutor(responses))
    assert _run(_check(), ctx) == []
    assert ctx.status.is_skipped is True


def test_check_lockfile_v1_zero_findings():
    """Old npm v1 lock has different shape; parser returns []; 0 findings, status=ok."""
    lock = '{"lockfileVersion": 1, "dependencies": {"lodash": {"version": "4.17.15"}}}'
    responses = {_CAT_KEY: _runresult(stdout=lock)}
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(_check(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is False
    assert ctx.status.is_not_applicable is False


def test_check_clean_lock_zero_findings():
    lock = (_FIXTURES / "package-lock-clean.json").read_text()
    responses = {_CAT_KEY: _runresult(stdout=lock)}
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(_check(), ctx)
    assert findings == []


def test_check_vulnerable_lock_emits_findings():
    lock = (_FIXTURES / "package-lock-vulnerable.json").read_text()
    responses = {_CAT_KEY: _runresult(stdout=lock)}
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(_check(), ctx)
    # Expected hits in fixture:
    # - lodash@4.17.15 → GHSA-jf85-cpcp-j695 (critical) + GHSA-test-multi-1 (high)
    # - minimist@1.2.0 → GHSA-vh95-rmgr-6w4m (medium)
    # - @scope/foo@1.0.0 → no advisory, skipped
    # - safe-pkg@5.0.0 → no advisory, skipped
    # - local-link → link entry skipped
    # - weird-version "file:../local" → unparseable → 1 low finding (audit trail)
    advisory_ids = sorted(f.evidence["advisory_id"] for f in findings
                          if f.evidence.get("advisory_id"))
    assert "GHSA-jf85-cpcp-j695" in advisory_ids
    assert "GHSA-vh95-rmgr-6w4m" in advisory_ids
    assert "GHSA-test-multi-1" in advisory_ids
    # Severity sanity: at least 1 critical, 1 high, 1 medium
    sevs = [f.severity for f in findings]
    assert "critical" in sevs
    assert "high" in sevs
    assert "medium" in sevs


def test_check_unparseable_semver_emits_low_audit_finding():
    """A package whose version doesn't parse → 1 low finding noting it,
    so the operator knows we couldn't audit that entry."""
    lock = '{"lockfileVersion": 3, "packages": {"node_modules/x": {"version": "file:../bad"}}}'
    responses = {_CAT_KEY: _runresult(stdout=lock)}
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(_check(), ctx)
    assert len(findings) == 1
    assert findings[0].severity == "low"
    assert "无法解析" in findings[0].title or "unparseable" in findings[0].title.lower()


def test_check_finding_evidence_shape():
    """Verify the evidence dict on a real finding contains all expected keys."""
    lock = (_FIXTURES / "package-lock-vulnerable.json").read_text()
    responses = {_CAT_KEY: _runresult(stdout=lock)}
    findings = _run(_check(), _ctx(_RecordingExecutor(responses)))
    lodash_findings = [f for f in findings
                       if f.evidence.get("package") == "lodash"]
    assert lodash_findings, "expected at least 1 lodash finding"
    f = lodash_findings[0]
    for key in ("package", "version", "advisory_id", "aliases", "fixed_in", "url", "npm_root"):
        assert key in f.evidence, f"missing evidence key: {key}"
    assert f.evidence["package"] == "lodash"
    assert f.evidence["version"] == "4.17.15"
    assert f.threat_refs == ["TI-OSV-NPM-CATALOG"]


def test_check_remediation_includes_fixed_version():
    lock = (_FIXTURES / "package-lock-vulnerable.json").read_text()
    responses = {_CAT_KEY: _runresult(stdout=lock)}
    findings = _run(_check(), _ctx(_RecordingExecutor(responses)))
    lodash_finding = next(
        f for f in findings
        if f.evidence.get("package") == "lodash"
        and f.evidence.get("advisory_id") == "GHSA-jf85-cpcp-j695"
    )
    assert "4.17.21" in lodash_finding.remediation
```

- [ ] **Step 3: Run new Check tests to verify they fail**

Run: `.venv/bin/pytest tests/test_supply_chain_audit.py -q -k "test_check_"`
Expected: All 12 new tests fail with `ImportError: SupplyChainAuditCheck` or similar (class not defined yet).

- [ ] **Step 4: Append Check class to src/agentsec/audit/checks/supply_chain_audit.py**

Append (do NOT replace existing helpers from Task 3) at the end of `src/agentsec/audit/checks/supply_chain_audit.py`:

```python


# ---- Check class (orchestrates the helpers above) ----

from pathlib import Path

from ..findings import Finding
from .base import Check, CheckContext

_OSV_DB_PATH = Path(__file__).parent.parent / "ioc" / "osv_npm.json"


def _extract_severity_label(adv: dict) -> Severity:
    """Read the compact-schema 'severity' field; fall back to 'medium'."""
    sev = adv.get("severity")
    if sev in ("critical", "high", "medium", "low"):
        return sev
    return "medium"


class SupplyChainAuditCheck(Check):
    id = "supply_chain_audit"
    _REQUIRED_PATHS = ("npm_root",)

    def __init__(self, db_path: Path | None = None):
        super().__init__()
        path = db_path if db_path is not None else _OSV_DB_PATH
        raw = json.loads(path.read_text())
        self._advisories_by_pkg: dict[str, list[dict]] = {}
        for adv in raw:
            self._advisories_by_pkg.setdefault(adv["package"], []).append(adv)

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(f"{self.id}：非 Linux profile")
            return []
        if not self.check_required_paths(ctx):
            return []

        npm_root = ctx.profile.paths["npm_root"]
        lock_path = f"{npm_root}/package-lock.json"
        result = ctx.executor.run(["cat", lock_path])

        if result.rejected:
            ctx.status.skipped(
                f"{self.id}：cat 被策略拒绝 ({result.reject_reason})"
            )
            return []
        if result.exit_code != 0:
            stderr_l = (result.stderr or "").lower()
            if "permission denied" in stderr_l:
                ctx.status.skipped(f"{self.id}：权限不足读 lock 文件")
            else:
                ctx.status.not_applicable(
                    f"{self.id}：{lock_path} 不可读（npm 安装路径不在 npm_root 候选）"
                )
            return []
        if not result.stdout.strip():
            ctx.status.skipped(f"{self.id}：lock 文件为空")
            return []

        # Parse + match
        try:
            data = json.loads(result.stdout)
        except json.JSONDecodeError as e:
            ctx.status.skipped(f"{self.id}：package-lock.json 非合法 JSON：{e}")
            return []

        # parse_npm_lock takes raw text, not the parsed dict, so re-stringify
        # is wasteful — call helpers directly on data
        pkgs = parse_npm_lock(result.stdout)
        findings: list[Finding] = []
        for pkg, version in pkgs:
            if _safe_parse_semver(version) is None:
                # Unparseable semver → emit low audit-trail finding
                findings.append(Finding(
                    check=self.id, severity="low",
                    title=f"无法解析 semver: {pkg}@{version}",
                    description="非标准版本字符串（如 file:、git: 引用），无法对照 OSV 数据库",
                    evidence={
                        "package": pkg, "version": version,
                        "npm_root": npm_root,
                    },
                    remediation="如需审计该包，改为发布的 semver 版本（npm publish）",
                    threat_refs=["TI-OSV-NPM-CATALOG"],
                ))
                continue
            for adv in self._advisories_by_pkg.get(pkg, []):
                if version_in_any_range(version, adv["ranges"]):
                    findings.append(self._make_advisory_finding(
                        pkg, version, adv, npm_root,
                    ))
        return findings

    def _make_advisory_finding(
        self, pkg: str, version: str, adv: dict, npm_root: str,
    ) -> Finding:
        sev = _extract_severity_label(adv)
        adv_id = adv["id"]
        fixed_in = next(
            (r["fixed"] for r in adv["ranges"] if r.get("fixed")),
            "(no fixed version)",
        )
        return Finding(
            check=self.id, severity=sev,
            title=f"npm {pkg}@{version} 命中 {adv_id}（severity={sev}）",
            description=adv.get("summary", "")[:200],
            evidence={
                "package": pkg,
                "version": version,
                "advisory_id": adv_id,
                "aliases": adv.get("aliases", []),
                "fixed_in": fixed_in,
                "url": adv.get("url", ""),
                "npm_root": npm_root,
            },
            remediation=f"npm upgrade {pkg}@{fixed_in}（或更高版本）",
            threat_refs=["TI-OSV-NPM-CATALOG"],
        )
```

- [ ] **Step 5: Run all tests to verify they pass**

Run: `.venv/bin/pytest tests/test_supply_chain_audit.py -q`
Expected: All 28 tests pass (16 from Task 3 + 12 new Check tests).

Run full suite:
- `.venv/bin/pytest tests/ -q` — Expected: 1167 passed (1155 + 12 new).

Run lint + types:
- `.venv/bin/ruff check src/agentsec/audit/checks/supply_chain_audit.py tests/test_supply_chain_audit.py`
- `.venv/bin/pyright src/agentsec/audit/checks/supply_chain_audit.py`

Expected: clean / 0 errors. (If ruff `--fix` removes anything, apply and re-run tests.)

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/supply_chain_audit.py \
        tests/test_supply_chain_audit.py \
        tests/fixtures/supply_chain/package-lock-clean.json \
        tests/fixtures/supply_chain/package-lock-vulnerable.json \
        tests/fixtures/supply_chain/osv_npm_subset.json
git commit -m "$(cat <<'EOF'
feat(7d): SupplyChainAuditCheck — npm package-lock vs OSV-npm DB

Reads package-lock.json from materialized npm_root path, walks
transitive deps, and emits one Finding per (pkg, version, advisory)
match. Each Finding carries threat_refs=["TI-OSV-NPM-CATALOG"] +
evidence{package, version, advisory_id, aliases, fixed_in, url,
npm_root}. Unparseable semver versions (file:, git:) emit a low
audit-trail finding so the operator knows we couldn't audit them.

12 Check tests cover gating, error paths, clean/vulnerable lock,
multi-advisory hits, evidence shape, remediation message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: OSV-npm Fetcher (TDD, no live HTTP)

**Files:**
- Create: `src/agentsec/audit/ioc_update/fetchers/osv_npm.py`
- Create: `tests/test_osv_npm_fetcher.py`
- Create: `tests/fixtures/osv/npm/normal-cvss.json`
- Create: `tests/fixtures/osv/npm/withdrawn.json`
- Create: `tests/fixtures/osv/npm/cross-ecosystem.json`
- Create: `tests/fixtures/osv/npm/no-npm-affected.json`
- Create: `tests/fixtures/osv/npm/git-range-no-semver.json`
- Create: `tests/fixtures/osv/npm/cvss-missing.json`

The Fetcher's pure normalization logic is tested in isolation against 6 OSV-shaped fixture JSONs. We don't test the network fetch here (consistent with other fetcher tests); Task 10 will exercise the real HTTP path operationally.

- [ ] **Step 1: Create the 6 OSV fixture JSONs**

Create `tests/fixtures/osv/npm/normal-cvss.json`:

```json
{
  "id": "GHSA-jf85-cpcp-j695",
  "modified": "2023-01-01T00:00:00Z",
  "summary": "Prototype Pollution in lodash",
  "affected": [
    {
      "package": {"ecosystem": "npm", "name": "lodash"},
      "ranges": [{"type": "SEMVER", "events": [
        {"introduced": "0"}, {"fixed": "4.17.21"}
      ]}]
    }
  ],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}],
  "aliases": ["CVE-2019-10744"]
}
```

Create `tests/fixtures/osv/npm/withdrawn.json`:

```json
{
  "id": "GHSA-withdrawn-test",
  "modified": "2024-01-01T00:00:00Z",
  "withdrawn": "2024-02-01T00:00:00Z",
  "summary": "Withdrawn advisory",
  "affected": [
    {"package": {"ecosystem": "npm", "name": "irrelevant"},
     "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]}]}
  ]
}
```

Create `tests/fixtures/osv/npm/cross-ecosystem.json`:

```json
{
  "id": "GHSA-cross-ecosystem",
  "modified": "2024-01-01T00:00:00Z",
  "summary": "Cross-ecosystem advisory",
  "affected": [
    {"package": {"ecosystem": "PyPI", "name": "django"},
     "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "4.0"}]}]},
    {"package": {"ecosystem": "npm", "name": "django-bridge"},
     "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "2.0.0"}]}]}
  ]
}
```

Create `tests/fixtures/osv/npm/no-npm-affected.json`:

```json
{
  "id": "GHSA-no-npm",
  "modified": "2024-01-01T00:00:00Z",
  "summary": "Pure PyPI advisory aliased into OSV; no npm package",
  "affected": [
    {"package": {"ecosystem": "PyPI", "name": "requests"},
     "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "2.31.0"}]}]}
  ]
}
```

Create `tests/fixtures/osv/npm/git-range-no-semver.json`:

```json
{
  "id": "GHSA-git-range-only",
  "modified": "2024-01-01T00:00:00Z",
  "summary": "Advisory with only GIT-type range",
  "affected": [
    {"package": {"ecosystem": "npm", "name": "weird-pkg"},
     "ranges": [{"type": "GIT", "events": [{"introduced": "abc123"}, {"fixed": "def456"}]}]}
  ]
}
```

Create `tests/fixtures/osv/npm/cvss-missing.json`:

```json
{
  "id": "GHSA-no-cvss",
  "modified": "2024-01-01T00:00:00Z",
  "summary": "Advisory without CVSS score",
  "affected": [
    {"package": {"ecosystem": "npm", "name": "minor-pkg"},
     "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]}]}
  ]
}
```

- [ ] **Step 2: Write the failing fetcher tests**

Create `tests/test_osv_npm_fetcher.py`:

```python
"""OsvNpmFetcher normalization tests (Phase 7d). No live HTTP."""

from __future__ import annotations

import json
from pathlib import Path

from agentsec.audit.ioc_update.fetchers.osv_npm import normalize_osv_entry

_FIXTURES = Path(__file__).parent / "fixtures" / "osv" / "npm"


def _load(name: str) -> dict:
    return json.loads((_FIXTURES / f"{name}.json").read_text())


def test_normalize_normal_cvss_entry():
    entry = _load("normal-cvss")
    out = normalize_osv_entry(entry)
    assert out is not None
    assert out["id"] == "GHSA-jf85-cpcp-j695"
    assert out["package"] == "lodash"
    assert out["ranges"] == [{"introduced": "0", "fixed": "4.17.21"}]
    assert out["severity"] == "critical"  # CVSS 3.1 base score 9.8 → critical
    assert out["aliases"] == ["CVE-2019-10744"]
    assert "lodash" in out["summary"].lower()
    assert "GHSA-jf85-cpcp-j695" in out["url"]


def test_normalize_withdrawn_returns_none():
    entry = _load("withdrawn")
    assert normalize_osv_entry(entry) is None


def test_normalize_cross_ecosystem_takes_npm_only():
    entry = _load("cross-ecosystem")
    out = normalize_osv_entry(entry)
    assert out is not None
    assert out["package"] == "django-bridge"
    assert out["ranges"] == [{"introduced": "0", "fixed": "2.0.0"}]


def test_normalize_no_npm_affected_returns_none():
    entry = _load("no-npm-affected")
    assert normalize_osv_entry(entry) is None


def test_normalize_git_range_no_semver_returns_none():
    entry = _load("git-range-no-semver")
    assert normalize_osv_entry(entry) is None


def test_normalize_cvss_missing_defaults_medium():
    entry = _load("cvss-missing")
    out = normalize_osv_entry(entry)
    assert out is not None
    assert out["severity"] == "medium"
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/test_osv_npm_fetcher.py -q`
Expected: All 6 tests fail with `ModuleNotFoundError: agentsec.audit.ioc_update.fetchers.osv_npm`.

- [ ] **Step 4: Implement the fetcher**

Create `src/agentsec/audit/ioc_update/fetchers/osv_npm.py`:

```python
"""OSV-npm full ecosystem fetcher (Phase 7d).

Pulls https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip
and normalizes the included OSV entries to the compact schema
consumed by SupplyChainAuditCheck. Differs from per-watchlist
fetchers (NVD/KEV/GHSA) in shape — emits a single FetchResult
keyed under '_osv_npm_full' rather than per-entry results.
"""

from __future__ import annotations

import asyncio
import io
import json
import re
import zipfile

import httpx

from agentsec.audit.checks.supply_chain_audit import severity_from_cvss_score

from ..watchlist import WatchlistEntry
from .base import Fetcher, FetcherContext, FetchResult, hash_response

OSV_NPM_URL = "https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip"
RETRY_BACKOFFS = [2, 4, 8]
_HTTP_TIMEOUT = 60  # seconds; the zip is ~10 MB and gcs is fast

# CVSS v3 vector regex — capture 'BS:9.8' shape OR full vector ending in /A:H
_CVSS_SCORE_RE = re.compile(r"\b(?:CVSS:3\.[01])/[A-Z:/.]+")


def _parse_cvss_score(vector_or_score: str) -> float | None:
    """The OSV severity[].score field is a CVSS vector string like
       'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H'.
       Compute the base score per CVSS v3 spec — we use a minimal
       lookup because we only need critical/high/medium/low buckets."""
    # For correctness we use the cvss library if present, else a
    # conservative fallback that defaults to medium severity.
    try:
        from cvss import CVSS3
        c = CVSS3(vector_or_score)
        return float(c.base_score)
    except (ImportError, Exception):  # noqa: BLE001 — cvss may raise opaque errors
        return None


def _extract_severity(severity_list: list[dict]) -> str:
    """OSV severity field: list of {type, score}. Prefer CVSS_V3."""
    for item in severity_list:
        if item.get("type") == "CVSS_V3":
            score = _parse_cvss_score(item.get("score", ""))
            if score is not None:
                return severity_from_cvss_score(score)
    return severity_from_cvss_score(None)


def _extract_semver_ranges(ranges: list[dict]) -> list[dict]:
    """Convert OSV 'ranges' (events: [{introduced}, {fixed}, ...])
       into our compact form [{'introduced': '0', 'fixed': '4.17.21'}]."""
    out: list[dict] = []
    for r in ranges:
        if r.get("type") not in ("SEMVER", "ECOSYSTEM"):
            continue
        intro: str | None = None
        fixed: str | None = None
        last: str | None = None
        for ev in r.get("events", []):
            if "introduced" in ev:
                intro = ev["introduced"]
            elif "fixed" in ev:
                fixed = ev["fixed"]
            elif "last_affected" in ev:
                last = ev["last_affected"]
        if intro is None and fixed is None and last is None:
            continue
        entry: dict[str, str] = {}
        if intro is not None:
            entry["introduced"] = intro
        if fixed is not None:
            entry["fixed"] = fixed
        if last is not None:
            entry["last_affected"] = last
        out.append(entry)
    return out


def normalize_osv_entry(osv_entry: dict) -> dict | None:
    """Return compact-schema dict, or None if the entry is unusable
       (no npm package, no semver range, withdrawn, etc.)."""
    if osv_entry.get("withdrawn"):
        return None
    affected_npm = [
        a for a in osv_entry.get("affected", [])
        if (a.get("package") or {}).get("ecosystem") == "npm"
    ]
    if not affected_npm:
        return None
    pkg = affected_npm[0]["package"]["name"]
    ranges = _extract_semver_ranges(affected_npm[0].get("ranges", []))
    if not ranges:
        return None
    severity = _extract_severity(osv_entry.get("severity", []))
    return {
        "id": osv_entry["id"],
        "package": pkg,
        "ranges": ranges,
        "severity": severity,
        "summary": osv_entry.get("summary", "")[:200],
        "url": f"https://github.com/advisories/{osv_entry['id']}",
        "aliases": osv_entry.get("aliases", []),
    }


class OsvNpmFetcher(Fetcher):
    """Single-source full-ecosystem fetcher for OSV-npm.

    Doesn't take per-entry watchlist input — npm is a single ecosystem
    and we pull everything in one shot. Returns a single FetchResult
    keyed under '_osv_npm_full' for the cli.py orchestrator.
    """

    name = "osv_npm"

    def __init__(self) -> None:
        self._client_factory = lambda: httpx.AsyncClient(
            timeout=httpx.Timeout(connect=10, read=_HTTP_TIMEOUT,
                                  write=10, pool=10),
        )
        self._sleep = asyncio.sleep

    async def run(
        self,
        ctx: FetcherContext,
        entries: list[WatchlistEntry],  # ignored
    ) -> dict[str, FetchResult]:
        if ctx.offline:
            # offline mode: caller stages a fixture instead of HTTP
            return {"_osv_npm_full": FetchResult.from_degraded(
                reason="offline mode without OSV-npm fixture support",
                fetcher=self.name,
            )}
        try:
            return await self._fetch_and_normalize(ctx)
        except (httpx.HTTPError, zipfile.BadZipFile) as e:
            return {"_osv_npm_full": FetchResult.from_degraded(
                reason=f"OSV-npm fetch failed: {e}",
                fetcher=self.name,
            )}

    async def _fetch_and_normalize(self, ctx: FetcherContext) -> dict[str, FetchResult]:
        async with self._client_factory() as client:
            for delay in [0, *RETRY_BACKOFFS]:
                if delay:
                    await self._sleep(delay)
                resp = await client.get(
                    OSV_NPM_URL, headers={"User-Agent": ctx.user_agent},
                )
                if resp.status_code == 200:
                    body = resp.content
                    break
            else:
                return {"_osv_npm_full": FetchResult.from_degraded(
                    reason="OSV-npm: all retries exhausted",
                    fetcher=self.name, http_status=resp.status_code,
                )}

        normalized: list[dict] = []
        seen_ids: set[str] = set()
        with zipfile.ZipFile(io.BytesIO(body)) as zf:
            for name in zf.namelist():
                if not name.endswith(".json"):
                    continue
                try:
                    raw = json.loads(zf.read(name))
                except (json.JSONDecodeError, KeyError):
                    continue
                norm = normalize_osv_entry(raw)
                if norm is None:
                    continue
                if norm["id"] in seen_ids:
                    continue
                seen_ids.add(norm["id"])
                normalized.append(norm)
        # Stable sort by advisory id for byte-deterministic output.
        normalized.sort(key=lambda x: x["id"])
        return {
            "_osv_npm_full": FetchResult.ok(
                records=normalized,
                http_status=200,
                response_sha256=hash_response(body),
                raw_record_count=len(normalized),
            ),
        }```

- [ ] **Step 5: Add the optional `cvss` dep to pyproject.toml**

Open `pyproject.toml`. In `[project] dependencies`, add (alphabetical):

```toml
    "cvss>=3,<4",
```

Reinstall: `.venv/bin/pip install -e ".[dev]"`
Confirm: `.venv/bin/python -c "from cvss import CVSS3; print(CVSS3('CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H').base_score)"` should print `9.8`.

- [ ] **Step 6: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_osv_npm_fetcher.py -q`
Expected: All 6 tests pass. (`test_normalize_normal_cvss_entry` requires the `cvss` package added in Step 5.)

Run lint + types:
- `.venv/bin/ruff check src/agentsec/audit/ioc_update/fetchers/osv_npm.py tests/test_osv_npm_fetcher.py`
- `.venv/bin/pyright src/agentsec/audit/ioc_update/fetchers/osv_npm.py`

Expected: clean / 0 errors.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_npm.py \
        tests/test_osv_npm_fetcher.py \
        tests/fixtures/osv/npm/ \
        pyproject.toml
git commit -m "$(cat <<'EOF'
feat(7d): OsvNpmFetcher — pull + normalize OSV-npm export

Full-ecosystem fetcher (single FetchResult keyed '_osv_npm_full')
since npm has only one OSV bucket. Pulls all.zip from osv-vulnerabilities
GCS bucket, walks every advisory JSON, applies normalize_osv_entry to
keep only entries with a usable npm semver range. Compact output
schema matches what SupplyChainAuditCheck reads (id, package, ranges,
severity, summary, url, aliases). Adds cvss>=3,<4 dep for proper CVSS
v3 base-score parsing (severity bucketing).

6 normalization tests cover: normal CVSS entry, withdrawn, cross-
ecosystem (PyPI alias has npm sub-package), no npm affected, GIT-only
range (no semver), CVSS missing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Wire OSV-npm Fetcher into `agentsec ioc-update`

**Files:**
- Modify: `src/agentsec/audit/ioc_update/cli.py`
- Test: `tests/test_ioc_update_osv_npm_wiring.py`

The OSV-npm fetcher runs as a side pipeline (not part of `_run_fetchers`'s normalize+merge flow). Its output goes to `.cache/ioc-update-<date>/proposed-osv_npm.json` for operator review and copy.

- [ ] **Step 1: Find the cli.py main flow + cache dir variable**

Run: `grep -nB 1 -A 3 "cache_dir\|\.cache.*ioc-update\|run_fetchers\|proposed-threat_intel" src/agentsec/audit/ioc_update/cli.py | head -40`

Note the cache dir creation site, the `_run_fetchers` invocation, and the proposed-output writing site (likely a `_write_proposed` helper or similar). Read enough lines around the orchestrator entry point (typically `async def cmd_ioc_update(...)` or the `main()` body) to understand where to insert a new step.

- [ ] **Step 2: Write the failing wiring test**

Create `tests/test_ioc_update_osv_npm_wiring.py`:

```python
"""ioc-update CLI must invoke OsvNpmFetcher and write proposed-osv_npm.json (Phase 7d)."""

from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

import pytest

from agentsec.audit.ioc_update.cli import _run_osv_npm
from agentsec.audit.ioc_update.fetchers.base import FetchResult, FetcherContext


@pytest.mark.asyncio
async def test_run_osv_npm_writes_proposed_file(tmp_path: Path):
    """_run_osv_npm should invoke the fetcher and dump records as JSON."""
    fake_records = [
        {"id": "GHSA-x", "package": "p", "ranges": [{"introduced": "0", "fixed": "1.0.0"}],
         "severity": "high", "summary": "x", "url": "u", "aliases": []}
    ]
    with patch(
        "agentsec.audit.ioc_update.cli.OsvNpmFetcher"
    ) as MockFetcher:
        async def fake_run(ctx, entries):
            return {"_osv_npm_full": FetchResult.ok(records=fake_records)}
        MockFetcher.return_value.run.side_effect = fake_run

        ctx = FetcherContext(offline=False)
        out_path = await _run_osv_npm(ctx, tmp_path)

    assert out_path == tmp_path / "proposed-osv_npm.json"
    assert out_path.exists()
    payload = json.loads(out_path.read_text())
    assert payload == fake_records


@pytest.mark.asyncio
async def test_run_osv_npm_handles_degraded_fetch(tmp_path: Path):
    """If the fetcher returns a degraded result, we write [] and surface the issue."""
    with patch(
        "agentsec.audit.ioc_update.cli.OsvNpmFetcher"
    ) as MockFetcher:
        async def fake_run(ctx, entries):
            return {"_osv_npm_full": FetchResult.from_degraded(
                reason="HTTP 503", fetcher="osv_npm",
            )}
        MockFetcher.return_value.run.side_effect = fake_run

        ctx = FetcherContext(offline=False)
        out_path = await _run_osv_npm(ctx, tmp_path)

    assert out_path is None  # signals degraded; no file written
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `.venv/bin/pytest tests/test_ioc_update_osv_npm_wiring.py -q`
Expected: All 2 tests fail with `ImportError: cannot import name '_run_osv_npm'`.

- [ ] **Step 4: Add `_run_osv_npm` to cli.py**

Open `src/agentsec/audit/ioc_update/cli.py`. Add the import near other fetcher imports (around line 20–40):

```python
from .fetchers.osv_npm import OsvNpmFetcher
```

Add the helper near `_run_fetchers` (find it via `grep -n "_run_fetchers" src/agentsec/audit/ioc_update/cli.py`):

```python
async def _run_osv_npm(ctx: FetcherContext, cache_dir: Path) -> Path | None:
    """Side pipeline: pull OSV-npm, write proposed-osv_npm.json.

    Independent of _run_fetchers because OSV-npm is a single full
    ecosystem dump (not per-watchlist-entry), and its output is a
    standalone DB file (not merged into threat_intel.yaml).

    Returns the written file path on success, None on degraded fetch.
    """
    fetcher = OsvNpmFetcher()
    results = await fetcher.run(ctx, entries=[])
    result = results.get("_osv_npm_full")
    if result is None or result.degraded is not None:
        return None
    out_path = cache_dir / "proposed-osv_npm.json"
    # Compact JSON for byte-deterministic diffs
    out_path.write_text(
        json.dumps(result.records, separators=(",", ":"), sort_keys=False)
    )
    return out_path
```

(`json` is already imported at the top of cli.py.)

In the main orchestrator function (the one that calls `_run_fetchers`), add a call to `_run_osv_npm` immediately after the existing fetcher run, e.g.:

```python
    osv_npm_path = await _run_osv_npm(fctx, cache_dir)
    if osv_npm_path is not None:
        audit_events.append({
            "ts": datetime.now().isoformat(),
            "event": "osv_npm_written",
            "path": str(osv_npm_path),
            "records": len(json.loads(osv_npm_path.read_text())),
        })
    else:
        audit_events.append({
            "ts": datetime.now().isoformat(),
            "event": "osv_npm_degraded",
            "note": "OSV-npm fetch failed or skipped; existing osv_npm.json unchanged",
        })
```

(Insert this between the existing `fetcher_results = await _run_fetchers(...)` line and the subsequent normalize/merge block.)

- [ ] **Step 5: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_ioc_update_osv_npm_wiring.py -q`
Expected: 2 tests pass.

Run full suite + lint:
- `.venv/bin/pytest tests/ -q` — Expected: 1175 passed (1167 + 6 fetcher + 2 wiring). The existing `tests/integration/test_ioc_update_*.py` files may incidentally test the orchestrator end-to-end; if any fail because the new `_run_osv_npm` call wasn't stubbed, fix by patching `OsvNpmFetcher` in those tests to return an empty degraded result.
- `.venv/bin/ruff check src/agentsec/audit/ioc_update/cli.py tests/test_ioc_update_osv_npm_wiring.py`
- `.venv/bin/pyright src/agentsec/audit/ioc_update/cli.py`

If end-to-end ioc-update integration tests fail with HTTP errors (because the OSV fetcher tries to actually hit GCS), find those tests and add a `monkeypatch` setting `OsvNpmFetcher.run` to return a degraded result. Report any unexpected behavior as DONE_WITH_CONCERNS.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/ioc_update/cli.py tests/test_ioc_update_osv_npm_wiring.py
git commit -m "$(cat <<'EOF'
infra(7d): wire OsvNpmFetcher into agentsec ioc-update

New _run_osv_npm helper runs alongside (not inside) the existing
per-watchlist _run_fetchers pipeline. Writes proposed-osv_npm.json
to the cache dir on success; emits an audit event entry on degraded
fetch. The full normalize+merge pipeline isn't invoked because OSV-
npm output is a standalone DB file, not part of threat_intel.yaml.

2 unit tests cover the happy + degraded paths via mocked fetcher.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: CLI Registration

**Files:**
- Modify: `src/agentsec/cli.py` — 1 import + 1 instantiation × 2 sites

- [ ] **Step 1: Add the import**

Open `src/agentsec/cli.py`. Locate the docker / firewall imports block (around lines 22–42). Insert immediately after the existing audit-check imports:

```python
from .audit.checks.supply_chain_audit import SupplyChainAuditCheck
```

- [ ] **Step 2: Register in `server_audit` (~line 524)**

Find the first `checks = [...]` list. After `DockerImageCveCheck()`, append:

```python
        SupplyChainAuditCheck(),
```

- [ ] **Step 3: Register in `_evaluate_server` (~line 826)**

Find the second registration site (identical shape). Apply the same insertion.

- [ ] **Step 4: Run lint + targeted CLI tests**

Run in parallel:
- `.venv/bin/ruff check src/agentsec/cli.py`
- `.venv/bin/pyright src/agentsec/cli.py`
- `.venv/bin/pytest tests/ -q`

Expected: ruff/pyright clean; pytest may fail on integration tests (4-ish tests) because the new Check now runs in mock SSH without a stub for `cat <npm_root>/package-lock.json` — that's the Task 8 work. Report DONE_WITH_CONCERNS listing those failures.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "$(cat <<'EOF'
cli(7d): register SupplyChainAuditCheck (1 check)

Wires the new Phase 7d Check into both the server_audit subcommand
and the evaluate orchestrator. Insertion order preserves Phase
timeline (7e.2 firewall → 7f Docker → 7d supply chain).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Mock SSH Stub + Sample Report Refresh

**Files:**
- Modify: `tests/integration/test_openclaw_mock.py`
- Modify: `tests/integration/test_evaluate_smoke.py`
- Modify: `tests/audit/test_cli_server_audit.py`
- Modify: `docs/samples/report-2026-04-28/*` (refresh via env var)

After Task 7 the mock SSH fixtures need to know about the `cat` to the npm lock path. Each of the 3 test files has its own mock pattern (per Phase 7e.2 Task 7's experience).

- [ ] **Step 1: Find the mock SSH stub site in test_openclaw_mock.py**

Run: `grep -nB 2 -A 6 "/usr/sbin/iptables\|firewall stat" tests/integration/test_openclaw_mock.py`

This locates the pattern for adding new stub responses — Phase 7e.2's firewall block is the immediate template.

- [ ] **Step 2: Add the lock-file stub to test_openclaw_mock.py**

Immediately after the firewall stat-miss block (and before the catch-all `return _runresult(stdout="{}", argv=list(argv))`), insert:

```python
        # supply_chain_audit (Phase 7d): mock a clean lock file so the Check
        # runs end-to-end and lands status=ok with 0 findings.
        if (key == ("cat", "/home/u/.npm-global/lib/node_modules/openclaw/package-lock.json")):
            return _runresult(
                stdout='{"name":"openclaw","lockfileVersion":3,"packages":'
                       '{"":{"name":"openclaw","version":"1.0.0"},'
                       '"node_modules/safe-pkg":{"version":"2.0.0"}}}',
                argv=list(argv),
            )
```

(`/home/u` comes from the materialize_paths call with remote_home="/home/u" used by the mock fixture; verify with `grep "remote_home" tests/integration/test_openclaw_mock.py`.)

- [ ] **Step 3: Apply the same stub to test_evaluate_smoke.py**

Run: `grep -nB 2 -A 6 "/usr/sbin/iptables\|firewall stat" tests/integration/test_evaluate_smoke.py`

Locate the equivalent firewall-stat block and add the same `cat` stub immediately after.

- [ ] **Step 4: Apply the same stub to test_cli_server_audit.py**

Run: `grep -nB 2 -A 6 "/usr/sbin/iptables\|firewall" tests/audit/test_cli_server_audit.py`

This file uses a dict-based `_make_canned_dict()` pattern. Add a key:

```python
        ("cat", "/home/u/.npm-global/lib/node_modules/openclaw/package-lock.json"):
            RunResult(
                stdout='{"name":"openclaw","lockfileVersion":3,"packages":'
                       '{"":{"name":"openclaw","version":"1.0.0"},'
                       '"node_modules/safe-pkg":{"version":"2.0.0"}}}',
                stderr="", exit_code=0, rejected=False, reject_reason=None,
                argv=[], command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
```

- [ ] **Step 5: Run integration tests once without sample refresh**

Run: `.venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v 2>&1 | tail -40`

Expected: drift assertion failure citing `<!-- check:supply_chain_audit ... -->` anchor missing from the committed sample. This is the sentinel — it confirms the new Check ran cleanly through the mock and emitted a `not_applicable` or `ok` card.

If the test fails differently (a Check crashing, a finding count mismatch unrelated to supply_chain), STOP and report — that's an unexpected condition.

- [ ] **Step 6: Refresh the committed sample report**

Run: `AGENTSEC_REFRESH_SAMPLE=1 .venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v 2>&1 | tail -10`
Expected: PASS; files in `docs/samples/report-2026-04-28/` updated.

- [ ] **Step 7: Re-run integration tests + full suite**

- `.venv/bin/pytest tests/integration/test_openclaw_mock.py -q` — Expected: PASS
- `.venv/bin/pytest tests/ -q` — Expected: 1175 passed (sample refresh doesn't add tests; mock stubs change behavior, not count)

- [ ] **Step 8: Commit**

```bash
git add tests/integration/test_openclaw_mock.py \
        tests/integration/test_evaluate_smoke.py \
        tests/audit/test_cli_server_audit.py \
        docs/samples/report-2026-04-28/
git commit -m "$(cat <<'EOF'
test(7d): mock SSH stub + sample refresh for supply_chain_audit

Adds a cat <npm_root>/package-lock.json stub returning a clean 2-pkg
lock to all three mock fixtures (test_openclaw_mock, test_evaluate_smoke,
test_cli_server_audit). Refreshes the committed sample report so the
drift guard stays green with the new check anchor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: ADR-0012

**Files:**
- Create: `docs/adr/0012-phase-7d-supply-chain.md`

- [ ] **Step 1: Write ADR-0012**

Create `docs/adr/0012-phase-7d-supply-chain.md`:

```markdown
# 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`.

## 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 5–10 MB; mitigated by 15 MB CI cap.
- Lightsail real-machine smoke expected to surface 5–25 supply-chain
  findings depending on OpenClaw's current transitive deps.
- Future 7d.x ecosystems (pip, go, rust, cargo) can adopt the same
  Check shape + OSV fetcher template.
```

- [ ] **Step 2: Commit**

```bash
git add docs/adr/0012-phase-7d-supply-chain.md
git commit -m "$(cat <<'EOF'
docs(adr): ADR-0012 Phase 7d supply chain audit decisions

Records six decisions: scope=npm only (defer pip/go/rust), offline DB
via ioc-update (mirrors cve_database.json), bundled distribution
(15 MB hard cap), TI umbrella TI-OSV-NPM-CATALOG (avoid 5000 per-GHSA
TI entries), strict path scope to OpenClaw install dir (lesson from
7e.1's overly-broad prefixes), and OsvNpmFetcher running as a side
pipeline rather than inside the per-WatchlistEntry _run_fetchers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 10: Real OSV-npm DB Pull + Commit

**Files:**
- Modify: `src/agentsec/audit/ioc/osv_npm.json` (populated for real)

This is the only task that requires live network. We run `agentsec ioc-update` against the real OSV bucket, copy the resulting `proposed-osv_npm.json` into the in-tree DB path, and commit.

- [ ] **Step 1: Run ioc-update against real OSV**

```bash
set -a; source ~/.config/agentsec/env; set +a   # load NVD_API_KEY etc.
.venv/bin/agentsec ioc-update 2>&1 | tail -20
```

Expected: completes with a line like `osv_npm_written` in the audit log; produces `.cache/ioc-update-$(date +%F)/proposed-osv_npm.json`. The OSV-npm pull alone takes ~10–20s (10 MB zip download + ~5000 normalizations).

If the run fails on NVD/GHSA/KEV (existing fetchers) but OSV-npm succeeds, we still get the DB file; check `.cache/ioc-update-*/proposed-osv_npm.json` exists.

- [ ] **Step 2: Inspect the DB before committing**

```bash
ls -la .cache/ioc-update-*/proposed-osv_npm.json
.venv/bin/python -c "
import json
data = json.loads(open('.cache/ioc-update-$(date +%F)/proposed-osv_npm.json').read())
print(f'advisories: {len(data)}')
print(f'sample[0]: {data[0]}')
print(f'severity counts: ', end='')
from collections import Counter
print(Counter(d['severity'] for d in data))
"
```

Expected: 5000–8000 advisories, severity distribution showing some critical / high / medium / low entries. If count is wildly off (e.g., 50 advisories), the fetcher had a problem; investigate before committing.

- [ ] **Step 3: Verify size is under the 15 MB cap**

```bash
wc -c .cache/ioc-update-*/proposed-osv_npm.json
```

Expected: < 15,728,640 bytes (15 MB). If over, re-examine `_extract_severity` / `_extract_semver_ranges` filter rules; may need to shorten `summary` further or drop additional fields.

- [ ] **Step 4: Copy into the in-tree DB path**

```bash
cp .cache/ioc-update-$(date +%F)/proposed-osv_npm.json src/agentsec/audit/ioc/osv_npm.json
```

- [ ] **Step 5: Run the guard + full regression**

```bash
.venv/bin/python scripts/check_osv_npm.py --check
.venv/bin/pytest tests/ -q
```

Expected: guard exits 0 with the new advisory count and byte size; all tests still pass.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/ioc/osv_npm.json
git commit -m "$(cat <<'EOF'
data(7d): populate osv_npm.json with $(date +%F) OSV-npm snapshot

First real OSV-npm DB pull via agentsec ioc-update + OsvNpmFetcher.
Source: https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip
on $(date +%F). Advisory count and size logged in commit's git diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

(Replace `$(date +%F)` with actual `2026-05-09` if shell interpolation isn't supported in your commit message workflow.)

---

## Task 11: Documentation + Version Bump

**Files:**
- Modify: `ROADMAP.md`
- Modify: `CHANGELOG.md`
- Modify: `README.md`
- Modify: `CLAUDE.md`

Note: `pyproject.toml` already bumped to 0.12.0 in Task 1.

- [ ] **Step 1: Update ROADMAP.md**

Find the Phase 7 section. Locate the line `- [x] Phase 7e.2 — ...` near the bottom. Append immediately after:

```markdown
- [x] Phase 7d — npm supply chain Check（OSV-npm bundled DB，TI-OSV-NPM-CATALOG）— 2026-05-09
```

- [ ] **Step 2: Update CHANGELOG.md**

Add to the top of the `[Unreleased]` `### Added` section (creating the section if missing):

```markdown
### Added

- **Phase 7d — npm 供应链审计 Check** (`supply_chain_audit`)。读
  OpenClaw 的 `~/.npm-global/lib/node_modules/openclaw/package-lock.json`，
  walk transitive 依赖，对照 bundled OSV-npm DB
  (`src/agentsec/audit/ioc/osv_npm.json`，5–10 MB 由 `agentsec
  ioc-update` 维护) emit 每个命中 (pkg, version, advisory) 一条 finding。
  TI umbrella `TI-OSV-NPM-CATALOG` 让所有 finding 通过 strict TI 校验
  而不必为每条 GHSA 建一行。服务端审计 Check 数：24 → 25
  （10 OpenClaw + 11 Linux hardening + 3 Docker + 1 supply chain）。
- `OsvNpmFetcher` 在 `agentsec ioc-update` 子流程拉
  `https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip`，
  归一化后写 `proposed-osv_npm.json`。
- 新依赖：`semver>=3,<4`（npm semver 比较，PEP 440 不通用）+
  `cvss>=3,<4`（CVSS v3 vector → severity 桶）。
```

- [ ] **Step 3: Update README.md**

Run: `grep -n "24 类\|24 个" README.md`

Find the line referencing `24 类（10 OpenClaw + 11 Linux hardening + 3 Docker）`. Update to:

```markdown
25 类（10 OpenClaw + 11 Linux hardening + 3 Docker + 1 supply chain）
```

- [ ] **Step 4: Update CLAUDE.md**

Open `CLAUDE.md`. Locate the `**Total checks (v0.11.0):**` line. Replace with:

```markdown
**Total checks (v0.12.0):** `native_audit`, `config_audit`, `version_patch`, `active_test` (Stage D); `exposure_scan`, `filesystem`, `process_forensics`, `credential_audit`, `plugin_static`, `log_review` (Stage E); `sysctl_audit`, `sshd_config_audit`, `sudoers_audit`, `suid_audit`, `world_writable`, `auditd_rules`, `systemd_service_blacklist`, `systemd_unit_lint` (Phase 7e/7e.1); `iptables_audit`, `nftables_audit`, `ufw_audit` (Phase 7e.2); `docker_daemon_audit`, `docker_runtime_audit`, `docker_image_cve` (Phase 7f); `supply_chain_audit` (Phase 7d) — 25 类。
```

After the existing `## Firewall audit (Phase 7e.2, v0.11.0)` block, add:

```markdown
## Supply chain audit (Phase 7d, v0.12.0)

- **`SupplyChainAuditCheck`** in `src/agentsec/audit/checks/supply_chain_audit.py` reads `<npm_root>/package-lock.json` (where `npm_root` is a `PlatformProfile.paths` key, default `~/.npm-global/lib/node_modules/openclaw`), walks every transitive dep in the v2/v3 lockfile `packages` map, and matches each (name, version) against the bundled `osv_npm.json` DB. Emits one Finding per (pkg, version, advisory) match; `threat_refs=["TI-OSV-NPM-CATALOG"]`.
- **`src/agentsec/audit/ioc/osv_npm.json`** is the bundled OSV-npm advisory subset (~5–10 MB). Refresh via `agentsec ioc-update`; the OSV-npm fetcher runs as a side pipeline and writes `.cache/ioc-update-<date>/proposed-osv_npm.json`. CI guard `scripts/check_osv_npm.py --check` enforces valid JSON + ≤ 15 MB hard cap.
- **TI umbrella** `TI-OSV-NPM-CATALOG` is the single `threat_intel.yaml` entry every supply_chain finding references. Per-GHSA TI entries would explode threat_intel.yaml to 5000+ rows; the actual advisory id lives in `evidence.advisory_id` and surfaces directly in the report.
- **Path scope is deliberately narrow** — `allowed_paths` only adds `~/.npm-global/lib/node_modules/openclaw/`, not bare `~/.npm-global/`. Operator-installed third-party globals are out of scope.
- **Adding a new ecosystem (e.g., pip in 7d.1)**: add a new Check `pip_supply_chain_audit` mirroring `SupplyChainAuditCheck`, add a `osv_pypi.json` DB + `OsvPyPIFetcher`, reuse the same TI umbrella pattern (`TI-OSV-PYPI-CATALOG`), reuse `severity_from_cvss_score` and the version-range matcher (move to a shared `_supply_chain_common.py` if it stays identical).
```

- [ ] **Step 5: Final regression gate**

Run all in parallel:
```
.venv/bin/pytest tests/ -q
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/python scripts/check_threat_refs.py
.venv/bin/python scripts/build_cve_db.py --check
.venv/bin/python scripts/check_osv_npm.py --check
```

Expected: all green; pytest ≈1175 passed; ruff clean; pyright 0 errors.

- [ ] **Step 6: Commit**

```bash
git add ROADMAP.md CHANGELOG.md README.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(7d): bump 0.12.0 + ROADMAP/CHANGELOG/README/CLAUDE.md

Phase 7d closure: server-side audit Check count 24 → 25, version
already at 0.12.0 (bumped in Task 1). CLAUDE.md gets a new "Supply
chain audit (Phase 7d, v0.12.0)" subsection covering the Check
shape, OSV refresh flow via ioc-update, TI umbrella strategy, path
scope decision, and how to add a new ecosystem.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 12: Lightsail Real-Machine Smoke (Operator Manual Step)

**Files:** none (manual operator step; results go into memory)

- [ ] **Step 1: Re-sign AUTHORIZATION.txt if window expired**

```bash
set -a; source ~/.config/agentsec/env; set +a
.venv/bin/python -c "
import os
from datetime import datetime, UTC
from pathlib import Path
from agentsec.audit.authorization import Authorization

a = Authorization.load('AUTHORIZATION.txt')
a.valid_from = datetime(2026, 5, 9, 0, 0, 0, tzinfo=UTC)
a.valid_until = datetime(2026, 5, 12, 0, 0, 0, tzinfo=UTC)
a.report_output_path_prefix = './report-local-prod-2026-05-09/'
key = os.environ['AGENTSEC_AUTH_SIGNING_KEY'].encode('utf-8')
a.signature = a.compute_signature(key)
out = (
    f'target_host: {a.target_host}\n'
    f'authorized_by: {a.authorized_by}\n'
    'identity_provider: \"\"\n'
    'identity_assertion: \"\"\n'
    f'valid_from: {a.valid_from.strftime(\"%Y-%m-%d %H:%M:%S+00:00\")}\n'
    f'valid_until: {a.valid_until.strftime(\"%Y-%m-%d %H:%M:%S+00:00\")}\n'
    'scope:\n'
    + ''.join(f'  - {s}\n' for s in a.scope) +
    f'report_output_path_prefix: {a.report_output_path_prefix}\n'
    f'signature_mode: {a.signature_mode}\n'
    f'signature: {a.signature}\n'
    f'signature_key_env: {a.signature_key_env}\n'
)
Path('AUTHORIZATION.txt').write_text(out)
"
```

- [ ] **Step 2: Run server-audit**

```bash
set -a; source ~/.config/agentsec/env; set +a
export OPENCLAW_GATEWAY_TOKEN=$(ssh -i ~/Work/MoveClaw/20250311lightsailuswest.pem ubuntu@34.208.65.73 'cat ~/.openclaw/openclaw.json' | jq -r '.gateway.auth.token')
.venv/bin/agentsec server-audit \
  --host 34.208.65.73 --user ubuntu \
  --key ~/Work/MoveClaw/20250311lightsailuswest.pem \
  --consent-file AUTHORIZATION.txt \
  --output ./report-local-prod-2026-05-09b/
```

Expected: completes without error. `supply_chain_audit` lands `status:ok` with 5–25 findings.

- [ ] **Step 3: Inspect supply_chain output**

```bash
grep -B 1 -A 3 "supply_chain_audit" ./report-local-prod-2026-05-09b/server-audit-report.md | head -40
.venv/bin/python -c "
import json
findings = json.loads(open('./report-local-prod-2026-05-09b/findings.json').read())
sc = [f for f in findings if f['check'] == 'supply_chain_audit']
print(f'supply_chain findings: {len(sc)}')
from collections import Counter
print('severity:', Counter(f['severity'] for f in sc))
for f in sc[:5]:
    print(f\"  {f['evidence']['package']}@{f['evidence']['version']} → {f['evidence']['advisory_id']} ({f['severity']})\")
"
```

- [ ] **Step 4: Update memory**

Append a section to `~/.claude/projects/-Users-roger-Work-AgentSec/memory/project_openclaw_lightsail_prod.md` under "Phase 7e.2 firewall smoke" — the new section "Phase 7d supply chain smoke (2026-05-09, v0.12.0)" should record:
- Date of run.
- supply_chain_audit finding count + severity distribution.
- Sample 5 hits (package@version → advisory_id severity).
- Whether the count surprised you given OpenClaw's expected dep count.

This step does not produce a commit; it's a memory update only.

---

## Self-Review

After completing all tasks, walk through this checklist:

1. **Spec coverage** — confirm every spec section maps to a task:
   - §1 目标 → Tasks 3–4 (Check + parser)
   - §2 范围 / §3.1 文件布局 → Tasks 1–11 collectively
   - §3.2–§3.6 平台 gating / required paths / DB load / run() / 不需要的东西 → Tasks 1, 4
   - §4 命令面 + 解析 + 匹配 + Finding → Tasks 3, 4
   - §5 SSH policy 扩展 → Task 1
   - §6 OSV-npm fetcher → Tasks 5, 6, 10
   - §7 Threat Intel + DB 体积 → Tasks 1, 2
   - §8 错误处理 → Task 4 (embedded)
   - §9 测试策略 → Tasks 3–8
   - §10 CLI 注册 → Task 7
   - §11 文档变更 → Tasks 9, 11
   - §12 验收清单 → Task 11 (final regression gate)
   - §13 后续展望 → out of scope (recorded in ADR-0012 D1)

2. **Final verification** — `git log --oneline 5b2c4d7..HEAD` (or current HEAD before this plan starts) should show 11 commits when all tasks complete + the spec/plan commits already on main.

3. **Push** when the user is ready.
