Skip to main content
DevSecOps (AppSec in the pipeline) · lesson 4 of 7
DevSecOps (AppSec in the pipeline) intermediate Lesson 4 of 7

Also part of: Web Security (OWASP Top 10, fix-side)

Dependency Scanning (SCA) and SBOMs with pip-audit, Syft and Grype

Pin an old requests release, read the advisories pip-audit reports, build a CycloneDX SBOM with Syft, scan it with Grype, then bump the pin to a clean run.

By SkillByExample · Published

devsecops sca sbom pip-audit syft grype cyclonedx

Most of the code in a typical application comes from its dependencies, and their vulnerabilities are published every day. This lesson pins a deliberately old library, finds its advisories two ways, captures the dependency list as an SBOM you can re-scan later, and fixes the pin. Tested with pip-audit 2.10.1 on Python 3.12.15 (python:3.12-slim), Syft 1.54.1 (anchore/syft:v1.54.1), Grype 0.120.1 (anchore/grype:v0.120.1) and Docker Engine 29.8.2.

Run date: 2026-10-14. Vulnerability databases change daily, so on a later date you may see more advisories, different identifiers or different EPSS scores. The method doesn’t change.

The lab

pip-audit runs in a small Python image. Syft and Grype run from their official images.

# Dockerfile
FROM python:3.12-slim
RUN pip install -q pip-audit==2.10.1
WORKDIR /w
$ docker build -q -t sca-lab .
sha256:8e6ffcafa19a48089dc6ba82c787066d046d6480059578e6922b154e4c07d3e1

The project is one pinned dependency in an app/ folder: requests 2.31.0, released in May 2023.

# requirements.txt
requests==2.31.0

Audit the requirements

pip-audit resolves the requirements and looks each package up in the PyPI advisory database. --aliases adds the matching CVE and GitHub advisory IDs.

$ docker run --rm -v "$PWD/app":/w sca-lab pip-audit -r requirements.txt --aliases
Name     Version ID              Fix Versions Aliases
-------- ------- --------------- ------------ -----------------------------------
requests 2.31.0  PYSEC-2026-1873 2.32.0       GHSA-9wx4-h78v-vm56, CVE-2024-35195
requests 2.31.0  PYSEC-2026-1872 2.32.4       CVE-2024-47081, GHSA-9hjg-9r4m-mvj7
requests 2.31.0  PYSEC-2026-2275 2.33.0       CVE-2026-25645, GHSA-gc5v-m9x4-r6x2
requests 2.31.0  PYSEC-2026-1873 2.32.0       GHSA-9wx4-h78v-vm56, CVE-2024-35195
requests 2.31.0  PYSEC-2026-1872 2.32.4       CVE-2024-47081, GHSA-9hjg-9r4m-mvj7
requests 2.31.0  PYSEC-2026-2275 2.33.0       CVE-2026-25645, GHSA-gc5v-m9x4-r6x2
Found 6 known vulnerabilities in 1 package
$ echo $?
1

“6 known vulnerabilities”, but read the ID column: there are three distinct advisories, each listed twice. On this run date every pip-audit invocation printed each row twice, so count distinct IDs, not rows. The column that matters is Fix Versions: the three fixes land in 2.32.0, 2.32.4 and 2.33.0, so nothing below 2.33.0 clears all of them. The order of the aliases also changes between runs, so don’t diff this table as text. Exit code 1 is what fails a build.

Capture the dependency list as an SBOM

Syft inventories a directory or image. Its default table shows what it found.

$ docker run --rm -v "$PWD/app":/src anchore/syft:v1.54.1 dir:/src -q
NAME      VERSION  TYPE    
requests  2.31.0   python

Now write the same inventory as CycloneDX JSON. -o format=path writes a file, here into the mounted folder.

$ docker run --rm -v "$PWD/app":/src anchore/syft:v1.54.1 dir:/src -q -o cyclonedx-json=/src/sbom.json
$ echo $?
0

A short script shows what’s inside the file.

# show_sbom.py
import json

with open("sbom.json") as f:
    bom = json.load(f)
print(bom["bomFormat"], bom["specVersion"])
for c in bom["components"]:
    print(c["type"], c["name"], c.get("version", "-"), c.get("purl", "-"))
$ docker run --rm -v "$PWD/app":/w sca-lab python show_sbom.py
CycloneDX 1.7
library requests 2.31.0 pkg:pypi/requests@2.31.0
file /src/requirements.txt - -

Two components: the library, identified by its package URL (pkg:pypi/requests@2.31.0), and the file it was found in. The purl is what makes an SBOM useful: it names the exact package in a form any scanner can look up, independent of the language tool that produced it.

Scan the SBOM, not the code

Grype scans an SBOM directly with the sbom: prefix, so it never needs the source or a rebuild. The named volume keeps Grype’s database between runs. --fail-on medium sets the exit code.

$ docker run --rm -v grype-db:/.cache/grype -v "$PWD/app":/src anchore/grype:v0.120.1 sbom:/src/sbom.json -q --fail-on medium
NAME      INSTALLED  FIXED IN  TYPE    VULNERABILITY        SEVERITY  EPSS         RISK   
requests  2.31.0     2.32.4    python  GHSA-9hjg-9r4m-mvj7  Medium    1.0% (61st)  0.5    
requests  2.31.0     2.32.0    python  GHSA-9wx4-h78v-vm56  Medium    0.3% (25th)  0.2    
requests  2.31.0     2.33.0    python  GHSA-gc5v-m9x4-r6x2  Medium    0.2% (7th)   < 0.1
$ echo $?
2

The same three advisories, this time once each and under their GitHub IDs, from a different database. Grype adds EPSS, the estimated probability that a vulnerability is exploited in the next 30 days, and a combined risk score, which help decide what to fix first. Note the exit code: 2, not 1. Without --fail-on, Grype exits 0 even with findings.

The first run downloads Grype’s database (Grype’s db status reported it built at 2026-10-14T06:30:34Z for this run). The SBOM, though, is a plain file: store it with each release, and a year from now you can re-scan exactly what shipped against that day’s database.

Fix the pin and re-run both

Change the pin to the highest fixed version from the table.

# requirements.txt
requests==2.33.0
$ docker run --rm -v "$PWD/app":/w sca-lab pip-audit -r requirements.txt --aliases
No known vulnerabilities found
$ echo $?
0
$ docker run --rm -v "$PWD/app":/src anchore/syft:v1.54.1 dir:/src -q -o cyclonedx-json=/src/sbom.json
$ docker run --rm -v grype-db:/.cache/grype -v "$PWD/app":/src anchore/grype:v0.120.1 sbom:/src/sbom.json -q --fail-on medium
No vulnerabilities found
$ echo $?
0

Both clean, both exit 0. The fix for all three advisories was a one-line version bump, which is typical: most dependency vulnerabilities are already fixed upstream by the time a scanner reports them, and the work is keeping pins current. Re-run your tests after any bump, since a security fix can still change behaviour.

Practice

  1. Pin requests==2.32.0 instead and run pip-audit. Which advisories remain?

    Solution

    2.32.0 fixes only the advisory whose fix version is 2.32.0 (CVE-2024-35195). The two with fix versions 2.32.4 and 2.33.0 remain, so pip-audit still exits 1. “Upgraded” isn’t the same as “fixed”: compare against the highest fix version in the table.

  2. Make Grype fail only on High or Critical findings. What exit code do you get on the original SBOM, and why?

    Solution

    Use --fail-on high. All three findings were Medium on this run date, so Grype prints the same table and exits 0. Choosing the threshold is a policy decision; write it down next to the CI job rather than leaving it implicit.

  3. Ask pip-audit for JSON (-f json) and print each distinct vulnerability ID once.

    Solution
    import json, subprocess
    
    out = subprocess.run(["pip-audit", "-r", "requirements.txt", "-f", "json"],
                         capture_output=True, text=True).stdout
    ids = {v["id"] for d in json.loads(out)["dependencies"] for v in d.get("vulns", [])}
    print(sorted(ids))

    A set removes the duplicate rows, and sorting makes the output stable enough to compare between runs.

  4. Generate an SPDX SBOM instead of CycloneDX and scan it with Grype.

    Solution

    Use -o spdx-json=/src/sbom.spdx.json in the Syft command and sbom:/src/sbom.spdx.json in the Grype command. Grype reads both formats and reports the same findings, because both carry the same package URL.

Where this fits

SCA and SBOMs cover the code you depend on. The next lesson tests the running application from the outside, with an OWASP ZAP baseline scan.

Frequently Asked Questions

What is software composition analysis (SCA)?
SCA checks the third-party packages your project depends on against databases of published vulnerabilities, such as the PyPI advisory database, OSV and GitHub's advisory database. It reports which installed versions are affected and which version fixes each issue. It finds known bugs in other people's code, so it complements SAST, which reads your own.
What is an SBOM?
A software bill of materials is a machine-readable list of every component in a piece of software, with versions and package URLs. CycloneDX and SPDX are the two standard formats. Generating one at build time means you can answer 'are we affected by this new CVE?' later by scanning the stored SBOM, without rebuilding or even having the original code.
Why do vulnerability scan results change from day to day?
The scanner's code doesn't change, but its database does: new advisories are published, and existing ones gain fixed versions, severities or aliases. The same requirements file can be clean today and fail tomorrow. That is why scans run on a schedule as well as on each change, and why a report should record the date it was produced.
What exit codes do pip-audit and Grype return?
pip-audit exits 1 when it finds any known vulnerability and 0 when it finds none. Grype exits 0 by default even with findings; with --fail-on and a severity such as medium it exits 2 when a finding at or above that severity is present. Check the exit code your CI step relies on, not just the printed table.
Should I fix a vulnerable dependency by upgrading to the latest version?
Upgrade to at least the highest 'fixed in' version among the advisories, then re-run the scan and your tests. Jumping to the newest release is often fine but can bring breaking changes; a lock file or pinned requirements make either choice explicit and reviewable.