Triage a security finding

./workflow.cmd secure runs pip-audit against the lockfile. When it reports a vulnerability, you have three responses: upgrade, override with an expiry, or accept and document.

Option 1. Upgrade if possible

The vast majority of findings have a fixed version available. Check the report:

./workflow.cmd secure.audit

If a fix exists upstream:

uv lock --upgrade-package <vulnerable-package>
./workflow.cmd secure.audit    # confirm clean
git commit -am "fix: bump <package> for <CVE-id>"

The .security-overrides file at the project root lists allowed-for-now vulnerabilities with expiry dates. Each entry is a single token — the id and the optional expiry joined by ::, with no justification field:

<VULN_ID>[::YYYY-MM-DD]

The justification goes in a # comment, since anything after the id on the same line is not part of the entry:

# upstream fix pending in v2.3 — see https://github.com/pkg/issue/42
PYSEC-2024-1234::2026-06-01

Malformed entries abort the task with a file:line: <reason> message rather than being silently ignored, so a typo fails loudly instead of quietly un-suppressing (or over-suppressing) a finding. Note the failure direction that matters: a mistyped expiry like ::2020-1-1 is rejected outright rather than being read as a permanent suppression.

After the expiry date, secure.audit fails again, forcing a re-triage. Don't extend overrides without revisiting the underlying issue. Omitting the expiry suppresses the finding permanently — prefer a date, because a permanent entry is never resurfaced.

Option 3. Accept and document (rare)

For findings that don't apply to your usage — e.g. the vulnerable code path is gated on a feature you don't use — write an override with a thorough # justification and a far-future expiry. Keep this rare; the next maintainer needs to trust the override list.

Where suppressions can come from

Three sources are merged, in this precedence order, and deduplicated by vulnerability id:

Source Permanent entries Leaves a record in the repo
--ignore on the command line rejected no
AFAS_MCP_SERVER_SECURITY_OVERRIDE env var rejected no
.security-overrides allowed yes

The first two must carry an ::YYYY-MM-DD expiry. They are convenient for unblocking a single run, but nothing in the repository records that they were used — so an expiry-less entry there could mute a finding forever with no code change to review. A permanent suppression has to go in .security-overrides, where it shows up in a diff and gets read by whoever touches it next.

Whichever source they come from, the suppressions actually applied are printed with their origin before the audit runs:

Applying 1 suppression(s):
  PYSEC-2024-1234 (expires 2026-06-01) from .security-overrides:12

Expired entries are reported too, naming the source, and then ignored — so the finding comes back rather than lingering behind a stale override.

Where the audit runs

secure.audit is not only a local command — it gates automatically, which is what makes the expiry dates above meaningful:

  • Every CI run — the secure job in .github/workflows/continuous-integration.yaml runs it alongside lint, test and build.
  • Daily on a schedule.github/workflows/security-audit.yaml. A dependency can turn out to be vulnerable, and an override can expire, with nobody pushing a commit; without the scheduled run a quiet repository would stop being checked and expiry dates would never come due. Trigger it by hand from the Actions tab (workflow_dispatch) to re-check on demand.
  • Pre-commit — only secure.validate-overrides, and only when .security-overrides itself changes. That checks the file's syntax, not your dependencies.

Re-running after a triage

./workflow.cmd secure

This runs the full security pass: audit, SBOM generation. Audit failures abort the run before SBOM upload.

See also