Build and supply chain

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.

Updated Houssam Hammoudi, CTOTested with gitleaks v8.30.1, semgrep 1.178.0, git 2.54

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

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 git command lets you scan local git repos. Under the hood, gitleaks uses the git log -p command 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.

yaml
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:

yaml
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:

bash
gitleaks dir /repo --redact --no-color
text
INF scanned ~447 bytes (447 bytes) in 3.99ms
INF no leaks found

The history scan of the same repository:

bash
gitleaks git /repo --redact -v --no-color
text
Finding:     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)):

bash
semgrep scan --config semgrep-rules.yml --error --metrics=off --emacs app/
text
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 1

The 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 free

H2 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