Secret detection

Catch the committed key before main.

Intermediate10 min · lesson 9 of 17

It's Friday afternoon and a deploy keeps failing. A developer pastes a working AWS (Amazon Web Services) key into a local .env file, the plain text file of environment variables the app reads at startup, to get the build green. Then they run git add ., which quietly sweeps that .env into the commit. It goes up on a feature branch, they open a merge request, and they go home. That key now lives in your Git history. Scrapers crawl public repositories and find exposed AWS keys within minutes, and a private repo is only as private as every person who has read access to it. Secret detection is the smoke alarm for that key. It catches the credential while the merge request is still open, before anything reaches main, and it tells you the moment one slips past. Of every scanner you can bolt onto a pipeline, this is the one that pays back fastest, because a committed credential is never a hypothetical risk.

What the GitLab Secret Detection template actually does

Secret detection reads your commits, your diffs and, if you ask it to, the entire history of the repository, hunting for strings that look like credentials: API (application programming interface) keys, access tokens, private keys, database connection strings. GitLab's built-in analyzer is a wrapper around gitleaks, an open source scanner that flags a string on two separate signals. Airport screening works the same way. Screeners have a list of specific banned items, and they also pull aside any bag that looks oddly dense on the X-ray, whatever is inside it. Rules are the banned-items list: regular expression patterns tuned per provider. The aws-access-token rule, for example, matches the AKIA-prefixed 20-character key IDs that Amazon hands out. Entropy is the X-ray. It is a Shannon-entropy score, a single number that measures how random a string looks. English prose scores low. A 40-character jumble of upper case, lower case and digits scores high, which lets gitleaks flag credentials that no named rule has ever heard of. You pull the maintained job into your pipeline with one include:

.gitlab-ci.yml
include:
- template: Jobs/Secret-Detection.gitlab-ci.yml
stages: [test]
# Shift the scan into MR review: scan the diff on every merge request and on main.
secret_detection:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# One-off / scheduled full-history sweep for secrets committed before adoption.
secret_detection_historic:
extends: secret_detection
variables:
SECRET_DETECTION_HISTORIC_SCAN: "true"
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"

The template ships a job called secret_detection. Out of the box it runs on branch pipelines and looks only at new commits, the diff. That is fast, and it is enough to catch a key the moment someone adds one. The override above moves the finding into merge request review instead, using rules:if (use that, never the deprecated only/except keywords), so a human sees the problem while there is still time to act before merge. The second job flips SECRET_DETECTION_HISTORIC_SCAN to "true", which walks every commit the repository has ever held. Run it once on the day you adopt the tool, then on a schedule afterwards, because a long-lived repo almost always hides a forgotten key that the diff scan will never look at.

Reading what the job tells you

When the merge request pipeline runs, the job scans the diff, matches that AKIA string against the aws-access-token rule, and writes it up. Read the last line of the job log closely. The exit code is the single most misunderstood part of secret detection.

secret_detection job log
$ /analyzer run
[INFO] [secrets] [2025-11-03T14:22:07Z] ▶ GitLab secret detection analyzer v7.33.0
[INFO] [secrets] [2025-11-03T14:22:07Z] ▶ Detecting commit range for scan
[INFO] [secrets] [2025-11-03T14:22:08Z] ▶ Running analyzer on new commits only
[INFO] [secrets] [2025-11-03T14:22:09Z] ▶ gitleaks version 8.30.1
Finding: AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
Secret: AKIAIOSFODNN7EXAMPLE
RuleID: aws-access-token
Entropy: 3.646439
File: .env
Line: 4
Commit: 9f3c1a2b7e4d0c5a...
Author: Priya Dev <[email protected]>
Fingerprint: 9f3c1a2b:.env:aws-access-token:4
[INFO] [secrets] [2025-11-03T14:22:10Z] ▶ gitleaks found 1 leak
[INFO] [secrets] [2025-11-03T14:22:10Z] ▶ Secret Detection found 1 vulnerability
Uploading artifacts...
gl-secret-detection-report.json: found 1 matching artifact files
Job succeeded

Job succeeded. The job exited 0 while holding a live credential in its hands. That is deliberate. The analyzer *reports*; it does not fail your pipeline for you. The finding is written to gl-secret-detection-report.json, which the template declares through artifacts:reports:secret_detection, and GitLab parses that file into the security widget on the merge request. Treat that JSON (JavaScript Object Notation, a plain text data format) as your source of truth. The green checkmark only tells you the scan finished:

gl-secret-detection-report.json
{
"version": "15.2.4",
"scan": {
"analyzer": { "id": "gitleaks", "name": "Gitleaks", "version": "8.30.1" },
"scanner": { "id": "gitleaks", "name": "Gitleaks", "vendor": { "name": "GitLab" } },
"type": "secret_detection",
"status": "success"
},
"vulnerabilities": [
{
"id": "a4d1c7...e9",
"category": "secret_detection",
"name": "AWS access token",
"description": "AWS access token detected; rotate the credential and remove it from the repository.",
"severity": "Critical",
"location": {
"file": ".env",
"start_line": 4,
"commit": { "sha": "9f3c1a2b7e4d0c5a..." }
},
"identifiers": [
{ "type": "gitleaks_rule_id", "name": "aws-access-token", "value": "aws-access-token" }
]
}
]
}
How a committed AWS key flows through the pipeline
1Commit .env
AWS key added, pushed to feature branch
2MR pipeline
merge_request_event triggers secret_detection
3gitleaks scans diff
aws-access-token rule matches AKIA key
4Report artifact
gl-secret-detection-report.json → MR security widget
5Approval policy
Critical finding → merge blocked, approval required
6Rotate + remove
kill the key, reissue, then scrub the commit

Finding the key is the easy half. Rotating it is the job

Deleting the .env in a follow-up commit does not un-leak anything. Git history is a filing cabinet with no shredder. The credential still sits in every clone, every fork and the reflog (Git's local record of where each branch has pointed), recoverable by anyone who cares to look. Treat a committed secret as compromised from the second it was pushed, not from the moment somebody noticed. The order of operations is always rotate first, scrub second. Deactivate the leaked key, issue a replacement, store that replacement as a masked and protected CI/CD (continuous integration and continuous delivery) variable rather than back in the repository, and only then think about rewriting history to purge the blob, which is Git's stored copy of the file's contents.

terminal — rotate the leaked AWS key
# 1. Kill the leaked key first — it is compromised the moment it hits history.
$ aws iam update-access-key --access-key-id AKIAIOSFODNN7EXAMPLE \
--status Inactive --user-name ci-deploy
# 2. Mint a replacement.
$ aws iam create-access-key --user-name ci-deploy
{
"AccessKey": {
"UserName": "ci-deploy",
"AccessKeyId": "AKIA2NEWKEYEXAMPLE07",
"Status": "Active",
"SecretAccessKey": "K7Md...NEWSECRET"
}
}
# 3. Store the new secret as a masked + protected CI/CD variable — never in the repo.
$ glab variable set AWS_SECRET_ACCESS_KEY "$NEW_SECRET" --masked --protected --scope "*"
✓ Created variable AWS_SECRET_ACCESS_KEY for group/deploy-app
# 4. Delete the dead key once nothing references it.
$ aws iam delete-access-key --access-key-id AKIAIOSFODNN7EXAMPLE --user-name ci-deploy

To verify: the old key is inert now, even though those characters still sit in your history, so anyone replaying a scrape of the repo walks away with a dead credential. Re-run the historic scan pipeline to confirm nothing else is hiding, and watch the merge request security widget clear once the offending commit is gone. The two flags on the replacement are what keep this from happening twice. Masked stops the value being printed in job logs. Protected stops merge requests from forks, and any unprotected branch, from reading the value at all.

Reporting is not blocking, so close the loop

Because the job exits 0, nothing on its own stops the merge. To make a Critical secret finding actually block, add a merge request approval policy (this used to be called a scan result policy). It demands approval, or forbids the merge outright, when secret_detection reports a finding above the severity threshold you set. That is what turns "we logged it" into "you cannot merge it," and it gates on the evidence in the report rather than on a brittle exit-code 1 hack. Then push the same protection further left, toward the developer's keyboard. A gitleaks git --staged pre-commit hook stops the key on the laptop. GitLab's server-side Secret Push Protection (generally available since 17.5) rejects the push itself when it recognizes a token, so the secret never lands in history at all. Pipeline detection is your backstop, the approval policy is your gate, and those two are the doors before either. Layer all four.

A green pipeline is not a clean repo
Two defaults catch teams out. First, the template looks only at the diff, so every secret committed before you switched it on stays invisible until you run a SECRET_DETECTION_HISTORIC_SCAN: "true" sweep. A green merge request tells you nothing about the thousands of commits behind it. Second, secret_detection exits 0 even when it is staring at a live credential. The checkmark means the *scan ran*, not that it *passed*. Gate on gl-secret-detection-report.json through a merge request approval policy, never on the job's exit status, or a Critical AWS key sails straight into main.
Quick check
01Your pipeline includes the Secret Detection template. A developer commits an AWS key in a merge request, the report shows a Critical finding, and the merge button still works. What is going on?
Incorrect — No. The built-in aws-access-token rule matched the AKIA string, and the finding is sitting right there in the report and in the security widget.
Correct — The analyzer writes the finding to gl-secret-detection-report.json and exits 0. An approval policy, formerly the scan result policy, is what enforces the gate.
Incorrect — No. The log shows gl-secret-detection-report.json uploading and the widget filling in. The artifact exists; nothing is enforcing it by default.
Incorrect — No. Historic scan only widens coverage to old commits. It has no say in whether a finding on a new commit blocks the merge.
02GitLab's secret detection wraps gitleaks, and gitleaks flags a string on two independent signals. Which two?
Incorrect — No. Neither the filename nor the author's role enters into it. Gitleaks inspects the content of the string.
Incorrect — No. The scanner never contacts the provider. It cannot tell you whether a key works, only that it is shaped like one.
Correct — Rules catch known credential shapes, and entropy catches high-randomness strings that no named rule covers.
Incorrect — No. The Advisory Database is what dependency scanning reads. Secret detection matches credential patterns and entropy, not version ranges.
03You switched on the Secret Detection template last month and every merge request since has come back clean. A teammate swears a production database password went into the repo two years ago. How can both be true, and what do you run?
Incorrect — No. By default the job reads the diff only, so anything committed before you adopted the template was never examined.
Correct — A green merge request says nothing about pre-adoption commits. The historic scan walks the whole history and surfaces them.
Incorrect — No. Artifacts do not age secrets out of Git history, and re-uploading changes nothing about which commits were scanned.
Incorrect — No. allow_failure decides whether a failing job blocks the pipeline. It has nothing to do with which commits get scanned.

Secret detection watches for credentials *you* put into the repo. The next lesson, Dependency scanning (SCA, software composition analysis), points the same kind of scanner outward, at the known vulnerabilities riding inside the third-party packages you never wrote but pulled into the build anyway.

Try this

Run aws iam create-access-key --user-name ci-deploy on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a green pipeline is not a clean repo. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related