Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · intermediate · ~75 min

Lab 11: Add secret scanning (`gitleaks`) and dependency review to a pipeline

C · SimulationB · Nested virtualisation

Objectives

  • Author a `.gitleaks.toml` configuration tuned for the team's secret landscape
  • Wire `gitleaks` into both pre-commit and a GitHub Actions PR workflow
  • Enable GitHub dependency review so PRs report vulnerable transitive dependencies
  • Configure severity policy: CRITICAL and HIGH findings fail the build, MEDIUM and LOW are reported but do not fail
  • Author a `deny-list.yml` of file paths and patterns that are always blocked even when no scanner finds them
  • Demonstrate the workflow blocks a deliberate high-entropy AWS access key

Prerequisites

Objective

By the end of this lab you will have authored the three controls that together prevent committed secrets from reaching the default branch: a gitleaks configuration tuned for the team’s secret landscape, a pre-commit hook that runs gitleaks before the secret ever leaves the developer’s laptop, and a GitHub Actions workflow that runs gitleaks on every pull request. You will also have enabled GitHub’s dependency review and written a severity policy that distinguishes findings that block merge from findings that are merely reported.

The point of this lab is not the scanner — gitleaks is one of several mature secret scanners and the lab in Lesson LXXXV-02 covered the detection model. The point is the integration: the scanner in pre-commit (the developer’s side), in CI (the repository’s side), and in dependency review (the ecosystem’s side), with a documented severity policy that the team can read without consulting the workflow file.

Architecture

A two-layer defense: local pre-commit plus CI. Each layer runs gitleaks independently so a missed finding on the developer’s machine is caught in CI. The dependency review job runs alongside gitleaks on the same PR trigger.

flowchart LR
    A["developer\ngit commit"] --> B["pre-commit\ngitleaks"]
    B -- "pass" --> C["git push"]
    B -- "fail" --> Z1["commit blocked"]
    C --> D["GitHub\npull request"]
    D --> E["gitleaks CI"]
    D --> F["dependency-review"]
    E -- "fail" --> Z2["PR check red"]
    F -- "fail" --> Z2
    E -- "pass" --> G["merge allowed"]
    F -- "pass" --> G

The two layers are independent. The pre-commit hook is fast (it sees only the staged diff); the CI run is authoritative (it sees the full repository at the PR head). A finding that escapes the local hook still fails in CI.

Requirements

  • Git 2.55.x on Linux or macOS.
  • A GitHub repository with pull requests enabled. The repository may be empty; the lab builds a sample from scratch.
  • Access to repository settings to enable GitHub Advanced Security features (dependency review requires the feature be enabled at the org or repo level). If GHA is not available, the lab reads as documentation; the workflow file is still the deliverable.
  • tar, python3 for the YAML validation step.

Scenario

A platform team has had two secret leaks in the past quarter: an AWS access key committed in a Terraform example, and a HashiCorp Vault token pasted into a Slack-to-PR screenshot. The remediation is a single, layered control: gitleaks everywhere (local and CI) plus dependency review for transitive vulnerabilities. The team wants the scanner to fail the build on real findings, but they do not want every commit that mentions “example” to fail.

The lab builds the workflow, the scanner configuration, and the policy document that makes the ruleset reviewable.

Tasks

Task 1 — Build a sample repository with a deliberate secret

# check-shell-blocks: allow-invalid
LAB="$HOME/secret-scan-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

git init -b main
git config user.email 'ops@example.com'
git config user.name  'Ops'

mkdir -p terraform

# An .envrc that contains the synthetic AWS access key. The key
# is the AWS documentation example; it is not a real credential.
cat > terraform/.envrc <<'EOF'
export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
EOF

# A main.tf that has no secret but does have IAM.
cat > terraform/main.tf <<'EOF'
terraform {
  required_version = ">= 1.9.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

resource "aws_s3_bucket" "data" {
  bucket = "runbook-data"
}
EOF

# A README that mentions "secret" but contains no actual secret.
# This file is the false-positive test: the scanner should NOT
# flag it.
cat > README.md <<'EOF'
# secret-scan-lab

This repository exists to demonstrate the secret scanning pipeline.
The string "AKIAIOSFODNN7EXAMPLE" in `terraform/.envrc` is the AWS
documentation example, not a real credential.
EOF

git add terraform/ README.md
git commit -m 'initial: terraform with a synthetic example credential'

The repository now has a Terraform module, an .envrc with the canonical AWS example key, and a README that references the key by name. The next task wires gitleaks to find that key.

Task 2 — Author the .gitleaks.toml configuration

# check-shell-blocks: allow-invalid
cd "$HOME/secret-scan-lab"

cat > .gitleaks.toml <<'EOF'
title = "RunBook Academy secret scanner"

# ─────────────────────────────────────────────────────────────────
# Default ruleset is enabled. The block below *extends* the default,
# it does not replace it.
# ─────────────────────────────────────────────────────────────────

[extend]
useDefault = true

# ─────────────────────────────────────────────────────────────────
# Allowlist: paths and patterns that should NEVER trigger a finding.
# These are documented in severity-policy.md.
# ─────────────────────────────────────────────────────────────────

[allowlist]
description = "team-curated allowlist for false positives"
paths = [
  '''(^|/)tests/fixtures/.*''',
  '''(^|/)examples/.*\.example$''',
  '''(^|/)docs/.*\.md$''',
  '''\.lock$''',
  '''\.svg$''',
]

# Stopwords: a finding is suppressed if the surrounding 8 lines
# contain any of these. They signal that the matched value is a
# test fixture or documentation example.
stopwords = [
  "example",
  "EXAMPLE",
  "fixture",
  "placeholder",
  "documentation",
  "Lorem",
]

# ─────────────────────────────────────────────────────────────────
# Custom rules: tokens that the default ruleset does not catch.
# ─────────────────────────────────────────────────────────────────

[[rules]]
id = "runbook-vault-token"
description = "HashiCorp Vault token issued by RunBook's internal PKI"
regex = '''hvb\.A[0-9a-z]{20,}\.[0-9a-z]{32,}\.[A-Za-z0-9+/=]{20,}'''
tags = ["vault", "internal"]

  [rules.allowlist]
  paths = ['''(^|/)tests/fixtures/.*''']

[[rules]]
id = "runbook-internal-tls-fingerprint"
description = "Internal CA TLS certificate fingerprint (40 hex chars)"
regex = '''(?i)tls-fingerprint:\s*[0-9a-f]{40}'''
tags = ["tls", "internal"]

  [rules.allowlist]
  paths = ['''(^|/)docs/.*\.md$''']
EOF

git add .gitleaks.toml
git commit -m 'gitleaks: extend defaults with team allowlist and internal rules'

The .gitleaks.toml has three sections. The [extend] block turns on gitleaks’s default ruleset (about 200 patterns) so the team does not start from zero. The [allowlist] block suppresses findings in test fixtures, example files, Markdown, lockfiles, and SVGs — the four paths that produce most of the team’s false positives. The [[rules]] blocks add two internal token shapes that the defaults miss: a Vault token and an internal CA TLS fingerprint.

Task 3 — Author the pre-commit configuration

# check-shell-blocks: allow-invalid
cd "$HOME/secret-scan-lab"

cat > .pre-commit-config.yaml <<'EOF'
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.1
    hooks:
      - id: gitleaks
        # The pre-commit hook invokes gitleaks on the staged diff
        # only. It is fast: typically under one second for a
        # typical commit. The CI workflow in Task 4 runs the full
        # repository scan.
        entry: gitleaks protect --staged --redact --config .gitleaks.toml
        language: system
        stages: [pre-commit]
EOF

git add .pre-commit-config.yaml
git commit -m 'pre-commit: gitleaks on staged changes'

The pre-commit hook pins gitleaks to commit SHA v8.18.1 (the tag, used as a stable ref). The hook runs gitleaks protect --staged, which scans only the staged diff. --redact rewrites any matched secret as ***REDACTED*** in the output so the developer sees that a finding exists without seeing what it is — important when the secret is a real one.

Task 4 — Author the GitHub Actions workflow

# check-shell-blocks: allow-invalid
cd "$HOME/secret-scan-lab"

mkdir -p .github/workflows

cat > .github/workflows/secret-scan.yml <<'EOF'
name: secret scan

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  contents: read
  pull-requests: read  # only for the dependency-review job comment
  # gitleaks itself needs no extra permissions.

concurrency:
  group: secret-scan-${ github.ref }
  cancel-in-progress: ${ github.ref != 'refs/heads/main' }

jobs:
  gitleaks:
    name: gitleaks
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
        with:
          fetch-depth: 0  # full history so gitleaks can diff
      - name: gitleaks detect
        uses: gitleaks/gitleaks-action@v2.3.4
        env:
          GITHUB_TOKEN: ${ secrets.GITHUB_TOKEN }
          GITLEAKS_CONFIG: .gitleaks.toml
          GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true
          GITLEAKS_ENABLE_SUMMARY: true

  dependency-review:
    name: dependency review
    runs-on: ubuntu-24.04
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - name: dependency review
        uses: actions/dependency-review-action@4a97007e9f5b5ec1a9b6e2f75e8a8e7e6e0a4b3a  # v4.4.0
        with:
          fail-on-severity: high
          fail-on-scopes: runtime, development
          comment-summary-in-pr: true
          # Vulnerability data sources: GitHub Advisory Database.
          # No extra config required.
EOF

git add .github/workflows/secret-scan.yml
git commit -m 'ci: gitleaks and dependency-review on PR'

The workflow has two jobs that run in parallel. The gitleaks job uses the official gitleaks/gitleaks-action; the dependency-review job uses actions/dependency-review-action. Both run on every pull request; dependency-review is gated to PRs only because dependency diffs are not meaningful on direct pushes.

Task 5 — Document the severity policy

# check-shell-blocks: allow-invalid
cd "$HOME/secret-scan-lab"

cat > severity-policy.md <<'EOF'
# Severity policy for secret scanning

This document is the canonical record of which findings fail the
build and which are merely reported. The workflow file
(`.github/workflows/secret-scan.yml`) is the implementation; this
document is the policy. Engineers should be able to answer
"will this finding block my PR?" by reading this file.

## CRITICAL findings → fail

A CRITICAL finding is a confirmed active credential for a system
the team uses in production. Examples:

- AWS access keys (`AKIA...`)
- GCP service account keys
- HashiCorp Vault tokens (the `hvb.*` shape, custom rule
  `runbook-vault-token`)
- Private keys matching the team's internal CA
- GitHub personal access tokens

A CRITICAL finding fails `gitleaks` and the PR cannot merge.

## HIGH findings → fail

A HIGH finding is a confirmed secret for a system the team uses
in non-production contexts but which could be promoted to
production. Examples:

- Slack webhook URLs
- Internal TLS certificate fingerprints
- Database connection strings in test fixtures that match the
  production schema

A HIGH finding fails `gitleaks`.

## MEDIUM findings → report

A MEDIUM finding is a possible secret that the scanner cannot
confirm. Examples:

- Strings that match a regex but fail entropy checks
- Strings in test directories that the allowlist does not cover

A MEDIUM finding is reported in the `gitleaks` summary and the
PR comment, but does not fail the build. The reviewer decides
whether to suppress or fix.

## LOW findings → report

A LOW finding is a near-miss that the team wants to track for
audit purposes. Examples:

- Hashes that look like secrets but are documented as such in the
  file

A LOW finding is logged but does not appear in the PR comment.

## Dependency review

`dependency-review-action` is configured with
`fail-on-severity: high`. That is, a CVE with severity HIGH or
CRITICAL in any dependency added or changed by the PR fails the
build. MEDIUM and below are reported in the PR comment but do
not fail.

## Why this policy?

The team has had two leaks in a quarter. Both were CRITICAL
findings that should have failed the build; both bypassed the
reviewer's eye because the secret was buried in a Terraform
example. The policy is strict because the cost of a leak is much
higher than the cost of a false-positive block.
EOF

git add severity-policy.md
git commit -m 'docs: severity policy for secret scanning'

The severity policy is the artefact the team reads when they ask “why is my PR failing?”. It is referenced from the workflow file commented above; engineers who hit a failing check are directed to the policy document, not to the workflow file’s YAML.

Task 6 — Author the dependency-review job file

# check-shell-blocks: allow-invalid
cd "$HOME/secret-scan-lab"

# The dependency-review job is part of the workflow in Task 4.
# The lab also authors a standalone documentation file so the
# configuration is reviewable outside the workflow context.

cat > dependency-review.yml <<'EOF'
# dependency-review.yml
#
# This is the configuration reference for the dependency-review
# job in .github/workflows/secret-scan.yml. The workflow is the
# implementation; this file is the rationale.
#
# fail-on-severity: high
#   GitHub Advisory Database severity levels are LOW, MEDIUM,
#   HIGH, CRITICAL. HIGH and CRITICAL fail the build. MEDIUM and
#   LOW are reported but do not fail.
#
# fail-on-scopes: runtime, development
#   A development-only dependency with a HIGH CVE is still a
#   finding, because the dependency is in the lockfile and a
#   future change might promote it to a runtime context.
#
# comment-summary-in-pr: true
#   The action posts a comment summarising the dependency diff,
#   including new vulnerabilities. The comment is sticky (the
#   action updates it on subsequent runs).
#
# For monorepos with many package manifests, add
# `config-file: .github/dependency-review-config.yml` and pin
# per-manifest policies in that file.
EOF

git add dependency-review.yml
git commit -m 'docs: dependency-review job configuration reference'

The standalone dependency-review.yml is documentation: it explains the four settings that govern the dependency review behaviour, why each one is set the way it is, and what to do when a new package manager is added to the repository.

Task 7 — Validate the YAML and TOML structure

cd "$HOME/secret-scan-lab"

# Workflow YAML parses and has two jobs.
python3 -c "
import yaml
with open('.github/workflows/secret-scan.yml') as f:
    doc = yaml.safe_load(f)
jobs = doc['jobs']
print('jobs:', list(jobs.keys()))
print('gitleaks uses:', jobs['gitleaks']['steps'][1]['uses'])
print('dep-review fail-on-severity:',
      jobs['dependency-review']['steps'][1]['with']['fail-on-severity'])
"

# gitleaks.toml parses with tomllib (Python 3.11+) or tomli.
python3 -c "
import sys
try:
    import tomllib
    mod = tomllib
except ImportError:
    import tomli as mod
with open('.gitleaks.toml', 'rb') as f:
    doc = mod.load(f)
rules = doc.get('rules', [])
print('custom rules:', [r['id'] for r in rules])
print('allowlist paths:', doc['allowlist']['paths'])
"

# pre-commit config parses and pins the gitleaks repo.
python3 -c "
import yaml
with open('.pre-commit-config.yaml') as f:
    doc = yaml.safe_load(f)
repos = doc['repos']
print('hook repo:', repos[0]['repo'])
print('hook rev:', repos[0]['rev'])
"

Expected output (excerpt):

jobs: ['gitleaks', 'dependency-review']
gitleaks uses: gitleaks/gitleaks-action@v2.3.4
dep-review fail-on-severity: high
custom rules: ['runbook-vault-token', 'runbook-internal-tls-fingerprint']
allowlist paths: ['(^|/)tests/fixtures/.*', '(^|/)examples/.*\\.example$',
                  '(^|/)docs/.*\\.md$', '\\.lock$', '\\.svg$']
hook repo: https://github.com/gitleaks/gitleaks
hook rev: v8.18.1

The workflow has exactly two jobs, both pinned to a commit SHA, and the fail-on-severity is high. The .gitleaks.toml has two custom rules and an allowlist with five path patterns. The pre-commit config pins the gitleaks repository and revision.

Task 8 — Capture the deliverables

cd "$HOME/secret-scan-lab"

# The four deliverable files in $HOME for review.
cp .github/workflows/secret-scan.yml    "$HOME/secret-scan.yml"
cp .gitleaks.toml                       "$HOME/.gitleaks.toml"
cp .pre-commit-config.yaml              "$HOME/.pre-commit-config.yaml"
cp severity-policy.md                   "$HOME/severity-policy.md"
cp dependency-review.yml                "$HOME/dependency-review.yml"

ls -l "$HOME"/secret-scan.yml \
       "$HOME"/.gitleaks.toml \
       "$HOME"/.pre-commit-config.yaml \
       "$HOME"/severity-policy.md \
       "$HOME"/dependency-review.yml

The deliverables are the five files in $HOME, plus the repository under $HOME/secret-scan-lab ready to be pushed.

Validation

  • .github/workflows/secret-scan.yml parses as valid YAML and has exactly two jobs: gitleaks and dependency-review.
  • .gitleaks.toml parses as valid TOML, extends the default rule set, and includes both custom rules (runbook-vault-token, runbook-internal-tls-fingerprint).
  • .pre-commit-config.yaml pins the gitleaks repository and revision.
  • severity-policy.md documents the four severity tiers and the policy per tier.
  • Every uses: reference in the workflow is a pinned commit SHA or versioned tag.
  • The deliverables in $HOME match the expected file set.

Expected Outcome

A workflow that scans every pull request for committed secrets and for vulnerable dependencies, plus the configuration and documentation that make the workflow reviewable.

$HOME/secret-scan-lab/
├── .github/workflows/secret-scan.yml   # the workflow
├── .gitleaks.toml                       # scanner configuration
├── .pre-commit-config.yaml              # local hook
├── severity-policy.md                   # policy document
├── dependency-review.yml                # dependency-review rationale
├── terraform/
│   ├── .envrc                           # contains the deliberate secret
│   └── main.tf                          # clean terraform
└── README.md                            # documentation

The workflow is the implementation; the .gitleaks.toml and the severity policy are the policy; the README and dependency-review.yml are the rationale.

Troubleshooting

gitleaks flags a finding in a test fixture. The allowlist in .gitleaks.toml has a paths block for tests/fixtures/; add the new fixture’s path to the list and commit. Do not delete findings from the scanner’s output; suppress them by adding to the allowlist and document the suppression in severity-policy.md.

dependency-review-action reports a CVE that is later withdrawn. GitHub’s Advisory Database occasionally retracts a CVE. Re-run the workflow after the retraction propagates; if the PR is still blocked, add the advisory ID to the license-allow or vulnerability-check allowlist in the action’s config file (.github/dependency-review-config.yml).

pre-commit cannot find gitleaks. The language: system hook requires gitleaks to be on the developer’s $PATH. Either install gitleaks system-wide, or switch the hook to language: golang and let pre-commit provision the binary. The lab uses language: system because it is the simpler configuration for a team that has already installed gitleaks for local use.

gitleaks protect --staged reports a secret that is not in the commit. The --staged flag scans the staging area, which may include files that were staged in a previous commit but not yet committed. Run git status to see what is staged; unstage any files that should not be in the commit.

The scanner reports a finding but the secret is a public test value. Add the value to the stopwords list in .gitleaks.toml if the file is a test fixture, or to the paths list if the file is documentation. Document the allowlist entry in severity-policy.md so the suppression is reviewable.

Cleanup

LAB="$HOME/secret-scan-lab"

# Keep the deliverables.
mv "$LAB"/severity-policy.md "$LAB"/dependency-review.yml \
   "$HOME"/ 2>/dev/null
mv "$LAB/.github/workflows/secret-scan.yml" \
   "$HOME/secret-scan.yml" 2>/dev/null
mv "$LAB/.gitleaks.toml" "$HOME/.gitleaks.toml" 2>/dev/null
mv "$LAB/.pre-commit-config.yaml" "$HOME/.pre-commit-config.yaml" 2>/dev/null

rm -rf "$LAB"

find "$HOME" -maxdepth 1 -name 'secret-scan-lab' -print
# expected: (no output)

If you applied the workflow to a real GitHub repository during the lab, disable or delete it through the GitHub UI or the API:

gh api --method DELETE \
  /repos/$OWNER/$REPO/actions/workflows/secret-scan.yml

What You Learned

  • Two-layer defense is the minimum. Local pre-commit plus CI is the floor for secret scanning; the developer’s hook is fast but sees only the staged diff, the CI run is authoritative but sees the leak only after the commit reaches the remote. Both are required because each layer catches what the other misses.
  • gitleaks rules are tuned, not just installed. The default rule set is a starting point; the team’s .gitleaks.toml extends it with allowlists for known false positives and custom rules for internal tokens the defaults miss. Tuning is a code review change because it changes what the scanner ignores.
  • Severity policy is documentation. The severity-policy.md document is the artefact the team reads when a finding blocks a PR. It is the bridge between the scanner’s output and the team’s expectations.
  • Dependency review catches what secret scanning does not. A dependency with a known CVE is not a secret leak, but it is a security finding that belongs in the same PR review as secret scanning. The two controls complement each other.
  • fetch-depth: 0 is required for gitleaks. A shallow clone hides what changed, which is the entire point of the scanner. The trade-off is checkout time; the cost is worth it.
  • Pin pre-commit hooks to commit SHAs. Tags are stable but mutable; a SHA is stable and immutable. The lab uses the tag for readability and notes the SHA in the production callout.
  • Allowlists are append-only. Removing an entry is a code review change because it changes what the scanner ignores. The team’s process is to add suppressions, never to remove them silently.

Deliverables

  • · .github/workflows/secret-scan.yml — the GitHub Actions workflow that runs `gitleaks` on PRs
  • · .gitleaks.toml — the team-tuned secret scanner configuration
  • · .pre-commit-config.yaml — the pre-commit hook that runs `gitleaks` before `git commit`
  • · dependency-review.yml — the GitHub Actions job that runs `dependency-review-action`
  • · severity-policy.md — the documented policy for which severities fail and which are reported
  • · sample-repo.tgz — the synthesised repository used to demonstrate the pipeline

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.