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 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
--redactprints 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 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)"' 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.
Related Jump to heading
- Secret Scanning & Remediation β the parent topic and the three layers.
- Blocking Secrets With a Pre-Push Scan β the local layer this backs up.
- Responding to a Leaked Credential β what to do when the scan fires for real.