Mert Özen Further With Every Line
Security DevSecOps — #02

Secret Scanning: Pre-commit, Push Protection and the First 30 Minutes After a Leak

Mert Özen Nov 14, 2026 13 min 5 views
Secret Scanning: Pre-commit, Push Protection and the First 30 Minutes After a Leak

Secret scanning is not one tool but layers: pre-commit on the developer machine, the PR commit range in the pipeline, push protection on GitHub and periodic scans of the whole history; after a leak the order is always rotate first, then impact analysis, and clean up history last. With real output we show why gitleaks 8.30's default rules missed two dev-prefixed secrets, Bookstore-specific rules, base64 detection and cleanup with git-filter-repo.

Version note: The code in this post was built and run with .NET 10; the scans were run with gitleaks 8.30.1, Git 2.54 and git-filter-repo 2.47.

Guess First

A developer at Bookstore cannot connect to the local database container (SQL Server) and is in a hurry. They write the connection string with its password, plus the API key they use to call the Inventory service, into appsettings.Development.json, skip the hook with git commit --no-verify and push. Ten minutes later they notice, delete the password from the file and make a new commit. Question: is the secret safe now? And although the team has gitleaks installed, how many of these two values would it catch with the default settings?

The answer to the first question is no: the deleted line still lives in Git history, on the remote it was pushed to, in any forks and with everyone who cloned in the meantime. The answer to the second, as we will see shortly, is zero. In this post we build secret scanning layer by layer: pre-commit on the developer machine, a commit range scan in the pipeline, push protection on GitHub and a scan of the whole history. Then we move on to the most important part: what to do, and in what order, in the first 30 minutes after a secret leaks.

Layers: Why One Scanner Is Not Enough

Each layer of secret scanning exists to catch what the previous one missed. A pre-commit hook is the cheapest layer: the secret stops before it even enters a commit, and the cost of the fix is close to zero. But the hook runs on the developer's machine, may not be installed, and, as we saw in the Git and GitHub series post on hooks and commit signing, can be skipped with --no-verify. Server-side push protection stops the secret before it reaches the remote and does not depend on what is installed on the developer's machine. The pipeline scan checks the PR's commits with our own rules and catches a skipped hook. A periodic scan of the whole history finds what a newly added rule matches in old commits.

None of these layers takes back a leaked secret. They all exist to shrink the leak window. Once the window is closed, the only correct move is to invalidate the secret; we will get to that at the end of the post.

On the Developer Machine: gitleaks and pre-commit

Among open source secret scanners gitleaks is widespread, and it ships as a single binary for Windows, Linux and macOS. First, let's stage the file from the scenario and scan it with the default rules. The values are fake values generated for this post; by team convention the password in the connection string has a dev-only- prefix, and the API key uses the bks_dev_<keyId>_<secret> format we designed for machine-to-machine authentication in the API Security series:

{
  "ConnectionStrings": {
    "Bookstore": "Server=localhost,1433;Database=Bookstore;User Id=bookstore_app;Password=dev-only-Xq7v9Lm2Rt8Kp4Wz;TrustServerCertificate=True"
  },
  "InventoryApi": {
    "BaseUrl": "http://localhost:5080",
    "ApiKey": "bks_dev_sjbcsri7b7q6_1Z62kfo84dCncxHxvfJE_6UGUlThcVCg0-0dDgtxuRc"
  }
}
git add src/Bookstore.Api/appsettings.Development.json
gitleaks git --pre-commit --staged --redact --no-banner .
12:26AM INF 0 commits scanned.
12:26AM INF scanned ~324 bytes (324 bytes) in 72.9ms
12:26AM INF no leaks found

Both slipped through. The reason lies in how gitleaks' generic-api-key rule works. This rule does not look for a specific provider's format; it catches a value with an entropy above 3.5 that follows a keyword like password, key or token. To reduce false positives, the default configuration of 8.30.1 also has more than fourteen hundred stopwords: if one of them appears inside the value, the finding is dropped. The dev- and only in our password and the dev_ in our key are on that list. So the prefix we added to make values "obviously fake" also means "fake" to the scanner, and it makes a real development secret invisible.

The lesson: generic rules are a safety net; for your own secret formats you write your own rules. Since Bookstore's API key was designed with a recognizable prefix, writing a rule is easy. For the connection string we add a rule specific to SQL Server. The rules live in .gitleaks.toml at the repo root and extend the default rules:

title = "Bookstore gitleaks config"

[extend]
useDefault = true

[[rules]]
id = "bookstore-api-key"
description = "Bookstore partner API key"
regex = '''\bbks_[a-z]+_[a-z2-7]{12}_[A-Za-z0-9_-]{43}\b'''
keywords = ["bks_"]

[[rules]]
id = "sqlserver-connection-password"
description = "Password inside a SQL Server connection string"
regex = '''(?i)(?:server|data source)=[^"';]+;[^"']*?(?:password|pwd)\s*=\s*([^;"'\s]{8,})'''
secretGroup = 1
keywords = ["password", "pwd"]

[[allowlists]]
description = "Fake keys in test fixtures"
targetRules = ["bookstore-api-key", "generic-api-key"]
paths = ['''^tests/.*/Fixtures/.*\.json$''']
regexes = ['''^bks_test_''']
condition = "AND"

The keywords field is for performance: gitleaks runs the regex only on lines containing these words. secretGroup = 1 makes only the password, not the whole connection string, count as the secret; redaction and the fingerprint are based on that value. The same scan now gives two findings:

Finding:     "ApiKey": "REDACTED
Secret:      REDACTED
RuleID:      bookstore-api-key
Entropy:     5.128524
File:        src/Bookstore.Api/appsettings.Development.json
Line:        7
Fingerprint: src/Bookstore.Api/appsettings.Development.json:bookstore-api-key:7

Finding:     "Bookstore": "Server=localhost,1433;Database=Bookstore;User Id=bookstore_app;Password=REDACTED;TrustServerCertific...
Secret:      REDACTED
RuleID:      sqlserver-connection-password
Entropy:     4.483856
File:        src/Bookstore.Api/appsettings.Development.json
Line:        3
Fingerprint: src/Bookstore.Api/appsettings.Development.json:sqlserver-connection-password:3

12:26AM WRN leaks found: 2

Now let's run this automatically before each commit. We covered how hooks work in the Git and GitHub series; with Git 2.54's config-based hooks a single command is enough. --verbose matters, otherwise the developer only sees "leaks found: 2" and does not know what is where:

git config set hook.gitleaks.command "gitleaks git --pre-commit --staged --redact --verbose --no-banner"
git config set --append hook.gitleaks.event pre-commit
git hook list pre-commit
git commit -m "Add development settings"
echo $?
gitleaks
Finding:     "ApiKey": "REDACTED
Secret:      REDACTED
RuleID:      bookstore-api-key
...
12:26AM WRN leaks found: 2
1

No commit was created. If you want to distribute the same hook across the team, the pre-commit framework's .pre-commit-config.yaml also works; the gitleaks repo publishes three hook definitions named gitleaks, gitleaks-docker and gitleaks-system, the last one using the binary already on the system. Whichever you pick, a hook is a seat belt: it can be skipped, so the same rules must also run in the pipeline.

The fix is to take the secret out of the file entirely. In the Development environment the password-free part of the connection string stays in the file, and the password and key come from environment variables (ConnectionStrings__Bookstore, InventoryApi__ApiKey) or user-secrets. We set up these mechanisms in the OWASP series post "OWASP A04: Cryptographic Failures (2) — Password Storage, Data Protection and Secret Management"; secret stores like Key Vault and Vault are the topic of the upcoming Configuration and Secret Management series.

{
  "ConnectionStrings": {
    "Bookstore": "Server=localhost,1433;Database=Bookstore;User Id=bookstore_app;TrustServerCertificate=True"
  },
  "InventoryApi": {
    "BaseUrl": "http://localhost:5080",
    "ApiKey": ""
  }
}

Test Fixtures and Allowlists

Bookstore's tests exercise API key validation with a key in the real format; that key lives in tests/Bookstore.Api.Tests/Fixtures/partner-keys.json with a bks_test_ prefix and is valid in no environment. I tied the allowlist to both conditions holding together (condition = "AND"): the file must be in a Fixtures folder and the value must start with bks_test_. So if someone accidentally pastes a bks_dev_ key into a fixture, it is still caught; that is exactly what happened in the test, where the scan exited with "leaks found: 1" for the dev key in oops.json.

In my first attempt I listed only my own rule in targetRules and the commit was still blocked, because generic-api-key caught the "validKey" line in the fixture: test_ is not on the stopword list. A value can match more than one rule; state explicitly which rules an allowlist applies to. There is also a gitleaks:allow comment for line-level exceptions, but since JSON (JavaScript Object Notation) has no comments and a line comment is easy to miss in code review, I prefer a central allowlist defined by path and regex that goes through review.

Base64 and Other Disguises

Secrets are not always plain text. In Kubernetes Secret manifests the values are base64, and base64 is an encoding, not encryption. In gitleaks 8.30.1 decoding is on by default (--max-decode-depth defaults to 5):

apiVersion: v1
kind: Secret
metadata:
  name: inventory-api
type: Opaque
data:
  apiKey: YmtzX2Rldl9zamJjc3JpN2I3cTZfMVo2Mmtmbzg0ZENuY3hIeHZmSkVfNlVHVWxUaGNWQ2cwLTBkRGd0eHVSYw==
RuleID:      kubernetes-secret-yaml
...
RuleID:      generic-api-key
...
Finding:     REDACTED
Secret:      REDACTED
RuleID:      bookstore-api-key
Entropy:     5.128524
Tags:        [decoded:base64 decode-depth:1]
File:        deploy/inventory-secret.yaml
Line:        7

12:27AM WRN leaks found: 3

The third finding is the important one: tagged decoded:base64, our own rule recognized the Bookstore key inside the encoded value. With decoding turned off via --max-decode-depth 0 this finding disappears and two findings remain. Why Kubernetes Secrets should not go into the repo as plain manifests is the topic of the Kubernetes series post ".NET Configuration with ConfigMaps and Secrets: Environment Variables, Volumes and Picking Up Changes".

appsettings.Development.json deserves extra attention. The project template creates it and it gets committed; the word "Development" in its name makes it feel harmless. But the development database is often a copy of production data, and a development key often reaches staging. The rule is simple: no secrets in this file either.

Scanning in the Pipeline and the Whole History

Because the hook can be skipped, the same .gitleaks.toml must also run on the PR. In the pipeline we download the gitleaks binary from the release page and verify its checksum; we will see in detail in the fifth post of the series that a scanner is itself part of the supply chain:

  secrets:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
      - name: Install gitleaks
        env:
          GITLEAKS_VERSION: 8.30.1
        run: |
          base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
          file="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
          curl -sSfL -o "$file" "$base/$file"
          curl -sSfL -o checksums.txt "$base/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
          grep " $file\$" checksums.txt | sha256sum -c -
          tar -xzf "$file" gitleaks
      # only this PR's commits are scanned
      - name: Scan PR commits
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: ./gitleaks git --redact --verbose --no-banner --log-opts="$BASE_SHA..$HEAD_SHA" .

I ran the install step locally; after appending a line to the archive, sha256sum printed FAILED, returned 1 and the step stopped. I also ran the scan step with the Linux binary in a container against the commit range of the scenario's branch. Even though the last commit removed the password, the --no-verify commit in between is caught:

Finding:     "Bookstore": "Server=localhost,1433;Database=Bookstore;User Id=bookstore_app;Password=REDACTED;TrustServerCertific...
Secret:      REDACTED
RuleID:      sqlserver-connection-password
Entropy:     4.483856
File:        src/Bookstore.Api/appsettings.Development.json
Line:        3
Commit:      f564e296a4103a49a856e3fae5d81dcb1f55fe0b
Author:      Ada
Email:       ada@example.test
Date:        2026-11-13T21:27:19Z
Fingerprint: f564e296a4103a49a856e3fae5d81dcb1f55fe0b:src/Bookstore.Api/appsettings.Development.json:sqlserver-connection-password:3

9:28PM INF 2 commits scanned.
9:28PM INF scanned ~255 bytes (255 bytes) in 174ms
9:28PM WRN leaks found: 1

Scanning only the PR's commits on a PR keeps findings in old history from failing every PR; this is the secrets counterpart of the "block new findings" principle from the first post. Run a separate job that scans the whole history on main and on a weekly schedule: that is the only way to see what a newly added rule catches in old commits. To exclude an old finding that has been rotated and is no longer valid from this scan, write its fingerprint into .gitleaksignore with a reason:

# Rotated on 2026-11-14, see SEC-142
f564e296a4103a49a856e3fae5d81dcb1f55fe0b:src/Bookstore.Api/appsettings.Development.json:sqlserver-connection-password:3

Because the fingerprint includes the commit SHA, the exception does not apply if the same secret shows up again in another commit. Passing an earlier report as a baseline with --baseline-path is also possible; .gitleaksignore, where each entry can carry its own reason, is easier to read in review.

GitHub Secret Scanning and Push Protection

gitleaks is the layer you control; the GitHub side runs on the server and does not depend on the developer's machine. I describe the behaviour in this section based on GitHub's documentation and changelogs and did not run it in my own organization; since product and licensing change quickly, check your own plan on docs.github.com.

Secret scanning runs automatically and for free on public repos; it scans the entire Git history of all branches, as well as issue, PR and discussion text and wikis. For an organization's private and internal repos you need the GitHub Secret Protection license, sold separately since April 2025 (Code Security became a separate product as well). Push protection comes in two kinds: user push protection at the account level, which is on by default and protects pushes to public repos; and repository push protection, which an admin turns on and which requires Secret Protection. The latter covers command line pushes, commits made in the web UI, file uploads and REST (Representational State Transfer) API requests.

When a push is blocked, by default anyone with write access can bypass it by choosing a reason: "used in tests" and "false positive" create a closed alert, "I'll fix it later" creates an open one; every bypass lands in the audit log and triggers an email to the relevant people. With delegated bypass enabled, a bypass becomes a request that only designated people or teams can approve; for Bookstore, that means the security champion's approval. You can define custom patterns for your own formats and enable push protection per pattern: the bks_ regex above goes into GitHub the same way.

The important change of 2026 is the extension announced as a public preview on 28 October 2026: push protection can now also look at unformatted values that the surrounding code reveals to be secrets (such as passwords), not only predefined formats. The feature is off by default, requires Secret Protection or Advanced Security and consumes AI credits. The dev-only- password we saw is exactly this kind of value; but before you build a gate on a preview feature, have your own rules in place.

After a Leak: The First 30 Minutes

When a secret leaks, the order is critical: first invalidate the secret, then understand the impact, clean up history last. Reversing the order is a classic mistake; every minute spent rewriting history is a minute in which the secret is still valid.

The first five minutes: revoke and rotate. For the SQL Server login, generate a new password, change it with ALTER LOGIN and give the application the new value from the secret store; close any open sessions that used the old password. For the API key Bookstore's design makes life easier: the keyId part of the key (sjbcsri7b7q6 in the example) is not secret, so it can safely appear in logs and tickets, and revocation works by that id. To rotate this quickly you must have rehearsed it beforehand; finding out where a never-rotated secret is used in the middle of a crisis is expensive.

The next twenty minutes: impact analysis. The questions are clear: when was the secret pushed and when was it revoked, is the repo public, are there forks, was the secret used in that window? On GitHub the audit log and the secret scanning alert's timeline, on SQL Server the login audit records, and on the API side the logs of requests that came with that keyId answer these questions. If you see an unexpected IP address or time, this is no longer a secret cleanup but an incident, and you proceed accordingly.

Last: cleaning up history. GitHub's documentation says plainly that this often may not be necessary; if the secret is invalid, the value in history is harmless. If you do clean up, the tool is git-filter-repo; version 2.47 introduced the --sensitive-data-removal option for this. We do the rewrite in a fresh clone:

git clone https://github.com/bookstore/bookstore.git cleanup
cd cleanup
echo 'dev-only-Xq7v9Lm2Rt8Kp4Wz==>***REMOVED***' > ../replacements.txt
git filter-repo --sensitive-data-removal --replace-text ../replacements.txt
NOTICE: Fetching all refs from origin to make sure we rewrite
        all history that may reference the sensitive data, via
      git fetch -q --prune --update-head-ok --refmap "" origin +refs/*:refs/*
Parsed 4 commitsHEAD is now at 813cc08 Remove password from settings

New history written in 0.10 seconds; now repacking/cleaning...
You rewrote 2 (of 4) commits.

NOTE: First Changed Commit(s) is/are:
  f564e296a4103a49a856e3fae5d81dcb1f55fe0b
...
NEXT STEPS FOR YOUR SENSITIVE DATA REMOVAL:
...
  * Force push the rewritten history to the server:
      git push --force --mirror origin

I ran the test with a local bare repo as origin. Running gitleaks again after the rewrite produced a surprise finding: the line Password=***REMOVED*** matched our own sqlserver-connection-password rule, because it saw a password value longer than eight characters. We added the marker left by filter-repo to the rule as an allowlist:

[[rules.allowlists]]
description = "Marker left by git filter-repo"
regexes = ['''^\*\*\*REMOVED\*\*\*$''']
12:28AM INF 4 commits scanned.
12:28AM INF scanned ~2516 bytes (2.52 KB) in 94.1ms
12:28AM INF no leaks found

Know the limits of a rewrite. Every SHA after the changed commit changes, signatures are dropped, and a push from an old clone can bring the secret back. In the old clone git cat-file -t f564e29 still returns "commit"; in the cleaned clone the object is gone. You cannot touch copies in forks and PR refs: according to GitHub's documentation, after the force push you contact GitHub Support with the "First Changed Commit(s)" information from filter-repo and the number of affected PRs. This is exactly why cleaning history without rotating solves nothing.

In Production

In a real repository, scanning the whole history can take minutes rather than seconds; do not put it on the PR path, put it on a schedule. Triage the findings of the first full scan in one go: for each one answer "is it valid, has it been rotated", move the rotated ones into .gitleaksignore with a reason, and apply the 30-minute sequence above to the valid ones.

The scanner's output is sensitive too. Without --redact gitleaks prints the secret to the log, and the CI log becomes a new copy of the secret. Use --redact when you produce a report file as well; in the test's JSON report the Secret field was written as "REDACTED".

Measure your rules. If a rule produces a few false positives a week, the team starts widening the allowlist, and the allowlist slowly replaces the rule. Protect .gitleaks.toml with CODEOWNERS and route allowlist changes to the security champion's review.

Common Pitfalls

First, thinking the deleting commit is the fix. The secret is still in history; the only fix is to rotate it.

Second, trusting the default rules. As we saw, the generic rule missed both dev-prefixed values because of stopwords. Write rules for your own secret formats; when you design a new kind of secret, give it a recognizable prefix.

Third, keeping the allowlist broad. Exempting the whole tests/ folder also exempts a real key pasted there. Require the path and the value prefix together.

Fourth, treating base64 as hiding. A Kubernetes Secret manifest or a base64-encoded config value is as readable as plain text.

Fifth, treating bypass as an ordinary button. Every push that goes through with "I'll fix it later" leaves an open alert; if those alerts have no owner and no date, push protection turns into just a warning screen.

A Small Experiment

★☆☆ Scan the whole history of your own project with gitleaks git --redact --verbose .. If there are findings, answer "is it still valid?" for each one.

★★☆ Write a rule in .gitleaks.toml for a secret format your team uses (a connection string, an internal service token). First check whether the default rules catch it, then try your own rule; then base64-encode the value and scan again.

★★★ Set up the scenario from this post with a local bare repo as origin: commit a secret with --no-verify, delete it in the next commit, scan the PR commit range, then clean up with git-filter-repo. Observe whether a normal push from the old clone brings the secret back.

Secrets were at the far left; in the next post we move on to dependencies. We will set up NuGetAudit auditing transitive packages by default in .NET 10, npm audit and Dependabot, and see how to prioritize a vulnerability beyond its CVSS score with EPSS, KEV and reachability.