# 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)

**Decision:** Operator lists each `go.sum` to scan in `ServerAuditConfig.go_lock_paths` (or repeats `--go-lock-path` on the standalone `agentsec server-audit` command). Empty list → `go_supply_chain_audit` reports `not_applicable`. No path is hardcoded.

**Considered alternatives:**
- (a) Add a `go_root` key to `PlatformProfile` like `npm_root` — presupposes a canonical Go module location that doesn't exist on OpenClaw hosts.
- (b) Scan the host's installed Go binaries via `go version -m <bin>` BuildInfo extraction — requires executing `go` CLI on the host, expanding attack surface.

**Rationale:** Operator config = consent. Zero-config defaults to no findings, no false positives. Operators who want Go coverage explicitly enumerate the `go.sum` files they trust. Mirrors the pip pattern from 7d.1 — same rationale (no canonical OpenClaw Go footprint).

### D2. Format = `go.sum` only; basename strict equality

**Decision:** Validator requires `basename(path) == "go.sum"`. Backups (`go.sum.bak`), historical variants (`go.sum.20260101`), or alternate names are rejected.

**Considered alternatives:**
- (a) Allow `go.sum.*` suffix variants — operators sometimes preserve old lockfiles for diff workflows.
- (b) Allow `go.mod` as well — covers direct deps but lacks transitive locking that defines audit precision.

**Rationale:** `go.sum` is the *only* canonical Go lockfile name; operators with backup variants can rename + symlink before audit. Strict equality is the simplest defensible rule. `go.mod` lacks transitive deps so emitting findings for it would be incomplete by design.

### D3. `_supply_chain_common.py` split into fetcher-side + check-side

**Decision:** Two modules — `src/agentsec/audit/ioc_update/fetchers/_supply_chain_common.py` (severity / CVSS / age helpers, `Severity` Literal, `MEDIUM_LOW_MAX_AGE_DAYS` constant) and `src/agentsec/audit/checks/_supply_chain_common.py` (`load_advisory_db` + re-exports of `Severity` / `severity_from_cvss_score`).

**Considered alternatives:**
- (a) Single shared file under `audit/_supply_chain_common.py` — simpler but breaks the existing layer separation (Check files would import from a peer of `audit/` rather than from the closer `checks/` subtree).
- (b) Don't extract Check-side common — `load_advisory_db` is just a few lines; rule-of-three doesn't strictly apply to it.

**Rationale:** Two-module split keeps dependency direction one-way (Check side imports from fetcher side, never reverse). Each Check file imports from the same-directory `_supply_chain_common.py` — consistent with how all other 7e/7f checks find their helpers. Re-exports in the Check-side common let Check files stay agnostic about the fetcher layout.

### D4. Go version handling = strip `v` prefix + drop `+incompatible` + `semver` library

**Decision:** `_safe_parse_go_version(s)` strips a leading `v` and a trailing `+incompatible`, then calls `semver.Version.parse`. Pseudo-versions (`v0.0.0-20210101120000-abcdef`) parse as semver pre-release with natural lexicographic ordering on the timestamp segment.

**Considered alternatives:**
- (a) Use a Go-specific version library (e.g., `golang.org/x/mod/semver` Python port if any) — none exists on PyPI; would need to write one.
- (b) Keep raw versions and write a custom comparator — duplicates work `semver` already does for the common cases.

**Rationale:** `semver>=3,<4` is already a project dep. Stripping known Go conventions (`v` and `+incompatible`) gets the version into pure-semver form which the lib handles correctly. Pseudo-versions naturally fit the semver pre-release slot — `0.0.0-20210101...` < `1.0.0` matches Go's intent.

### D5. Each ecosystem matcher independent; no `version_in_any_range_with(parse_fn)` generic

**Decision:** `version_in_any_npm_range` (semver), `version_in_any_pep440_range` (PEP-440), `version_in_any_go_range` (semver-with-Go-prefix-stripping) all live in their own Check modules — no shared generic abstraction.

**Considered alternatives:**
- (a) Extract a `version_in_any_range_with(parse_fn)` generic into `_supply_chain_common.py` — three matchers share the same outer loop structure (iterate ranges, compare against introduced/fixed/last_affected).

**Rationale:** Algorithm shape is shared but version *type* is ecosystem-specific (`semver.Version` vs `packaging.version.Version` vs Go-stripped semver). A generic `Callable[[str], Version | None]` parameter would require a Protocol that all 3 version types satisfy — overhead not justified at N=3 when each implementation is ~20 lines.

### D6. exact-kind dynamic injection (mirrors 7d.1)

**Decision:** `policy.add_runtime_allowed_paths(operator_paths, kind="exact")` after `materialize_paths`. Each operator-supplied `go.sum` becomes its own `AllowedPath(kind="exact", path=...)` entry valid only for the current process lifetime. Audit-logged as `event="runtime_allowed_paths_added"` with `source="ServerAuditConfig.go_lock_paths"` (or `"agentsec server-audit --go-lock-path"` for the standalone CLI).

**Considered alternatives:**
- (a) Static entries in `ssh_policy.yaml` — operator must hand-edit the install-bundled file every time they add a `go.sum`. Friction + drift risk.
- (b) `kind="prefix"` injection — directory entry expands read scope beyond the lockfile.

**Rationale:** Mirror of 7d.1 D6. `exact` minimizes blast radius. Audit-log entry preserves traceability of why a path was readable.

### D7. Route 3 supply-chain Checks through `Check.make_finding`

**Decision:** All 3 Checks (`SupplyChainAuditCheck` / `PipSupplyChainAuditCheck` / `GoSupplyChainAuditCheck`) construct findings via `self.make_finding(ctx, ...)` — no direct `Finding(...)` invocations. The base helper routes evidence string fields through `ctx.redactor.redact`.

**Considered alternatives:**
- (a) Maintain status quo (direct `Finding(...)` calls) — keeps existing code unchanged but leaves a per-Check Redactor gap.

**Rationale:** Closes 7d.1 final-review #5. Future Redactor pattern additions (e.g., new secret formats) automatically cover all 3 supply-chain Checks without per-Check changes. Behavior unchanged in all existing tests (no-op redactor); 3 new redaction tests in `test_supply_chain_redaction.py` verify the routing.

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

**Decision:** `normalize_osv_go_entry` applies the same 5 filter axes as npm/pypi: drop withdrawn / no-Go-affected / no-range / no-CVSS / scored medium-low > 2 years old.

**Considered alternatives:**
- (a) Adjust thresholds for Go (smaller / younger ecosystem) — possibly keep older medium/low entries since Go has fewer overall.

**Rationale:** Same heuristics worked for npm (3673 entries) and pypi (3452 entries) — Go bundle of 2760 entries is in line. Single shared filter logic via `_supply_chain_common.py` keeps maintenance to one place.

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

**Decision:** One `threat_intel.yaml` row covers every Go advisory. Per-CVE TI rows would balloon the file.

**Considered alternatives:**
- (a) Per-GHSA TI rows — 2760 new entries in `threat_intel.yaml`, intractable.

**Rationale:** Same logic as `TI-OSV-NPM-CATALOG` and `TI-OSV-PYPI-CATALOG`. The actual CVE/GHSA id surfaces as `evidence.advisory_id` in each Finding, fully traceable.

## Consequences

- Three ecosystems share `_supply_chain_common.py` (one source of truth for severity / age / DB-load helpers); 7d.3 (rust / cargo) only needs ecosystem-specific bits — no further extraction.
- `Check.make_finding` routing makes the Redactor universal across all current and future supply-chain Checks. New Redactor patterns automatically cover npm / pip / go.
- The wheel grew by ~888 KB (osv_go.json bundled). Total wheel size around 2.7 MB; cap of 15 MB still comfortable.
- 7d.1 final-review #5 closed. 7d.1 final-review #4 (`_expand_tilde` / `PathMatcher._expanduser` dedup) and final-review minor follow-ups (#3 / #5 / #7 / #9 from PyPI side) deferred to follow-up cleanup commits — not blocking 7d.2 release.

## 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
- ADR-0012: 7d (npm) — original supply-chain Check design
