By the end you'll know
- Why a version tag isn't a safe way to name an action, and what is
- How Dependabot keeps pins current, and why it should wait before proposing a release
- How to fail a build on a known vulnerability in your dependencies
- How to scan an image with Trivy, and decide what fails the build
- How to trace a finding to where it really lives before fixing it
- How to run the same scans in Jenkins
Where you are
Your pipelines now test honestly, queue sensibly and pin their tools. But look at what actually runs in them: actions written by other people, packages from PyPI, a base image from Docker Hub, and the scanner you're about to add. None of it is your code, and every piece of it runs with access to your pipeline's secrets. This lesson is about checking what you're trusting.
The one new setup step is pinning Trivy, the image scanner, by its exact image ID.
Two real attacks
March 2025: tj-actions/changed-files. An action that more than 23,000 repositories used to list the files a commit changed. Attackers got hold of a token that could write to its repository, pushed a commit that printed the runner's secrets into the build log, and then moved the action's existing version tags to that commit. Workflows that said uses: tj-actions/changed-files@v35 ran the attacker's code on their next run, without a single change on their side. Workflows pinned to a full commit ID were untouched.
March 2026: Trivy. This time it was a security scanner, the very tool teams add to make their pipelines safer. After getting into the project's own build setup, attackers moved almost every version tag of trivy-action to an infostealer for about 12 hours, and published a poisoned Trivy release, v0.69.4, that stayed up for about 3. The code collected cloud keys, tokens and SSH keys from the runner and sent them to a look-alike domain. The project's advice afterwards was the same as in 2025: pin actions to full commit IDs.
Try both sides of it:
Pin actions to commit IDs
A tag like v7 is just a name pointing at a commit, and whoever controls the repository can point it somewhere else. A commit ID, the 40-character SHA, names one exact set of files, and it can't be changed to mean anything else. So pin every action to one, and keep the tag as a comment for humans. This script looks up the commit each tag points to today and rewrites ci.yml. On your laptop, in ~/Apps/cicd-sandbox:
cd ~/Apps/cicd-sandbox
python3 - <<'EOF'
import re
import subprocess
from pathlib import Path
p = Path(".github/workflows/ci.yml")
s = p.read_text()
shas = {}
def pin(m):
repo, tag = m.group(1), m.group(2)
if (repo, tag) not in shas:
out = subprocess.run(["gh", "api", f"repos/{repo}/commits/{tag}", "--jq", ".sha"], capture_output=True, text=True, check=True)
shas[(repo, tag)] = out.stdout.strip()
print(f"{repo}@{tag} -> {shas[(repo, tag)]}")
return f"uses: {repo}@{shas[(repo, tag)]} # {tag}"
s = re.sub(r"uses: ([\w.-]+/[\w.-]+)@(v[\w.]+)[ \t]*$", pin, s, flags=re.M)
p.write_text(s)
EOF
Mine:
actions/checkout@v7 -> 3d3c42e5aac5ba805825da76410c181273ba90b1
actions/setup-python@v7 -> 6d4fd22b20465ccc0f7354476588e8df7c9ca0e0
docker/setup-buildx-action@v4 -> f87e5991a6d7451dcb8d9637bfbc97413f497069
docker/login-action@v4 -> 81c6f93d4bc991e510b342a00b419b3bb4efc5c6
docker/metadata-action@v6 -> dc802804100637a589fabce1cb79ff13a1411302
docker/build-push-action@v7 -> c3c9e263c25d99ce0380d002d59b67737d91b0dc
Then check the file:
grep -n "uses:" .github/workflows/ci.yml
16: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
17: - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
53: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
54: - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
69: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
70: - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
73: uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4
80: uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
86: - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
Every action now names an exact commit, with its tag kept as a comment for humans. Your commit IDs will differ if the actions have released since.
Pinning has a cost the widget showed: a pinned action never picks up a fix by itself. Something has to keep the pins current, or they slowly turn into old, unpatched code. That's Dependabot's job.
Updates on your terms: Dependabot
Dependabot is built into GitHub. Once a week it checks every pinned version, and when a newer one exists, it opens a pull request that changes the pin. The pull request runs your whole pipeline against the new version, so an update reaches main only after it passes the same gates as your own code. Add its config:
cat > .github/dependabot.yml <<'EOF'
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
EOF
git add .github/dependabot.yml
git commit -am "Pin actions to commit SHAs and let Dependabot keep them current"
git push
The three entries cover the actions in your workflows, the pins in your requirements files, and the base image in your Dockerfile. Dependabot also understands the # v7 comments, and updates them along with the commit IDs. Not long after, it had opened its first pull request:
gh pr list --state all --limit 10
ID TITLE BRANCH CREATED AT
#1 Bump pytest from 9.0.3 to 9.1.1 dependabot/pip/pytest-9.1.1 about 1 hour ago
None for the actions, which means the commit IDs you just pinned were already each action's newest release. Leave this pull request open for now; you'll merge it once the new scans are in place.
One more setting. The poisoned Trivy release was public for about 3 hours. Anyone who installed new versions the moment they appeared got it; anyone who waited a day never saw it. Dependabot's cooldown sets how long a release must have been out before it's proposed. GitHub's default is 3 days; make it a week:
python3 - <<'EOF'
from pathlib import Path
p = Path(".github/dependabot.yml")
s = p.read_text()
old = " schedule:\n interval: weekly\n"
assert s.count(old) == 3
p.write_text(s.replace(old, old + " cooldown:\n default-days: 7\n"))
EOF
git commit -am "Dependabot: wait 7 days before proposing a new release"
git push
Cooldowns only apply to ordinary version updates. When a release fixes a known vulnerability, Dependabot's security updates propose it straight away, which is what you want.
Known holes in your dependencies: pip-audit
What breaks. A package you depend on has a published vulnerability, and your tests pass anyway, because tests check what code does, not whether it's safe. Nothing in the pipeline so far would notice.
pip-audit checks every package in a requirements file against a database of known vulnerabilities, and fails if it finds one. Add it as its own job, and make build wait for it. Then, to see the gate work, pin redis back to 4.5.3, an old release with a known hole, and push both commits together:
python3 - <<'EOF'
from pathlib import Path
Path("requirements-audit.txt").write_text("pip-audit==2.10.1\n")
p = Path(".github/workflows/ci.yml")
s = p.read_text()
build = " build:\n needs: [lint, test]\n"
assert s.count(build) == 1
audit = """ audit:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.13'
- run: pip install -r requirements-audit.txt
- run: pip-audit -r requirements-dev.txt
"""
s = s.replace(build, audit + " build:\n needs: [lint, test, audit]\n")
p.write_text(s)
EOF
git add requirements-audit.txt
git commit -am "Audit the Python dependencies for known vulnerabilities"
sed -i '' 's/^redis==8.1.0$/redis==4.5.3/' requirements-dev.txt
git commit -am "Pin an old redis with known vulnerabilities, on purpose"
git push
sleep 10
gh run watch "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
gh run view "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --log-failed | sed -E 's/[0-9T:.-]+Z //' | grep -v -E "^\s*$" | tail -25
pip-audit is pinned like everything else, in a file of its own. Mine, from the end of the failed job:
audit Run pip-audit -r requirements-dev.txt Found 2 known vulnerabilities in 1 package
audit Run pip-audit -r requirements-dev.txt Name Version ID Fix Versions
audit Run pip-audit -r requirements-dev.txt ----- ------- ------------- ------------
audit Run pip-audit -r requirements-dev.txt redis 4.5.3 PYSEC-2023-46 4.4.4,4.5.4
audit Run pip-audit -r requirements-dev.txt redis 4.5.3 PYSEC-2023-46 4.4.4,4.5.4
audit Run pip-audit -r requirements-dev.txt ##[error]Process completed with exit code 1.
The audit job failed and build never ran. The test job passed: the old redis works perfectly well, it just isn't safe, which is exactly why this is a separate gate. Fix Versions says where to go: 4.5.4, or 4.4.4 if you were stuck on the older line. The same advisory is listed twice, which is how pip-audit printed it. You'll put redis back at the start of the next step.
What's inside the image: Trivy
Your requirements are only part of what ships. The image also holds the base image's operating system packages and whatever came with Python itself. Trivy scans an image for all of it.
You'll run Trivy from its own container image, pinned by its digest, the image's exact ID, not by a tag, and not through trivy-action. After 2026, that's the point. Pick a release that has been out for a while; Trivy's newest was four days old when I wrote this, so I used the newest one older than two weeks:
gh release list -R aquasecurity/trivy --limit 5
docker pull aquasec/trivy:0.75.0
docker image inspect --format '{{index .RepoDigests 0}}' aquasec/trivy:0.75.0
TITLE TYPE TAG NAME PUBLISHED
v0.76.1 Latest v0.76.1 about 4 days ago
v0.76.0 v0.76.0 about 11 days ago
v0.75.0 v0.75.0 about 24 days ago
v0.74.0 v0.74.0 about 2 months ago
v0.73.0 v0.73.0 about 2 months ago
aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
Before adding it to the pipeline, try it on your laptop against the image from Lesson 4:
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.75.0 image --severity HIGH,CRITICAL --ignore-unfixed cicd-sandbox:local
--severity HIGH,CRITICALshows only the two most serious levels.--ignore-unfixedleaves out problems nobody has fixed yet, since there's nothing you can update to.
Together, that's a sensible rule for failing a build: serious, and fixable. The first run downloads Trivy's vulnerability database, about 130 MB. Then, with the download progress and the plain information lines taken out:
WARN Third-party SBOM may lead to inaccurate vulnerability detection
WARN Recommend using Trivy to generate SBOMs
INFO Detected OS family="debian" version="13.7"
Report Summary
┌──────────────────────────────────────────────────────────────────────┬────────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ cicd-sandbox:local (debian 13.7) │ debian │ 0 │ - │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ Python │ python-pkg │ 4 │ - │
├──────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ usr/local/lib/python3.13/site-packages/pip-26.2.2.dist-info/METADATA │ python-pkg │ 0 │ - │
└──────────────────────────────────────────────────────────────────────┴────────────┴─────────────────┴─────────┘
Python (python-pkg)
===================
Total: 4 (HIGH: 4, CRITICAL: 0)
┌────────────┬─────────────────────┬──────────┬────────┬───────────────────┬───────────────┬────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├────────────┼─────────────────────┼──────────┼────────┼───────────────────┼───────────────┼────────────────────────────────────────────────────────────┤
│ msgpack │ GHSA-6v7p-g79w-8964 │ HIGH │ fixed │ 1.1.2 │ 1.2.1 │ MessagePack for Python: Out-of-bounds read / crash on │
│ │ │ │ │ │ │ Unpacker reuse after a... │
├────────────┼─────────────────────┤ │ ├───────────────────┼───────────────┼────────────────────────────────────────────────────────────┤
│ setuptools │ CVE-2025-47273 │ │ │ 70.3.0 │ 78.1.1 │ setuptools: Path Traversal Vulnerability in setuptools │
│ │ │ │ │ │ │ PackageIndex │
├────────────┼─────────────────────┤ │ ├───────────────────┼───────────────┼────────────────────────────────────────────────────────────┤
│ urllib3 │ CVE-2026-97687 │ │ │ 2.7.0 │ 2.8.0 │ urllib3: urllib3: Traffic interception via HTTPS proxy TLS │
│ │ │ │ │ │ │ configuration override │
│ ├─────────────────────┤ │ │ │ ├────────────────────────────────────────────────────────────┤
│ │ CVE-2026-97689 │ │ │ │ │ urllib3: urllib3: Denial of Service via unbounded memory │
│ │ │ │ │ │ │ allocation in chunk parser... │
└────────────┴─────────────────────┴──────────┴────────┴───────────────────┴───────────────┴────────────────────────────────────────────────────────────┘
The operating system layer is clean. The four HIGH findings are Python packages: msgpack, setuptools and urllib3. Here's the interesting part: your app installs none of them. The image doesn't even contain your app's own dependencies; it only runs python -m app.
Find where a finding lives before you fix it
The obvious "fix" is to install newer versions of those packages. It would be wrong, because you never installed them in the first place. The warning line is the clue: Trivy found them in an SBOM, a software bill of materials, which is a list of everything a piece of software contains. The only Python package in the image is pip, and pip carries its own private copies of a few libraries. Check:
docker run --rm --entrypoint sh cicd-sandbox:local -c 'python -c "import pip._vendor.urllib3 as u, pip._vendor.msgpack as m; print(\"pip bundles urllib3\", u.__version__, \"and msgpack\", m.version)"'
pip bundles urllib3 2.7.0 and msgpack (1, 1, 2)
Exactly the versions Trivy flagged. They're inside pip, the tool that installs packages, and the app never runs pip. So the right fix isn't an update; it's not shipping a tool the app doesn't need. Test that idea on a throwaway copy before touching the Dockerfile:
printf 'FROM cicd-sandbox:local\nRUN python -m pip uninstall -y pip\n' | docker build -q -t cicd-sandbox:nopip -
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.75.0 image -q --skip-version-check --severity HIGH,CRITICAL --ignore-unfixed cicd-sandbox:nopip
┌──────────────────────────────────┬────────┬─────────────────┐
│ Target │ Type │ Vulnerabilities │
├──────────────────────────────────┼────────┼─────────────────┤
│ cicd-sandbox:nopip (debian 13.7) │ debian │ 0 │
└──────────────────────────────────┴────────┴─────────────────┘
Clean. Most scan findings in real images are like this: in the base image, in a tool that came along for the ride, or in a package nothing uses. Tracing each one to where it lives is most of the work, and it's what turns a scary list into one small change.
Scan in the pipeline
Now make it a gate: a scan job that builds the image and runs Trivy against it, with --exit-code 1 so a finding fails the job, and build waiting for it. Push this first with pip still in the image, to watch it catch the same four findings. The first line puts redis back:
git revert --no-edit HEAD
python3 - <<'EOF'
from pathlib import Path
p = Path(".github/workflows/ci.yml")
s = p.read_text()
build = " build:\n needs: [lint, test, audit]\n"
assert s.count(build) == 1
scan = """ scan:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- run: docker build -t cicd-sandbox:scan .
# Trivy 0.75.0, pinned by digest
- name: Scan the image with Trivy
run: >-
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock
aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
image --quiet --skip-version-check --scanners vuln
--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1
cicd-sandbox:scan
"""
s = s.replace(build, scan + " build:\n needs: [lint, test, audit, scan]\n")
p.write_text(s)
EOF
git commit -am "Scan the image with Trivy, pinned by digest"
git push
sleep 10
gh run watch "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
gh run view "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --log-failed | sed -E 's/[0-9T:.-]+Z //' | grep -E "Total|HIGH|CRITICAL|Report|exit code"
--scanners vuln skips Trivy's secret scan to keep the job quick. Mine:
scan Scan the image with Trivy Report Summary
scan Scan the image with Trivy Total: 4 (HIGH: 4, CRITICAL: 0)
scan Scan the image with Trivy │ msgpack │ GHSA-6v7p-g79w-8964 │ HIGH │ fixed │ 1.1.2 │ 1.2.1 │ MessagePack for Python: Out-of-bounds read / crash on │
scan Scan the image with Trivy ##[error]Process completed with exit code 1.
Red at scan, and nothing was pushed. Now the one-line fix, in the Dockerfile:
python3 - <<'EOF'
from pathlib import Path
p = Path("Dockerfile")
s = p.read_text()
old = "FROM python:3.13-slim\n"
assert s.count(old) == 1
p.write_text(s.replace(old, old + "RUN python -m pip uninstall -y pip\n"))
EOF
git commit -am "Remove pip from the image: the app never uses it"
git push
sleep 10
gh run watch "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
gh run view "$(gh run list --limit 1 --json databaseId --jq '.[0].databaseId')" --log | sed -E 's/[0-9T:.-]+Z //' | grep -E "^scan.*(Report|Total|debian|Successfully uninstalled)"
scan Run docker build -t cicd-sandbox:scan . #7 1.333 Successfully uninstalled pip-26.2.2
scan Scan the image with Trivy Report Summary
scan Scan the image with Trivy │ cicd-sandbox:scan (debian 13.7) │ debian │ 0 │
All five jobs green, and build pushed again.
On AWS
A scan in the pipeline checks an image once, when it's built. If a new vulnerability is published next month, that image stays "clean" until something scans it again. AWS's answer is Amazon Inspector. With enhanced scanning turned on for ECR (Elastic Container Registry, AWS's image registry), Inspector scans every image pushed and keeps rescanning images as new vulnerabilities are published, and it covers EC2 servers and Lambda functions too. ECR also has a free basic scan that runs when an image is pushed. The two kinds fit together: the pipeline's scan stops a bad image going out, and the registry's scan tells you when an image that's already out goes bad.
An update through the gates
Now Dependabot's pull request. It was made before these jobs existed, so ask Dependabot to rebase it onto today's main. Typing @dependabot rebase in a comment is how you talk to it:
gh pr comment 1 --body "@dependabot rebase"
gh pr checks 1 --watch
My first look still showed an older run, in which scan had failed: Dependabot had rebased once by itself when the scan job arrived, before pip was removed. A pull request is tested against the main it was based on, so an old branch can fail, or pass, for reasons that are already out of date. A minute later, with the rebase done:
All checks were successful
0 cancelled, 0 failing, 5 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED
✓ CI/audit (pull_request) 17s
✓ CI/build (pull_request) 14s
✓ CI/lint (pull_request) 8s
✓ CI/scan (pull_request) 19s
✓ CI/test (pull_request) 33s
Every gate ran against pytest 9.1.1, so merge it:
gh pr merge 1 --squash --delete-branch
git pull
That's the whole update path: Dependabot waits out the cooldown, opens a pull request, the gates check the new version, and you merge.
In Jenkins: the same two gates
Add an Audit stage beside Lint and Tests, and a Scan stage between Build and Push, so nothing reaches GHCR unscanned:
python3 - <<'EOF'
from pathlib import Path
p = Path("Jenkinsfile")
s = p.read_text()
tests = " stage('Tests') {\n"
push = " stage('Push') {\n"
assert s.count(tests) == 1 and s.count(push) == 1
audit = """ stage('Audit') {
agent {
docker {
image 'python:3.13-slim'
reuseNode true
}
}
steps {
sh '''
python -m venv .venv-audit
. .venv-audit/bin/activate
pip install --quiet -r requirements-audit.txt
pip-audit -r requirements-dev.txt
'''
}
}
"""
scan = """ stage('Scan') {
steps {
sh '''
docker save -o image.tar cicd-sandbox:$IMAGE_TAG
docker run --rm -v "$WORKSPACE":/work -v trivy-cache:/root/.cache \\
aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \\
image --input /work/image.tar --quiet --skip-version-check --scanners vuln \\
--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1
'''
}
}
"""
s = s.replace(tests, audit + tests).replace(push, scan + push)
p.write_text(s)
EOF
git commit -am "Jenkins: audit the dependencies and scan the image before pushing"
git push
Two things differ from GitHub:
docker savewrites the image to a file in the workspace, and Trivy scans the file with--input. GitHub's runner let Trivy read images through Docker's socket. Jenkins talks to its own Docker engine over the network, from Lesson 2, so a file is the simple way to hand the image over. It works because that engine hasjenkins_homemounted at the same path.-v trivy-cache:/root/.cachekeeps Trivy's 130 MB database in a named volume on Jenkins's Docker engine, so only the first build downloads it. GitHub's runners start empty every time; a Jenkins server doesn't, and that's one of the few places it's faster.
Build Now. Mine failed, and not because of a vulnerability:
+ pip-audit -r requirements-dev.txt
FileNotFoundError: [Errno 2] No such file or directory: '/.cache/pip-audit'
PermissionError: [Errno 13] Permission denied: '/.cache'
Failed in branch Audit
Jenkins runs the Python container as its own user, -u 1000:1000 in the docker run line. That user has no home folder inside the container, so its home is /, and pip-audit couldn't create /.cache. pip never hit this in Lint or Tests because PIP_NO_CACHE_DIR = '1' has been in the Jenkinsfile since Lesson 3. Give pip-audit a cache folder inside the workspace, which deleteDir() cleans up anyway:
python3 - <<'EOF'
from pathlib import Path
p = Path("Jenkinsfile")
s = p.read_text()
old = " pip-audit -r requirements-dev.txt\n"
assert s.count(old) == 1
p.write_text(s.replace(old, " pip-audit --cache-dir .pip-audit-cache -r requirements-dev.txt\n"))
EOF
git commit -am "Jenkins: give pip-audit a cache folder it can write to"
git push
Build Now again:
+ pip-audit --cache-dir .pip-audit-cache -r requirements-dev.txt
No known vulnerabilities found
[Pipeline] { (Scan)
+ docker save -o image.tar cicd-sandbox:1.0.21-25b3a7c
Report Summary
│ /work/image.tar (debian 13.7) │ debian │ 0 │
[Pipeline] { (Push)
1.0.21-25b3a7c: digest: sha256:2276706dd80eaba59e8c5ba9c66ae5419a75d38027ca99e18b5309fc6b7743e7 size: 856
Finished: SUCCESS
Both gates pass, and the image went to GHCR only after its scan came back clean.
Secrets, briefly
Lesson 5 covered keeping secrets out of logs and images. Two more checks belong in the pipeline:
- A secret scanner on the code. Tools like gitleaks search a repository and its history for keys and passwords. Trivy has one built in too; it's the part
--scanners vulnswitched off. - GitHub push protection. On public repositories, GitHub checks pushes for known kinds of secrets and blocks them before they land. It's the cheapest check there is: it stops the leak before anything needs revoking.
In my real setup
When I copied my StackIT pipeline into jenkins-aws-wordpress-pipeline (opens in a new tab), I ran gitleaks over the copy before the first push, because the original had grown up with real credentials around it. It came back clean, and that check is why I was comfortable making it public.
The original pipeline's security gate was SonarQube: a scanner stage, then a Quality Gate stage that waited for SonarQube's verdict and failed the build if it wasn't OK. SonarQube reads your own code for bugs and risky patterns, which is a different job from this lesson's scanners. pip-audit and Trivy look at everything you didn't write.
CivicGrid has none of this lesson's scans yet: no pip-audit and no Trivy in its workflow. Its Dockerfile is in better shape than the sandbox's was, with a multi-stage build and a non-root user. But its final image still carries pip twice, once in the base image and once inside the virtual environment it copies across, so a first Trivy run will very likely find the same four. The same fix applies: once the packages are installed, pip has no job left in the running app.
Spot the problem
The safe-looking tag. A workflow uses some/deploy-action@v2.4.1, an exact release, not @v2. The team feels pinned. Are they? No. An exact-looking tag is still a tag, and the 2025 and 2026 attacks moved tags like v35 and 0.34.2. Only a full commit ID can't be moved.
The scanner that blocks everything. A team adds Trivy with --exit-code 1 and no other settings. Every build now fails on dozens of low-severity findings, most with no fix available, so they turn the scanner off. What should they have done? Choose what fails the build: HIGH and CRITICAL, with a fix available. Report the rest. A gate that always fails is one nobody keeps.
The fix that fixed nothing. Trivy flags urllib3 in an image, so a developer adds urllib3==2.8.0 to requirements.txt. The next scan still shows the old version. Why? The flagged copy was bundled inside pip, not installed by the app, so the new pin added a second copy and left the old one in place. Find where a finding lives before choosing the fix.
Where you are now
Your pipeline now checks what it trusts. Every action is pinned to a commit ID, and Dependabot proposes updates as pull requests, a week after release, that go through every gate. pip-audit fails the build on known holes in your dependencies, and Trivy, itself pinned by digest, fails it on serious, fixable problems in the image. You traced four findings to a tool the app never used and removed it, rather than patching around it. Lesson 8 is the last step of the pipeline: getting a tested, scanned image from the registry into production.
Check yourself
Why were workflows pinned to a commit ID safe in the tj-actions attack?
What does Dependabot's cooldown protect against?
Tests passed but pip-audit failed on redis 4.5.3. Why didn't the tests catch it?
Why use --ignore-unfixed in the build's scan?
Trivy flagged urllib3 2.7.0 in an image whose app never installs it. Where was it?
Why does the Jenkins Scan stage use docker save and --input?
Key terms
- Supply chain attack: an attack through software you depend on, rather than on your own code.
- Commit ID (SHA): the 40-character ID of one exact commit, which can't be moved like a tag.
- Dependabot: GitHub's built-in tool that opens pull requests to update pinned versions.
- Cooldown: how long a release must be public before Dependabot proposes it.
- Vulnerability: a known security flaw, usually with an ID like
CVE-2025-47273orGHSA-.... - pip-audit: a tool that checks Python requirements against known vulnerabilities.
- Trivy: a scanner that checks images for vulnerable operating system and language packages.
- Digest: an image's exact ID,
sha256:..., which can't be moved like a tag. - Severity: how serious a finding is: low, medium, high or critical.
- SBOM (software bill of materials): a list of everything a piece of software contains.
- Secret scanner: a tool, like gitleaks, that searches code and history for keys and passwords.
- Push protection: GitHub's check that blocks known kinds of secrets from being pushed.
Go deeper
- The Trivy project's security advisory for the March 2026 compromise (opens in a new tab), with the exact versions and times.
- GitHub's Dependabot options reference (opens in a new tab), including
cooldown. - The Hacker News on the tj-actions/changed-files compromise (opens in a new tab).