SAST and secret scanning in CI, the secure way
A developer commits a token, notices, deletes the line in the next commit, and the secret scanner on main says "no leaks found". Everyone relaxes. The token is still in the history, and so is everyone who cloned it.
The short answer
Scan Git history, not only files: run gitleaks git on the commits of every pull request with --redact, and fail on any finding. Run semgrep with a rule file you own and --error so findings block the merge. Run both offline from pinned images, and rotate any secret that reached a commit.
On this page
What goes wrong
Secret scanning and static analysis are easy to add and easy to make useless:
- The secret scanner looks at the files in the checkout. A secret added and removed in two commits of the same pull request never appears in the final files, but it is in the history forever.
- The SAST job reports findings but never fails, so after a month nobody reads it.
- The scanner downloads rules or sends results to a vendor service on every run, which a build network should not allow.
- A found secret is "fixed" by deleting the line. The secret is still valid and still in every clone.
What the docs say
The
gitcommand lets you scan local git repos. Under the hood, gitleaks uses thegit log -pcommand to scan patches.
Source: gitleaks README
You can always set the exit code when leaks are encountered with the --exit-code flag.
Source: gitleaks README
Exit 1 if there are findings. Useful for CI and scripts.
Source: Semgrep docs, CLI reference (--error)
Push protection is a secret scanning feature designed to prevent hardcoded credentials, such as secrets or tokens, from ever being pushed to your repository.
Source: GitHub docs, About push protection
gitleaks documents both dir and git modes but does not say which one CI
should use for pull requests. The difference is the whole point, as the test
below shows. Self-hosted forges such as Forgejo have no built-in push
protection, so the CI check is the place it happens.
The secure configuration
A pull request workflow with both checks. The job container is your own scanner image, pinned by digest: the upstream gitleaks and semgrep images do not include the Node.js runtime that the checkout action needs.
on: [pull_request]
jobs:
secrets:
runs-on: docker
container:
# Your scanner image: node and git (for the checkout action) plus
# gitleaks v8.30.1 and semgrep 1.178.0, built from pinned inputs.
image: registry.example.com/ci/scanners@sha256:<digest>
steps:
- uses: https://git.example.com/actions/checkout@<40-hex sha> # vendored
with:
fetch-depth: 0 # full history, or the scan cannot see earlier commits
persist-credentials: false
- name: gitleaks over the commits of this pull request
run: |
gitleaks git . \
--log-opts="origin/${{ github.base_ref }}..HEAD" \
--redact \
--no-banner \
--exit-code 1
sast:
runs-on: docker
container:
image: registry.example.com/ci/scanners@sha256:<digest>
steps:
- uses: https://git.example.com/actions/checkout@<40-hex sha> # vendored
with:
persist-credentials: false
- name: semgrep with repository-owned rules
env:
SEMGREP_SEND_METRICS: "off"
run: |
semgrep scan \
--config .semgrep/rules.yml \
--error \
--metrics=off \
--disable-version-check \
.The rules live in the repository and change by pull request, like code. A small starting set for Python:
rules:
- id: subprocess-shell-true
languages: [python]
severity: ERROR
message: subprocess with shell=True and a non-constant command allows shell injection
patterns:
- pattern: subprocess.$FUNC($CMD, ..., shell=True, ...)
- pattern-not: subprocess.$FUNC("...", ..., shell=True, ...)
- id: yaml-unsafe-load
languages: [python]
severity: ERROR
message: yaml.load without SafeLoader can construct arbitrary Python objects
patterns:
- pattern: yaml.load(...)
- pattern-not: yaml.load(..., Loader=yaml.SafeLoader, ...)
- id: requests-verify-false
languages: [python]
severity: WARNING
message: TLS certificate verification is disabled
pattern: requests.$METHOD(..., verify=False, ...)Make both jobs required checks for merging into protected branches. Add a
scheduled job that runs gitleaks git . over the full history of every
repository, for secrets that predate the check.
Prove it
A throwaway repository: the first commit adds a random value in the GitHub personal access token format, the second commit replaces it with an environment variable. The working tree scan:
gitleaks dir /repo --redact --no-colorINF scanned ~447 bytes (447 bytes) in 3.99ms
INF no leaks foundThe history scan of the same repository:
gitleaks git /repo --redact -v --no-colorFinding: REDACTED
Secret: REDACTED
RuleID: github-pat
File: app/settings.py
Line: 1
Commit: <commit sha>
Author: dev
Date: 2026-09-24T22:04:52Z
Fingerprint: <commit sha>:app/settings.py:github-pat:1
WRN leaks found: 1
exit code 1--redact keeps the secret out of the CI log, where it would leak a second
time. semgrep with the rules above, on a file with three problems and one
safe call (subprocess.run("uptime", shell=True)):
semgrep scan --config semgrep-rules.yml --error --metrics=off --emacs app/app/deploy.py:6:11:error(yaml-unsafe-load): cfg = yaml.load(config_text) # unsafe loader:yaml.load without SafeLoader can construct arbitrary Python objects
app/deploy.py:7:5:error(subprocess-shell-true): subprocess.run("git checkout " + branch, shell=True) # shell injection:subprocess with shell=True and a non-constant command allows shell injection
app/deploy.py:9:5:warning(requests-verify-false): requests.get(cfg["health_url"], verify=False) # TLS check off:TLS certificate verification is disabled
exit code 1The constant shell=True call is not reported, and the job fails. Both
scanners ran with --network none.
Mistakes people make
Scanning only the checkout
gitleaks dir, or any scanner pointed at files, misses secrets that were
added and removed inside the same pull request. Scan the commits with
gitleaks git and a --log-opts range, with full fetch depth.
Deleting the line and moving on
Once a secret is in a pushed commit, assume it is known. Rotate it first, then decide whether to rewrite history. Rewriting history without rotating fixes nothing.
Unredacted findings in CI logs
A scanner that prints the secret it found copies it into logs that more
people can read than the repository. Use --redact.
Advisory-only SAST
A job that never fails gets ignored. Start with a few high-confidence rules that block, and add rules as the team fixes what they find.
Rules from the internet on every run
--config auto or registry rule packs fetch rules at run time and change
without review. Vendor the rules you use into the repository, and run the
scanner without network access.
Checklist
- gitleaks scans the commits of every pull request (
gitleaks git, full fetch depth). - A scheduled job scans the full history of every repository.
- Findings are redacted in logs.
- semgrep runs with repository-owned rules and
--error. - Both checks are required for merging into protected branches.
- The scanner image is built from pinned inputs and referenced by digest; scanners need no network access.
- Every secret that reached a commit is rotated, then removed.
- Allowlists for false positives are in the repository and reviewed like code.
Scanners do not keep secrets out of Git; they tell you quickly that one got in. The faster you hear it, the cheaper the rotation.
H2-CSDE
Learn it on a live range
Policy gates and scanning, in DevSecOps and Supply Chain: a real host in your browser, and every objective checked on the machine.
Start freeH2 Scanner
Want this caught before it merges?
The H2 Scanner runs in your CI and flags the weaknesses pages like this one warn about, on every pull request.
Talk to us