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

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

devsecops ci github-actions gitleaks bandit pip-audit shift-left

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

  1. Plant a dependency problem instead: change the pin to requests==2.31.0 and 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, then FAIL pip-audit, and the gate exits 1. Gitleaks and Bandit still print PASS, because the script runs every check.

  2. 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 app

    Put it before exit $status. --error is essential: without it Semgrep exits 0 on findings and the check would always print PASS.

  3. Remove fetch-depth: 0 from 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.

Frequently Asked Questions

What is a security gate in CI?
It is a pipeline step that runs security checks on every push or pull request and fails the build when one of them finds a problem at or above an agreed threshold. Branch protection can then require it to pass before merging. The point is that the policy is enforced by the pipeline, not remembered by reviewers.
Why does Gitleaks need fetch-depth: 0 in GitHub Actions?
actions/checkout makes a shallow clone with only the latest commit by default. A secret that was added in an earlier commit and later removed isn't in that clone, so a history scan passes without seeing it. Setting fetch-depth: 0 fetches the full history so gitleaks git can scan every commit.
Should a security gate stop at the first failing check?
Usually not. If the gate exits at the first failure, a developer fixes that one, pushes, and only then learns about the next. Running every check and failing at the end shows all the problems in one run, which is what the gate script in this lesson does.
Why run the same script locally and in CI?
When the workflow only calls a script that also runs on a laptop, a failing build can be reproduced exactly, with the same tool versions and flags. It also keeps the CI file short, so the security policy lives in one reviewed script rather than spread across YAML steps.
How do I check a GitHub Actions workflow without pushing it?
actionlint is a static checker for workflow files. It catches syntax errors, unknown keys, bad expressions and shell problems in run steps. It doesn't execute the jobs, so you still test the commands themselves by running them locally.