AgenticCLI
▮ shipguardis a free, local secure-ship agent for AI-built apps — 5 checks in ~2s.$ npm i -g @agenticcli/shipguard

GitLab Secret Detection: 1 Finding, Then 0, Then 2

15 min · jul 2026

Short answer: GitLab secrets detection scans a project for credentials, and what it finds depends on which commits the pipeline hands it. In the version we ran, the default branch read the working tree; GitLab now documents a commit range from analyzer v7.38.1. A feature branch reads its own commits. History is skipped unless you ask for a historic scan.

We built a repository so that three credentials would sit in three different places. Its oldest commit scaffolds a checkout route with a Stripe secret test key written straight into src/config/payments.ts.

Two commits later a SendGrid token comes out of the mailer, process.env.SENDGRID_TOKEN goes in, and that fix is the newest thing in the log. A third credential, a GitLab personal access token, sits in a file nobody has committed at all.

We handed a fresh clone of that repository to GitLab's own Secret Detection analyzer image three times, changing nothing but the CI variables that select the scan scope.

It returned one finding. Then none. Then two, one of them a credential the working tree no longer held.

What did GitLab's own analyzer return on one repository?

The image is registry.gitlab.com/security-products/secrets, which is what GitLab's Secret Detection CI job runs. Two separate things are bundled inside it: gitleaks 8.30.1 as the matching engine, and GitLab's own secret detection rules at v0.25.0, which live in a separate project under rules/mit/ rather than in gitleaks.

That ruleset is not one file. At tag v0.25.0, rules/mit/ is a directory per credential issuer: a folder named stripe, one named sendgrid, one named gitlab for GitLab's own tokens, and a long run of other vendors alongside them.

Coverage is therefore organised by who issued the key, and whether this would name yours comes down to whether that issuer has a folder in the tree. Ours has one, which is why a Stripe key gets named at all.

What did each run actually read?

A clone went in, rather than the working directory, because a runner checks the repository out and never sees a file that was not committed.

Real terminal output from capture gitlab-secret-scope-matrix. 3 hardcoded credentials seeded: 2 of them committed into the history, 1 written to disk and never committed. 3 scan scopes compared: the default-branch scope (a working-tree scan on analyzer 7.38.0) returned 1 finding; the branch commit-range scope returned 0 findings; the historic scope returned 2 findings

Real capture. GitLab secrets analyzer v7.38.0, secret detection rules v0.25.0, gitleaks 8.30.1, 28 July 2026. The analyzer prints those three strings at startup and this run keeps only the parsed reports, so they are recorded under image_provenance in content-pipeline/captures/gitlab-secret-scope-matrix/manifest.json, alongside the fixture.

Nothing about the repository changed between those runs. Same ruleset, same engine, and the six committed files byte-for-byte identical in all three. The only difference was a pair of CI variables most projects never think about, because GitLab sets them for you.

Why did the scan on the fix commit report nothing?

Run B's range covered exactly one commit, and that commit was the remediation: the SendGrid literal came out, process.env went in.

A commit range shows what those commits added. A scan of a removal therefore has nothing to look at, reports nothing, and is entirely correct to do so.

The job passes. The pipeline goes green. The SendGrid token is still one commit back, unchanged and readable by anyone who clones the repository, which by then is everyone on the team plus whatever CI, mirrors and forks exist.

Per GitLab's own pipeline documentation, the scope depends on where the pipeline is running: on the default branch, in analyzer v7.38.1 and later, the pipeline scans the content of all commits from the prior commit to the current commit, and falls back to the Git working tree when no valid prior commit is available. On a feature branch, in analyzer v7.35.0 and later, the content of all commits from the merge base to the latest commit is scanned.

The whole description of that analyzer release is one line: "Scan all commits of a feature branch (forked from the default branch) in branch pipeline (!479)". The behaviour a pipeline gets is decided in a changelog most people running the job will never open.

What does GitLab say its default scan does not read?

The same pipeline page carries the answer, in a sentence sitting under a heading about an optional feature, which is a reliable way for a sentence to go unread:

By default, pipeline secret detection scans only the current state of the Git repository. Any secrets contained in the repository’s history are not detected.

Having the vendor document the boundary is better evidence than any writeup of the feature could be, and GitLab goes further: it recommends running a historic scan once, immediately after switching the analyzer on, precisely because everything already in the history is otherwise invisible to it.

GitLab is equally plain about what a deletion does not do: "Removed secrets also persist in the Git history." Deleting the line is the fix almost everyone reaches for, and it is the one move that reliably makes a problem look solved while changing nothing about it.

The article on how a key gets hardcoded in the first place covers the arrival. This is the part afterwards, and it is where a pre-deploy scan of your own tree starts earning its place.

What can a scan of your own disk see that GitLab cannot?

The uncommitted file is the other half of this. It exists, it holds a credential shaped exactly like a real one, and the GitLab side has never received a byte of it.

Real ShipGuard 0.5.1 terminal output from capture gitlab-secret-before-push. 3 hardcoded credentials seeded. ShipGuard 1 secrets finding; Gitleaks 2 findings; the GitLab analyzer 2 findings. 1 credential named by every side; 1 named only by a scan of the disk; 1 only by the scan of the clone

Real capture. @agenticcli/[email protected], Gitleaks 8.30.1, GitLab secrets analyzer v7.38.0 on its historic scope, 28 July 2026. Fixture and manifest: content-pipeline/captures/gitlab-secret-before-push/.

The GitLab side was handed its widest documented setting there, so nothing it failed to name can be put down to a badly configured pipeline. It read every commit and every branch in the repository, and the token in the untracked file was still not among the things it had any way to report on.

Which window is each credential inside?

Credential Where it sits Which scan reaches it
Stripe secret test key committed, still at HEAD every one of them
SendGrid API token committed, then deleted only a scan that reads commits
GitLab personal access token never committed only a scan of the disk

Three credentials · one repository · two sides

Each side is blind to exactly what the other one reads.

A scan of your disk reads files at their current content. A scan on GitLab reads what was pushed. One credential sits outside each of those windows, and neither side reports anything unusual about the one it cannot see.

YOUR DISK · BEFORE THE PUSH 1 · WHAT IS HERE working tree · .git Files as they stand right now, tracked or not. 2 · WHAT THE SCAN READS File contents, at HEAD and at whatever is uncommitted beside them. 3 · OUTSIDE THE WINDOW The SendGrid token. Deleted by the latest commit. 4 · WHAT CAME BACK gitleaks 8.30.1 2 of the 3. shipguard 0.5.1 named 1 of the 3 GITLAB · AFTER THE PUSH 1 · WHAT IS HERE the clone tracked files · commits Whatever was pushed, and nothing else. 2 · WHAT THE SCAN READS by scope Default branch: working tree (7.38.0). Feature branch: commits unique to it. 3 · OUTSIDE THE WINDOW The GitLab token. Never committed, never sent. 4 · WHAT CAME BACK analyzer v7.38.0 · historic 2 of the 3. 1 on the default scope · 0 on a range
One repository holding three credentials, read from either side of the push. The window on the left is file contents on disk, so it covers a file git has never tracked and misses a credential the most recent commit deleted from its file. The window on the right is whatever was pushed, so it covers the history and misses the file that has not been sent yet. GitLab states the same boundary in its own documentation: any secrets contained in the repository history are not detected by the default pipeline scan. Both panels are measured runs, recorded in content-pipeline/captures/gitlab-secret-before-push/ and content-pipeline/captures/gitlab-secret-scope-matrix/.

Each side is blind to exactly what the other one reads, and neither reports anything unusual while being blind to it. A clean result from either is a true statement about a window rather than about a repository. The equivalent comparison on GitHub's side of the same question lands in the same place for a different platform, which suggests this is a property of where a scan runs rather than of anybody's product decisions.

Where does our own scanner land on this?

ShipGuard named the Stripe key and neither of the other two. It reads files rather than commits, so the deleted SendGrid token was never within its reach, and it carries no rule for a GitLab personal access token at any tier.

Its report also lists two findings that have nothing to do with credentials — a missing middleware.ts and a payment route without error handling — because a release gate asks a wider question than a secret scanner does.

ShipGuard 0.5.1 scores the missing middleware at 30, the Stripe key at 15 and the payment route at 5, against a DO_NOT_SHIP band that starts at 50. The tree lands on exactly 50, so all three findings are holding that verdict up.

Drop the payment route and 45 is left; drop the Stripe key and 35 is left. Both come back REVIEW_BEFORE_SHIP. Drop the missing middleware and 20 is left, which dist/cli.js:216-217 returns as SAFE_TO_SHIP: it tests score <= 20 first.

One of three — from the tool this site sells, on a fixture this site built. On the fixture behind a dependency scanner reading a lockfile, the same tool published a zero.

Doesn't secret push protection close the gap?

Per GitLab's push protection documentation, a push carrying a credential is rejected outright, and the message GitLab returns names the commit ID holding the secret, the filename and line, and the type of secret. So it closes part of the gap, and it is the feature to reach for.

That makes it the one GitLab mechanism acting before a credential reaches the server, which is the honest answer to anyone asking why a check on your own machine is needed at all. What such a check can name is a separate question: of six credentials seeded into one service, two were named by no scanner at all.

Its documentation page also carries the tier line Ultimate, while the secret detection overview page — the page setting out all three of GitLab's detection methods — carries Free, Premium, Ultimate. The mechanism that keeps a credential from arriving is the one on the top plan; the mechanism every plan gets runs after the push has already been accepted.

What are push protection's documented limits?

Narrower ones than the name suggests, and GitLab spells them out:

Secret push protection scans only the diffs of commits pushed over HTTP(S) and SSH. If a secret is already present in a file and not part of the changes, it is not detected.

The feature is skipped entirely when a push changes more than 3,150 paths or 350,000 lines, and a file or diff patch larger than 1 MiB is not checked either.

Reading diffs rather than whole modified files became the default in GitLab 17.11, when the feature flag holding it back was removed. Earlier releases scanned every modified file, which meant a push could be stopped over a secret the change had never touched.

A developer can also waive it deliberately, and GitLab documents both routes as ordinary steps. One is the push option git push -o secret_push_protection.skip_all. The other, in full: "Add [skip secret push protection] to one of the commit messages, on either an existing line or a new line, then push the commits." An audit event is recorded either way.

Is a documented bypass a weakness?

A gate with no escape hatch gets switched off the first week it is confidently wrong about a placeholder, and an audited waiver keeps the record while letting the work continue.

The waiver does make the guarantee procedural rather than absolute: it holds while the people using it agree to let it hold. That is worth planning around, and it is worth knowing which of your credentials would have been in the diff at all.

Push protection is the one thing here we did not run ourselves. It is server-side, on a hosted GitLab instance, and exercising it would have meant pushing real-shaped credentials to a real project, so every statement in these three sections is GitLab's documentation cited as documentation.

What do you do about a key that already shipped?

Rotate it. Not later, and not once the history is tidy.

GitHub's guidance on removing sensitive data opens by saying that where the exposed data is a credential you need to revoke or rotate it as a first step, and that once revoked "it can no longer be used for access, and that may be sufficient to solve your problem".

The same page is candid about the limits of the alternative: "If the commit that introduced the sensitive data exists in any forks, it will continue to be accessible there." Cached pull-request views are on that list too, and neither is something a rewrite of your own history reaches.

Rotation ends the exposure. Everything else is housekeeping on a value that no longer opens anything, which is a much better problem to have on a Friday afternoon. Before rotating, run a scan for the ones you have forgotten: an audit of four ordinary feature prompts found hardcoded AWS credentials in one of them.

What does replacing the key actually change?

Real ShipGuard 0.5.1 output, capture gitlab-secret-replace-vs-rotate: the same tree before and after the Stripe literal became an environment variable, 7 files read each time. 3 findings before the edit, 2 after it; 1 of them a secrets finding, 0 secrets findings afterwards; risk score 50 before, 35 after, moving the verdict from DO_NOT_SHIP to REVIEW_BEFORE_SHIP

Real capture. @agenticcli/[email protected], 28 July 2026. The fixture goes back to its starting state, src/config/payments.ts is pointed at process.env.STRIPE_SECRET_KEY, and the same binary reads the tree again with nothing else touched. Fixture and manifest: content-pipeline/captures/gitlab-secret-replace-vs-rotate/.

That is the edit everybody makes, and it is worth making. What moved there is a tool's answer about a file. Nothing in it reached Stripe, so the key already sitting in the first commit, and in every clone taken since, opens exactly what it opened before. Only the issuer can end that. The issuer was never asked.

Is rewriting the history worth it?

OWASP's Secrets Management Cheat Sheet puts revocation and rotation ahead of any cleanup in the credential lifecycle, and is specific about what the cleanup costs:

Secrets in code could have commit history for the exposure squashed to before the introduction of the secret, however, this may introduce other problems as it rewrites git history and will break any other links to a given commit.

Two platform vendors and one standards body, none of them selling the same product, converge on the same order of operations. Rotate first, confirm the old value no longer authenticates, and only then treat the history as a separate question with its own cost.

Sometimes the answer to that question is yes: a public repository with a credential that cannot be rotated quickly is a real case for a rewrite. More often the rewrite is the part that feels like progress while the rotation is the part that ends the exposure.

Where does each check actually belong?

The question What answers it
Is there a credential in the file I just wrote? a scan of the working tree, before you commit
Is there one in what I am about to push? secret push protection, on Ultimate
Is there one in what already got pushed? pipeline secret detection, on the branch it runs on
Is there one anywhere in the history? a historic scan, once, after enabling the feature

A working-tree scan runs on a machine you already have, over files you already have, before anything leaves the building. It is also the only row of the four that can see a file git has never tracked, which is the cheapest coverage on the list by a distance.

It belongs beside a scan of a stack's own configuration rather than instead of the three below it, and it is worth running on the same reflex as a formatter. Whether your particular credential is even the kind a pattern matcher can grip is a separate question with its own experiment, where the answer turns out to depend on the variable name as much as on the value.

What this run does not show

Commit SHAs in those screenshots are not reproducible. The fixture rebuilds its history on every run, so the counts, the file paths and the commit-versus-working-tree distinction all survive a re-run while the seven-character SHAs do not, and the manifests record that.

We set the CI variables by hand rather than running a real GitLab pipeline. What is measured is therefore what the analyzer does with a given scope, not that GitLab always selects that scope; the mapping from branch type to scope is GitLab's documented behaviour and is quoted as such above.

Both rulesets sit on the machine that produced these captures, and both are countable. Neither count appears here.

A count would be stale on the next release of either one, and it would invite a comparison that tells a reader nothing about whether their own key gets caught. A green secret-detection job is a statement about an artifact and a scope, and knowing which pair produced yours is most of what makes the green mean anything.

FAQ

What is GitLab secret detection?
GitLab Secret Detection is the credential scanning built into GitLab, and it comes in more than one form. Pipeline secret detection runs as a CI/CD job and scans commits to the repository, and the secret detection overview page carries the tier line Free, Premium, Ultimate. Secret push protection runs when you push and blocks the push if it finds something, and its own page carries the tier line Ultimate. Client-side detection reads issue and merge-request text. The engine underneath the pipeline job is gitleaks, applying GitLab's own ruleset rather than the gitleaks default: the analyzer image states its own identity on every run, and the copy pulled on 28 July 2026 reported analyzer v7.38.0 running gitleaks 8.30.1 against GitLab's own secret detection ruleset at v0.25.0.
Does GitLab secret detection scan the whole git history?
Not by default. GitLab's own pipeline documentation states that by default it scans only the current state of the Git repository and that secrets contained in the repository history are not detected. A historic scan reads all commits and branches, and it is enabled by setting the SECRET_DETECTION_HISTORIC_SCAN variable to true; GitLab recommends running one once, immediately after switching the feature on. We measured the difference on 28 July 2026 against a purpose-built fixture: GitLab's own analyzer image returned 1 finding on the default-branch scope and 2 on the historic scope, on the same clone of the same repository, with the extra finding being a token that the most recent commit had deleted from its file.
Why did a GitLab secret detection job pass when a secret was in the repository?
Because a passing job means nothing was found in the scope that job was given, which is not the same as nothing being in the repository. GitLab documents a different scope per branch type: on the default branch the Git working tree is scanned by the version we ran (from analyzer v7.38.1 the documented default-branch scope is the commits since the prior commit, with the working tree as the fallback), and on a feature branch the content of all commits from the merge base to the latest commit is scanned. A commit range only shows what those commits added. We pointed GitLab's analyzer at a range whose single commit was a remediation, one that replaced a hardcoded token with an environment variable, and it returned 0 findings. That is the correct answer to the question it was asked and a misleading answer to the question you had in mind.
Is GitLab secret push protection available on the free tier?
No. GitLab's secret push protection documentation carries the tier line Ultimate, while the secret detection overview page carries Free, Premium, Ultimate. So the mechanism that stops a credential reaching the server is the one that needs the top plan, and the mechanism available on every plan runs after the push has already been accepted. Push protection also has documented limits worth knowing before relying on it. It scans only the diffs of commits pushed over HTTP(S) and SSH, and reading diffs rather than whole modified files became the default in GitLab 17.11. It is skipped when a push changes more than 3,150 paths or 350,000 lines, and a developer can skip it deliberately with a push option or a marker in a commit message.
Is deleting the line and committing enough to fix a leaked key?
No, and GitLab says so on its own documentation page: removed secrets also persist in the Git history. Anyone with the repository can read the earlier commit. Rewriting the history does not finish the job either. GitHub's guidance on removing sensitive data notes that a commit which exists in any fork stays accessible there, and OWASP's Secrets Management Cheat Sheet describes squashing exposure out of history as something that introduces its own problems by breaking links to commits. Both put revocation first. Rotate the credential, confirm the old value no longer authenticates, and treat the history cleanup as a separate and optional piece of work.
Should I run a secret scan locally as well as in GitLab?
Running one locally covers a window GitLab structurally cannot reach: a file that has not been committed yet exists on your disk and in nothing the server holds. We measured that directly on 28 July 2026. A GitLab personal access token sitting in an untracked file was named by gitleaks reading the disk and by nothing on the GitLab side, even with the analyzer set to its most thorough historic scope. The reverse also held: a token that had been deleted from its file was named by the GitLab side and by neither disk scan. Each covers what the other does not, so the useful arrangement is both rather than either.

────────[ ▮ gate ]────────

Don't ship the next one.

Free, local, no account. Catches this exact bug class before deploy.

$ npx @agenticcli/shipguard scan