Scanning pull requests for secrets in CI Jump to heading

The pre-commit scan is the one people set up and the one that covers the fewest contributors: it does not run on a fork, it does not run for a web-interface edit, and it does not run for anyone who skipped the setup step. The pipeline is the first machine you control that sees every change regardless of where it came from, which makes it the layer that decides whether your scanning policy is real. This recipe builds it, within secret scanning and remediation.

When to use this approach Jump to heading

  • Your repository accepts contributions from outside the team.
  • Local hooks exist and you want the same rule to apply universally.
  • A credential reached the default branch despite a pre-commit scan.
  • Compliance requires evidence that every change was scanned.
  • If the repository is private, single-team and everyone has hooks installed, this is still worth having as a backstop but is less urgent.

Step 1 β€” Scan the range, not the tree Jump to heading

Scanning the working tree at the tip finds what is present now; a credential added and removed within the branch is missed.

# .github/workflows/secret-scan.yml
name: secret-scan
on: pull_request
permissions:
  contents: read
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }        # the merge base must exist
      - name: Scan the commits this change introduces
        run: |
          base=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
          gitleaks detect --log-opts="$base..HEAD" --redact --exit-code 1
# Verification: the same command locally covers the same commits
base=$(git merge-base origin/main HEAD)
git rev-list --count "$base"..HEAD
gitleaks detect --log-opts="$base..HEAD" --redact
Why scanning the tip is not enoughA credential committed in one commit and removed in the next is absent from the branch tip and present in the history the merge will carry. Scanning the range catches it; scanning the working tree does not.C2 adds the credential, C3 removes itmainBbranchBC2C3C4the tip is clean and the range is not β€” and the range is what merges

Step 2 β€” Never let the value reach the logs Jump to heading

CI logs are widely readable, retained, and frequently forwarded.

      - run: gitleaks detect --log-opts="$base..HEAD" --redact --report-format json --report-path /tmp/r.json
      - name: Summarise without the values
        if: failure()
        run: |
          jq -r '.[] | "- `\(.File):\(.StartLine)` β€” \(.RuleID) (commit \(.Commit[0:8]))"' /tmp/r.json \
            >> "$GITHUB_STEP_SUMMARY"
# Verification: the summary names locations, never values
grep -ci 'secret\|token' /tmp/r.json
jq -r '.[0].Secret' /tmp/r.json      # with --redact this is masked

SAFETY WARNING β€” a scanner run without --redact prints the matched value into the build log, which converts a credential in a branch into a credential in your log retention, your log aggregation and anyone’s screenshot. Always redact in CI, and treat any credential that has appeared in a log as exposed regardless of where it was originally found.

Step 3 β€” Report where the contributor will see it Jump to heading

A failing check with no explanation produces a re-run, not a fix.

      - name: Explain what to do
        if: failure()
        run: |
          {
            echo "### Possible credential in this change"
            echo
            jq -r '.[] | "- `\(.File):\(.StartLine)` β€” \(.RuleID)"' /tmp/r.json
            echo
            echo "If this is a real credential: **rotate it first**, then remove it."
            echo "If it is a test fixture, add a narrow allowlist entry with a reason."
            echo "Do not paste the value anywhere while investigating."
          } >> "$GITHUB_STEP_SUMMARY"
# Verification: the summary appears without needing write permissions
gh run view --json jobs --jq '.jobs[].name'

The job summary is the right medium here specifically because it needs no token β€” a fork build cannot comment, and reaching for an elevated trigger to make it possible is the anti-pattern described in linting commit messages for forked pull requests.

Step 4 β€” Decide whether it blocks Jump to heading

# Blocking: a finding prevents the merge
gitleaks detect --log-opts="$base..HEAD" --redact --exit-code 1
# Reporting: a finding is visible but does not gate
gitleaks detect --log-opts="$base..HEAD" --redact --exit-code 0
# Verification: the required-check list reflects the decision
gh api repos/:owner/:repo/rulesets --jq '.[].rules[]?
  | select(.type=="required_status_checks")
  | .parameters.required_status_checks[].context' | grep -i secret
Blocking against reporting for a secret scanA blocking scan prevents a credential reaching the default branch and will occasionally stop legitimate work at an inconvenient moment. A reporting scan never blocks and relies on somebody reading it, which after a few false positives nobody does.Report onlyBlock the mergecredential can still mergeyesnofalse positive costnonea documented bypassdepends on someone readingyesnoappropriate once tunednoyesblock once the false-positive rate is low enough that blocking is rare

This is one of the few checks that should block despite the friction, because the failure it prevents is unrecoverable in a way that most check failures are not β€” the mitigation is tuning the scanner first, so blocking is rare, rather than choosing not to block.

Step 5 β€” Provide a bypass that is visible Jump to heading

A blocking check with no escape hatch produces a disabled check.

      - name: Allow a reviewed bypass, loudly
        if: contains(github.event.pull_request.labels.*.name, 'secret-scan-reviewed')
        run: echo "::warning::secret scan bypassed by label β€” see the pull request discussion"
# Verification: bypasses are findable afterwards
gh pr list --state merged --label secret-scan-reviewed --json number,title,mergedAt \
  --jq '.[] | "\(.mergedAt[0:10]) #\(.number) \(.title)"'
What each scanning layer coversA pre-commit scan covers people who installed it. A pull request scan covers every contributor including forks and web edits. A scheduled full-history scan covers what both missed and what new detection rules now recognise.Pre-commitfastest feedbacknot on forksskippablePull requestevery contributorevery commit in rangeblocks the mergeScheduledall historynew rulesfinds the old onesonly the middle column covers everybody, which is why it is the one that blocks

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Why not rely on the provider’s push protection instead? Jump to heading

Use both. Push protection is better because it stops the credential reaching the server at all, and it covers only the formats the provider recognises. The pipeline scan catches your internal token formats and anything the provider’s rules miss, and it produces the evidence trail that a compliance requirement usually asks for.

What about a credential in a commit message rather than a file? Jump to heading

Most scanners cover message content when scanning a range, and it is worth verifying with a deliberate test β€” a token in a commit message is exactly as exposed as one in a file and is missed by any check that only looks at diffs of tracked content.

Does scanning every pull request cost much? Jump to heading

A range scan is seconds, because it reads the diff of the commits introduced rather than the whole repository. The full-history scan is the expensive one, which is why it belongs in a scheduled job β€” the layering exists to keep the pull request path fast.