DevSecOps (AppSec in the pipeline) · lesson 6 of 7
Beginner
3Intermediate
3Also part of DevSecOps (AppSec in the pipeline)
10- + Bash Security Best Practices from Bash
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + Docker Security from Docker
- + Multi-Stage Builds from Docker
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
- + Scanning Terraform for Misconfiguration with Checkov from Cloud Security
- + Policy as Code: Testing Terraform Plans with OPA and Conftest from Cloud Security
- + MCP Authorization: OAuth 2.1, CIMD and a Real Identity Provider from MCP (Model Context Protocol)
- + Running MCP in Production: Scaling, Caching and Operations from MCP (Model Context Protocol)
Also part of: DevOps GitHub Actions
A CI Security Gate: Gitleaks, Bandit and pip-audit in One Workflow
Combine secrets, code and dependency scans into one script and one GitHub Actions job that fails on a planted bug, passes after the fix, and sees history.
By SkillByExample · Published
Each scanner in this topic is useful on its own, but only if someone runs it. A security gate runs
them on every push and fails the build when one finds something. This lesson wraps three checks
from earlier lessons into one script, wires it into a GitHub Actions workflow, and runs the same
script locally to watch it fail on a planted bug and pass after the fix. Tested with Gitleaks
8.30.1, Bandit 1.9.4, pip-audit 2.10.1, actionlint 1.7.12 (rhysd/actionlint:1.7.12), git 2.54.0
and Docker Engine 29.8.2.
The workflow file below was checked with actionlint, not run on GitHub. Every command it runs was executed locally, and that’s the output shown.
The project
The repository has three things to scan: its git history, an app/ folder and a
requirements.txt. app/deploy.py is the vulnerable version from the
SAST lesson, with shell=True and yaml.load.
requirements.txt pins requests==2.33.0, the fixed version from the
SCA lesson. A ci/ folder holds the two Dockerfiles from those lessons,
saved as ci/sast.Dockerfile and ci/sca.Dockerfile.
The gate script
One function runs a check, prints PASS or FAIL, and remembers any failure. The script exits at the end, so a single run reports every problem.
#!/bin/sh
# gate.sh: run every check, then fail if any of them failed
status=0
check() {
name=$1; shift
if "$@"; then echo "PASS $name"; else echo "FAIL $name"; status=1; fi
}
check gitleaks docker run --rm -v "$PWD":/repo zricethezav/gitleaks:v8.30.1 \
git /repo --no-banner --no-color --redact -v -l error
check bandit docker run --rm -v "$PWD":/w sast-lab bandit -q -ll -r app \
-f custom --msg-template "{relpath}:{line} {test_id} {severity} {msg}"
check pip-audit docker run --rm -v "$PWD":/w sca-lab pip-audit -r requirements.txt
exit $status
Each threshold is the decision from its own lesson. Gitleaks fails on any finding in history and
--redact keeps secrets out of the CI log. Bandit fails on Medium and above (-ll). pip-audit
fails on any known vulnerability. -l error hides Gitleaks’ info lines, so a passing check prints
nothing.
Run it against the planted bug
$ docker build -q -t sast-lab -f ci/sast.Dockerfile ci
$ docker build -q -t sca-lab -f ci/sca.Dockerfile ci
$ sh gate.sh
PASS gitleaks
app/deploy.py:8 B506 MEDIUM Use of unsafe yaml load. Allows instantiation of arbitrary objects. Consider yaml.safe_load().
app/deploy.py:12 B602 HIGH subprocess call with shell=True identified, security issue.
FAIL bandit
No known vulnerabilities found
PASS pip-audit
$ echo $?
1
The two docker build lines print image IDs, left out here. Bandit failed, and the gate still ran
pip-audit after it, so you see the whole picture in one run. The low-severity B404 note from the
SAST lesson doesn’t appear, because -ll filters it. Exit code 1 is what fails the CI job.
Fix it and run again
Replace app/deploy.py with the fixed version from the SAST lesson: yaml.safe_load and a
list-form subprocess.run.
$ sh gate.sh
PASS gitleaks
PASS bandit
No known vulnerabilities found
PASS pip-audit
$ echo $?
0
All three pass, exit 0. pip-audit prints its “No known vulnerabilities found” line even when it passes, because it writes that summary itself; the other two are silent on success.
The workflow
The CI job only builds the two images and calls the same script, so a red build can be reproduced on any laptop with Docker.
# .github/workflows/security.yml
name: security
on: [push, pull_request]
jobs:
gate:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # gitleaks scans history; the default clone has 1 commit
- run: docker build -q -t sast-lab -f ci/sast.Dockerfile ci
- run: docker build -q -t sca-lab -f ci/sca.Dockerfile ci
- run: sh gate.sh
actionlint checks the file for unknown keys, bad expressions and shell problems in run: steps.
It needs to run inside a git repository.
$ docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:1.7.12 -color=false
$ echo $?
0
No output, exit 0. To make the gate binding, set it as a required status check in the repository’s branch protection rules, so a pull request can’t merge while it’s red.
Why fetch-depth: 0 matters
By default actions/checkout clones only the latest commit. This script builds a repository where
a key was committed and then removed, and then clones it the way CI would.
# shallow-demo.sh
set -e
export GIT_AUTHOR_NAME=dev GIT_AUTHOR_EMAIL=dev@example.com
export GIT_COMMITTER_NAME=dev GIT_COMMITTER_EMAIL=dev@example.com
rm -rf hist shallow && git init -q -b main hist && cd hist
echo 'PAYMENTS_API_KEY = "kq7Vx2Lm9Pz4Rt8Wb3Nc6Hy1"' > config.py
git add config.py && git commit -q -m "Add config"
echo 'import os; PAYMENTS_API_KEY = os.environ["PAYMENTS_API_KEY"]' > config.py
git commit -q -am "Read the key from the environment"
cd .. && git clone -q --depth 1 "file://$PWD/hist" shallow
echo "full clone: $(git -C hist rev-list --count HEAD) commits"
echo "shallow clone: $(git -C shallow rev-list --count HEAD) commit"
$ docker run --rm -v "$PWD":/w -w /w --entrypoint sh alpine/git:v2.54.0 shallow-demo.sh
full clone: 2 commits
shallow clone: 1 commit
$ docker run --rm -v "$PWD/shallow":/repo zricethezav/gitleaks:v8.30.1 git /repo --no-banner --no-color --redact -v -l error
$ echo $?
0
$ docker run --rm -v "$PWD/hist":/repo zricethezav/gitleaks:v8.30.1 git /repo --no-banner --no-color --redact -v -l error
Finding: PAYMENTS_API_KEY = "REDACTED"
Secret: REDACTED
RuleID: generic-api-key
Entropy: 4.584962
File: config.py
Line: 1
Commit: 5305c40acb608c26a5d79b90591cf787f36c5c59
Author: dev
Email: dev@example.com
Date: 2026-10-14T13:03:35Z
Fingerprint: 5305c40acb608c26a5d79b90591cf787f36c5c59:config.py:generic-api-key:1
$ echo $?
1
The same scan of the shallow clone passes, because the commit that holds the key isn’t in it.
The full clone fails. Without fetch-depth: 0, the gate’s secrets check would quietly pass on
exactly the leak it exists to catch. This script doesn’t fix the commit dates, so your commit hash
and date will differ.
Practice
-
Plant a dependency problem instead: change the pin to
requests==2.31.0and run the gate. Which checks fail?Solution
With the fixed
deploy.py, only pip-audit fails. It prints the advisory table from the SCA lesson, thenFAIL pip-audit, and the gate exits 1. Gitleaks and Bandit still print PASS, because the script runs every check. -
Add the custom Semgrep rule from the SAST lesson as a fourth check.
Solution
check semgrep docker run --rm -v "$PWD":/w sast-lab semgrep scan \ --config rules.yml --metrics=off --quiet --emacs --error appPut it before
exit $status.--erroris essential: without it Semgrep exits 0 on findings and the check would always print PASS. -
Remove
fetch-depth: 0from the workflow and run actionlint again. Does it complain?Solution
No. A shallow clone is valid configuration, so actionlint has nothing to report. Linters check that the workflow is well formed, not that it does what you meant, which is why the shallow-clone demo above is worth keeping as a test.
Where this fits
The gate checks your code, history and dependencies before they ship. The last lesson in this topic checks what ships: signing a container image and refusing one that doesn’t match.