# Recipe — Dependency-vulnerability scanning (`scan-deps`)

> **Audience:** any L1+ consumer that ships a Python (and
> optionally JavaScript) dependency tree to production — CLI tool,
> service, desktop app, or library. The pattern below is the
> shortest-correct way to add a "block PRs that introduce known
> CVEs" gate to CI.

This recipe closes [FEEDBACK PAPER-34](../../FEEDBACK.md). The
canonical reference implementation is PlasmaMapper's SH-25
hardening row (commit
[`849d867`](https://codeberg.org/magogi-admin/PlasmaMapper/commit/849d867)),
which caught two real CVEs on the very PR that introduced the
gate (`urllib3 2.6.3 → 2.7.0`, `devalue 5.8.0 → 5.8.1`) — that's
the proof-of-value the security standard explicitly asks for.

---

## When to use this recipe

Every L1+ consumer. The cost is small (one CI step plus a
`.pip-audit-ignore` file); the value is catching the dependency-
chain CVE that lands between your last pin bump and your next
release. Skip only if:

- Your project genuinely has zero runtime Python dependencies
  (a single-file script with stdlib only).
- You already run the equivalent gate via a different tool
  (Dependabot security-advisories with auto-blocking, Snyk,
  Trivy with PyPI awareness). In that case file the equivalent
  as a project-side ADR rather than wiring this recipe.

---

## The two-tool shape

Two CI steps run in parallel, one per ecosystem:

| Tool | Scope | Severity gate | Triage file |
|---|---|---|---|
| **pip-audit** | every Python wheel in the lock | strict (any advisory fails) | `.pip-audit-ignore` |
| **pnpm/npm audit** | every JS package in the frontend lock (if any) | `--audit-level=high` | inline ignore comments in `package.json` workspace overrides, or pnpm's `.pnpmfile.cjs` audit-ignore block |

The Python tool fails on any matched advisory; the per-CVE
escape lives in `.pip-audit-ignore`. The JavaScript tool fails
only on `high` or `critical` because transitive dev-tool noise
floods the lower thresholds.

If your project is Python-only, drop the pnpm step.

---

## Run shape — against the lock export, not the active env

The single most important shape choice: **run pip-audit against
your lock export, not the active virtualenv**.

```yaml
# .woodpecker.yml fragment (or .github/workflows/ci.yml,
# .gitlab-ci.yml — adapt the surrounding YAML to your CI)

  scan-deps-python:
    image: ghcr.io/astral-sh/uv:0.5
    commands:
      - >
        uv export
        --format requirements-txt
        --no-dev
        --no-hashes
        --no-sources
        > requirements.scan.txt
      - >
        uv tool run --from pip-audit
        pip-audit
        --requirement requirements.scan.txt
        --strict
        --ignore-vuln-file .pip-audit-ignore
```

Why "against the lock export, not the active env":

- **Editable sibling installs** (`[tool.uv.sources]` pointing at
  `../mgf-fastapi`, `../mgf-http`, etc.) resolve in the active
  env to the local checkout, which has no PyPI advisory shape.
  pip-audit would skip them entirely. The export materialises
  the PyPI version pins the lock will produce in CI's
  `--no-sources` install path, which IS the version range
  consumers see.
- **CI's install path is `uv sync --no-sources`** for sibling
  builds; running pip-audit against THAT is the only way to
  reproduce what the consumer's install would scan.

`--no-dev` strips test-only deps from the scan. Dev-tool CVEs
are a separate hygiene exercise; keep this gate focused on the
runtime surface a deployed consumer is exposed to. If a dev-only
CVE matters (e.g., pytest plugin RCE), file it as a project-side
hygiene task and bump independently — do not relax this gate's
`--no-dev`.

---

## The `.pip-audit-ignore` triage file

`.pip-audit-ignore` lives at the project root and lists
advisories the team has reviewed and accepted (one ID per line,
optional inline comment for date + rationale):

```
# Accepted advisories. Format: one PYSEC-/GHSA-ID per line.
# Comment with date + rationale so future-you can re-audit.

PYSEC-2024-118    # 2026-04-12 — urllib3<2.5 ssrf via punycode; we
                  #              pin >=2.5 in pyproject; advisory
                  #              applies to versions <2.5 only.
                  #              Re-evaluate 2026-10-12.
GHSA-xxxx-yyyy-zzzz   # 2026-05-01 — devalue<5.8 prototype-pollution;
                      #              same shape (we pin >=5.8).
```

**Strict mode + ignore-file = the audit trail.** A consumer who
runs `pip-audit --strict --ignore-vuln-file .pip-audit-ignore`
gets one of three outcomes:

- **Exit 0** with the ignore-file empty: zero advisories on the
  current dep tree.
- **Exit 0** with non-empty ignore-file: every advisory is in
  the file with a documented rationale.
- **Exit 1**: a new advisory landed that's not in the file.
  The PR must either fix the underlying pin or add the advisory
  ID with a fresh rationale comment.

The ignore-file IS the audit trail. An empty file = strict
mode, no exceptions. A file with entries = strict mode with
documented exceptions. Both are conformant; the team's
discipline lives in the rationale comments.

**Time-bound ignores.** Each entry should carry a re-evaluation
date or condition ("re-evaluate when urllib3 4.0 ships," "remove
after we cut over to httpx"). Without a sunset, the file
silently accumulates and the gate's signal degrades.

---

## Frontend audit (if applicable)

For projects that ship a JS frontend (PlasmaMapper, the
`mgf-website-template` consumers):

```yaml
  scan-deps-frontend:
    image: node:20
    commands:
      - cd frontend
      - pnpm install --frozen-lockfile
      - pnpm audit --audit-level=high
```

Why `--audit-level=high`:

- `low` / `moderate` thresholds drown in transitive dev-tool
  noise (vite plugins, esbuild's transitive dep tree, etc.).
- `high` / `critical` IS the action-required tier: anything at
  that level is a real fix.
- The same time-bound discipline applies — if a specific
  advisory can't be fixed today, override via
  `pnpm.overrides` in `package.json` with a date + rationale
  comment.

---

## CI placement — pre-merge gate

Place `scan-deps-*` steps **early** in CI, alongside lint /
typecheck. Reasoning:

- A new CVE in the dep tree should fail BEFORE the test suite
  spins up containers, not after.
- The gate runs in <30 seconds for a typical project; the
  feedback loop is short.
- Branch-protection rules can require `scan-deps-*` checks
  separately from the test job, so security and correctness
  signals stay distinguishable in PR reviews.

Sample placement:

```yaml
when:
  branch:
    - main
  event:
    - push
    - pull_request

steps:
  lint: ...
  typecheck: ...
  scan-deps-python: ...    # <-- here, in parallel with lint/typecheck
  scan-deps-frontend: ...  # <-- and here, if you have a frontend
  test: ...                # after the cheap checks
```

---

## Per-release advisory walk

When you cut a release, walk the ignore-file:

1. Run `pip-audit --requirement requirements.scan.txt --strict`
   WITHOUT the `--ignore-vuln-file` flag. Note every advisory it
   surfaces.
2. For each entry in `.pip-audit-ignore`, confirm the rationale
   still holds. If a referenced version range no longer matches
   the current pin (e.g., your pyproject moved past the affected
   versions), remove the entry — it's stale.
3. For each NEW advisory introduced by the release's pin walks,
   either fix the underlying pin or add an entry with rationale.

This walk is the difference between "we have a scanning gate"
and "we have a security posture." The gate is automation; the
walk is the audit.

---

## Cross-references

- `mgf-fed standards SECURITY_STANDARD` — the cross-cutting
  security rules. This recipe is the concrete CI shape for the
  "dependency vulnerability scanning" posture the standard
  prescribes.
- `mgf-fed standards COMMON_COMPONENTS` — the component
  catalogue; `scan-deps` joins that index as a CI-gate recipe
  (rather than a Python primitive).
- PlasmaMapper SH-25 — the reference implementation. The
  recipe's `pip-audit` command, the `.pip-audit-ignore` triage
  shape, and the `--audit-level=high` choice are all from that
  hardening row's commit
  [`849d867`](https://codeberg.org/magogi-admin/PlasmaMapper/commit/849d867).
- [`FEEDBACK.md`](../../FEEDBACK.md) PAPER-34 — the consumer
  request that motivated this recipe.
