# Phase 7d.3 — rust / crates.io 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 `rust_supply_chain_audit` Check that scans operator-supplied `Cargo.lock` paths against a bundled OSV-Cargo advisory DB, directly reusing `_supply_chain_common.py` (no new abstraction work).

**Architecture:** Operator lists `Cargo.lock` paths in `openclaw-target.yaml`; CLI dynamic-injects them into `CommandPolicy.allowed_paths` (kind=exact); Check parses TOML with `tomllib` (stdlib), filters to `crates.io` registry source only, matches each (crate, version) against bundled `osv_cargo.json` using strict semver 2.0, emits findings via `Check.make_finding` under TI umbrella `TI-OSV-CRATES-CATALOG`. Existing fetcher + check `_supply_chain_common.py` modules used directly — no extraction.

**Tech Stack:** Python 3.11+ · `tomllib` (stdlib) · `semver>=3,<4` (existing dep) · `httpx` (OSV fetch) · `zipfile` (OSV bundle) · `pydantic` (config) · `pytest` · `typer` (CLI)

**Spec:** `docs/superpowers/specs/2026-05-11-phase-7d3-rust-supply-chain-design.md`

---

## File Structure

**Create:**
- `src/agentsec/audit/checks/rust_supply_chain_audit.py` — Check + helpers (parser via tomllib, matcher, version parser)
- `src/agentsec/audit/ioc_update/fetchers/osv_cargo.py` — OSV-Cargo fetcher (offline normalize + HTTP path)
- `src/agentsec/audit/ioc/osv_cargo.json` — bundled DB (empty `[]` placeholder; populated in Task 13)
- `scripts/check_osv_cargo.py` — CI guard
- `tests/audit/checks/test_rust_supply_chain_audit.py`
- `tests/audit/ioc_update/fetchers/test_osv_cargo.py`
- `tests/test_ioc_update_osv_cargo_wiring.py`
- `tests/scripts/test_check_osv_cargo.py`
- `tests/fixtures/supply_chain_cargo/{Cargo.lock-clean, Cargo.lock-vulnerable, Cargo.lock-edge-cases, osv_cargo_subset.json}`
- `tests/fixtures/osv/cargo/{normal, withdrawn, no-cargo, no-range, no-cvss, multi-affected}.json`
- `docs/adr/0015-phase-7d3-rust-supply-chain.md`

**Modify:**
- `src/agentsec/config.py` — `ServerAuditConfig.cargo_lock_paths` + validator
- `src/agentsec/audit/checks/base.py` — `CheckContext.cargo_lock_paths`
- `src/agentsec/audit/server_audit.py` — propagate `cargo_lock_paths`
- `src/agentsec/audit/ioc_update/cli.py` — `_run_osv_cargo` 4-way pipeline
- `src/agentsec/audit/ssh_policy.yaml` — comment annotation
- `src/agentsec/audit/ioc/threat_intel.yaml` — append `TI-OSV-CRATES-CATALOG`
- `src/agentsec/cli.py` — register `RustSupplyChainAuditCheck` + wire `cargo_lock_paths`
- `.github/workflows/ci.yml` — add `check_osv_cargo.py` step
- `tests/audit/checks/test_supply_chain_redaction.py` — extend with rust case
- `pyproject.toml` — version 0.14.0 → 0.15.0
- `ROADMAP.md` / `CHANGELOG.md` / `README.md` / `CLAUDE.md` — docs sync (Task 16)
- `docs/samples/report-2026-04-28/**` — refresh via env-flag pytest (Task 12)

---

## Pre-flight

```bash
cd /Users/roger/Work/AgentSec
git status   # must be clean (current HEAD: b2aaadd, after spec commit)
.venv/bin/pytest tests/ -q  # must be 1302 passing
```

If not green, stop and fix.

---

## Task 1: `ServerAuditConfig.cargo_lock_paths` field + validator

**Files:**
- Modify: `src/agentsec/config.py`
- Test: `tests/audit/test_config.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/audit/test_config.py`:

```python
def test_cargo_lock_paths_default_empty():
    sa = ServerAuditConfig(host="h", user="u", key=Path("/k"))
    assert sa.cargo_lock_paths == []


def test_cargo_lock_paths_accepts_absolute_and_tilde():
    sa = ServerAuditConfig(
        host="h", user="u", key=Path("/k"),
        cargo_lock_paths=[
            "/srv/myservice/Cargo.lock",
            "~/projects/foo/Cargo.lock",
        ],
    )
    assert len(sa.cargo_lock_paths) == 2


def test_cargo_lock_paths_rejects_relative():
    with pytest.raises(ValidationError, match="must start with"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            cargo_lock_paths=["./Cargo.lock"],
        )


def test_cargo_lock_paths_rejects_shell_metachar():
    with pytest.raises(ValidationError, match="shell metacharacter"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            cargo_lock_paths=["/srv/$(whoami)/Cargo.lock"],
        )


def test_cargo_lock_paths_rejects_non_cargo_lock_basename():
    with pytest.raises(ValidationError, match="basename"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            cargo_lock_paths=["/srv/Cargo.toml"],
        )


def test_cargo_lock_paths_rejects_lowercase_cargo_lock():
    """basename is case-sensitive — 'cargo.lock' must be rejected (Cargo uses Cargo.lock)."""
    with pytest.raises(ValidationError, match="basename"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            cargo_lock_paths=["/srv/cargo.lock"],
        )
```

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

Run: `.venv/bin/pytest tests/audit/test_config.py -k cargo_lock -v`
Expected: 6 FAIL.

- [ ] **Step 3: Add field + validator**

In `src/agentsec/config.py`, add this validator and field to `ServerAuditConfig` (after the existing `go_lock_paths` field + validator from 7d.2):

```python
    cargo_lock_paths: list[str] = Field(default_factory=list)

    @field_validator("cargo_lock_paths")
    @classmethod
    def _validate_cargo_lock_paths(cls, v: list[str]) -> list[str]:
        for p in v:
            if not (p.startswith("/") or p.startswith("~/")):
                raise ValueError(
                    f"cargo_lock_paths entry {p!r} must start with '/' or '~/'"
                )
            if contains_shell_metachar([p]) is not None:
                raise ValueError(
                    f"cargo_lock_paths entry {p!r} contains shell metacharacter"
                )
            basename = p.rsplit("/", 1)[-1]
            if basename != "Cargo.lock":
                raise ValueError(
                    f"cargo_lock_paths entry {p!r} basename must equal 'Cargo.lock' "
                    "(case-sensitive; variants must be renamed before audit)"
                )
        return v
```

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

Run: `.venv/bin/pytest tests/audit/test_config.py -k cargo_lock -v`
Expected: 6 PASS.

- [ ] **Step 5: Run full config module tests**

Run: `.venv/bin/pytest tests/audit/test_config.py -q`
Expected: all green.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/config.py tests/audit/test_config.py
git commit -m "$(cat <<'EOF'
feat(7d.3): ServerAuditConfig.cargo_lock_paths + validator

Absolute or ~/-rooted paths; reject shell metachars + basename
must equal exactly 'Cargo.lock' (case-sensitive — Cargo official form).

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

---

## Task 2: `CheckContext.cargo_lock_paths` field

**Files:**
- Modify: `src/agentsec/audit/checks/base.py`
- Test: `tests/audit/checks/test_base.py`

- [ ] **Step 1: Write the failing tests**

Append to `tests/audit/checks/test_base.py`:

```python
def test_check_context_cargo_lock_paths_default_empty():
    ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
    )
    assert ctx.cargo_lock_paths == ()


def test_check_context_cargo_lock_paths_assignable():
    ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
        cargo_lock_paths=("/srv/Cargo.lock",),
    )
    assert ctx.cargo_lock_paths == ("/srv/Cargo.lock",)
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_base.py -k cargo_lock -v`
Expected: 2 FAIL.

- [ ] **Step 3: Add field**

In `src/agentsec/audit/checks/base.py`, add `cargo_lock_paths` after `go_lock_paths`:

```python
@dataclass
class CheckContext:
    executor: Any
    redactor: Any
    ioc_dir: Path | None
    profile: PlatformProfile
    status: CheckStatus = field(default_factory=CheckStatus)
    log_review_config: LogReviewConfig | None = None
    pip_lock_paths: tuple[str, ...] = ()
    go_lock_paths: tuple[str, ...] = ()
    cargo_lock_paths: tuple[str, ...] = ()
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_base.py -v`
Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/base.py tests/audit/checks/test_base.py
git commit -m "$(cat <<'EOF'
feat(7d.3): CheckContext.cargo_lock_paths channel

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

---

## Task 3: `server_audit.run_server_audit` propagates `cargo_lock_paths`

**Files:**
- Modify: `src/agentsec/audit/server_audit.py` (per-check ctx copy)
- Test: `tests/audit/test_server_audit_orchestrator.py`

- [ ] **Step 1: Write the failing test**

Append to `tests/audit/test_server_audit_orchestrator.py`:

```python
class _CaptureCargoPathsCheck(Check):
    id = "capture_cargo"

    def __init__(self):
        super().__init__()
        self.captured: tuple[str, ...] | None = None

    async def run(self, ctx: CheckContext) -> list[Finding]:
        self.captured = ctx.cargo_lock_paths
        return []


def test_run_server_audit_propagates_cargo_lock_paths():
    parent_ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
        cargo_lock_paths=("/srv/a/Cargo.lock", "~/b/Cargo.lock"),
    )
    cap = _CaptureCargoPathsCheck()
    asyncio.run(run_server_audit(checks=[cap], ctx=parent_ctx))
    assert cap.captured == ("/srv/a/Cargo.lock", "~/b/Cargo.lock")
```

- [ ] **Step 2: Run test to verify it fails**

Run: `.venv/bin/pytest tests/audit/test_server_audit_orchestrator.py::test_run_server_audit_propagates_cargo_lock_paths -v`
Expected: FAIL.

- [ ] **Step 3: Modify `run_server_audit`**

In `src/agentsec/audit/server_audit.py`, add `cargo_lock_paths=ctx.cargo_lock_paths,` to the per-check ctx kwargs:

```python
        check_ctx = CheckContext(
            executor=ctx.executor,
            redactor=ctx.redactor,
            ioc_dir=ctx.ioc_dir,
            profile=ctx.profile,
            status=CheckStatus(),
            log_review_config=ctx.log_review_config,
            pip_lock_paths=ctx.pip_lock_paths,
            go_lock_paths=ctx.go_lock_paths,
            cargo_lock_paths=ctx.cargo_lock_paths,
        )
```

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

```bash
.venv/bin/pytest tests/audit/test_server_audit_orchestrator.py -v
.venv/bin/pytest tests/ -q
```
Expected: green; ~1305 passing.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/server_audit.py tests/audit/test_server_audit_orchestrator.py
git commit -m "$(cat <<'EOF'
feat(7d.3): run_server_audit propagates cargo_lock_paths

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

---

## Task 4: Cargo version helpers + `parse_cargo_lock` parser + matcher

**Files:**
- Create: `src/agentsec/audit/checks/rust_supply_chain_audit.py`
- Create: `tests/audit/checks/test_rust_supply_chain_audit.py`

- [ ] **Step 1: Write the failing tests**

Create `tests/audit/checks/test_rust_supply_chain_audit.py`:

```python
"""Tests for parser, matcher, version parser in rust_supply_chain_audit."""

from agentsec.audit.checks.rust_supply_chain_audit import (
    _safe_parse_cargo_version,
    parse_cargo_lock,
    version_in_any_cargo_range,
)


# ---- _safe_parse_cargo_version ----

def test_safe_parse_simple_semver():
    v = _safe_parse_cargo_version("1.0.150")
    assert v is not None
    assert str(v) == "1.0.150"


def test_safe_parse_pre_release():
    v = _safe_parse_cargo_version("0.4.0-beta.1")
    assert v is not None


def test_safe_parse_garbage_returns_none():
    assert _safe_parse_cargo_version("not a version") is None
    assert _safe_parse_cargo_version("v1.2.3") is None   # Cargo doesn't use v prefix
    assert _safe_parse_cargo_version("") is None


# ---- parse_cargo_lock ----

_CARGO_LOCK_SIMPLE = '''\
version = 3

[[package]]
name = "serde"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abcdef"
'''


def test_parse_simple_crate():
    assert parse_cargo_lock(_CARGO_LOCK_SIMPLE) == [("serde", "1.0.150")]


def test_parse_skip_workspace_local():
    """A package with no `source` field is workspace-local; skip it."""
    src = '''\
[[package]]
name = "my-local-crate"
version = "0.1.0"
'''
    assert parse_cargo_lock(src) == []


def test_parse_skip_git_source():
    src = '''\
[[package]]
name = "git-dep"
version = "0.5.0"
source = "git+https://github.com/foo/bar"
'''
    assert parse_cargo_lock(src) == []


def test_parse_skip_alternate_registry():
    src = '''\
[[package]]
name = "private-dep"
version = "0.1.0"
source = "registry+https://other.io/index"
'''
    assert parse_cargo_lock(src) == []


def test_parse_pre_release_version():
    src = '''\
[[package]]
name = "alpha-crate"
version = "0.4.0-beta.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
'''
    assert parse_cargo_lock(src) == [("alpha-crate", "0.4.0-beta.1")]


def test_parse_invalid_toml_returns_empty():
    assert parse_cargo_lock("{not toml") == []


def test_parse_multiple_packages():
    src = '''\
[[package]]
name = "serde"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"

[[package]]
name = "tokio"
version = "1.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"

[[package]]
name = "local"
version = "0.1.0"
'''
    assert parse_cargo_lock(src) == [
        ("serde", "1.0.150"),
        ("tokio", "1.30.0"),
    ]


# ---- version_in_any_cargo_range ----

def test_match_exact_inside_range():
    ranges = [{"introduced": "1.0.0", "fixed": "2.0.0"}]
    assert version_in_any_cargo_range("1.5.0", ranges) is True


def test_match_at_introduced_boundary():
    ranges = [{"introduced": "1.0.0", "fixed": "2.0.0"}]
    assert version_in_any_cargo_range("1.0.0", ranges) is True


def test_match_at_fixed_boundary_excluded():
    ranges = [{"introduced": "1.0.0", "fixed": "2.0.0"}]
    assert version_in_any_cargo_range("2.0.0", ranges) is False


def test_match_pre_release_in_range():
    ranges = [{"introduced": "0", "fixed": "1.0.0"}]
    # pre-release < release per semver
    assert version_in_any_cargo_range("0.4.0-beta.1", ranges) is True


def test_match_unparseable_returns_false():
    ranges = [{"introduced": "1.0.0", "fixed": "2.0.0"}]
    assert version_in_any_cargo_range("garbage", ranges) is False


def test_match_multiple_ranges_or():
    ranges = [
        {"introduced": "1.0.0", "fixed": "1.5.0"},
        {"introduced": "2.0.0", "fixed": "2.5.0"},
    ]
    assert version_in_any_cargo_range("2.3.0", ranges) is True
    assert version_in_any_cargo_range("1.7.0", ranges) is False
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_rust_supply_chain_audit.py -v`
Expected: ImportError — module doesn't exist.

- [ ] **Step 3: Create the module with helpers + parser + matcher only (no Check class yet)**

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

```python
"""rust / crates.io 供应链漏洞审计（Phase 7d.3）。

Reads operator-configured Cargo.lock files on the audited host, parses
the TOML, filters to crates.io-registry packages, and matches each
(crate, version) against a bundled OSV-Cargo advisory subset using
strict semver 2.0. Emits one Finding per matching (crate, version,
advisory) tuple.

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

from __future__ import annotations

import tomllib

import semver

_CRATES_IO_REGISTRY = "registry+https://github.com/rust-lang/crates.io-index"


def _safe_parse_cargo_version(s: str) -> semver.Version | None:
    """Cargo uses strict semver 2.0 — no v prefix, no +incompatible.

    Returns None on parse failure.
    """
    if not s:
        return None
    try:
        return semver.Version.parse(s)
    except (ValueError, TypeError):
        return None


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

    Non-parseable versions return False (caller treats as 'cannot match').
    """
    v = _safe_parse_cargo_version(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")
        if intro != "0":
            intro_v = _safe_parse_cargo_version(intro)
            if intro_v is not None and v.compare(intro_v) < 0:
                continue
        if fixed is not None:
            fixed_v = _safe_parse_cargo_version(fixed)
            if fixed_v is not None and v.compare(fixed_v) >= 0:
                continue
        if last is not None:
            last_v = _safe_parse_cargo_version(last)
            if last_v is not None and v.compare(last_v) > 0:
                continue
        return True
    return False


def parse_cargo_lock(stdout: str) -> list[tuple[str, str]]:
    """Return [(crate_name, version)] from Cargo.lock TOML.

    Filters to packages whose `source` field equals the official crates.io
    registry URL. Workspace-local (no source) / git / path / alternate
    registry deps are silently skipped — only canonical crates.io
    packages are auditable against OSV-Cargo.
    """
    try:
        data = tomllib.loads(stdout)
    except tomllib.TOMLDecodeError:
        return []
    out: list[tuple[str, str]] = []
    for pkg in data.get("package", []):
        if not isinstance(pkg, dict):
            continue
        if pkg.get("source") != _CRATES_IO_REGISTRY:
            continue
        name = pkg.get("name")
        version = pkg.get("version")
        if not name or not version:
            continue
        out.append((name, version))
    return out
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_rust_supply_chain_audit.py -v`
Expected: 17 PASS (3 version + 8 parser + 6 matcher).

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/rust_supply_chain_audit.py \
        tests/audit/checks/test_rust_supply_chain_audit.py
git commit -m "$(cat <<'EOF'
feat(7d.3): Cargo version parser + parse_cargo_lock + matcher

Strict semver 2.0 (no v prefix / +incompatible cleanup needed).
tomllib (stdlib, Python 3.11+) for Cargo.lock TOML parsing; filters
to crates.io registry source only; workspace local / git / path /
alternate-registry deps silently skipped.

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

---

## Task 5: `RustSupplyChainAuditCheck` class + DB load + run() + TI fold-forward

**Files:**
- Modify: `src/agentsec/audit/checks/rust_supply_chain_audit.py` (append Check class)
- Create: `src/agentsec/audit/ioc/osv_cargo.json` (empty `[]`)
- Create: `tests/fixtures/supply_chain_cargo/osv_cargo_subset.json`
- Modify: `tests/audit/checks/test_rust_supply_chain_audit.py` (append e2e tests)
- (Likely) Modify: `src/agentsec/audit/ioc/threat_intel.yaml` (fold-forward TI entry — see Step 7)

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

```bash
echo '[]' > src/agentsec/audit/ioc/osv_cargo.json
```

- [ ] **Step 2: Create the fixture**

Create `tests/fixtures/supply_chain_cargo/osv_cargo_subset.json`:

```json
[
  {
    "id": "GHSA-rust-aaaa-bbbb",
    "package": "serde",
    "ranges": [{"introduced": "0", "fixed": "1.0.150"}],
    "severity": "high",
    "summary": "Type confusion in serde deserialization",
    "url": "https://github.com/advisories/GHSA-rust-aaaa-bbbb",
    "aliases": ["CVE-2023-12345", "RUSTSEC-2023-0001"]
  },
  {
    "id": "GHSA-rust-cccc-dddd",
    "package": "tokio",
    "ranges": [{"introduced": "1.0.0", "fixed": "1.30.0"}],
    "severity": "critical",
    "summary": "Race condition in tokio task scheduling",
    "url": "https://github.com/advisories/GHSA-rust-cccc-dddd",
    "aliases": ["CVE-2023-99999"]
  }
]
```

- [ ] **Step 3: Write failing e2e tests**

Append to `tests/audit/checks/test_rust_supply_chain_audit.py`:

```python
import asyncio
from dataclasses import dataclass
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.rust_supply_chain_audit import RustSupplyChainAuditCheck
from agentsec.audit.platform_profile import LINUX

_FIXTURE_DB = Path("tests/fixtures/supply_chain_cargo/osv_cargo_subset.json")


@dataclass
class _ExecResult:
    stdout: str = ""
    stderr: str = ""
    exit_code: int = 0
    rejected: bool = False
    reject_reason: str = ""


class _StubExecutor:
    def __init__(self, files: dict[str, str], rejected_paths: set[str] = frozenset()):
        self._files = files
        self._rejected = rejected_paths

    def run(self, argv: list[str]) -> _ExecResult:
        assert argv[0] == "cat"
        path = argv[1]
        if path in self._rejected:
            return _ExecResult(rejected=True, reject_reason="not in allowed_paths")
        if path not in self._files:
            return _ExecResult(stderr="No such file", exit_code=1)
        return _ExecResult(stdout=self._files[path])


class _NoOpRedactor:
    def redact(self, s: str) -> str:
        return s


def _ctx(*, cargo_lock_paths: tuple[str, ...], executor) -> CheckContext:
    return CheckContext(
        executor=executor, redactor=_NoOpRedactor(),
        ioc_dir=Path("/x"), profile=LINUX,
        cargo_lock_paths=cargo_lock_paths,
    )


_VULN_LOCK = '''\
[[package]]
name = "serde"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
'''

_CLEAN_LOCK = '''\
[[package]]
name = "serde"
version = "1.0.200"
source = "registry+https://github.com/rust-lang/crates.io-index"
'''


def test_check_empty_config_not_applicable():
    check = RustSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(cargo_lock_paths=(), executor=_StubExecutor({}))
    findings = asyncio.run(check.run(ctx))
    assert findings == []
    assert ctx.status.is_not_applicable
    assert "未配置" in ctx.status.not_applicable_reason


def test_check_single_lockfile_one_hit():
    check = RustSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        cargo_lock_paths=("/srv/Cargo.lock",),
        executor=_StubExecutor({"/srv/Cargo.lock": _VULN_LOCK}),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    f = findings[0]
    assert f.check == "rust_supply_chain_audit"
    assert f.severity == "high"
    assert "serde@1.0.100" in f.title
    assert f.evidence["advisory_id"] == "GHSA-rust-aaaa-bbbb"
    assert f.evidence["fixed_in"] == "1.0.150"
    assert f.evidence["lock_path"] == "/srv/Cargo.lock"
    assert f.threat_refs == ["TI-OSV-CRATES-CATALOG"]


def test_check_multi_lockfile_mixed():
    check = RustSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        cargo_lock_paths=("/a/Cargo.lock", "/b/Cargo.lock", "/missing/Cargo.lock"),
        executor=_StubExecutor({
            "/a/Cargo.lock": _VULN_LOCK,
            "/b/Cargo.lock": _CLEAN_LOCK,  # > 1.0.150, no hit
        }),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert findings[0].evidence["lock_path"] == "/a/Cargo.lock"
    assert findings[0].severity == "high"


def test_check_all_paths_unreadable_skipped():
    check = RustSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        cargo_lock_paths=("/x/Cargo.lock", "/y/Cargo.lock"),
        executor=_StubExecutor({}, rejected_paths={"/x/Cargo.lock", "/y/Cargo.lock"}),
    )
    findings = asyncio.run(check.run(ctx))
    assert findings == []
    assert ctx.status.is_skipped
    assert "n=2" in ctx.status.skip_reason


def test_check_unparseable_version_low_finding():
    check = RustSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    bad_lock = '''\
[[package]]
name = "weird-crate"
version = "not-a-version"
source = "registry+https://github.com/rust-lang/crates.io-index"
'''
    ctx = _ctx(
        cargo_lock_paths=("/req/Cargo.lock",),
        executor=_StubExecutor({"/req/Cargo.lock": bad_lock}),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert findings[0].severity == "low"
    assert "无法解析 semver" in findings[0].title


def test_check_non_linux_not_applicable():
    from agentsec.audit.platform_profile import MACOS
    check = RustSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = CheckContext(
        executor=_StubExecutor({}), redactor=_NoOpRedactor(),
        ioc_dir=Path("/x"), profile=MACOS,
        cargo_lock_paths=("/x/Cargo.lock",),
    )
    findings = asyncio.run(check.run(ctx))
    assert findings == []
    assert ctx.status.is_not_applicable
    assert "非 Linux" in ctx.status.not_applicable_reason
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_rust_supply_chain_audit.py -k 'check_' -v`
Expected: FAIL — `RustSupplyChainAuditCheck` doesn't exist.

- [ ] **Step 5: Append the Check class**

Add to the top of `src/agentsec/audit/checks/rust_supply_chain_audit.py` (with the existing imports):

```python
from pathlib import Path

from ..findings import Finding
from ._supply_chain_common import (
    Severity,
    load_advisory_db,
)
from .base import Check, CheckContext
```

(Add these imports immediately after `import semver` so all module-top imports stay grouped — no E402 risk.)

Then append at the bottom of the module:

```python
_OSV_DB_PATH = Path(__file__).parent.parent / "ioc" / "osv_cargo.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 RustSupplyChainAuditCheck(Check):
    id = "rust_supply_chain_audit"

    def __init__(self, db_path: Path | None = None):
        super().__init__()
        path = db_path if db_path is not None else _OSV_DB_PATH
        self._advisories_by_pkg = load_advisory_db(path)

    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 ctx.cargo_lock_paths:
            ctx.status.not_applicable(f"{self.id}：未配置 cargo_lock_paths")
            return []

        findings: list[Finding] = []
        n_skipped = 0
        for lock_path in ctx.cargo_lock_paths:
            result = ctx.executor.run(["cat", lock_path])
            if result.rejected:
                n_skipped += 1
                continue
            if result.exit_code != 0:
                n_skipped += 1
                continue
            if not result.stdout.strip():
                n_skipped += 1
                continue
            findings.extend(self._scan_lockfile(ctx, lock_path, result.stdout))
        if not findings and n_skipped == len(ctx.cargo_lock_paths):
            ctx.status.skipped(
                f"{self.id}：所有 cargo_lock_paths 均不可读 (n={n_skipped})"
            )
        return findings

    def _scan_lockfile(
        self, ctx: CheckContext, lock_path: str, stdout: str,
    ) -> list[Finding]:
        crates = parse_cargo_lock(stdout)
        findings: list[Finding] = []
        for name, version in crates:
            if _safe_parse_cargo_version(version) is None:
                findings.append(
                    self._unparseable_finding(ctx, name, version, lock_path)
                )
                continue
            for adv in self._advisories_by_pkg.get(name, []):
                if version_in_any_cargo_range(version, adv["ranges"]):
                    findings.append(
                        self._advisory_finding(ctx, name, version, adv, lock_path)
                    )
        return findings

    def _advisory_finding(
        self, ctx: CheckContext, name: str, version: str,
        adv: dict, lock_path: 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 self.make_finding(
            ctx,
            severity=sev,
            title=f"rust {name}@{version} 命中 {adv_id}（severity={sev}）",
            description=adv.get("summary", "")[:200],
            evidence={
                "package": name,
                "version": version,
                "advisory_id": adv_id,
                "aliases": adv.get("aliases", []),
                "fixed_in": fixed_in,
                "url": adv.get("url", ""),
                "lock_path": lock_path,
            },
            remediation=f"cargo update -p {name} --precise {fixed_in}",
            threat_refs=["TI-OSV-CRATES-CATALOG"],
        )

    def _unparseable_finding(
        self, ctx: CheckContext, name: str, version: str, lock_path: str,
    ) -> Finding:
        return self.make_finding(
            ctx,
            severity="low",
            title=f"无法解析 semver: {name}@{version}",
            description=(
                "非标准 semver 版本字符串；Cargo 应该不会出现，"
                "可能是手改 Cargo.lock"
            ),
            evidence={
                "package": name, "version": version, "lock_path": lock_path,
            },
            remediation="跑 `cargo update` 重生成 Cargo.lock",
            threat_refs=["TI-OSV-CRATES-CATALOG"],
        )
```

- [ ] **Step 6: Run tests**

```bash
.venv/bin/pytest tests/audit/checks/test_rust_supply_chain_audit.py -v
```

Expected: ~23 PASS (17 from Task 4 + 6 e2e).

- [ ] **Step 7: TI fold-forward (CRITICAL — same lesson from 7d.1/7d.2)**

`Finding.threat_refs=["TI-OSV-CRATES-CATALOG"]` will trigger strict TI validation. Run:

```bash
.venv/bin/python scripts/check_threat_refs.py
```

If this fails (likely), append this entry to `src/agentsec/audit/ioc/threat_intel.yaml`:

```yaml
- id: TI-OSV-CRATES-CATALOG
  source: osv
  url: https://osv.dev/list?ecosystem=crates.io
  observed_at: '2026-05-11'
  claim: OSV.dev crates.io ecosystem advisory catalog — rust_supply_chain_audit Check 引用根
  confidence: high
  mapped_tests: []
  auto_refresh: false
  note: 真实 advisory 索引在 src/agentsec/audit/ioc/osv_cargo.json；本 TI 仅作为 rust_supply_chain finding 的 threat_refs 目标
```

Then re-run `check_threat_refs.py` to confirm green.

- [ ] **Step 8: Final pre-commit verification**

```bash
.venv/bin/pytest tests/ -q
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/python scripts/check_threat_refs.py
```

Expected: all green.

- [ ] **Step 9: Commit**

```bash
git add src/agentsec/audit/checks/rust_supply_chain_audit.py \
        src/agentsec/audit/ioc/osv_cargo.json \
        tests/audit/checks/test_rust_supply_chain_audit.py \
        tests/fixtures/supply_chain_cargo/osv_cargo_subset.json
# Include threat_intel.yaml IF you added the TI entry in Step 7:
git add src/agentsec/audit/ioc/threat_intel.yaml 2>/dev/null || true

git commit -m "$(cat <<'EOF'
feat(7d.3): RustSupplyChainAuditCheck

Reads ctx.cargo_lock_paths, cats each Cargo.lock via executor, parses
TOML via tomllib, filters to crates.io-registry source, matches
against bundled osv_cargo.json (empty placeholder; populated in
Task 13). Per-(crate, version, advisory) findings under
TI-OSV-CRATES-CATALOG via Check.make_finding (automatic Redactor pass).

Also adds TI-OSV-CRATES-CATALOG umbrella entry to threat_intel.yaml
so strict threat-ref validation passes.

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

---

## Task 6: `scripts/check_osv_cargo.py` CI guard + ci.yml integration

**Files:**
- Create: `scripts/check_osv_cargo.py`
- Create: `tests/scripts/test_check_osv_cargo.py`
- Modify: `.github/workflows/ci.yml`

- [ ] **Step 1: Look at the go precedent**

```bash
cat scripts/check_osv_go.py
```

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

Create `tests/scripts/test_check_osv_cargo.py`:

```python
import json
import subprocess
import sys
from pathlib import Path


def _run_script(*args, cwd: Path) -> subprocess.CompletedProcess:
    return subprocess.run(
        [sys.executable, "scripts/check_osv_cargo.py", *args],
        cwd=cwd, capture_output=True, text=True, check=False,
    )


def test_check_valid_json_passes(tmp_path):
    db = tmp_path / "osv_cargo.json"
    db.write_text(json.dumps([
        {"id": "GHSA-x", "package": "serde",
         "ranges": [{"fixed": "1.0.0"}],
         "severity": "high", "summary": "x", "url": "u", "aliases": []},
    ]))
    p = _run_script("--db", str(db), cwd=Path.cwd())
    assert p.returncode == 0, p.stderr


def test_check_oversize_fails(tmp_path):
    db = tmp_path / "osv_cargo.json"
    db.write_text("[" + ",".join(['"x"'] * (16 * 1024 * 1024 // 4)) + "]")
    p = _run_script("--db", str(db), cwd=Path.cwd())
    assert p.returncode != 0
    assert "MB" in (p.stderr + p.stdout)


def test_check_invalid_json_fails(tmp_path):
    db = tmp_path / "osv_cargo.json"
    db.write_text("{not json")
    p = _run_script("--db", str(db), cwd=Path.cwd())
    assert p.returncode != 0
```

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

Run: `.venv/bin/pytest tests/scripts/test_check_osv_cargo.py -v`
Expected: FAIL — script doesn't exist.

- [ ] **Step 4: Write the script**

Create `scripts/check_osv_cargo.py`:

```python
#!/usr/bin/env python3
"""CI guard for src/agentsec/audit/ioc/osv_cargo.json.

Validates JSON well-formedness and a 15 MB hard cap on file size.
Exits 0 on success; 1 on any failure.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

_DEFAULT_DB = Path("src/agentsec/audit/ioc/osv_cargo.json")
_MAX_BYTES = 15 * 1024 * 1024


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--db", type=Path, default=_DEFAULT_DB)
    parser.add_argument(
        "--check", action="store_true",
        help="(no-op flag for parity with check_osv_npm.py / check_osv_pypi.py / check_osv_go.py)",
    )
    args = parser.parse_args()

    db: Path = args.db
    if not db.exists():
        print(f"ERR: {db} does not exist", file=sys.stderr)
        return 1

    size = db.stat().st_size
    if size > _MAX_BYTES:
        size_mb = size / (1024 * 1024)
        print(
            f"ERR: {db} is {size_mb:.1f} MB, exceeds 15 MB cap",
            file=sys.stderr,
        )
        return 1

    try:
        data = json.loads(db.read_text())
    except json.JSONDecodeError as e:
        print(f"ERR: {db} is not valid JSON: {e}", file=sys.stderr)
        return 1

    if not isinstance(data, list):
        print(f"ERR: {db} top-level must be a JSON list", file=sys.stderr)
        return 1

    print(f"OK: {db} ({size / 1024:.1f} KB, {len(data)} entries)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

```bash
chmod +x scripts/check_osv_cargo.py
```

- [ ] **Step 5: Run tests + script**

```bash
.venv/bin/pytest tests/scripts/test_check_osv_cargo.py -v
.venv/bin/python scripts/check_osv_cargo.py
```

Expected: 3 PASS + `OK: src/agentsec/audit/ioc/osv_cargo.json (0.0 KB, 0 entries)`.

- [ ] **Step 6: Add ci.yml steps**

Inspect: `grep -n 'check_osv_go' .github/workflows/ci.yml`

You should see `check_osv_go.py` steps in BOTH the `ioc-sync` and `test-macos` jobs (Task 11 of 7d.2 added them). Add a `check_osv_cargo.py` step right after each, mirroring whatever invocation pattern that job uses.

- [ ] **Step 7: Verify ci.yml**

```bash
.venv/bin/python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"
```

Expected: no error.

- [ ] **Step 8: Commit**

```bash
git add scripts/check_osv_cargo.py \
        tests/scripts/test_check_osv_cargo.py \
        .github/workflows/ci.yml
git commit -m "$(cat <<'EOF'
build(7d.3): scripts/check_osv_cargo.py CI guard

Validates JSON + 15 MB hard cap, mirrors check_osv_npm.py / check_osv_pypi.py / check_osv_go.py.
.github/workflows/ci.yml invokes it in both ioc-sync and test-macos jobs.

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

---

## Task 7: `OsvCargoFetcher.normalize_osv_cargo_entry` (offline filter chain)

**Files:**
- Create: `src/agentsec/audit/ioc_update/fetchers/osv_cargo.py` (offline path only here; HTTP class in Task 8)
- Create: `tests/audit/ioc_update/fetchers/test_osv_cargo.py`
- Create: `tests/fixtures/osv/cargo/{normal, withdrawn, no-cargo, no-range, no-cvss, multi-affected}.json`

- [ ] **Step 1: Look at the go fetcher template**

```bash
head -200 src/agentsec/audit/ioc_update/fetchers/osv_go.py
```

- [ ] **Step 2: Create 6 fixture files**

Create directory `tests/fixtures/osv/cargo/`:

`tests/fixtures/osv/cargo/normal.json`:
```json
{
  "id": "GHSA-norm-aaaa-bbbb",
  "published": "2025-06-01T00:00:00Z",
  "modified": "2025-06-02T00:00:00Z",
  "summary": "Type confusion in serde",
  "affected": [{
    "package": {"ecosystem": "crates.io", "name": "serde"},
    "ranges": [{
      "type": "ECOSYSTEM",
      "events": [{"introduced": "0"}, {"fixed": "1.0.150"}]
    }]
  }],
  "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-2025-99001", "RUSTSEC-2025-0001"]
}
```

`tests/fixtures/osv/cargo/withdrawn.json`:
```json
{
  "id": "GHSA-with-aaaa-bbbb",
  "withdrawn": "2025-01-01T00:00:00Z",
  "affected": [{"package": {"ecosystem": "crates.io", "name": "x"},
                "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]}]}],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
}
```

`tests/fixtures/osv/cargo/no-cargo.json` (only npm affected):
```json
{
  "id": "GHSA-cross-aaaa-bbbb",
  "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"}]
}
```

`tests/fixtures/osv/cargo/no-range.json`:
```json
{
  "id": "GHSA-norang-aaaa-bbbb",
  "affected": [{"package": {"ecosystem": "crates.io", "name": "foo"}, "ranges": []}],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
}
```

`tests/fixtures/osv/cargo/no-cvss.json`:
```json
{
  "id": "GHSA-noscore-aaaa-bbbb",
  "affected": [{
    "package": {"ecosystem": "crates.io", "name": "foo"},
    "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]}]
  }],
  "severity": []
}
```

`tests/fixtures/osv/cargo/multi-affected.json`:
```json
{
  "id": "GHSA-multi-aaaa-bbbb",
  "published": "2025-06-01T00:00:00Z",
  "summary": "Affects two crates",
  "affected": [
    {
      "package": {"ecosystem": "crates.io", "name": "fork-a"},
      "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "1.0.0"}]}]
    },
    {
      "package": {"ecosystem": "crates.io", "name": "fork-b"},
      "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "2.0.0"}]}]
    }
  ],
  "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
}
```

- [ ] **Step 3: Write failing fetcher tests**

Create `tests/audit/ioc_update/fetchers/test_osv_cargo.py`:

```python
import asyncio
import io
import json
import zipfile
from pathlib import Path

import httpx

from agentsec.audit.ioc_update.fetchers.base import FetcherContext
from agentsec.audit.ioc_update.fetchers.osv_cargo import (
    OsvCargoFetcher,
    normalize_osv_cargo_entry,
)

_FIX = Path("tests/fixtures/osv/cargo")


def _load(name: str) -> dict:
    return json.loads((_FIX / name).read_text())


def test_normalize_normal_entry():
    out = normalize_osv_cargo_entry(_load("normal.json"))
    assert out is not None
    assert isinstance(out, list)
    assert len(out) == 1
    rec = out[0]
    assert rec["id"] == "GHSA-norm-aaaa-bbbb"
    assert rec["package"] == "serde"   # no normalization
    assert rec["severity"] == "critical"
    assert rec["ranges"] == [{"introduced": "0", "fixed": "1.0.150"}]
    assert "RUSTSEC-2025-0001" in rec["aliases"]


def test_normalize_filters_withdrawn():
    assert normalize_osv_cargo_entry(_load("withdrawn.json")) is None


def test_normalize_filters_no_cargo_affected():
    assert normalize_osv_cargo_entry(_load("no-cargo.json")) is None


def test_normalize_filters_no_range():
    assert normalize_osv_cargo_entry(_load("no-range.json")) is None


def test_normalize_filters_no_cvss():
    assert normalize_osv_cargo_entry(_load("no-cvss.json")) is None


def test_normalize_drops_old_medium_low():
    entry = _load("normal.json")
    entry["severity"] = [{
        "type": "CVSS_V3",
        "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N",
    }]
    entry["published"] = "2020-01-01T00:00:00Z"
    assert normalize_osv_cargo_entry(entry) is None


def test_normalize_keeps_old_critical():
    entry = _load("normal.json")
    entry["published"] = "2020-01-01T00:00:00Z"
    out = normalize_osv_cargo_entry(entry)
    assert out is not None and len(out) == 1
    assert out[0]["severity"] == "critical"


def test_normalize_multi_affected_expansion():
    out = normalize_osv_cargo_entry(_load("multi-affected.json"))
    assert out is not None
    assert len(out) == 2
    pkgs = sorted(r["package"] for r in out)
    assert pkgs == ["fork-a", "fork-b"]
    assert {r["id"] for r in out} == {"GHSA-multi-aaaa-bbbb"}
```

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

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_cargo.py -v`
Expected: FAIL — module doesn't exist.

- [ ] **Step 5: Create the fetcher module (offline path + skeleton class with placeholders)**

Create `src/agentsec/audit/ioc_update/fetchers/osv_cargo.py`. Include all imports needed for both offline and HTTP paths (so Task 8 is a pure-append diff):

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

Pulls https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip
and normalizes the included OSV entries to the compact schema
consumed by RustSupplyChainAuditCheck. Returns a single FetchResult
keyed under '_osv_cargo_full' for the cli.py orchestrator.
"""

from __future__ import annotations

import asyncio
import io
import json
import zipfile

import httpx

from ..watchlist import WatchlistEntry
from ._supply_chain_common import (
    MEDIUM_LOW_MAX_AGE_DAYS,
    _extract_severity,
    _is_within_age,
)
from .base import Fetcher, FetcherContext, FetchResult, hash_response

OSV_CARGO_URL = (
    "https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip"
)
RETRY_BACKOFFS = [2, 4, 8]
_HTTP_TIMEOUT = 60


def _extract_cargo_ranges(ranges: list[dict]) -> list[dict]:
    """OSV-Cargo uses ECOSYSTEM type with strict semver 2.0 strings."""
    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_cargo_entry(osv_entry: dict) -> list[dict] | None:
    """Return [compact-schema dict, ...] (one per crates.io affected pkg),
    or None if the entry is unusable / filtered out by bundle-size policy.

    Filter rules (5 axes): withdrawn, no-crates.io-affected, no-range, no-CVSS,
    medium/low > 2 years old.
    """
    if osv_entry.get("withdrawn"):
        return None
    affected_cargo = [
        a for a in osv_entry.get("affected", [])
        if (a.get("package") or {}).get("ecosystem") == "crates.io"
    ]
    if not affected_cargo:
        return None

    severity, had_cvss = _extract_severity(osv_entry.get("severity", []))
    if not had_cvss:
        return None

    if severity in ("medium", "low") and not _is_within_age(
        osv_entry.get("published", "") or osv_entry.get("modified", ""),
        MEDIUM_LOW_MAX_AGE_DAYS,
    ):
        return None

    out: list[dict] = []
    for aff in affected_cargo:
        ranges = _extract_cargo_ranges(aff.get("ranges", []))
        if not ranges:
            continue
        pkg = aff["package"]["name"]   # crate name; no normalization
        out.append({
            "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", []),
        })
    return out if out else None


# OsvCargoFetcher class lands in Task 8 (HTTP path)
class OsvCargoFetcher(Fetcher):
    """Placeholder — full implementation in Task 8."""
    name = "osv_cargo"
```

- [ ] **Step 6: Run tests**

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_cargo.py -k normalize -v`
Expected: 8 PASS (the 8 normalize tests; HTTP tests are Task 8).

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_cargo.py \
        tests/audit/ioc_update/fetchers/test_osv_cargo.py \
        tests/fixtures/osv/cargo/
git commit -m "$(cat <<'EOF'
feat(7d.3): OsvCargoFetcher.normalize_osv_cargo_entry (offline)

5-axis filter chain reusing _supply_chain_common helpers; crates.io
ecosystem only (literal string match, case-sensitive); crate names
kept as-is (no normalization — serde-json ≠ serde_json in Cargo);
multi-affected expands to multiple records.

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

---

## Task 8: `OsvCargoFetcher` HTTP path

**Files:**
- Modify: `src/agentsec/audit/ioc_update/fetchers/osv_cargo.py` (replace stub class with full impl)
- Modify: `tests/audit/ioc_update/fetchers/test_osv_cargo.py` (append HTTP tests)

- [ ] **Step 1: Write the failing HTTP tests**

Append to `tests/audit/ioc_update/fetchers/test_osv_cargo.py`:

```python
def _make_zip(entries: dict[str, dict]) -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        for name, payload in entries.items():
            zf.writestr(name, json.dumps(payload))
    return buf.getvalue()


def test_fetch_success_one_zip():
    payload = _make_zip({
        "GHSA-norm-aaaa-bbbb.json": _load("normal.json"),
        "GHSA-with-aaaa-bbbb.json": _load("withdrawn.json"),  # filtered
    })

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, content=payload)

    fetcher = OsvCargoFetcher()
    fetcher._client_factory = lambda: httpx.AsyncClient(
        transport=httpx.MockTransport(handler),
    )

    ctx = FetcherContext(offline=False)
    results = asyncio.run(fetcher.run(ctx, []))
    assert "_osv_cargo_full" in results
    r = results["_osv_cargo_full"]
    assert r.records is not None
    assert len(r.records) == 1
    assert r.records[0]["id"] == "GHSA-norm-aaaa-bbbb"


def test_fetch_retry_exhaustion_degraded():
    call_count = {"n": 0}

    def handler(request: httpx.Request) -> httpx.Response:
        call_count["n"] += 1
        return httpx.Response(503)

    fetcher = OsvCargoFetcher()
    fetcher._client_factory = lambda: httpx.AsyncClient(
        transport=httpx.MockTransport(handler),
    )
    fetcher._sleep = lambda _s: asyncio.sleep(0)

    ctx = FetcherContext(offline=False)
    results = asyncio.run(fetcher.run(ctx, []))
    r = results["_osv_cargo_full"]
    assert r.degraded is not None
    assert call_count["n"] == 4
```

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

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_cargo.py -k fetch -v`
Expected: FAIL — `OsvCargoFetcher` is still a stub.

- [ ] **Step 3: Replace the stub class with the full implementation**

In `src/agentsec/audit/ioc_update/fetchers/osv_cargo.py`, replace the stub `OsvCargoFetcher` placeholder with:

```python
class OsvCargoFetcher(Fetcher):
    """Single-source full-ecosystem fetcher for OSV-Cargo.

    Doesn't take per-entry watchlist input — crates.io is one ecosystem
    and we pull everything in one shot. Returns a single FetchResult
    keyed under '_osv_cargo_full' for cli.py orchestration.
    """

    name = "osv_cargo"

    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 — full ecosystem fetch
    ) -> dict[str, FetchResult]:
        if ctx.offline:
            return {
                "_osv_cargo_full": FetchResult.from_degraded(
                    reason="offline mode without OSV-Cargo fixture support",
                    fetcher=self.name,
                )
            }
        try:
            return await self._fetch_and_normalize(ctx)
        except (httpx.HTTPError, zipfile.BadZipFile) as e:
            return {
                "_osv_cargo_full": FetchResult.from_degraded(
                    reason=f"OSV-Cargo fetch failed: {e}",
                    fetcher=self.name,
                )
            }

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

        normalized: list[dict] = []
        seen_keys: set[tuple[str, 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_cargo_entry(raw)
                if norm is None:
                    continue
                for rec in norm:
                    key = (rec["id"], rec["package"])
                    if key in seen_keys:
                        continue
                    seen_keys.add(key)
                    normalized.append(rec)
        normalized.sort(key=lambda x: (x["id"], x["package"]))
        return {
            "_osv_cargo_full": FetchResult.ok(
                records=normalized,
                http_status=200,
                response_sha256=hash_response(body),
                raw_record_count=len(normalized),
            ),
        }
```

- [ ] **Step 4: Run tests**

```bash
.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_cargo.py -v
```

Expected: 10 PASS (8 normalize + 2 HTTP).

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_cargo.py \
        tests/audit/ioc_update/fetchers/test_osv_cargo.py
git commit -m "$(cat <<'EOF'
feat(7d.3): OsvCargoFetcher HTTP path

httpx + zipfile + retry backoff [2,4,8]s; (id, package) dedup so
multi-affected expansions don't double-insert.

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

---

## Task 9: `_run_osv_cargo` 4-way parallel pipeline

**Files:**
- Modify: `src/agentsec/audit/ioc_update/cli.py`
- Create: `tests/test_ioc_update_osv_cargo_wiring.py`

- [ ] **Step 1: Read existing osv_go wiring**

```bash
cat tests/test_ioc_update_osv_go_wiring.py
grep -n "_run_osv_npm\|_run_osv_pypi\|_run_osv_go\|asyncio.gather" src/agentsec/audit/ioc_update/cli.py
```

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

Create `tests/test_ioc_update_osv_cargo_wiring.py`:

```python
"""ioc-update CLI must invoke OsvCargoFetcher and write proposed-osv_cargo.json (Phase 7d.3)."""

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_cargo
from agentsec.audit.ioc_update.fetchers.base import FetcherContext, FetchResult


@pytest.mark.asyncio
async def test_run_osv_cargo_writes_proposed_file(tmp_path: Path):
    fake_records = [
        {"id": "GHSA-w", "package": "serde",
         "ranges": [{"introduced": "0", "fixed": "1.0.0"}],
         "severity": "high", "summary": "x", "url": "u", "aliases": []}
    ]
    with patch(
        "agentsec.audit.ioc_update.cli.OsvCargoFetcher"
    ) as MockFetcher:
        async def fake_run(ctx, entries):
            return {"_osv_cargo_full": FetchResult.ok(records=fake_records)}
        MockFetcher.return_value.run.side_effect = fake_run

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

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


@pytest.mark.asyncio
async def test_run_osv_cargo_handles_degraded_fetch(tmp_path: Path):
    with patch(
        "agentsec.audit.ioc_update.cli.OsvCargoFetcher"
    ) as MockFetcher:
        async def fake_run(ctx, entries):
            return {"_osv_cargo_full": FetchResult.from_degraded(
                reason="HTTP 503", fetcher="osv_cargo",
            )}
        MockFetcher.return_value.run.side_effect = fake_run

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

    assert out_path is None
```

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

Run: `.venv/bin/pytest tests/test_ioc_update_osv_cargo_wiring.py -v`
Expected: ImportError on `_run_osv_cargo`.

- [ ] **Step 4: Add `_run_osv_cargo` and extend gather to 4-way**

In `src/agentsec/audit/ioc_update/cli.py`:

a. Add import near `from .fetchers.osv_go import OsvGoFetcher`:

```python
from .fetchers.osv_cargo import OsvCargoFetcher
```

b. Add the function after `_run_osv_go`:

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

    Mirrors _run_osv_npm / _run_osv_pypi / _run_osv_go. Runs in parallel
    with all three via asyncio.gather in the main entry point.
    """
    fetcher = OsvCargoFetcher()
    results = await fetcher.run(ctx, [])
    result = results.get("_osv_cargo_full")
    if result is None or result.degraded is not None:
        return None
    out_path = cache_dir / "proposed-osv_cargo.json"
    out_path.write_text(json.dumps(result.records, indent=2, sort_keys=True))
    return out_path
```

c. Find the existing 3-way `asyncio.gather(_run_osv_npm, _run_osv_pypi, _run_osv_go)` and extend to 4-way:

```python
osv_npm_path, osv_pypi_path, osv_go_path, osv_cargo_path = await asyncio.gather(
    _run_osv_npm(fctx, out_dir),
    _run_osv_pypi(fctx, out_dir),
    _run_osv_go(fctx, out_dir),
    _run_osv_cargo(fctx, out_dir),
)
# ... existing 3 log_event blocks unchanged
# add a fourth for osv_cargo (mirror the existing pattern):
if osv_cargo_path is not None:
    log_event({
        "event": "osv_cargo_written",
        "path": str(osv_cargo_path),
        "records": len(json.loads(osv_cargo_path.read_text())),
    })
else:
    log_event({
        "event": "osv_cargo_degraded",
        "note": "OSV-Cargo fetch failed or skipped; existing osv_cargo.json unchanged",
    })
```

(Use the exact `log_event` shape from the surrounding blocks.)

- [ ] **Step 5: Run tests + full suite**

```bash
.venv/bin/pytest tests/test_ioc_update_osv_cargo_wiring.py tests/test_ioc_update_osv_go_wiring.py tests/test_ioc_update_osv_pypi_wiring.py tests/test_ioc_update_osv_npm_wiring.py -v
.venv/bin/pytest tests/ -q
```

Expected: 8 wiring tests PASS + full suite green.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/ioc_update/cli.py \
        tests/test_ioc_update_osv_cargo_wiring.py
git commit -m "$(cat <<'EOF'
feat(7d.3): _run_osv_cargo 4-way parallel side pipeline

Runs alongside _run_osv_npm + _run_osv_pypi + _run_osv_go via
asyncio.gather; any single ecosystem degraded doesn't block the other three.

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

---

## Task 10: Register `RustSupplyChainAuditCheck` + wire `cargo_lock_paths`

**Files:**
- Modify: `src/agentsec/cli.py` (multiple sites)

- [ ] **Step 1: Read existing go wiring as template**

```bash
grep -n 'GoSupplyChainAuditCheck\|go_lock_paths\|--go-lock-path' src/agentsec/cli.py
```

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

After `from .audit.checks.go_supply_chain_audit import GoSupplyChainAuditCheck`:

```python
from .audit.checks.rust_supply_chain_audit import RustSupplyChainAuditCheck
```

- [ ] **Step 3: Standalone `server_audit` typer command**

a. After the existing `go_lock_paths` typer.Option, add:

```python
    cargo_lock_paths: list[str] = typer.Option(
        [], "--cargo-lock-path",
        help="Repeat for each Cargo.lock to scan; absolute or ~/-rooted.",
    ),
```

b. After the existing `if go_lock_paths:` injection block, add:

```python
    if cargo_lock_paths:
        from .config import ServerAuditConfig
        ServerAuditConfig.model_validate({
            "host": "validation-only",
            "user": "validation-only",
            "key": "/dev/null",
            "cargo_lock_paths": cargo_lock_paths,
        })
        expanded_cargo = [_expand_tilde(p, remote_home) for p in cargo_lock_paths]
        policy.add_runtime_allowed_paths(expanded_cargo, kind="exact")
        _log_runtime_paths_added(
            audit_log, expanded_cargo,
            "agentsec server-audit --cargo-lock-path",
        )
```

c. In `checks = [...]`, append `RustSupplyChainAuditCheck()` right after `GoSupplyChainAuditCheck()`.

d. In `ctx = CheckContext(...)`, add `cargo_lock_paths=` kwarg:

```python
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=Path(__file__).parent / "audit" / "ioc",
        profile=profile,
        log_review_config=LogReviewConfig(),
        pip_lock_paths=tuple(_expand_tilde(p, remote_home) for p in pip_lock_paths),
        go_lock_paths=tuple(_expand_tilde(p, remote_home) for p in go_lock_paths),
        cargo_lock_paths=tuple(_expand_tilde(p, remote_home) for p in cargo_lock_paths),
    )
```

- [ ] **Step 4: `_evaluate_server` helper**

a. After the existing `if sa.go_lock_paths:` injection block, add:

```python
    if sa.cargo_lock_paths:
        expanded_cargo = [_expand_tilde(p, remote_home) for p in sa.cargo_lock_paths]
        policy.add_runtime_allowed_paths(expanded_cargo, kind="exact")
        _log_runtime_paths_added(
            audit_log, expanded_cargo,
            "ServerAuditConfig.cargo_lock_paths",
        )
```

b. In the second `checks = [...]`, append `RustSupplyChainAuditCheck()` right after `GoSupplyChainAuditCheck()`.

c. In the second `ctx = CheckContext(...)`, add:

```python
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=sa.ioc_db, profile=profile,
        log_review_config=sa.log_review,
        pip_lock_paths=tuple(_expand_tilde(p, remote_home) for p in sa.pip_lock_paths),
        go_lock_paths=tuple(_expand_tilde(p, remote_home) for p in sa.go_lock_paths),
        cargo_lock_paths=tuple(_expand_tilde(p, remote_home) for p in sa.cargo_lock_paths),
    )
```

- [ ] **Step 5: Run full suite**

```bash
.venv/bin/pytest tests/ -q
```

Expected: 1 failure on `test_committed_sample_matches_pipeline_output` (sample drift); Task 12 will refresh. Otherwise green.

- [ ] **Step 6: Verify CLI flag**

```bash
.venv/bin/agentsec server-audit --help | grep -E 'pip-lock-path|go-lock-path|cargo-lock-path'
```

Expected: all 3 flags visible.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "$(cat <<'EOF'
cli(7d.3): register RustSupplyChainAuditCheck + wire cargo_lock_paths

Standalone server-audit gains --cargo-lock-path repeatable flag;
evaluate flow reads ServerAuditConfig.cargo_lock_paths; both inject
paths into CommandPolicy via add_runtime_allowed_paths(kind=exact),
audit-log the injection, and propagate to CheckContext.

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

---

## Task 11: ssh_policy.yaml comment annotation

**Files:**
- Modify: `src/agentsec/audit/ssh_policy.yaml`

- [ ] **Step 1: Add comment block**

Append to the `allowed_paths:` list in `src/agentsec/audit/ssh_policy.yaml`, right after the existing 7d.2 comment block:

```yaml
  # Phase 7d.3 — rust supply chain audit; per-run dynamic injection.
  # Operator-supplied paths in ServerAuditConfig.cargo_lock_paths are appended
  # at orchestrator startup via CommandPolicy.add_runtime_allowed_paths(...,
  # kind="exact"). No static path lives in ssh_policy.yaml — operator's
  # config entry == consent. See ADR-0015.
```

- [ ] **Step 2: Verify YAML parses**

```bash
.venv/bin/python -c "import yaml; yaml.safe_load(open('src/agentsec/audit/ssh_policy.yaml'))"
```

- [ ] **Step 3: Commit**

```bash
git add src/agentsec/audit/ssh_policy.yaml
git commit -m "$(cat <<'EOF'
docs(7d.3): ssh_policy.yaml note about dynamic cargo_lock_paths injection

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

---

## Task 12: Sample report refresh

**Files:**
- Refresh: `docs/samples/report-2026-04-28/**`

- [ ] **Step 1: Refresh the committed sample**

```bash
AGENTSEC_REFRESH_SAMPLE=1 .venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v
```

- [ ] **Step 2: Inspect the diff**

```bash
git diff docs/samples/report-2026-04-28/
```

Expected: only `27 → 28` count + new `rust_supply_chain_audit: not_applicable (未配置 cargo_lock_paths)` line + new meta anchor.

- [ ] **Step 3: Commit**

```bash
git add docs/samples/report-2026-04-28/
git commit -m "$(cat <<'EOF'
test(7d.3): refresh mock sample report (rust Check not_applicable)

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

---

## Task 13: First osv_cargo.json populate

**Files:**
- Modify: `src/agentsec/audit/ioc/osv_cargo.json`

- [ ] **Step 1: Run ioc-update**

```bash
source ~/.config/agentsec/env
GITHUB_TOKEN="$(gh auth token)" .venv/bin/agentsec ioc-update --force
```

The CLI runs all 4 OSV side pipelines in parallel.

- [ ] **Step 2: Inspect the Cargo bundle**

```bash
DATE=$(date +%Y-%m-%d)
wc -c .cache/ioc-update-$DATE/proposed-osv_cargo.json
.venv/bin/python -c "
import json
d = json.load(open('.cache/ioc-update-$DATE/proposed-osv_cargo.json'))
print(f'Cargo entries: {len(d)}')
sev = {}
for r in d: sev[r['severity']] = sev.get(r['severity'], 0) + 1
print('Severity:', sev)
"
```

Expected: ~2000-3000 entries / ~600-900 KB.

- [ ] **Step 3: Copy + verify CI guard**

```bash
DATE=$(date +%Y-%m-%d)
cp .cache/ioc-update-$DATE/proposed-osv_cargo.json src/agentsec/audit/ioc/osv_cargo.json
.venv/bin/python scripts/check_osv_cargo.py
```

Expected: `OK: src/agentsec/audit/ioc/osv_cargo.json (... KB, NNN entries)`.

- [ ] **Step 4: Run full suite**

```bash
.venv/bin/pytest tests/ -q
```

Expected: green.

- [ ] **Step 5: Commit**

```bash
DATE=$(date +%Y-%m-%d)
git add src/agentsec/audit/ioc/osv_cargo.json
git commit -m "$(cat <<'EOF'
data(7d.3): populate osv_cargo.json with $DATE OSV-Cargo snapshot

Initial OSV-Cargo bundle: NNN entries / M.M KB after 5-axis filter chain
(critical XX / high YY / medium ZZ / low WW).

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

(Replace NNN / M.M / XX / YY / ZZ / WW with actual values from Step 2.)

---

## Task 14: Extend redaction test for rust

**Files:**
- Modify: `tests/audit/checks/test_supply_chain_redaction.py`

- [ ] **Step 1: Append the rust redaction test**

Add at the bottom of `tests/audit/checks/test_supply_chain_redaction.py`:

```python
from agentsec.audit.checks.rust_supply_chain_audit import RustSupplyChainAuditCheck

_CARGO_FIXTURE_DB = Path("tests/fixtures/supply_chain_cargo/osv_cargo_subset.json")


def test_rust_check_routes_through_redactor():
    check = RustSupplyChainAuditCheck(db_path=_CARGO_FIXTURE_DB)
    cargo_lock_with_serde = '''\
[[package]]
name = "serde"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
'''
    ctx = CheckContext(
        executor=_StubExecutor({
            "/srv/SECRET-XYZ-GHI789/Cargo.lock": cargo_lock_with_serde,
        }),
        redactor=_SecretReplacingRedactor(),
        ioc_dir=Path("/x"), profile=LINUX,
        cargo_lock_paths=("/srv/SECRET-XYZ-GHI789/Cargo.lock",),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert "SECRET-XYZ" not in findings[0].evidence["lock_path"]
    assert "[REDACTED]" in findings[0].evidence["lock_path"]
```

(The existing test file imports `asyncio`, `CheckContext`, `LINUX`, `Path`, `_StubExecutor`, `_SecretReplacingRedactor` at top from Task 18 of 7d.2 — just add the new import + test.)

- [ ] **Step 2: Run tests**

```bash
.venv/bin/pytest tests/audit/checks/test_supply_chain_redaction.py -v
```

Expected: 4 PASS (3 existing + 1 new rust).

- [ ] **Step 3: Commit**

```bash
git add tests/audit/checks/test_supply_chain_redaction.py
git commit -m "$(cat <<'EOF'
test(7d.3): extend redaction routing test for rust Check

Verifies RustSupplyChainAuditCheck routes Finding evidence through
ctx.redactor.redact (via Check.make_finding).

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

---

## Task 15: ADR-0015

**Files:**
- Create: `docs/adr/0015-phase-7d3-rust-supply-chain.md`

- [ ] **Step 1: Read ADR-0014 as structural template**

```bash
cat docs/adr/0014-phase-7d2-go-supply-chain.md
```

- [ ] **Step 2: Write the ADR**

Create `docs/adr/0015-phase-7d3-rust-supply-chain.md`:

```markdown
# ADR-0015: Phase 7d.3 — rust / crates.io supply chain audit

**Status:** Accepted
**Date:** 2026-05-11
**Related:** ADR-0014 (Phase 7d.2 go + common extraction), ADR-0013 (Phase 7d.1 pip), ADR-0012 (Phase 7d npm)

## Context

Phase 7d.2 (v0.14.0) shipped go supply chain audit AND extracted
`_supply_chain_common.py` into fetcher-side + check-side modules.
Phase 7d.3 is the "investment payoff" — adding the 4th ecosystem
(Rust / crates.io) with zero new abstraction work, just direct import
of the existing common helpers.

Spec: `docs/superpowers/specs/2026-05-11-phase-7d3-rust-supply-chain-design.md`.

## Decisions

### D1. Lockfile paths come from operator config (mirroring 7d.1/7d.2)

**Decision:** Operator lists each `Cargo.lock` to scan in `ServerAuditConfig.cargo_lock_paths` (or repeats `--cargo-lock-path` on the standalone `agentsec server-audit` command). Empty list → `rust_supply_chain_audit` reports `not_applicable`.

**Considered alternatives:**
- (a) Add a `cargo_root` key to `PlatformProfile` — OpenClaw is npm-based, no canonical Rust footprint.
- (b) Scan the host's installed Cargo binaries via `cargo metadata` — requires executing `cargo` CLI.

**Rationale:** Operator config = consent. Consistent with 7d.1 / 7d.2.

### D2. Format = `Cargo.lock` only; basename strict equality, case-sensitive

**Decision:** Validator requires `basename(path) == "Cargo.lock"` exactly. `cargo.lock` (lowercase) and `Cargo.lock.bak` rejected.

**Considered alternatives:**
- (a) Case-insensitive match — would accept user-typo `cargo.lock`.
- (b) Allow `Cargo.lock.*` variants.

**Rationale:** Cargo official convention is exactly `Cargo.lock` with leading capital. Strict equality is simplest defensible rule.

### D3. Crate names NOT normalized

**Decision:** Crate names from `Cargo.lock` and from OSV-Cargo records are kept as-is for matching. Unlike pip (PEP-503 normalizes `Foo_Bar` → `foo-bar`), Cargo treats `serde-json` and `serde_json` as completely different crates.

**Considered alternatives:**
- (a) Normalize to lowercase + collapse `[-_]` like PEP-503 — would create false matches between `serde-json` and `serde_json`.

**Rationale:** Cargo's own naming semantics.

### D4. Cargo versions use strict semver 2.0; no prefix/suffix cleanup

**Decision:** `_safe_parse_cargo_version(s)` just calls `semver.Version.parse(s)`. No `v` prefix stripping (unlike Go), no `+incompatible` handling (Cargo doesn't use this).

**Considered alternatives:**
- (a) Mirror Go's strip-and-parse pattern defensively.

**Rationale:** Cargo enforces strict semver. Any pre-processing would be dead code.

### D5. Filter to `source = registry+...crates.io-index` only

**Decision:** `parse_cargo_lock` only includes packages whose `source` field equals the official crates.io registry URL. Workspace-local (no `source`) / git deps / path deps / alternate registries silently skipped.

**Considered alternatives:**
- (a) Also emit Low findings for non-crates.io deps — would generate noise for legitimate uses (workspace crates, private registries).
- (b) Include alternate registries — would require operator to configure registry URLs.

**Rationale:** OSV-Cargo only covers crates.io. Other sources aren't in the public advisory database; emitting findings for them would be misleading. Consistent with pip silently skipping VCS URL pins.

### D6. Direct import `_supply_chain_common`, zero new abstraction

**Decision:** `RustSupplyChainAuditCheck` imports `load_advisory_db` / `Severity` from `checks/_supply_chain_common.py`. `OsvCargoFetcher` imports `_is_within_age` / `_extract_severity` / `MEDIUM_LOW_MAX_AGE_DAYS` from `fetchers/_supply_chain_common.py`. No new shared module work.

**Considered alternatives:**
- (a) Re-evaluate common module at N=4 — confirmed all helpers still applicable.
- (b) Generalize the matcher across all 4 ecosystems now that we have 4 — still rejected per ADR-0014 D5 (Version types still differ).

**Rationale:** "Rule of three" investment from 7d.2 paying off. N=4 doesn't change the analysis.

### D7. exact-kind dynamic injection (mirrors 7d.1 / 7d.2)

**Decision:** `policy.add_runtime_allowed_paths(operator_paths, kind="exact")` + audit-log entry.

**Rationale:** Same as prior phases. Minimal blast radius.

### D8. OSV-Cargo 5-axis filter chain identical to npm/pypi/go

**Decision:** `normalize_osv_cargo_entry` applies the same 5 filter axes: drop withdrawn / no-crates.io-affected / no-range / no-CVSS / scored medium-low > 2 years old.

**Rationale:** Same heuristics validated across npm/pypi/go. Single shared filter logic via `_supply_chain_common.py`.

### D9. TI = single umbrella `TI-OSV-CRATES-CATALOG`

**Decision:** One `threat_intel.yaml` row covers every Cargo advisory.

**Rationale:** Same pattern as `TI-OSV-NPM-CATALOG` / `TI-OSV-PYPI-CATALOG` / `TI-OSV-GO-CATALOG`.

## Consequences

- Four ecosystems share `_supply_chain_common.py`; no abstraction debt accumulating.
- The wheel grows by ~600-900 KB (osv_cargo.json bundled). Total wheel size still well below 15 MB cap.
- Pattern is now mature; adding a 5th ecosystem (if ever) is purely additive.
- 7d.2 final-review follow-ups (8 items) explicitly NOT addressed in this phase; tracked for separate cleanup commit.

## References

- Spec: `docs/superpowers/specs/2026-05-11-phase-7d3-rust-supply-chain-design.md`
- Plan: `docs/superpowers/plans/2026-05-11-phase-7d3-rust-supply-chain.md`
- ADR-0014: 7d.2 (go) — extracted the common modules this phase consumes
- ADR-0013: 7d.1 (pip) — established operator-config-path pattern
- ADR-0012: 7d (npm) — original supply-chain Check design
```

- [ ] **Step 3: Commit**

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

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

---

## Task 16: Docs sync + version 0.15.0

**Files:**
- Modify: `pyproject.toml` (version)
- Modify: `ROADMAP.md`
- Modify: `CHANGELOG.md`
- Modify: `README.md`
- Modify: `CLAUDE.md`

- [ ] **Step 1: Bump version**

In `pyproject.toml`: `version = "0.14.0"` → `version = "0.15.0"`.

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

Find Phase 7 section. Replace:

```
- [x] Phase 7d.2 — go 供应链 Check（OSV-Go bundled DB，TI-OSV-GO-CATALOG，2760 advisories）+ `_supply_chain_common.py` 抽取 — 2026-05-11
- [ ] Phase 7d.3+ — Skills / Plugins 深化的剩余面（rust 生态 + plugin_static 静态扫描扩展）
```

with:

```
- [x] Phase 7d.2 — go 供应链 Check（OSV-Go bundled DB，TI-OSV-GO-CATALOG，2760 advisories）+ `_supply_chain_common.py` 抽取 — 2026-05-11
- [x] Phase 7d.3 — rust 供应链 Check（OSV-Cargo bundled DB，TI-OSV-CRATES-CATALOG，NNN advisories）— 2026-05-11
- [ ] Phase 7d.x — `plugin_static` 静态扫描扩展
```

(NNN from Task 13.)

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

Insert new section at top under `## [Unreleased]`:

```markdown
## [0.15.0] — 2026-05-11

### Added

- **Phase 7d.3 — rust / crates.io 供应链审计 Check** (`rust_supply_chain_audit`)。读
  operator 在 `openclaw-target.yaml` `server_audit.cargo_lock_paths` 列出的
  `Cargo.lock`（绝对路径或 `~/-rooted`，basename 必须严格等于 `Cargo.lock`），
  用 `tomllib`（Python 3.11+ stdlib）解析 TOML，过滤到 crates.io 官方
  registry 源的 package，按 crate 名字面匹配（**不**做 PEP-503 风格归一化
  —— Cargo 中 `serde-json` 与 `serde_json` 是两个不同 crate）+ 纯 semver
  2.0 比较（无 v 前缀，无 +incompatible）对照 bundled OSV-Cargo DB
  (`src/agentsec/audit/ioc/osv_cargo.json`，NNN advisories ≈ MMM KB) emit
  每个命中 (crate, version, advisory) 一条 finding。TI umbrella
  `TI-OSV-CRATES-CATALOG`。服务端审计 Check 数：27 → 28。
- `OsvCargoFetcher` 在 `agentsec ioc-update` 中作 side pipeline，与
  `OsvNpmFetcher` / `OsvPyPIFetcher` / `OsvGoFetcher` `asyncio.gather` 四方并行。
- `agentsec server-audit` 新增可重复 `--cargo-lock-path` flag。
- `ServerAuditConfig.cargo_lock_paths` 配置字段 + Pydantic validator
  （绝对/`~/`-rooted、无 metachar、basename == `Cargo.lock` **大小写敏感**）。
- `CheckContext.cargo_lock_paths: tuple[str, ...]` 字段。
- `scripts/check_osv_cargo.py` CI 守门 + ci.yml 集成。
- ADR-0015 记录全 9 项决策。

### Internal

- 测试数 1302 → ~1346 passing（+44）。
- 新测试位置：`tests/audit/checks/test_rust_supply_chain_audit.py`、
  `tests/audit/ioc_update/fetchers/test_osv_cargo.py`、
  `tests/scripts/test_check_osv_cargo.py`、
  `tests/test_ioc_update_osv_cargo_wiring.py`；扩展
  `tests/audit/checks/test_supply_chain_redaction.py` +1 case。
- **零新 PyPI 依赖** — `tomllib` 是 Python 3.11+ stdlib，`semver>=3,<4` 已在 deps。
- **零新抽象工作** — 直接复用 7d.2 抽取的 `_supply_chain_common.py`（rule of three 投资回收）。

---
```

(NNN / MMM from Task 13.)

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

```bash
sed -i.bak 's/27 类（10 OpenClaw + 11 Linux hardening + 3 Docker + 1 npm supply chain + 1 pip supply chain + 1 go supply chain）/28 类（10 OpenClaw + 11 Linux hardening + 3 Docker + 1 npm supply chain + 1 pip supply chain + 1 go supply chain + 1 rust supply chain）/g' README.md
rm README.md.bak
grep -n "27 类\|28 类" README.md
```

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

a. Update "Total checks" line:

Change `v0.14.0` → `v0.15.0`, change `27 类` → `28 类`, append `; \`rust_supply_chain_audit\` (Phase 7d.3)` to the check enumeration.

b. Add new section after the existing "## go supply chain audit (Phase 7d.2, v0.14.0)" section:

```markdown
## rust supply chain audit (Phase 7d.3, v0.15.0)

- **`RustSupplyChainAuditCheck`** in `src/agentsec/audit/checks/rust_supply_chain_audit.py` reads operator-configured `Cargo.lock` paths from `CheckContext.cargo_lock_paths` (sourced from `ServerAuditConfig.cargo_lock_paths`), parses TOML via `tomllib` (Python 3.11+ stdlib), filters to crates.io registry source only, and matches each (crate, version) against the bundled `osv_cargo.json` using strict semver 2.0. Emits one Finding per (crate, version, advisory) match; `threat_refs=["TI-OSV-CRATES-CATALOG"]`.
- **Lockfile paths come from operator config** — same as 7d.1 / 7d.2 (OpenClaw has no Rust footprint). Empty `cargo_lock_paths` ⇒ `not_applicable`.
- **Path validation** — `ServerAuditConfig.cargo_lock_paths` validator: must start with `/` or `~/`; no shell metachars; basename must equal exactly `Cargo.lock` **case-sensitive** (Cargo's official convention; `cargo.lock` lowercase is rejected).
- **Crate names NOT normalized** — Unlike pip (PEP-503 normalizes `Foo_Bar` → `foo-bar`), Cargo treats `serde-json` and `serde_json` as completely distinct crates. Names are matched literally.
- **Cargo uses pure semver 2.0** — `_safe_parse_cargo_version` is just `semver.Version.parse(s)`. No `v` prefix stripping (unlike Go), no `+incompatible` handling (Cargo doesn't use this).
- **Source filter** — only packages whose `source = registry+https://github.com/rust-lang/crates.io-index` are included. Workspace-local (no source) / git deps / path deps / alternate-registry deps silently skipped — OSV-Cargo only covers the public crates.io index.
- **`OsvCargoFetcher`** mirrors `OsvNpmFetcher` / `OsvPyPIFetcher` / `OsvGoFetcher` (5-axis filter chain) but pulls `https://osv-vulnerabilities.storage.googleapis.com/crates.io/all.zip`. Runs as a side pipeline (`_run_osv_cargo`, parallel to npm/pypi/go via `asyncio.gather` 4-way).
- **Multi-affected expansion** — each crates.io package in an OSV entry's `affected[]` becomes its own normalized record; caller dedups on `(id, package)`.
- **`scripts/check_osv_cargo.py`** CI guard enforces valid JSON + ≤ 15 MB hard cap (mirrors npm/pypi/go).
- **Bundled DB**: `src/agentsec/audit/ioc/osv_cargo.json` (NNN advisories, MMM KB at 2026-05-11).
- **`agentsec server-audit --cargo-lock-path <path>`** is repeatable on the standalone CLI; for the `evaluate` flow set `server_audit.cargo_lock_paths` in `openclaw-target.yaml`.
- **`_supply_chain_common.py` directly reused** — no new extraction needed (this is the rule-of-three investment payoff from 7d.2).
- **OSV ecosystem string is `"crates.io"`** (literal, with dot, case-sensitive) — not `"cargo"` or `"rust"`.
- **Adding a 5th ecosystem** (if ever): the pattern is now mature — no new abstraction work needed, just ecosystem-specific bits (parser / version handling / OSV ecosystem name).
```

(NNN / MMM from Task 13.)

- [ ] **Step 6: Run full pre-commit verification**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright src/agentsec/
.venv/bin/python scripts/check_owasp_coverage.py
.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
.venv/bin/python scripts/check_osv_pypi.py
.venv/bin/python scripts/check_osv_go.py
.venv/bin/python scripts/check_osv_cargo.py
.venv/bin/pytest tests/ -q
```

All must exit 0; pytest reports ~1346 passing.

- [ ] **Step 7: Commit**

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

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

---

## Task 17: Pre-release verification

**Files:** none (verification only)

- [ ] **Step 1: Re-run all CI gates**

```bash
.venv/bin/pytest tests/ -q
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright src/agentsec/
.venv/bin/python scripts/check_owasp_coverage.py
.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
.venv/bin/python scripts/check_osv_pypi.py
.venv/bin/python scripts/check_osv_go.py
.venv/bin/python scripts/check_osv_cargo.py
```

All exit 0.

- [ ] **Step 2: Build the wheel**

```bash
rm -rf dist/
.venv/bin/python -m build
ls -lah dist/
```

Verify `agentsec_eval-0.15.0-py3-none-any.whl` exists; size should be the v0.14.0 wheel + ~200-400 KB (for `osv_cargo.json`).

- [ ] **Step 3: Smoke test the wheel**

```bash
rm -rf /tmp/smoke-7d3 && python3.12 -m venv /tmp/smoke-7d3
/tmp/smoke-7d3/bin/pip install -q dist/agentsec_eval-0.15.0-py3-none-any.whl
/tmp/smoke-7d3/bin/agentsec server-audit --help | grep -E 'pip-lock-path|go-lock-path|cargo-lock-path'
```

Expected: all 3 flags visible.

- [ ] **Step 4: Hand off to operator for release**

```
v0.15.0 ready locally. Operator next steps:
  1. git push origin main
  2. git tag -a v0.15.0 -m 'Phase 7d.3 — rust / crates.io supply chain audit'
  3. git push origin v0.15.0
  4. .venv/bin/twine upload dist/agentsec_eval-0.15.0*
```

---

## Self-Review Checklist

- [ ] Spec §1 (config + scope): Tasks 1, 2, 3 ✓
- [ ] Spec §3 (architecture): Tasks 1-5 ✓
- [ ] Spec §4 (data model): Task 5 ✓
- [ ] Spec §5 (CommandPolicy injection): Task 10 ✓
- [ ] Spec §6 (fetcher): Tasks 7, 8 ✓
- [ ] Spec §7 (CLI 4-way): Task 9 ✓
- [ ] Spec §8 (tests): distributed; ~44 new tests ✓
- [ ] Spec §9 (release): Tasks 16, 17 ✓
- [ ] Spec §10 (decisions → ADR): Task 15 ✓
- [ ] Spec §11 (7d.2 follow-ups deferred): explicitly NOT in plan ✓
- [ ] Spec §12 (out of scope): respected ✓
- [ ] TI fold-forward note in Task 5 (lesson from 7d.1/7d.2) ✓
- [ ] make_finding routing (Task 5 uses `self.make_finding(ctx, ...)`) + redaction test (Task 14) ✓
