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,python3for 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.ymlparses as valid YAML and has exactly two jobs:gitleaksanddependency-review..gitleaks.tomlparses as valid TOML, extends the default rule set, and includes both custom rules (runbook-vault-token,runbook-internal-tls-fingerprint)..pre-commit-config.yamlpins thegitleaksrepository and revision.severity-policy.mddocuments 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
$HOMEmatch 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.
gitleaksrules are tuned, not just installed. The default rule set is a starting point; the team’s.gitleaks.tomlextends 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.mddocument 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: 0is required forgitleaks. 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.