Gating pip-audit in Python CI Pipelines

pip-audit matches your installed or declared Python packages against the PyPI Advisory Database and OSV, and returns a non-zero exit code when any of them carries a known vulnerability. That exit code is what makes it a CI gate — but only if it runs against a fully resolved dependency set rather than the loose top-level pins most requirements.txt files contain. This guide walks through resolving dependencies to a lockfile with pip-tools, Poetry, or uv, running pip-audit against that lockfile, ignoring specific unfixable advisory IDs with a justification, and emitting SARIF so findings land in code scanning. It implements, for the Python toolchain, the enforcement pattern from the parent Dependency Scanning & CI Security Gates guide within Supply Chain & Dependency Security. For the Node.js counterpart, see the sibling failing npm audit builds on high severity walkthrough.

Prerequisites

  • Python 3.9+ and one resolver: pip-tools, Poetry, or uv
  • pip-audit available in CI (pip install pip-audit or uv tool install pip-audit)
  • A CI runner (examples use GitHub Actions) able to fail a required check
  • Repository code scanning enabled if you want SARIF findings in the security tab

Expected Outcomes

  • Dependencies resolved to a hashed, pinned lockfile that the audit and the build share
  • pip-audit failing CI on any advisory in the resolved set
  • Specific unfixable advisories ignored by exact PYSEC/GHSA ID, each with a recorded justification
  • SARIF output uploaded so findings appear in code scanning and gate pull requests

Step 1: Pin and Resolve Dependencies to a Lockfile

An audit is only as trustworthy as the version set it reads. Loose specifiers like django>=4.2 resolve differently over time, so audit and deploy the exact same fully pinned, hashed lockfile. Pick the resolver your project already uses.

# pip-tools: compile a hashed lockfile from requirements.in
pip-compile --generate-hashes --output-file=requirements.lock requirements.in

# Poetry: export the locked graph to a requirements file for auditing
poetry export --without-hashes=false --format requirements.txt --output requirements.lock

# uv: compile a locked, hashed requirements file
uv pip compile requirements.in --generate-hashes --output-file requirements.lock

Commit requirements.lock. Every transitive dependency now appears with an exact version, which is what allows pip-audit to map advisories precisely and reproducibly across every CI run.

Step 2: Run pip-audit Against the Resolved Requirements

Point pip-audit at the lockfile with --requirement rather than letting it inspect the ambient environment. Requirement scanning is deterministic; environment scanning depends on whatever the runner happened to install.

# Audit the resolved lockfile (deterministic, reproducible)
pip-audit --requirement requirements.lock

# Ignore specific unfixable advisories by exact ID, and emit machine-readable output
pip-audit \
  --requirement requirements.lock \
  --ignore-vuln PYSEC-2024-48 \
  --ignore-vuln GHSA-3ww4-gg4f-jr7f \
  --format json \
  --output pip-audit.json

--ignore-vuln removes only the named advisories from the failure decision; any other vulnerability still produces a non-zero exit. Keep the ignored IDs in a versioned policy file so each suppression carries an owner, a reason, and a review date rather than living as an opaque command-line flag:

# .security/pip-audit-ignore.yml
# Consumed by scripts/build-pip-audit-args.py to render --ignore-vuln flags.
ignored:
  - id: PYSEC-2024-48
    package: py
    reason: "ReDoS in py.path; only reached by pytest at build time, not shipped."
    owner: platform-security
    review_by: "2026-08-15"
  - id: GHSA-3ww4-gg4f-jr7f
    package: certifi
    reason: "Bundled CA removal; we pin our own trust store. No fixed release for our pin."
    owner: platform-security
    review_by: "2026-07-30"

The end-to-end flow from lockfile to gate decision looks like this:

pip-audit Python CI Gate Flow A resolved requirements lockfile is scanned by pip-audit. Advisory IDs on the ignore list are filtered out. If any vulnerability remains the build fails; otherwise the build passes. In parallel a SARIF report is uploaded to code scanning. requirements.lock pinned + hashed pip-audit --requirement --ignore-vuln filter SARIF upload code scanning Build passes no vulns remain Build blocked non-zero exit clean vuln found

Step 3: Wire CI and Upload SARIF to Code Scanning

Run the resolver, run pip-audit in SARIF mode, upload the report, then let a dedicated gate step fail the job on any non-ignored vulnerability. Splitting the upload from the gate ensures findings reach the security tab even when the build is destined to fail.

# .github/workflows/pip-audit-gate.yml
name: pip-audit gate
on: [pull_request]

permissions:
  contents: read
  security-events: write

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install tooling
        run: pip install pip-audit pip-tools

      - name: Resolve lockfile
        run: pip-compile --generate-hashes -o requirements.lock requirements.in

      - name: pip-audit (SARIF for code scanning)
        run: |
          pip-audit \
            --requirement requirements.lock \
            --ignore-vuln PYSEC-2024-48 \
            --format sarif \
            --output pip-audit.sarif
        continue-on-error: true

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: pip-audit.sarif

      - name: Enforce gate (fail on remaining vulnerabilities)
        run: pip-audit --requirement requirements.lock --ignore-vuln PYSEC-2024-48

The first pip-audit run produces SARIF and is marked continue-on-error so the upload always executes; the final run has no error tolerance and is the authoritative pass/fail decision.

Step 4: Verify Vulnerabilities Fail the Build

Prove each branch of the gate by exercising it locally and reading the exit code that CI depends on.

# Clean lockfile -> exit 0 (build passes)
pip-audit --requirement requirements.lock
echo "exit: $?"     # expect: exit: 0

# Introduce a known-vulnerable release to prove the gate fires
echo "jinja2==2.11.2" >> requirements.lock
pip-audit --requirement requirements.lock
echo "exit: $?"     # expect: exit: 1 (build blocked)

# Ignore that specific advisory by ID -> exit 0 again
pip-audit --requirement requirements.lock --ignore-vuln GHSA-h5c8-rqwp-cp95
echo "exit: $?"     # expect: exit: 0 when the ID matches the finding

The exit status is the contract: 0 lets the CI step succeed, non-zero fails it. Remove the deliberately vulnerable pin after verifying, and confirm the SARIF file lists the advisory ID you expect with jq '.runs[0].results[].ruleId' pip-audit.sarif.

Troubleshooting

Symptom Likely cause Fix
pip-audit finds nothing but you know a dependency is vulnerable Auditing loose top-level pins, not the resolved graph Resolve to a lockfile with pip-compile/poetry export/uv pip compile and audit that file with --requirement
Different results locally vs CI Environment scan reads whatever is installed on each host Always pass --requirement requirements.lock; never audit the ambient environment in CI
--ignore-vuln has no effect ID format or value mismatch (PYSEC vs GHSA vs aliased ID) Copy the exact ruleId from the JSON or SARIF output; pip-audit matches the advisory alias precisely
Advisory is transitive-only, no direct pin to bump The vulnerable package is pulled in by another dependency Add a constraint pin forcing the transitive package to a patched version, then re-resolve the lockfile
SARIF upload succeeds but no alerts appear security-events: write permission missing or code scanning disabled Grant the permission in the workflow and enable code scanning for the repository

Common Implementation Mistakes

Frequently Asked Questions

Should pip-audit scan the environment or a requirements file?

Scan a fully resolved requirements or lockfile with --requirement rather than the ambient environment. Requirement scanning is reproducible and covers the exact versions the build ships, whereas environment scanning depends on whatever happens to be installed on the runner and can silently miss or add packages between runs. Resolve first with pip-compile, poetry export, or uv pip compile, then audit that committed file.

How do I ignore a single unfixable vulnerability in pip-audit?

Pass --ignore-vuln with the exact PYSEC or GHSA advisory ID printed in the report. pip-audit removes only that advisory from the failure decision and still fails on every other finding. Track each ignored ID in a versioned policy file with a package name, a justification, and a review date so the suppression is auditable and does not quietly become permanent when a fixed release later appears upstream.

Can pip-audit output feed GitHub code scanning?

Yes. Run pip-audit with --format sarif and write the result to a file, then upload it with the CodeQL upload-sarif action. Findings then appear in the repository security tab with full advisory details, and pull requests can be gated on new alerts in addition to the non-zero exit code from the audit step itself. Grant the workflow security-events: write or the upload silently fails.