# Phase 7d.2 — go / OSV-Go 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 `go_supply_chain_audit` Check that scans operator-supplied `go.sum` paths against a bundled OSV-Go advisory DB, while extracting `_supply_chain_common.py` modules (rule of three) and routing all 3 supply-chain Checks through `Check.make_finding` for automatic Redactor pass.

**Architecture:** Operator lists `go.sum` paths in `openclaw-target.yaml`; CLI dynamic-injects them into `CommandPolicy.allowed_paths` (kind=exact); Check parses raw `module vX.Y.Z` lines, matches against bundled `osv_go.json` using semver-with-Go-prefix-stripping, emits findings under TI umbrella `TI-OSV-GO-CATALOG`. Common helpers (CVSS scoring, age filter, severity extraction, DB indexer) extracted to two `_supply_chain_common.py` modules — fetcher-side and check-side — so npm/pip/go all share one source of truth.

**Tech Stack:** Python 3.11+ · `semver>=3,<4` (already in deps; Go versions strip `v`/`+incompatible` then parse) · `httpx` (OSV fetch) · `zipfile` (OSV bundle) · `pydantic` (config) · `pytest` · `typer` (CLI)

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

---

## File Structure

**Create:**
- `src/agentsec/audit/checks/go_supply_chain_audit.py` — Check + helpers (parser, matcher, version normalizer)
- `src/agentsec/audit/checks/_supply_chain_common.py` — `load_advisory_db` + re-exports
- `src/agentsec/audit/ioc_update/fetchers/_supply_chain_common.py` — `severity_from_cvss_score` / `_parse_cvss_score` / `_is_within_age` / `_extract_severity` / `MEDIUM_LOW_MAX_AGE_DAYS` / `Severity`
- `src/agentsec/audit/ioc_update/fetchers/osv_go.py` — OSV-Go fetcher
- `src/agentsec/audit/ioc/osv_go.json` — bundled DB (empty `[]` placeholder; populated in Task 22)
- `scripts/check_osv_go.py` — CI guard
- `tests/audit/checks/test_go_supply_chain_audit.py`
- `tests/audit/checks/test_supply_chain_common.py`
- `tests/audit/checks/test_supply_chain_redaction.py`
- `tests/audit/ioc_update/fetchers/test_osv_go.py`
- `tests/audit/ioc_update/fetchers/test_supply_chain_common.py`
- `tests/test_ioc_update_osv_go_wiring.py`
- `tests/scripts/test_check_osv_go.py`
- `tests/fixtures/supply_chain_go/{go.sum-clean, go.sum-vulnerable, go.sum-edge-cases, osv_go_subset.json}`
- `tests/fixtures/osv/go/{normal, withdrawn, no-go, no-range, no-cvss, multi-affected}.json`
- `docs/adr/0014-phase-7d2-go-supply-chain.md`

**Modify:**
- `src/agentsec/config.py` — `ServerAuditConfig.go_lock_paths` + validator
- `src/agentsec/audit/checks/base.py` — `CheckContext.go_lock_paths`
- `src/agentsec/audit/server_audit.py` — propagate `go_lock_paths`
- `src/agentsec/audit/ioc_update/fetchers/osv_npm.py` — import from `_supply_chain_common`
- `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py` — same
- `src/agentsec/audit/checks/supply_chain_audit.py` — import + use `make_finding`
- `src/agentsec/audit/checks/pip_supply_chain_audit.py` — import + use `make_finding`
- `src/agentsec/audit/ioc_update/cli.py` — `_run_osv_go` parallel pipeline
- `src/agentsec/audit/ssh_policy.yaml` — comment annotation
- `src/agentsec/audit/ioc/threat_intel.yaml` — append `TI-OSV-GO-CATALOG`
- `src/agentsec/cli.py` — register `GoSupplyChainAuditCheck` + wire `go_lock_paths`
- `.github/workflows/ci.yml` — add `check_osv_go.py` step
- `pyproject.toml` — version 0.13.0 → 0.14.0
- `ROADMAP.md` / `CHANGELOG.md` / `README.md` / `CLAUDE.md` — docs sync (Task 24)
- `docs/samples/report-2026-04-28/**` — refresh via env-flag pytest (Task 21)

---

## Pre-flight

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

If not green, stop and fix before starting tasks.

---

## Task 1: `ServerAuditConfig.go_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_go_lock_paths_default_empty():
    sa = ServerAuditConfig(host="h", user="u", key=Path("/k"))
    assert sa.go_lock_paths == []


def test_go_lock_paths_accepts_absolute_and_tilde():
    sa = ServerAuditConfig(
        host="h", user="u", key=Path("/k"),
        go_lock_paths=[
            "/srv/myservice/go.sum",
            "~/projects/foo/go.sum",
        ],
    )
    assert len(sa.go_lock_paths) == 2


def test_go_lock_paths_rejects_relative():
    with pytest.raises(ValidationError, match="must start with"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            go_lock_paths=["./go.sum"],
        )


def test_go_lock_paths_rejects_shell_metachar():
    with pytest.raises(ValidationError, match="shell metacharacter"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            go_lock_paths=["/srv/$(whoami)/go.sum"],
        )


def test_go_lock_paths_rejects_non_go_sum_basename():
    with pytest.raises(ValidationError, match="basename"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            go_lock_paths=["/srv/go.mod"],
        )


def test_go_lock_paths_rejects_go_sum_with_suffix():
    """basename must equal exactly 'go.sum' — backups must be renamed first."""
    with pytest.raises(ValidationError, match="basename"):
        ServerAuditConfig(
            host="h", user="u", key=Path("/k"),
            go_lock_paths=["/srv/go.sum.bak"],
        )
```

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

Run: `.venv/bin/pytest tests/audit/test_config.py -k go_lock -v`
Expected: 6 FAIL — `go_lock_paths` field doesn't exist yet.

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

In `src/agentsec/config.py`, add this validator and field to `ServerAuditConfig`:

```python
class ServerAuditConfig(BaseModel):
    model_config = _FORBID
    host: str
    user: str
    key: Path
    checks: Literal["all"] | list[str] = "all"
    ioc_db: Path = Path("./src/agentsec/audit/ioc/")
    ssh_audit_log: Path = Path("./audit-log.jsonl")
    ssh_policy: Path = Path("./src/agentsec/audit/ssh_policy.yaml")
    native_audit_argv: list[str] = Field(
        default_factory=lambda: ["openclaw", "security", "audit", "--json"]
    )
    log_review: LogReviewConfig = Field(default_factory=LogReviewConfig)
    pip_lock_paths: list[str] = Field(default_factory=list)
    go_lock_paths: list[str] = Field(default_factory=list)

    @field_validator("pip_lock_paths")
    @classmethod
    def _validate_pip_lock_paths(cls, v: list[str]) -> list[str]:
        # ... existing impl unchanged ...
        return v

    @field_validator("go_lock_paths")
    @classmethod
    def _validate_go_lock_paths(cls, v: list[str]) -> list[str]:
        for p in v:
            if not (p.startswith("/") or p.startswith("~/")):
                raise ValueError(
                    f"go_lock_paths entry {p!r} must start with '/' or '~/'"
                )
            if contains_shell_metachar([p]) is not None:
                raise ValueError(
                    f"go_lock_paths entry {p!r} contains shell metacharacter"
                )
            basename = p.rsplit("/", 1)[-1]
            if basename != "go.sum":
                raise ValueError(
                    f"go_lock_paths entry {p!r} basename must equal 'go.sum' "
                    "(backups / 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 go_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 (existing pip_lock + new go_lock).

- [ ] **Step 6: Commit**

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

Absolute or ~/-rooted paths; reject shell metachars + basename
must equal exactly 'go.sum' (no .bak / variants).

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

---

## Task 2: `CheckContext.go_lock_paths` field

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

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

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

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


def test_check_context_go_lock_paths_assignable():
    ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
        go_lock_paths=("/srv/go.sum",),
    )
    assert ctx.go_lock_paths == ("/srv/go.sum",)
```

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

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

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

In `src/agentsec/audit/checks/base.py`, add `go_lock_paths` after `pip_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, ...] = ()
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_base.py -v`
Expected: 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.2): CheckContext.go_lock_paths channel

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

---

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

**Files:**
- Modify: `src/agentsec/audit/server_audit.py:22-30` (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
def test_run_server_audit_propagates_go_lock_paths():
    parent_ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
        go_lock_paths=("/srv/a.sum", "~/b.sum"),
    )
    cap = _CapturePipPathsCheck()  # reuse the existing capture-check helper from Task 3 of 7d.1

    # Slightly modify or extend the capture check so it also captures go_lock_paths
    # — or add a new _CaptureGoPathsCheck class. See pattern below.
    asyncio.run(run_server_audit(checks=[cap], ctx=parent_ctx))
    assert cap.captured_go == ("/srv/a.sum", "~/b.sum")
```

The simplest path is to extend the existing capture helper class that 7d.1 added; modify its `run()` to also capture `ctx.go_lock_paths` into a `self.captured_go` attribute. Then add the new test.

If the capture helper isn't easily extensible, write a fresh capture class in the same file:

```python
class _CaptureGoPathsCheck(Check):
    id = "capture_go"

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

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


def test_run_server_audit_propagates_go_lock_paths():
    parent_ctx = CheckContext(
        executor=object(), redactor=object(),
        ioc_dir=Path("/x"), profile=LINUX,
        go_lock_paths=("/srv/a.sum", "~/b.sum"),
    )
    cap = _CaptureGoPathsCheck()
    asyncio.run(run_server_audit(checks=[cap], ctx=parent_ctx))
    assert cap.captured == ("/srv/a.sum", "~/b.sum")
```

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

Run: `.venv/bin/pytest tests/audit/test_server_audit_orchestrator.py::test_run_server_audit_propagates_go_lock_paths -v`
Expected: FAIL — current `run_server_audit` doesn't copy the new field.

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

In `src/agentsec/audit/server_audit.py`, add `go_lock_paths=ctx.go_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,
        )
```

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

Run: `.venv/bin/pytest tests/audit/test_server_audit_orchestrator.py -v`
Expected: PASS, no regression.

- [ ] **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.2): run_server_audit propagates go_lock_paths

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

---

## Task 4: Fetcher-side `_supply_chain_common.py` + migrate `osv_npm.py`

This task creates the fetcher common module AND migrates `osv_npm.py` to use it in the same commit (the common module would be unused dead code if migrated separately).

**Files:**
- Create: `src/agentsec/audit/ioc_update/fetchers/_supply_chain_common.py`
- Modify: `src/agentsec/audit/ioc_update/fetchers/osv_npm.py` (delete 5 funcs + 2 constants; import from common)
- Create: `tests/audit/ioc_update/fetchers/test_supply_chain_common.py`

- [ ] **Step 1: Write the failing tests for the common module**

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

```python
"""Tests for _supply_chain_common (fetcher-side helpers extracted in 7d.2)."""

from datetime import UTC, datetime, timedelta

from agentsec.audit.ioc_update.fetchers._supply_chain_common import (
    MEDIUM_LOW_MAX_AGE_DAYS,
    _extract_severity,
    _is_within_age,
    _parse_cvss_score,
    severity_from_cvss_score,
)


def test_severity_from_cvss_score_buckets():
    assert severity_from_cvss_score(9.5) == "critical"
    assert severity_from_cvss_score(7.0) == "high"
    assert severity_from_cvss_score(4.0) == "medium"
    assert severity_from_cvss_score(3.5) == "low"
    assert severity_from_cvss_score(None) == "medium"  # conservative default


def test_parse_cvss_score_v3_vector():
    score = _parse_cvss_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H")
    assert score is not None
    assert 9.0 <= score <= 10.0


def test_parse_cvss_score_garbage_returns_none():
    assert _parse_cvss_score("not a vector") is None
    assert _parse_cvss_score("") is None


def test_is_within_age_recent_returns_true():
    now = datetime.now(UTC)
    recent = (now - timedelta(days=30)).isoformat().replace("+00:00", "Z")
    assert _is_within_age(recent, MEDIUM_LOW_MAX_AGE_DAYS) is True


def test_is_within_age_old_returns_false():
    now = datetime.now(UTC)
    old = (now - timedelta(days=3 * 365)).isoformat().replace("+00:00", "Z")
    assert _is_within_age(old, MEDIUM_LOW_MAX_AGE_DAYS, now=now) is False


def test_is_within_age_unparseable_keeps():
    """fail-open: unparseable timestamps return True so we don't silently drop on bad data."""
    assert _is_within_age("not-a-date", MEDIUM_LOW_MAX_AGE_DAYS) is True


def test_extract_severity_picks_cvss_v3():
    sev_list = [{"type": "CVSS_V3",
                 "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}]
    label, had = _extract_severity(sev_list)
    assert label == "critical"
    assert had is True


def test_extract_severity_no_cvss_v3_returns_unhad():
    label, had = _extract_severity([{"type": "CVSS_V4", "score": "..."}])
    assert had is False
```

If the test directory doesn't have an `__init__.py`, ensure one exists (Task 10 of 7d.1 may have already created it).

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

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

- [ ] **Step 3: Create the common module**

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

```python
"""Shared OSV bundle filter / severity helpers used by all 3 ecosystem fetchers.

Extracted in Phase 7d.2 (rule of three: npm + pip + go all need the same
CVSS scoring, age filter, severity-list parser, and 2-year cutoff constant).
"""

from __future__ import annotations

from typing import Literal

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

MEDIUM_LOW_MAX_AGE_DAYS = 2 * 365


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

    None → 'medium' (conservative; not 'low' so unscored entries 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"


def _parse_cvss_score(vector: str) -> float | None:
    """Parse a CVSS v3 vector string and return the base score.

    Returns None on any parse failure (cvss lib raises various opaque errors).
    """
    try:
        from cvss import CVSS3  # noqa: PLC0415
        c = CVSS3(vector)
        return float(c.base_score) if c.base_score is not None else None
    except Exception:  # noqa: BLE001 — cvss may raise opaque errors
        return None


def _is_within_age(modified_iso: str, max_age_days: int, now=None) -> bool:
    """Return True iff `modified_iso` is within max_age_days of `now`.

    Unparseable timestamps return True (fail open — don't silently drop on bad data).
    """
    from datetime import UTC, datetime, timedelta  # noqa: PLC0415

    if not modified_iso:
        return True
    try:
        ts = datetime.fromisoformat(modified_iso.replace("Z", "+00:00"))
    except (ValueError, TypeError):
        return True
    if now is None:
        now = datetime.now(UTC)
    return (now - ts) <= timedelta(days=max_age_days)


def _extract_severity(severity_list: list[dict]) -> tuple[str, bool]:
    """Prefer CVSS_V3. Returns (label, had_cvss).

    had_cvss=False means OSV didn't supply a v3 vector — caller should drop
    the entry entirely (the default 'medium' bucket for unscored entries
    creates ~190K noise rows in a real OSV-npm pull). CVSS_V4 ignored for
    now (cvss lib has no v4 support yet).
    """
    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), True
    return severity_from_cvss_score(None), False
```

- [ ] **Step 4: Migrate `osv_npm.py` to import from the common module**

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

a. Replace the local definitions of `Severity`, `MEDIUM_LOW_MAX_AGE_DAYS`, `severity_from_cvss_score`, `_parse_cvss_score`, `_is_within_age`, `_extract_severity` with:

```python
from ._supply_chain_common import (
    MEDIUM_LOW_MAX_AGE_DAYS,
    Severity,
    _extract_severity,
    _is_within_age,
    _parse_cvss_score,
    severity_from_cvss_score,
)
```

b. Delete the now-removed local function bodies.

c. **Important:** check if any imports of `severity_from_cvss_score` *from* `osv_npm` exist elsewhere (e.g., the existing `pip_supply_chain_audit.py` may have done this). Search:

```bash
grep -rn "from agentsec.audit.ioc_update.fetchers.osv_npm import" src/ tests/
grep -rn "from .osv_npm import" src/agentsec/
```

If anything imports from osv_npm, redirect it to import from `_supply_chain_common` directly.

- [ ] **Step 5: Run all related tests**

```bash
.venv/bin/pytest tests/audit/ioc_update/fetchers/test_supply_chain_common.py -v
.venv/bin/pytest tests/test_osv_npm_fetcher.py -v
.venv/bin/pytest tests/ -q
```

Expected: 8 new common tests pass + all existing osv_npm tests still green + full suite stays green.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/_supply_chain_common.py \
        src/agentsec/audit/ioc_update/fetchers/osv_npm.py \
        tests/audit/ioc_update/fetchers/test_supply_chain_common.py
git commit -m "$(cat <<'EOF'
refactor(7d.2): extract fetcher _supply_chain_common + migrate osv_npm

Rule of three (npm + pip + go). Severity / CVSS / age / extract_severity
helpers + Severity Literal + MEDIUM_LOW_MAX_AGE_DAYS constant pulled out
of osv_npm.py into a shared module under fetchers/. osv_pypi.py and
the new osv_go.py will migrate next.

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

---

## Task 5: Migrate `osv_pypi.py` to common module

**Files:**
- Modify: `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py`

- [ ] **Step 1: Verify which symbols osv_pypi imports / defines locally**

```bash
grep -nE "^(from|import|def|MEDIUM_LOW|Severity)" src/agentsec/audit/ioc_update/fetchers/osv_pypi.py
```

You should see local definitions or imports of `severity_from_cvss_score`, `_parse_cvss_score`, `_is_within_age`, `_extract_severity`, `MEDIUM_LOW_MAX_AGE_DAYS`. Originally `severity_from_cvss_score` was imported from `pip_supply_chain_audit.py` (per Task 10 of 7d.1).

- [ ] **Step 2: Replace local / cross-Check imports with common-module imports**

In `src/agentsec/audit/ioc_update/fetchers/osv_pypi.py`:

a. Remove any line like `from agentsec.audit.checks.pip_supply_chain_audit import severity_from_cvss_score, normalize_pep503`.

b. Replace with:

```python
from agentsec.audit.checks.pip_supply_chain_audit import normalize_pep503

from ._supply_chain_common import (
    MEDIUM_LOW_MAX_AGE_DAYS,
    _extract_severity,
    _is_within_age,
    _parse_cvss_score,
    severity_from_cvss_score,
)
```

c. Delete any local `_is_within_age`, `_parse_cvss_score`, `_extract_severity`, `MEDIUM_LOW_MAX_AGE_DAYS` defined in the file. (They were copy-pasted from `osv_npm.py` during 7d.1.)

- [ ] **Step 3: Run all OSV-PyPI tests**

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

Expected: green; no behavior change.

- [ ] **Step 4: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_pypi.py
git commit -m "$(cat <<'EOF'
refactor(7d.2): osv_pypi imports from _supply_chain_common

Removes locally-duplicated _is_within_age / _parse_cvss_score /
_extract_severity / MEDIUM_LOW_MAX_AGE_DAYS; imports from the
shared module. severity_from_cvss_score now sourced from common
instead of pip_supply_chain_audit (which itself migrates next).

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

---

## Task 6: Check-side `_supply_chain_common.py` + migrate `supply_chain_audit.py` (npm Check)

**Files:**
- Create: `src/agentsec/audit/checks/_supply_chain_common.py`
- Modify: `src/agentsec/audit/checks/supply_chain_audit.py` (delete `severity_from_cvss_score` + DB-load handwritten code; import from common)
- Create: `tests/audit/checks/test_supply_chain_common.py`

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

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

```python
"""Tests for _supply_chain_common (check-side helpers extracted in 7d.2)."""

import json
from pathlib import Path

from agentsec.audit.checks._supply_chain_common import load_advisory_db


def test_load_advisory_db_indexes_by_package(tmp_path):
    db_file = tmp_path / "osv.json"
    db_file.write_text(json.dumps([
        {"id": "GHSA-a", "package": "django", "ranges": [], "severity": "high",
         "summary": "x", "url": "u", "aliases": []},
        {"id": "GHSA-b", "package": "django", "ranges": [], "severity": "low",
         "summary": "y", "url": "u", "aliases": []},
        {"id": "GHSA-c", "package": "requests", "ranges": [], "severity": "critical",
         "summary": "z", "url": "u", "aliases": []},
    ]))
    by_pkg = load_advisory_db(db_file)
    assert set(by_pkg.keys()) == {"django", "requests"}
    assert len(by_pkg["django"]) == 2
    assert len(by_pkg["requests"]) == 1


def test_load_advisory_db_empty_list(tmp_path):
    db_file = tmp_path / "osv.json"
    db_file.write_text("[]")
    by_pkg = load_advisory_db(db_file)
    assert by_pkg == {}


def test_load_advisory_db_severity_re_export():
    """severity_from_cvss_score is re-exported for Check-layer convenience."""
    from agentsec.audit.checks._supply_chain_common import severity_from_cvss_score
    assert severity_from_cvss_score(9.5) == "critical"
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_supply_chain_common.py -v`
Expected: FAIL.

- [ ] **Step 3: Create the check-side common module**

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

```python
"""Shared advisory-DB load helper used by all 3 ecosystem Checks.

Extracted in Phase 7d.2 (rule of three). Re-exports
severity_from_cvss_score / Severity so Check files don't need to reach
into the fetchers/ subtree directly.
"""

from __future__ import annotations

import json
from pathlib import Path

from ..ioc_update.fetchers._supply_chain_common import (
    Severity,
    severity_from_cvss_score,
)

__all__ = ["Severity", "severity_from_cvss_score", "load_advisory_db"]


def load_advisory_db(path: Path) -> dict[str, list[dict]]:
    """Load OSV bundle JSON file and index advisories by package name."""
    raw = json.loads(path.read_text())
    by_pkg: dict[str, list[dict]] = {}
    for adv in raw:
        by_pkg.setdefault(adv["package"], []).append(adv)
    return by_pkg
```

- [ ] **Step 4: Migrate `supply_chain_audit.py` (npm Check) to use common module**

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

a. At the top, add:

```python
from ._supply_chain_common import (
    Severity,
    load_advisory_db,
    severity_from_cvss_score,
)
```

b. Delete the local `severity_from_cvss_score` function and the local `Severity = Literal[...]` definition (now imported).

c. In `SupplyChainAuditCheck.__init__`, replace the manual DB load + index loop:

```python
    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)
```

(Replace the previous `raw = json.loads(path.read_text())` + for-loop block.)

- [ ] **Step 5: Run all related tests**

```bash
.venv/bin/pytest tests/audit/checks/test_supply_chain_common.py -v
.venv/bin/pytest tests/ -k 'supply_chain or osv_npm' -v --no-header -q
.venv/bin/pytest tests/ -q
```

Expected: 3 new common tests pass + all existing supply_chain tests still green.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/_supply_chain_common.py \
        src/agentsec/audit/checks/supply_chain_audit.py \
        tests/audit/checks/test_supply_chain_common.py
git commit -m "$(cat <<'EOF'
refactor(7d.2): extract check _supply_chain_common + migrate npm Check

load_advisory_db pulled out of SupplyChainAuditCheck.__init__;
severity_from_cvss_score / Severity re-exported from check-side
common (sourcing from fetcher-side common but Check files don't
need to know that). pip Check migrates next.

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

---

## Task 7: Migrate `pip_supply_chain_audit.py` to common module

**Files:**
- Modify: `src/agentsec/audit/checks/pip_supply_chain_audit.py`

- [ ] **Step 1: Replace local helpers with common imports**

In `src/agentsec/audit/checks/pip_supply_chain_audit.py`:

a. Add at the top (with other imports):

```python
from ._supply_chain_common import (
    Severity,
    load_advisory_db,
    severity_from_cvss_score,
)
```

b. Delete the local `severity_from_cvss_score` function.

c. Delete the local `Severity = Literal["critical", "high", "medium", "low"]` — now imported.

d. In `PipSupplyChainAuditCheck.__init__`, replace the manual DB load + index:

```python
    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)
```

- [ ] **Step 2: Verify external imports (osv_pypi.py imported normalize_pep503 + severity_from_cvss_score from here)**

```bash
grep -rn "from agentsec.audit.checks.pip_supply_chain_audit import" src/ tests/
```

Task 5 already redirected `osv_pypi.py`'s `severity_from_cvss_score` import. `normalize_pep503` should still source from here (it's pip-specific). Verify nothing else needs updating.

- [ ] **Step 3: Run all related tests**

```bash
.venv/bin/pytest tests/audit/checks/test_pip_supply_chain_audit.py -v
.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_pypi.py -v
.venv/bin/pytest tests/ -q
```

Expected: all green.

- [ ] **Step 4: Commit**

```bash
git add src/agentsec/audit/checks/pip_supply_chain_audit.py
git commit -m "$(cat <<'EOF'
refactor(7d.2): pip Check imports from _supply_chain_common

severity_from_cvss_score + Severity + DB load all sourced from the
shared check-side common module.

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

---

## Task 8: Go version helpers + parser + matcher

**Files:**
- Create: `src/agentsec/audit/checks/go_supply_chain_audit.py`
- Create: `tests/audit/checks/test_go_supply_chain_audit.py`

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

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

```python
"""Tests for parser, matcher, version normalizer in go_supply_chain_audit."""

from agentsec.audit.checks.go_supply_chain_audit import (
    _safe_parse_go_version,
    parse_go_sum,
    version_in_any_go_range,
)


# ---- _safe_parse_go_version ----

def test_safe_parse_strips_v_prefix():
    v = _safe_parse_go_version("v1.2.3")
    assert v is not None
    assert str(v) == "1.2.3"


def test_safe_parse_strips_incompatible_suffix():
    v = _safe_parse_go_version("v1.2.3+incompatible")
    assert v is not None
    assert str(v) == "1.2.3"


def test_safe_parse_pseudo_version():
    """v0.0.0-20210101120000-abcdef parses as semver pre-release."""
    v = _safe_parse_go_version("v0.0.0-20210101120000-abcdef123456")
    assert v is not None


def test_safe_parse_garbage_returns_none():
    assert _safe_parse_go_version("not a version") is None
    assert _safe_parse_go_version("v1") is None     # missing patch
    assert _safe_parse_go_version("v1.0") is None   # missing patch


def test_safe_parse_keeps_non_incompatible_build_metadata():
    """+cuda118 is build metadata, not +incompatible — should NOT be stripped."""
    v = _safe_parse_go_version("v1.0.0+cuda118")
    assert v is not None
    # semver with build metadata still parseable; behavior of `str(v)` may include the +meta
    # The point: parse succeeds and the version is comparable.


# ---- parse_go_sum ----

def test_parse_simple_line():
    src = "github.com/foo/bar v1.2.3 h1:abcdef==\n"
    assert parse_go_sum(src) == [("github.com/foo/bar", "v1.2.3")]


def test_parse_skip_go_mod_suffix():
    """The /go.mod-suffixed line duplicates the main entry; skip it to avoid double-finding."""
    src = (
        "github.com/foo/bar v1.2.3 h1:abc==\n"
        "github.com/foo/bar v1.2.3/go.mod h1:def==\n"
    )
    assert parse_go_sum(src) == [("github.com/foo/bar", "v1.2.3")]


def test_parse_keep_v_prefix():
    """Parser preserves raw version exactly; no stripping at parse time."""
    src = "pkg v1.2.3 h1:abc==\n"
    assert parse_go_sum(src) == [("pkg", "v1.2.3")]


def test_parse_keep_incompatible_suffix():
    src = "pkg v1.2.3+incompatible h1:abc==\n"
    assert parse_go_sum(src) == [("pkg", "v1.2.3+incompatible")]


def test_parse_pseudo_version():
    src = "golang.org/x/text v0.0.0-20210101120000-abcdef h1:xyz==\n"
    assert parse_go_sum(src) == [
        ("golang.org/x/text", "v0.0.0-20210101120000-abcdef"),
    ]


def test_parse_module_path_with_slash():
    src = "github.com/owner/repo/v2 v2.5.0 h1:abc==\n"
    assert parse_go_sum(src) == [("github.com/owner/repo/v2", "v2.5.0")]


def test_parse_blank_lines_skipped():
    src = "\n\ngithub.com/foo/bar v1.2.3 h1:abc==\n\n"
    assert parse_go_sum(src) == [("github.com/foo/bar", "v1.2.3")]


def test_parse_invalid_field_count_skipped():
    src = (
        "incomplete-line\n"
        "github.com/foo/bar v1.2.3 h1:ok==\n"
        "too many fields here extra\n"
    )
    assert parse_go_sum(src) == [("github.com/foo/bar", "v1.2.3")]


def test_parse_multiple_versions_same_module():
    src = (
        "github.com/foo/bar v1.0.0 h1:a==\n"
        "github.com/foo/bar v2.0.0 h1:b==\n"
    )
    assert parse_go_sum(src) == [
        ("github.com/foo/bar", "v1.0.0"),
        ("github.com/foo/bar", "v2.0.0"),
    ]


def test_parse_local_build_metadata():
    src = "pkg v1.0.0+cuda118 h1:abc==\n"
    assert parse_go_sum(src) == [("pkg", "v1.0.0+cuda118")]


# ---- version_in_any_go_range ----

def test_match_exact_inside_range():
    ranges = [{"introduced": "v1.0.0", "fixed": "v2.0.0"}]
    assert version_in_any_go_range("v1.5.0", ranges) is True


def test_match_at_introduced_boundary():
    ranges = [{"introduced": "v1.0.0", "fixed": "v2.0.0"}]
    assert version_in_any_go_range("v1.0.0", ranges) is True


def test_match_at_fixed_boundary_excluded():
    ranges = [{"introduced": "v1.0.0", "fixed": "v2.0.0"}]
    assert version_in_any_go_range("v2.0.0", ranges) is False


def test_match_just_below_fixed():
    ranges = [{"introduced": "v1.0.0", "fixed": "v2.0.0"}]
    assert version_in_any_go_range("v1.99.0", ranges) is True


def test_match_pseudo_version_in_range():
    ranges = [{"introduced": "0", "fixed": "v1.0.0"}]
    # pseudo-version sorts as pre-release < 1.0.0
    assert version_in_any_go_range(
        "v0.0.0-20210101120000-abc", ranges,
    ) is True


def test_match_unparseable_returns_false():
    ranges = [{"introduced": "v1.0.0", "fixed": "v2.0.0"}]
    assert version_in_any_go_range("garbage", ranges) is False


def test_match_multiple_ranges_or():
    ranges = [
        {"introduced": "v1.0.0", "fixed": "v1.5.0"},
        {"introduced": "v2.0.0", "fixed": "v2.5.0"},
    ]
    assert version_in_any_go_range("v2.3.0", ranges) is True
    assert version_in_any_go_range("v1.7.0", ranges) is False


def test_match_incompatible_stripped_at_compare():
    """v1.2.3+incompatible compared as 1.2.3 — falls into [v1.0.0, v2.0.0)."""
    ranges = [{"introduced": "v1.0.0", "fixed": "v2.0.0"}]
    assert version_in_any_go_range("v1.2.3+incompatible", ranges) is True
```

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

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

- [ ] **Step 3: Create the helpers module**

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

```python
"""go / OSV-Go 供应链漏洞审计（Phase 7d.2）。

Reads operator-configured go.sum files on the audited host, parses each
entry, matches each (module_path, version) against a bundled OSV-Go
advisory subset using semver-with-Go-prefix-stripping. Emits one
Finding per matching (module, version, advisory) tuple.

DB schema (src/agentsec/audit/ioc/osv_go.json):
  [{"id": "GHSA-xxxx", "package": "github.com/foo/bar",
    "ranges": [{"introduced": "0", "fixed": "v1.2.3"}],
    "severity": "critical|high|medium|low", "summary": "...",
    "url": "...", "aliases": ["CVE-..."]}, ...]
"""

from __future__ import annotations

import semver

# --- Version handling ---

def _safe_parse_go_version(s: str) -> semver.Version | None:
    """Strip Go-version conventions (v prefix, +incompatible) and parse as semver.

    Pseudo-versions (v0.0.0-20210101120000-abcdef) parse as semver pre-release
    where the timestamp segment provides natural lexicographic ordering.

    Returns None on parse failure.
    """
    if not s:
        return None
    cleaned = s
    if cleaned.startswith("v"):
        cleaned = cleaned[1:]
    if cleaned.endswith("+incompatible"):
        cleaned = cleaned[: -len("+incompatible")]
    try:
        return semver.Version.parse(cleaned)
    except (ValueError, TypeError):
        return None


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

    Range entry shape (Go subset, ECOSYSTEM type with v-prefixed strings):
      {"introduced": "v1.0.0", "fixed": "v2.0.0"}

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


# --- go.sum parser ---

def parse_go_sum(stdout: str) -> list[tuple[str, str]]:
    """Return [(module_path, version)] from go.sum content.

    Each go.sum line has shape '<module> <version> <hash>' (3 whitespace-
    separated fields). Lines whose version field ends with '/go.mod' duplicate
    the main entries with a different hash flavor and are skipped. Versions
    are kept raw (with 'v' prefix and any '+incompatible' suffix); cleanup
    happens at compare time in _safe_parse_go_version.
    """
    out: list[tuple[str, str]] = []
    for line in stdout.splitlines():
        s = line.strip()
        if not s or s.startswith("#"):
            continue
        parts = s.split()
        if len(parts) != 3:
            continue
        module, version, _hash = parts
        if version.endswith("/go.mod"):
            continue
        out.append((module, version))
    return out
```

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

Run: `.venv/bin/pytest tests/audit/checks/test_go_supply_chain_audit.py -v`
Expected: 19 PASS (5 normalizer + 11 parser + 8 matcher = 24 — adjust as your tests count).

(If `test_safe_parse_keeps_non_incompatible_build_metadata` fails because semver doesn't accept `+cuda118`, soften the test to assert it's None — semver semantics for build metadata is non-trivial.)

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/go_supply_chain_audit.py \
        tests/audit/checks/test_go_supply_chain_audit.py
git commit -m "$(cat <<'EOF'
feat(7d.2): Go version normalizer + parse_go_sum + matcher

semver-based comparison after stripping v prefix and +incompatible
suffix; pseudo-versions parse as semver pre-release with natural
timestamp ordering. Parser keeps raw version (matches 7d-npm /
7d.1-pip pattern: parser is format-shaping only).

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

---

## Task 9: `GoSupplyChainAuditCheck` class + DB load + run() (using `make_finding`)

**Files:**
- Modify: `src/agentsec/audit/checks/go_supply_chain_audit.py` (append Check class)
- Create: `src/agentsec/audit/ioc/osv_go.json` (empty `[]`)
- Create: `tests/fixtures/supply_chain_go/osv_go_subset.json`
- Modify: `tests/audit/checks/test_go_supply_chain_audit.py` (append e2e tests)

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

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

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

Create `tests/fixtures/supply_chain_go/osv_go_subset.json`:

```json
[
  {
    "id": "GHSA-go-aaaa-bbbb",
    "package": "github.com/foo/bar",
    "ranges": [{"introduced": "v1.0.0", "fixed": "v1.5.0"}],
    "severity": "high",
    "summary": "Path traversal in github.com/foo/bar",
    "url": "https://github.com/advisories/GHSA-go-aaaa-bbbb",
    "aliases": ["CVE-2023-12345"]
  },
  {
    "id": "GHSA-go-cccc-dddd",
    "package": "golang.org/x/text",
    "ranges": [{"introduced": "0", "fixed": "v0.3.8"}],
    "severity": "critical",
    "summary": "Denial of service in golang.org/x/text",
    "url": "https://github.com/advisories/GHSA-go-cccc-dddd",
    "aliases": ["CVE-2023-99999"]
  }
]
```

- [ ] **Step 3: Write the failing Check end-to-end tests**

Append to `tests/audit/checks/test_go_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.go_supply_chain_audit import GoSupplyChainAuditCheck
from agentsec.audit.platform_profile import LINUX

_FIXTURE_DB = Path("tests/fixtures/supply_chain_go/osv_go_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(*, go_lock_paths: tuple[str, ...], executor) -> CheckContext:
    return CheckContext(
        executor=executor, redactor=_NoOpRedactor(),
        ioc_dir=Path("/x"), profile=LINUX,
        go_lock_paths=go_lock_paths,
    )


def test_check_empty_config_not_applicable():
    check = GoSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(go_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 = GoSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        go_lock_paths=("/srv/go.sum",),
        executor=_StubExecutor({
            "/srv/go.sum": "github.com/foo/bar v1.2.0 h1:abc==\n",
        }),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    f = findings[0]
    assert f.check == "go_supply_chain_audit"
    assert f.severity == "high"
    assert "github.com/foo/bar@v1.2.0" in f.title
    assert f.evidence["advisory_id"] == "GHSA-go-aaaa-bbbb"
    assert f.evidence["fixed_in"] == "v1.5.0"
    assert f.evidence["lock_path"] == "/srv/go.sum"
    assert f.threat_refs == ["TI-OSV-GO-CATALOG"]


def test_check_multi_lockfile_mixed():
    check = GoSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        go_lock_paths=("/a.sum", "/b.sum", "/missing.sum"),
        executor=_StubExecutor({
            "/a.sum": "golang.org/x/text v0.3.0 h1:abc==\n",
            "/b.sum": "github.com/foo/bar v2.0.0 h1:def==\n",  # > 1.5.0, no hit
        }),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert findings[0].evidence["lock_path"] == "/a.sum"
    assert findings[0].severity == "critical"


def test_check_all_paths_unreadable_skipped():
    check = GoSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        go_lock_paths=("/x.sum", "/y.sum"),
        executor=_StubExecutor({}, rejected_paths={"/x.sum", "/y.sum"}),
    )
    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 = GoSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = _ctx(
        go_lock_paths=("/req.sum",),
        executor=_StubExecutor({
            "/req.sum": "weird-pkg garbage-version h1:abc==\n",
        }),
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert findings[0].severity == "low"
    assert "无法解析 Go 版本" in findings[0].title


def test_check_non_linux_not_applicable():
    from agentsec.audit.platform_profile import MACOS
    check = GoSupplyChainAuditCheck(db_path=_FIXTURE_DB)
    ctx = CheckContext(
        executor=_StubExecutor({}), redactor=_NoOpRedactor(),
        ioc_dir=Path("/x"), profile=MACOS,
        go_lock_paths=("/x.sum",),
    )
    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_go_supply_chain_audit.py -k 'check_' -v`
Expected: FAIL — `GoSupplyChainAuditCheck` doesn't exist.

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

Append to `src/agentsec/audit/checks/go_supply_chain_audit.py`:

```python
import json  # noqa: E402 — ensure at top with other imports
from pathlib import Path  # noqa: E402

from ..findings import Finding  # noqa: E402
from ._supply_chain_common import (  # noqa: E402
    Severity,
    load_advisory_db,
    severity_from_cvss_score,  # re-export available; not used directly here
)
from .base import Check, CheckContext  # noqa: E402


_OSV_DB_PATH = Path(__file__).parent.parent / "ioc" / "osv_go.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 GoSupplyChainAuditCheck(Check):
    id = "go_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.go_lock_paths:
            ctx.status.not_applicable(f"{self.id}：未配置 go_lock_paths")
            return []

        findings: list[Finding] = []
        n_skipped = 0
        for lock_path in ctx.go_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.go_lock_paths):
            ctx.status.skipped(
                f"{self.id}：所有 go_lock_paths 均不可读 (n={n_skipped})"
            )
        return findings

    def _scan_lockfile(
        self, ctx: CheckContext, lock_path: str, stdout: str,
    ) -> list[Finding]:
        modules = parse_go_sum(stdout)
        findings: list[Finding] = []
        for module, version in modules:
            if _safe_parse_go_version(version) is None:
                findings.append(
                    self._unparseable_finding(ctx, module, version, lock_path)
                )
                continue
            for adv in self._advisories_by_pkg.get(module, []):
                if version_in_any_go_range(version, adv["ranges"]):
                    findings.append(
                        self._advisory_finding(ctx, module, version, adv, lock_path)
                    )
        return findings

    def _advisory_finding(
        self, ctx: CheckContext, module: 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"go {module}@{version} 命中 {adv_id}（severity={sev}）",
            description=adv.get("summary", "")[:200],
            evidence={
                "package": module,
                "version": version,
                "advisory_id": adv_id,
                "aliases": adv.get("aliases", []),
                "fixed_in": fixed_in,
                "url": adv.get("url", ""),
                "lock_path": lock_path,
            },
            remediation=f"go get {module}@{fixed_in}",
            threat_refs=["TI-OSV-GO-CATALOG"],
        )

    def _unparseable_finding(
        self, ctx: CheckContext, module: str, version: str, lock_path: str,
    ) -> Finding:
        return self.make_finding(
            ctx,
            severity="low",
            title=f"无法解析 Go 版本: {module}@{version}",
            description=(
                "非标准 Go module 版本（应为 vX.Y.Z 或 v0.0.0-timestamp-hash 形式）"
            ),
            evidence={
                "package": module, "version": version, "lock_path": lock_path,
            },
            remediation="升级到合法 vX.Y.Z 版本",
            threat_refs=["TI-OSV-GO-CATALOG"],
        )
```

(Move the `import json` / `from pathlib import Path` etc. to the top of the file alongside the existing imports — they need to be at module top to satisfy ruff E402.)

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

Run: `.venv/bin/pytest tests/audit/checks/test_go_supply_chain_audit.py -v`
Expected: ~24-25 PASS.

- [ ] **Step 7: Commit**

**Pro-tip from 7d.1 experience:** the `tests/test_repo_threat_refs_all_resolve` strict TI check will likely fail right now because the Check uses `threat_refs=["TI-OSV-GO-CATALOG"]` but the TI entry doesn't exist yet (Task 10). To avoid the failure, do **Task 10's Step 1 (append TI entry to threat_intel.yaml)** as part of this commit. Run `.venv/bin/python scripts/check_threat_refs.py` first; if it fails, append the TI entry, run again to confirm green, then commit everything together. If you do this, **skip Task 10 entirely** — it's already done.

```bash
# Run pre-commit verification:
.venv/bin/pytest tests/audit/checks/test_go_supply_chain_audit.py -v
.venv/bin/python scripts/check_threat_refs.py    # if this fails, do Task 10 Step 1 now
.venv/bin/pytest tests/ -q                       # full suite

# Stage:
git add src/agentsec/audit/checks/go_supply_chain_audit.py \
        src/agentsec/audit/ioc/osv_go.json \
        tests/audit/checks/test_go_supply_chain_audit.py \
        tests/fixtures/supply_chain_go/osv_go_subset.json
# Include threat_intel.yaml ONLY IF you folded Task 10 forward:
git add src/agentsec/audit/ioc/threat_intel.yaml 2>/dev/null || true
git commit -m "$(cat <<'EOF'
feat(7d.2): GoSupplyChainAuditCheck

Reads ctx.go_lock_paths, cats each go.sum via executor, parses
module-version pairs, matches against bundled osv_go.json (empty
placeholder; populated in Task 22). Per-(module, version, advisory)
findings under TI-OSV-GO-CATALOG. Uses Check.make_finding for
automatic Redactor pass (sets the new convention).

Also adds TI-OSV-GO-CATALOG umbrella entry to threat_intel.yaml
so strict threat-ref validation passes (folded from Task 10).

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

(If the strict TI check passed without Task 10 work, drop the "Also adds..." paragraph from the commit message and proceed to Task 10 separately.)

---

## Task 10: TI umbrella `TI-OSV-GO-CATALOG` *(skip if folded into Task 9)*

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

- [ ] **Step 1: Append the TI entry**

Append to `src/agentsec/audit/ioc/threat_intel.yaml`:

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

- [ ] **Step 2: Verify CI cross-checks pass**

```bash
.venv/bin/python scripts/check_threat_refs.py
.venv/bin/python scripts/build_cve_db.py --check
```

Expected: both exit 0.

- [ ] **Step 3: Run full test suite**

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

Expected: green.

- [ ] **Step 4: Commit**

```bash
git add src/agentsec/audit/ioc/threat_intel.yaml
git commit -m "$(cat <<'EOF'
data(7d.2): add TI-OSV-GO-CATALOG umbrella entry

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

---

## Task 11: `scripts/check_osv_go.py` CI guard

**Files:**
- Create: `scripts/check_osv_go.py`
- Create: `tests/scripts/test_check_osv_go.py`

- [ ] **Step 1: Inspect the pypi precedent**

Run: `cat scripts/check_osv_pypi.py`

This is the structural template (mirror its CLI flags + exit codes).

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

Create `tests/scripts/test_check_osv_go.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_go.py", *args],
        cwd=cwd, capture_output=True, text=True, check=False,
    )


def test_check_valid_json_passes(tmp_path):
    db = tmp_path / "osv_go.json"
    db.write_text(json.dumps([
        {"id": "GHSA-x", "package": "github.com/foo/bar",
         "ranges": [{"fixed": "v1.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_go.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_go.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_go.py -v`
Expected: FAIL — script doesn't exist.

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

Create `scripts/check_osv_go.py`:

```python
#!/usr/bin/env python3
"""CI guard for src/agentsec/audit/ioc/osv_go.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_go.json")
_MAX_BYTES = 15 * 1024 * 1024  # 15 MB


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)",
    )
    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_go.py
```

- [ ] **Step 5: Run tests + script against committed DB**

```bash
.venv/bin/pytest tests/scripts/test_check_osv_go.py -v
.venv/bin/python scripts/check_osv_go.py
```

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

- [ ] **Step 6: Add the step to `.github/workflows/ci.yml`**

Find the existing `check_osv_npm.py` step in both `ioc-sync` job and `test-macos` job (Task 19 of 7d.1 added them). Add a `check_osv_go.py` step right after each:

```yaml
      - run: python scripts/check_osv_go.py --check
```

(Inspect `.github/workflows/ci.yml` first to confirm exact step shape.)

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

```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_go.py \
        tests/scripts/test_check_osv_go.py \
        .github/workflows/ci.yml
git commit -m "$(cat <<'EOF'
build(7d.2): scripts/check_osv_go.py CI guard

Validates JSON + 15 MB hard cap, mirrors check_osv_npm.py / check_osv_pypi.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 12: `OsvGoFetcher.normalize_osv_go_entry` (offline filter chain)

**Files:**
- Create: `src/agentsec/audit/ioc_update/fetchers/osv_go.py`
- Create: `tests/audit/ioc_update/fetchers/test_osv_go.py`
- Create: `tests/fixtures/osv/go/{normal, withdrawn, no-go, no-range, no-cvss, multi-affected}.json`

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

Run: `head -200 src/agentsec/audit/ioc_update/fetchers/osv_pypi.py`

Note `normalize_osv_pypi_entry`, `_extract_pep440_ranges`, the multi-affected fan-out pattern.

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

Create directory `tests/fixtures/osv/go/` and these 6 JSON files:

`tests/fixtures/osv/go/normal.json`:
```json
{
  "id": "GHSA-norm-aaaa-bbbb",
  "published": "2025-06-01T00:00:00Z",
  "modified": "2025-06-02T00:00:00Z",
  "summary": "SSRF in github.com/foo/bar",
  "affected": [{
    "package": {"ecosystem": "Go", "name": "github.com/foo/bar"},
    "ranges": [{
      "type": "ECOSYSTEM",
      "events": [{"introduced": "0"}, {"fixed": "v2.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"}],
  "aliases": ["CVE-2025-99001"]
}
```

`tests/fixtures/osv/go/withdrawn.json`:
```json
{
  "id": "GHSA-with-aaaa-bbbb",
  "withdrawn": "2025-01-01T00:00:00Z",
  "affected": [{"package": {"ecosystem": "Go", "name": "x"},
                "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "v1.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/go/no-go.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/go/no-range.json`:
```json
{
  "id": "GHSA-norang-aaaa-bbbb",
  "affected": [{"package": {"ecosystem": "Go", "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/go/no-cvss.json`:
```json
{
  "id": "GHSA-noscore-aaaa-bbbb",
  "affected": [{
    "package": {"ecosystem": "Go", "name": "foo"},
    "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "v1.0.0"}]}]
  }],
  "severity": []
}
```

`tests/fixtures/osv/go/multi-affected.json`:
```json
{
  "id": "GHSA-multi-aaaa-bbbb",
  "published": "2025-06-01T00:00:00Z",
  "summary": "Affects two Go modules",
  "affected": [
    {
      "package": {"ecosystem": "Go", "name": "github.com/owner/pkg-a"},
      "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "v1.0.0"}]}]
    },
    {
      "package": {"ecosystem": "Go", "name": "github.com/owner/pkg-b"},
      "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"fixed": "v2.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 the failing fetcher tests**

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

```python
import json
from pathlib import Path

from agentsec.audit.ioc_update.fetchers.osv_go import normalize_osv_go_entry

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


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


def test_normalize_normal_entry():
    out = normalize_osv_go_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"] == "github.com/foo/bar"   # no normalization
    assert rec["severity"] == "critical"
    assert rec["ranges"] == [{"introduced": "0", "fixed": "v2.0.0"}]
    assert rec["aliases"] == ["CVE-2025-99001"]


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


def test_normalize_filters_no_go_affected():
    assert normalize_osv_go_entry(_load("no-go.json")) is None


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


def test_normalize_filters_no_cvss():
    assert normalize_osv_go_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_go_entry(entry) is None


def test_normalize_keeps_old_critical():
    entry = _load("normal.json")
    entry["published"] = "2020-01-01T00:00:00Z"
    out = normalize_osv_go_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_go_entry(_load("multi-affected.json"))
    assert out is not None
    assert len(out) == 2
    pkgs = sorted(r["package"] for r in out)
    assert pkgs == ["github.com/owner/pkg-a", "github.com/owner/pkg-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_go.py -v`
Expected: FAIL — module doesn't exist.

- [ ] **Step 5: Write the fetcher module (offline path only)**

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

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

Pulls https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip
and normalizes the included OSV entries to the compact schema
consumed by GoSupplyChainAuditCheck. Returns a single FetchResult
keyed under '_osv_go_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_GO_URL = (
    "https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip"
)
RETRY_BACKOFFS = [2, 4, 8]
_HTTP_TIMEOUT = 60


def _extract_go_ranges(ranges: list[dict]) -> list[dict]:
    """OSV-Go uses ECOSYSTEM type with v-prefixed semver-ish 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_go_entry(osv_entry: dict) -> list[dict] | None:
    """Return [compact-schema dict, ...] (one per Go affected pkg),
    or None if the entry is unusable / filtered out by bundle-size policy.

    Filter rules (5 axes): withdrawn, no-Go-affected, no-range, no-CVSS,
    medium/low > 2 years old.
    """
    if osv_entry.get("withdrawn"):
        return None
    affected_go = [
        a for a in osv_entry.get("affected", [])
        if (a.get("package") or {}).get("ecosystem") == "Go"
    ]
    if not affected_go:
        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_go:
        ranges = _extract_go_ranges(aff.get("ranges", []))
        if not ranges:
            continue
        pkg = aff["package"]["name"]   # module path; 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
```

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

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_go.py -v`
Expected: 8 PASS.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_go.py \
        tests/audit/ioc_update/fetchers/test_osv_go.py \
        tests/fixtures/osv/go/
git commit -m "$(cat <<'EOF'
feat(7d.2): OsvGoFetcher.normalize_osv_go_entry (offline)

5-axis filter chain reusing _supply_chain_common helpers; Go ecosystem
only; module paths kept as-is (no normalization); multi-affected
expands to multiple records.

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

---

## Task 13: `OsvGoFetcher` HTTP path

**Files:**
- Modify: `src/agentsec/audit/ioc_update/fetchers/osv_go.py`
- Modify: `tests/audit/ioc_update/fetchers/test_osv_go.py`

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

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

```python
import asyncio
import io
import zipfile

import httpx

from agentsec.audit.ioc_update.fetchers.base import FetcherContext
from agentsec.audit.ioc_update.fetchers.osv_go import OsvGoFetcher


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 = OsvGoFetcher()
    fetcher._client_factory = lambda: httpx.AsyncClient(
        transport=httpx.MockTransport(handler),
    )

    ctx = FetcherContext(offline=False)
    results = asyncio.run(fetcher.run(ctx, []))
    assert "_osv_go_full" in results
    r = results["_osv_go_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 = OsvGoFetcher()
    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_go_full"]
    assert r.degraded is not None
    assert call_count["n"] == 4
```

(Move `import asyncio`, `import io`, `import zipfile`, `import httpx` to the file's import block at top — the existing top-of-file imports already have `import json`. Avoid E402.)

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

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_go.py -k fetch -v`
Expected: FAIL — `OsvGoFetcher` not yet defined.

- [ ] **Step 3: Append the Fetcher class**

Append to `src/agentsec/audit/ioc_update/fetchers/osv_go.py`:

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

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

    name = "osv_go"

    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_go_full": FetchResult.from_degraded(
                    reason="offline mode without OSV-Go fixture support",
                    fetcher=self.name,
                )
            }
        try:
            return await self._fetch_and_normalize(ctx)
        except (httpx.HTTPError, zipfile.BadZipFile) as e:
            return {
                "_osv_go_full": FetchResult.from_degraded(
                    reason=f"OSV-Go 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_GO_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_go_full": FetchResult.from_degraded(
                    reason="OSV-Go: 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_go_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_go_full": FetchResult.ok(
                records=normalized,
                http_status=200,
                response_sha256=hash_response(body),
                raw_record_count=len(normalized),
            ),
        }
```

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

Run: `.venv/bin/pytest tests/audit/ioc_update/fetchers/test_osv_go.py -v`
Expected: 10 PASS (8 normalize + 2 HTTP).

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/ioc_update/fetchers/osv_go.py \
        tests/audit/ioc_update/fetchers/test_osv_go.py
git commit -m "$(cat <<'EOF'
feat(7d.2): OsvGoFetcher 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 14: `_run_osv_go` 3-way parallel pipeline in ioc-update CLI

**Files:**
- Modify: `src/agentsec/audit/ioc_update/cli.py` (add `_run_osv_go` + 3-way `asyncio.gather`)
- Create: `tests/test_ioc_update_osv_go_wiring.py`

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

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

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

Create `tests/test_ioc_update_osv_go_wiring.py`:

```python
"""ioc-update CLI must invoke OsvGoFetcher and write proposed-osv_go.json (Phase 7d.2)."""

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


@pytest.mark.asyncio
async def test_run_osv_go_writes_proposed_file(tmp_path: Path):
    fake_records = [
        {"id": "GHSA-z", "package": "github.com/foo/bar",
         "ranges": [{"introduced": "0", "fixed": "v1.0.0"}],
         "severity": "high", "summary": "x", "url": "u", "aliases": []}
    ]
    with patch(
        "agentsec.audit.ioc_update.cli.OsvGoFetcher"
    ) as MockFetcher:
        async def fake_run(ctx, entries):
            return {"_osv_go_full": FetchResult.ok(records=fake_records)}
        MockFetcher.return_value.run.side_effect = fake_run

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

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


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

        ctx = FetcherContext(offline=False)
        out_path = await _run_osv_go(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_go_wiring.py -v`
Expected: ImportError on `_run_osv_go`.

- [ ] **Step 4: Add `_run_osv_go` and 3-way gather**

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

a. Add import near `from .fetchers.osv_pypi import OsvPyPIFetcher`:

```python
from .fetchers.osv_go import OsvGoFetcher
```

b. Add the function after `_run_osv_pypi`:

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

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

(Use `result.degraded is not None` to match the npm/pypi convention you confirmed in Task 12 of 7d.1.)

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

```python
osv_npm_path, osv_pypi_path, osv_go_path = await asyncio.gather(
    _run_osv_npm(fctx, out_dir),
    _run_osv_pypi(fctx, out_dir),
    _run_osv_go(fctx, out_dir),
)
# existing osv_npm + osv_pypi log_event blocks unchanged
# add a third for osv_go:
if osv_go_path is not None:
    log_event({
        "event": "osv_go_written",
        "path": str(osv_go_path),
        "records": len(json.loads(osv_go_path.read_text())),
    })
else:
    log_event({
        "event": "osv_go_degraded",
        "note": "OSV-Go fetch failed or skipped; existing osv_go.json unchanged",
    })
```

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

```bash
.venv/bin/pytest 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: 6 wiring tests PASS + full suite green.

- [ ] **Step 6: Commit**

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

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

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

---

## Task 15: Register `GoSupplyChainAuditCheck` + wire `go_lock_paths`

**Files:**
- Modify: `src/agentsec/cli.py` (4 sites: 2 imports + 2 register + 2 wire)

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

```bash
grep -n 'PipSupplyChainAuditCheck\|pip_lock_paths\|--pip-lock-path' src/agentsec/cli.py
```

This is your structural template — the new go wiring mirrors it exactly.

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

After `from .audit.checks.pip_supply_chain_audit import PipSupplyChainAuditCheck` (cli.py:37):

```python
from .audit.checks.go_supply_chain_audit import GoSupplyChainAuditCheck
```

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

a. After the `pip_lock_paths` typer.Option, add:

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

b. After the `if pip_lock_paths:` block, add:

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

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

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

```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),
    )
```

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

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

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

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

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),
    )
```

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

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

Expected: 1 failure on the sample-drift guard `test_committed_sample_matches_pipeline_output` (because `GoSupplyChainAuditCheck` is now in `checks_run`); Task 21 refreshes the sample.

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

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

Expected: `--go-lock-path` flag visible.

- [ ] **Step 7: Commit**

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

Standalone server-audit gains --go-lock-path repeatable flag;
evaluate flow reads ServerAuditConfig.go_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 16: Migrate `SupplyChainAuditCheck` (npm) to `make_finding`

**Files:**
- Modify: `src/agentsec/audit/checks/supply_chain_audit.py`

- [ ] **Step 1: Read current Finding-construction sites**

```bash
grep -n 'Finding(' src/agentsec/audit/checks/supply_chain_audit.py
```

You'll see two: the unparseable-semver finding inside `run()` and the advisory-hit finding in `_make_advisory_finding`.

- [ ] **Step 2: Refactor to `make_finding`**

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

a. Change `_make_advisory_finding` signature to take `ctx`:

```python
    def _make_advisory_finding(
        self, ctx: CheckContext, 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 self.make_finding(
            ctx,
            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"],
        )
```

b. Change the inline unparseable-semver `Finding(...)` to use `self.make_finding(ctx, ...)`. The finding signature inside `run()` becomes:

```python
                findings.append(self.make_finding(
                    ctx,
                    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"],
                ))
```

c. Update the call site of `self._make_advisory_finding(...)` to pass `ctx`:

```python
                    findings.append(self._make_advisory_finding(
                        ctx, pkg, version, adv, npm_root,
                    ))
```

- [ ] **Step 3: Run all related tests**

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

Expected: all green (existing tests use `_NoOpRedactor`, behavior unchanged).

If `tests/audit/checks/test_supply_chain_audit.py` doesn't exist (npm tests live elsewhere — check with `grep -rln "SupplyChainAuditCheck" tests/`), run whatever existing tests cover the npm Check.

- [ ] **Step 4: Commit**

```bash
git add src/agentsec/audit/checks/supply_chain_audit.py
git commit -m "$(cat <<'EOF'
refactor(7d): npm SupplyChainAuditCheck via Check.make_finding

Routes all Finding construction through the base helper so future
Redactor patterns automatically cover npm supply-chain findings.
Behavior unchanged with default redactor; test stubs unaffected.

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

---

## Task 17: Migrate `PipSupplyChainAuditCheck` to `make_finding`

**Files:**
- Modify: `src/agentsec/audit/checks/pip_supply_chain_audit.py`

- [ ] **Step 1: Refactor to `make_finding`**

In `src/agentsec/audit/checks/pip_supply_chain_audit.py`:

a. Change `_advisory_finding` and `_unparseable_finding` signatures to take `ctx` as first arg, and use `self.make_finding(ctx, ...)`:

```python
    def _advisory_finding(
        self, ctx: CheckContext, pkg: 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"pip {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", ""),
                "lock_path": lock_path,
            },
            remediation=f"pip install -U {pkg}=={fixed_in}",
            threat_refs=["TI-OSV-PYPI-CATALOG"],
        )

    def _unparseable_finding(
        self, ctx: CheckContext, pkg: str, version: str, lock_path: str,
    ) -> Finding:
        return self.make_finding(
            ctx,
            severity="low",
            title=f"无法解析 PEP-440: {pkg}@{version}",
            description="非标准版本字符串（如 git URL pin），无法对照 OSV 数据库",
            evidence={
                "package": pkg, "version": version, "lock_path": lock_path,
            },
            remediation="改为 PEP-440 合规版本（pip 发布版）",
            threat_refs=["TI-OSV-PYPI-CATALOG"],
        )
```

b. Update `_scan_lockfile` to pass `ctx` through:

```python
    def _scan_lockfile(self, ctx: CheckContext, lock_path: str, stdout: str) -> list[Finding]:
        pkgs = parse_pip_requirements(stdout)
        findings: list[Finding] = []
        for pkg, version in pkgs:
            norm = normalize_pep503(pkg)
            if _safe_parse_pep440(version) is None:
                findings.append(self._unparseable_finding(ctx, pkg, version, lock_path))
                continue
            for adv in self._advisories_by_pkg.get(norm, []):
                if version_in_any_pep440_range(version, adv["ranges"]):
                    findings.append(
                        self._advisory_finding(ctx, pkg, version, adv, lock_path)
                    )
        return findings
```

c. Update `run()` to pass ctx into `_scan_lockfile`:

```python
            findings.extend(self._scan_lockfile(ctx, lock_path, result.stdout))
```

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

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

Expected: all green.

- [ ] **Step 3: Commit**

```bash
git add src/agentsec/audit/checks/pip_supply_chain_audit.py
git commit -m "$(cat <<'EOF'
refactor(7d.1): pip Check via Check.make_finding

Routes all Finding construction through the base helper so the
Redactor automatically covers pip supply-chain findings. Behavior
unchanged with default redactor.

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

---

## Task 18: Redaction tests for all 3 supply-chain Checks

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

- [ ] **Step 1: Read the existing Redactor**

```bash
grep -n "class Redactor\|def redact" src/agentsec/audit/redactor.py | head
```

The Redactor uses configurable patterns (e.g., regex for API keys). To test redaction routing, construct a `Redactor` with a known pattern and verify the finding evidence shows the pattern replaced.

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

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

```python
"""Verify all 3 supply-chain Checks route Finding through the Redactor.

Phase 7d.2 follow-up to 7d.1 final-review #5: Finding evidence may carry
operator-derived strings (lock_path) and OSV-derived strings (summary);
make_finding routes them through ctx.redactor.redact so future patterns
automatically cover supply-chain findings without per-Check changes.
"""

import asyncio
import json
import re
from dataclasses import dataclass
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.go_supply_chain_audit import GoSupplyChainAuditCheck
from agentsec.audit.checks.pip_supply_chain_audit import PipSupplyChainAuditCheck
from agentsec.audit.checks.supply_chain_audit import SupplyChainAuditCheck
from agentsec.audit.platform_profile import LINUX


@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]):
        self._files = files

    def run(self, argv: list[str]) -> _ExecResult:
        path = argv[1] if len(argv) >= 2 else ""
        if path not in self._files:
            return _ExecResult(stderr="No such file", exit_code=1)
        return _ExecResult(stdout=self._files[path])


class _SecretReplacingRedactor:
    """Replaces 'SECRET-XYZ-...' tokens with [REDACTED] in any string passed."""
    _PAT = re.compile(r"SECRET-XYZ-[A-Z0-9]+")

    def redact(self, s: str) -> str:
        if not isinstance(s, str):
            return s
        return self._PAT.sub("[REDACTED]", s)


_PIP_FIXTURE_DB = Path("tests/fixtures/supply_chain_pypi/osv_pypi_subset.json")
_GO_FIXTURE_DB = Path("tests/fixtures/supply_chain_go/osv_go_subset.json")


def test_pip_check_routes_through_redactor():
    """Verify a finding's evidence has secret-like substring redacted."""
    check = PipSupplyChainAuditCheck(db_path=_PIP_FIXTURE_DB)
    ctx = CheckContext(
        executor=_StubExecutor({
            # The lock_path itself contains a secret-looking token; redactor scrubs it
            "/srv/SECRET-XYZ-ABC123/requirements.txt": "django==3.2.0\n",
        }),
        redactor=_SecretReplacingRedactor(),
        ioc_dir=Path("/x"), profile=LINUX,
        pip_lock_paths=("/srv/SECRET-XYZ-ABC123/requirements.txt",),
    )
    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"]


def test_go_check_routes_through_redactor():
    check = GoSupplyChainAuditCheck(db_path=_GO_FIXTURE_DB)
    ctx = CheckContext(
        executor=_StubExecutor({
            "/srv/SECRET-XYZ-DEF456/go.sum":
                "github.com/foo/bar v1.2.0 h1:abc==\n",
        }),
        redactor=_SecretReplacingRedactor(),
        ioc_dir=Path("/x"), profile=LINUX,
        go_lock_paths=("/srv/SECRET-XYZ-DEF456/go.sum",),
    )
    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"]


def test_npm_check_routes_through_redactor(tmp_path):
    """SupplyChainAuditCheck reads from npm_root path (PlatformProfile-based);
    its evidence['npm_root'] must be redacted if the path contains a secret pattern."""
    # Construct a PlatformProfile variant where npm_root path contains the secret
    from agentsec.audit.checks.supply_chain_audit import SupplyChainAuditCheck
    from agentsec.audit.platform_profile import PlatformProfile

    npm_lock_content = json.dumps({
        "lockfileVersion": 3,
        "packages": {
            "node_modules/lodash": {"version": "4.17.20"},
        },
    })

    # Patch the LINUX profile's npm_root to contain a secret-like substring
    profile = PlatformProfile(
        name="linux",
        paths={
            **LINUX.paths,
            "npm_root": "/srv/SECRET-XYZ-NPMNODE/openclaw",
        },
        services=LINUX.services,
        listening_check=LINUX.listening_check,
        log_source=LINUX.log_source,
    )

    # Use a fixture DB with lodash advisory
    fixture_db = tmp_path / "osv_npm_subset.json"
    fixture_db.write_text(json.dumps([
        {"id": "GHSA-loda-xxxx", "package": "lodash",
         "ranges": [{"introduced": "0", "fixed": "4.17.21"}],
         "severity": "high", "summary": "Prototype pollution",
         "url": "u", "aliases": []}
    ]))

    check = SupplyChainAuditCheck(db_path=fixture_db)
    ctx = CheckContext(
        executor=_StubExecutor({
            "/srv/SECRET-XYZ-NPMNODE/openclaw/package-lock.json": npm_lock_content,
        }),
        redactor=_SecretReplacingRedactor(),
        ioc_dir=Path("/x"), profile=profile,
    )
    findings = asyncio.run(check.run(ctx))
    assert len(findings) == 1
    assert "SECRET-XYZ" not in findings[0].evidence["npm_root"]
    assert "[REDACTED]" in findings[0].evidence["npm_root"]
```

- [ ] **Step 3: Run tests + verify pass**

Run: `.venv/bin/pytest tests/audit/checks/test_supply_chain_redaction.py -v`
Expected: 3 PASS — proves all 3 Checks route Findings through the Redactor.

If any fails, the implementer didn't migrate that Check correctly to `make_finding`; go back and verify that Check's `_advisory_finding` calls `self.make_finding(ctx, ...)` properly.

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

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

Expected: green.

- [ ] **Step 5: Commit**

```bash
git add tests/audit/checks/test_supply_chain_redaction.py
git commit -m "$(cat <<'EOF'
test(7d.2): redaction routing for all 3 supply-chain Checks

Verifies npm / pip / go Checks all route Finding evidence through
ctx.redactor.redact (via Check.make_finding). Closes 7d.1 final-
review #5 follow-up.

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

---

## Task 19: ssh_policy.yaml comment annotation

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

- [ ] **Step 1: Add the documentary comment**

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

```yaml
  # Phase 7d.2 — go supply chain audit; per-run dynamic injection.
  # Operator-supplied paths in ServerAuditConfig.go_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-0014.
```

- [ ] **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.2): ssh_policy.yaml note about dynamic go_lock_paths injection

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

---

## Task 20: 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
```

Expected: PASS, sample dir updated.

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

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

Expected: only `26 → 27` count + new `go_supply_chain_audit: not_applicable (未配置 go_lock_paths)` rows + new `<!-- meta -->` anchor for go.

- [ ] **Step 3: Commit**

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

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

---

## Task 21: First osv_go.json populate

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

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

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

The CLI will write `proposed-osv_go.json` to `.cache/ioc-update-2026-05-11/`.

- [ ] **Step 2: Inspect the proposed Go bundle**

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

Expected: ~500-1500 entries / ~200-500 KB; healthy severity distribution.

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

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

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

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

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

Expected: green (the new bundle doesn't affect any existing test — fixtures use isolated `osv_go_subset.json`).

- [ ] **Step 5: Commit**

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

Initial OSV-Go bundle: NNN entries / M.M MB 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 22: ADR-0014

**Files:**
- Create: `docs/adr/0014-phase-7d2-go-supply-chain.md`

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

```bash
cat docs/adr/0013-phase-7d1-pip-supply-chain.md
```

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

Open spec §14 (decision table) and create `docs/adr/0014-phase-7d2-go-supply-chain.md`. For each D-row, include three subsections (decision / Considered alternatives / Rationale) — copy "决策" / "备选" / "选择理由" cell content verbatim from spec §14.

```markdown
# ADR-0014: Phase 7d.2 — go / OSV-Go supply chain audit + common module extraction

**Status:** Accepted
**Date:** 2026-05-11
**Related:** ADR-0013 (Phase 7d.1 pip), ADR-0012 (Phase 7d npm)

## Context

Phase 7d.1 (v0.13.0) shipped pip / PyPI supply chain audit with the
operator-config'd path model. Phase 7d.2 extends to Go; with three
ecosystems landed, this phase triggers ADR-0013 D3's promised
extraction of `_supply_chain_common.py` (rule of three). It also
closes 7d.1 final-review #5: routing all 3 supply-chain Checks through
`Check.make_finding` so Redactor patterns automatically cover their
findings.

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

## Decisions

### D1. Lockfile paths come from operator config (mirroring 7d.1)
（按 ADR-0013 D1 同款格式展开 — 决策 / Considered alternatives / Rationale，从 spec §14 D1 行复制）

### D2. Format = `go.sum` only; basename strict equality
（同上格式，spec §14 D2 行）

### D3. `_supply_chain_common.py` split into fetcher-side + check-side
（同上格式，spec §14 D3 行 — 强调依赖方向单向不跨层）

### D4. Go version handling = strip `v` prefix + drop `+incompatible` + semver lib
（同上格式，spec §14 D4 行 — 解释 pseudo-versions 用 semver pre-release 自然排序）

### D5. Each ecosystem matcher independent; no `version_in_any_range_with(parse_fn)` generic
（同上格式，spec §14 D5 行）

### D6. exact-kind dynamic injection (mirrors 7d.1)
（同上格式，spec §14 D6 行）

### D7. Route 3 supply-chain Checks through `Check.make_finding`
（同上格式，spec §14 D7 行 — 关闭 7d.1 final-review #5）

### D8. OSV-Go 5-axis filter chain identical to npm/pypi
（同上格式，spec §14 D8 行）

### D9. TI = single umbrella `TI-OSV-GO-CATALOG`
（同上格式，spec §14 D9 行）

## Consequences

- Three ecosystems share `_supply_chain_common.py` (one source of truth for severity / age / DB-load helpers); 7d.3 (rust) only needs ecosystem-specific bits.
- `Check.make_finding` routing makes the Redactor universal across all current and future supply-chain Checks.
- The wheel grew by ~300-500 KB (osv_go.json bundled).

## References

- Spec: `docs/superpowers/specs/2026-05-11-phase-7d2-go-supply-chain-design.md`
- Plan: `docs/superpowers/plans/2026-05-11-phase-7d2-go-supply-chain.md`
- ADR-0013: 7d.1 (pip) — D3 promised this extraction; #5 follow-up closed here
```

For each "（按 ADR-0013 ... 同款格式展开 — ... spec §14 D# 行）" placeholder, expand using the full three-subsection pattern shown by ADR-0013 D1, copying spec §14 D# row verbatim.

- [ ] **Step 3: Commit**

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

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

---

## Task 23: Docs sync + version 0.14.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.13.0"` → `version = "0.14.0"`.

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

Find Phase 7 section. Change:

```
- [x] Phase 7d.1 — pip 供应链 Check（OSV-PyPI bundled DB，TI-OSV-PYPI-CATALOG，3452 advisories）— 2026-05-11
- [ ] Phase 7d.2+ — Skills / Plugins 深化的剩余面（go / rust 生态 + plugin_static 静态扫描扩展）
```

to:

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

(NNN from Task 21.)

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

Insert a new section at top under "Unreleased":

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

### Added

- **Phase 7d.2 — go / OSV-Go 供应链审计 Check** (`go_supply_chain_audit`)。读
  operator 在 `openclaw-target.yaml` `server_audit.go_lock_paths` 列出的
  `go.sum`（绝对路径或 `~/-rooted`，basename 必须严格等于 `go.sum`），
  解析 `<module> <version> <hash>` 三字段行，跳过 `/go.mod` 后缀重复行，
  按 module path 字面匹配 + semver 比较（剥 `v` 前缀 + `+incompatible`
  后缀）对照 bundled OSV-Go DB (`src/agentsec/audit/ioc/osv_go.json`，
  NNN advisories ≈ MMM KB) emit 每个命中 (module, version, advisory)
  一条 finding。TI umbrella `TI-OSV-GO-CATALOG`。服务端审计 Check 数：
  26 → 27。
- `OsvGoFetcher` 在 `agentsec ioc-update` 中作 side pipeline，与
  `OsvNpmFetcher` / `OsvPyPIFetcher` `asyncio.gather` 三方并行。
- `agentsec server-audit` 新增可重复 `--go-lock-path` flag。
- `ServerAuditConfig.go_lock_paths` 配置字段 + Pydantic validator
  （绝对/`~/`-rooted、无 metachar、basename == `go.sum`）。
- `CheckContext.go_lock_paths: tuple[str, ...]` 字段。
- `scripts/check_osv_go.py` CI 守门 + ci.yml 集成。
- ADR-0014 记录全 9 项决策。

### Refactored

- **`_supply_chain_common.py` 抽取（rule of three 触发，ADR-0013 D3 兑现）**。
  - `src/agentsec/audit/ioc_update/fetchers/_supply_chain_common.py` 持有
    `severity_from_cvss_score` / `_parse_cvss_score` / `_is_within_age` /
    `_extract_severity` + `MEDIUM_LOW_MAX_AGE_DAYS` + `Severity` Literal。
  - `src/agentsec/audit/checks/_supply_chain_common.py` 持有
    `load_advisory_db` + re-exports of `Severity` / `severity_from_cvss_score`。
  - 3 个 fetcher (`osv_npm` / `osv_pypi` / `osv_go`) + 3 个 Check
    (`supply_chain_audit` / `pip_supply_chain_audit` / `go_supply_chain_audit`)
    都从 common 模块 import；本地副本删除。

- **3 个 supply-chain Check 改走 `Check.make_finding`**（关闭 7d.1
  final-review #5）。所有 Finding evidence 现在自动通过 `ctx.redactor.redact`
  做 string 字段脱敏；新加的 `tests/audit/checks/test_supply_chain_redaction.py`
  覆盖 3 Check 各一例 secret-pattern 替换。

### Internal

- 测试数 1235 → ~1284 passing（+49）。
- 新测试位置：`tests/audit/checks/test_go_supply_chain_audit.py`、
  `tests/audit/checks/test_supply_chain_common.py`、
  `tests/audit/checks/test_supply_chain_redaction.py`、
  `tests/audit/ioc_update/fetchers/test_osv_go.py`、
  `tests/audit/ioc_update/fetchers/test_supply_chain_common.py`、
  `tests/scripts/test_check_osv_go.py`、
  `tests/test_ioc_update_osv_go_wiring.py`。
- 零新 PyPI 依赖（`semver>=3,<4` 已在 deps）。

---
```

(NNN / MMM from Task 21.)

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

Find the lines containing "26 类" — there should be 3 (matching 7d.1 pattern). Update each to "27 类（10 OpenClaw + 11 Linux hardening + 3 Docker + 1 npm + 1 pip + 1 go supply chain）":

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

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

Find the "Total checks" line in the Phase 7d section:

```
- **Total checks (v0.13.0):** ... 26 类。
```

Change to:

```
- **Total checks (v0.14.0):** ... `pip_supply_chain_audit` (Phase 7d.1); `go_supply_chain_audit` (Phase 7d.2) — 27 类。
```

Add a new section after the existing "pip supply chain audit (Phase 7d.1, v0.13.0)" section:

```markdown
## go supply chain audit (Phase 7d.2, v0.14.0)

- **`GoSupplyChainAuditCheck`** in `src/agentsec/audit/checks/go_supply_chain_audit.py` reads operator-configured `go.sum` paths from `CheckContext.go_lock_paths` (sourced from `ServerAuditConfig.go_lock_paths`), parses `<module> <version> <hash>` lines (skipping `/go.mod` suffix duplicates), and matches each (module, version) against the bundled `osv_go.json` using semver-with-Go-prefix-stripping. Emits one Finding per (module, version, advisory) match; `threat_refs=["TI-OSV-GO-CATALOG"]`.
- **Lockfile paths come from operator config** — same as 7d.1 (OpenClaw has no Go footprint). Empty `go_lock_paths` ⇒ `not_applicable`.
- **Path validation** — `ServerAuditConfig.go_lock_paths` validator: must start with `/` or `~/`; no shell metachars; basename must equal exactly `go.sum` (variants like `go.sum.bak` rejected).
- **Go version normalization** — `_safe_parse_go_version` strips leading `v` and trailing `+incompatible`, then parses with `semver.Version.parse`. Pseudo-versions (`v0.0.0-20210101120000-abcdef`) parse as semver pre-release with natural lexicographic ordering on the timestamp segment.
- **`OsvGoFetcher`** mirrors `OsvNpmFetcher` / `OsvPyPIFetcher` (5-axis filter chain) but pulls `https://osv-vulnerabilities.storage.googleapis.com/Go/all.zip`. Runs as a side pipeline (`_run_osv_go`, parallel to npm/pypi via `asyncio.gather` 3-way).
- **Module paths kept as-is** — Go module names are URL-form paths (e.g., `github.com/foo/bar`), case-sensitive; OSV uses the same form, no normalization needed.
- **Multi-affected expansion** — each Go module in an OSV entry's `affected[]` becomes its own normalized record; caller dedups on `(id, package)`. Matches the post-fix npm + pypi behavior.
- **`scripts/check_osv_go.py`** CI guard enforces valid JSON + ≤ 15 MB hard cap (mirrors npm/pypi).
- **Bundled DB**: `src/agentsec/audit/ioc/osv_go.json` (NNN advisories, MMM KB at 2026-05-11).
- **`agentsec server-audit --go-lock-path <path>`** is repeatable on the standalone CLI; for the `evaluate` flow set `server_audit.go_lock_paths` in `openclaw-target.yaml`.
- **`_supply_chain_common.py` extracted (rule of three)** — fetcher side at `src/agentsec/audit/ioc_update/fetchers/_supply_chain_common.py` (severity / CVSS / age helpers), check side at `src/agentsec/audit/checks/_supply_chain_common.py` (`load_advisory_db` + re-exports). All 6 supply-chain modules (3 fetchers + 3 Checks) import from it.
- **All 3 Checks now use `Check.make_finding`** for automatic Redactor pass — closes 7d.1 final-review #5.
- **Adding a new ecosystem (e.g., rust in 7d.3)**: new Check `rust_supply_chain_audit` + `osv_crates.json` DB + `OsvCratesFetcher` + TI umbrella `TI-OSV-CRATES-CATALOG`. All common helpers already live in `_supply_chain_common.py` — no extraction needed; just import.
```

(NNN / MMM from Task 21.)

- [ ] **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/pytest tests/ -q
```

All must exit 0.

- [ ] **Step 7: Commit**

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

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

---

## Task 24: 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
```

All exit 0; pytest reports ~1284 passing.

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

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

Verify `agentsec_eval-0.14.0-py3-none-any.whl` exists; size should be the v0.13.0 wheel + ~300-500 KB (for `osv_go.json`).

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

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

Expected: both `--pip-lock-path` and `--go-lock-path` flags visible.

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

The user (operator) decides when to push tags + upload to PyPI. **Stop here**
and report:

```
v0.14.0 ready locally. Operator next steps:
  1. git push origin main
  2. git tag -a v0.14.0 -m 'Phase 7d.2 — go supply chain audit + common module extraction'
  3. git push origin v0.14.0
  4. .venv/bin/twine upload dist/agentsec_eval-0.14.0*
```

---

## Self-Review Checklist (run after writing the plan)

- [ ] Spec §1 (config + scope): Tasks 1, 2, 3 ✓
- [ ] Spec §3 (architecture): Tasks 1-9 ✓
- [ ] Spec §4 (data model): Task 9 ✓
- [ ] Spec §5 (CommandPolicy injection): Tasks 4, 15, 19 ✓
- [ ] Spec §6 (parser): Task 8 ✓
- [ ] Spec §7 (matcher): Task 8 ✓
- [ ] Spec §8 (fetcher): Tasks 12, 13 ✓
- [ ] Spec §9 (CLI 3-way): Task 14 ✓
- [ ] Spec §10 (common extraction): Tasks 4, 5, 6, 7 ✓
- [ ] Spec §11 (make_finding migration): Tasks 9, 16, 17, 18 ✓
- [ ] Spec §12 (tests): distributed; ~50 new tests ✓
- [ ] Spec §13 (release): Tasks 23, 24 ✓
- [ ] Spec §14 (ADR): Task 22 ✓
- [ ] Spec §15 (out of scope): respected — no native go.mod parser, no SEMVER cleanup, no CI cap reduction
