Secret detection
Catch the committed key before main.
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:
include:- template: Jobs/Secret-Detection.gitlab-ci.ymlstages: [test]# Shift the scan into MR review: scan the diff on every merge request and on main.secret_detection:stage: testrules:- 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_detectionvariables: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.
$ /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.1Finding: AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLESecret: AKIAIOSFODNN7EXAMPLERuleID: aws-access-tokenEntropy: 3.646439File: .envLine: 4Commit: 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 vulnerabilityUploading artifacts...gl-secret-detection-report.json: found 1 matching artifact filesJob 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:
{"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" }]}]}
.envFinding 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.
# 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.
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.aws-access-token rule matched the AKIA string, and the finding is sitting right there in the report and in the security widget.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.