Secret Scanning
Secret scanning finds API keys, tokens, and private keys in code, git history, logs, and packages so they can be revoked before attackers use them.
Also known as secrets scanning, secrets detection, secret detection, credential scanning, leaked secrets detection
Secret scanning, also called secrets detection, is automated search for credentials such as API keys, access tokens, and private keys in source code, git history, build logs, and published packages. Scanners match known token formats, filter by checksum and entropy, and often test each hit against the provider's API, so the credential can be revoked before an attacker finds it.
The detection step is mostly solved for modern tokens. What decides the outcome is who runs the scan first. In September 2025 the Shai-Hulud worm, spread through the @ctrl/tinycolor compromise, downloaded TruffleHog, a legitimate open source secret scanner, and ran it on every machine that installed an infected package. Our position follows from that: secret scanning is a race against people with the same tools, so the metric that matters is time to revocation, and the only durable fix is a credential that is worthless by the time anyone finds it.
What is secret scanning?
#Secret scanning is the practice of finding bearer credentials in places where they should not be stored, then revoking them. A bearer credential grants access to whoever holds it. API tokens, cloud access keys, database connection strings, SSH private keys, and package registry tokens all work this way. There is no second factor between possession and use.
Credentials leak because code needs them at runtime and developers put them where the code is. The common resting places are source files, .env files, git history, issue and pull request comments, CI logs, container image layers, package tarballs, and, increasingly, AI coding tool configuration such as .claude/settings.json, which the Mini Shai-Hulud payload explicitly harvests.
The volume is large and growing. GitGuardian counted 28.65 million new hardcoded secrets in public GitHub commits in 2025, a 34% increase over 2024. GitHub itself detected more than 1 million leaked secrets in public repositories in the first eight weeks of 2024. The same GitGuardian report found that more than 64% of secrets confirmed valid in 2022 were still valid in January 2026.
A leaked credential stays dangerous until someone revokes it, not until someone deletes it. That one fact shapes everything below.
How do secret scanners detect a credential?
#Secret scanners detect a credential in up to four stages: a format match, a checksum test, an entropy or context filter, and live verification against the issuing service. Each stage exists to throw away false positives cheaply before the next, more expensive stage runs.
Stage 1: format
#The cheapest test is a regular expression for a known token shape. Here are the default rules for GitHub personal access tokens, npm tokens, and AWS access key IDs in Gitleaks, as of its October 23, 2026 commit, with entropy and allowlist fields omitted:
[[rules]]
id = "github-pat"
regex = '''ghp_[0-9a-zA-Z]{36}'''
keywords = ["ghp_"]
[[rules]]
id = "npm-access-token"
regex = '''(?i)\b(npm_[a-z0-9]{36})(?:[\x60'"\s;]|\\[nr]|$)'''
keywords = ["npm_"]
[[rules]]
id = "aws-access-token"
regex = '''\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b'''The keywords field is a prefilter. The scanner only runs the regex on chunks that contain the literal string, which is why fixed prefixes make scanning fast as well as accurate.
Prefixes were a deliberate design change. Before April 2021, a GitHub personal access token was 40 lowercase hex characters. That is exactly the shape of a SHA-1 hash, so every git commit ID in every log file looked like a token. TruffleHog's detector for the legacy format still shows the workaround: it only fires when the hex string sits next to a keyword like github_token or gh_pat, and it discards candidates with Shannon entropy below 3.5.
keyPat = regexp.MustCompile(
detectors.PrefixRegex([]string{"github_token", "github_secret", "gh_token", "gh_pat" /* … */}) +
`\b([0-9a-f]{40})\b`,
)A token without a distinctive shape can only be found by guessing from the variable name next to it.
Stage 2: checksum
#GitHub's April 2021 token format added a prefix (ghp_, gho_, ghu_, ghs_, ghr_), an underscore separator, and a CRC32 checksum in the last six characters, encoded in base62. "Token prefixes are a clear way to make tokens identifiable," Indigo K wrote in GitHub's announcement. GitHub expected the new format to bring its secret scanning false positive rate down to 0.5%. npm adopted the same layout for npm_ tokens in September 2021.
The checksum lets a scanner reject a random 40-character string that happens to start with ghp_ without making a network request. It is evidence that the string came from a token generator rather than from chance. It is not a security property: anyone can compute a CRC32, as our demo does.
Stage 3: entropy and context
#Generic secrets, such as a password or a vendor key with no prefix, have no format to match. Scanners fall back on context, like a variable named secret or password, and on Shannon entropy, a measure of how random the characters look. A human-chosen password such as changeme_changeme_changeme scores low. A machine-generated key scores high. This stage produces most of the noise in any secret scanner, because test fixtures, hashes, and base64-encoded images also score high.
We wrote a 30-line scanner that implements the first three stages, to make the trade-offs concrete. The checksum function follows the layout GitHub published, with the base62 alphabet ordered as in the open source CredSweeper implementation. The tokens are fakes we generated with valid checksums, and each says NotReal in its body.
import math, re, sys, zlib
B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def b62(n, width=6):
s = ""
while n: n, r = divmod(n, 62); s = B62[r] + s
return s.rjust(width, "0")
def checksum_ok(tok): # ghp_ / npm_: 30 random chars + 6-char base62 CRC32
body, check = tok[4:-6], tok[-6:]
return b62(zlib.crc32(body.encode())) == check
def entropy(s):
return -sum(s.count(c)/len(s) * math.log2(s.count(c)/len(s)) for c in set(s))
RULES = [
("github-pat", re.compile(r"\bghp_[0-9A-Za-z]{36}\b"), checksum_ok),
("npm-token", re.compile(r"\bnpm_[0-9A-Za-z]{36}\b"), checksum_ok),
("aws-key-id", re.compile(r"\b(?:AKIA|ASIA)[A-Z2-7]{16}\b"), None),
("generic", re.compile(r"(?i)(?:secret|token|passw(?:or)?d)\w*\s*[:=]\s*['\"]([^'\"]{16,})['\"]"), None),
]
for path in sys.argv[1:]:
for n, line in enumerate(open(path), 1):
hit = False
for name, rx, check in RULES:
if name == "generic" and hit: continue
for m in rx.finditer(line):
hit = True
s = m.group(m.lastindex or 0)
if check and not check(s):
print(f"{path}:{n} {name:10} DROP checksum mismatch"); continue
if name == "generic" and entropy(s) < 3.5:
print(f"{path}:{n} {name:10} DROP entropy {entropy(s):.2f} < 3.5"); continue
print(f"{path}:{n} {name:10} KEEP {s[:12]}…")const gh = "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";
const typo = "ghp_SocketGlossaryExampleNotRealGX2uLTqh";
const npmToken = "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";
const awsKeyId = "AKIAIOSFODNN7EXAMPLE";
const password = "changeme_changeme_changeme";
const apiSecret = "q8Zr2vN0pXw7LmT4yB1sK9cJ";$ python3 scan.py config.js
config.js:1 github-pat KEEP ghp_SocketGl…
config.js:2 github-pat DROP checksum mismatch
config.js:3 npm-token KEEP npm_SocketGl…
config.js:4 aws-key-id KEEP AKIAIOSFODNN…
config.js:5 generic DROP entropy 2.93 < 3.5
config.js:6 generic KEEP q8Zr2vN0pXw7…Line 2 differs from line 1 by one character, and the checksum catches it. Line 4 is the example key from AWS's own documentation. An AWS access key ID has no checksum, so nothing offline separates the documentation example from a live key. Gitleaks' AWS rule allowlists any match ending in EXAMPLE for exactly this reason. Line 6 survives only because it looks random, which is also true of a SHA-256 test fixture.
Stage 4: verification
#Verification sends the candidate to the issuing service and asks whether it works. TruffleHog's GitHub detector calls GET https://api.github.com/user with the token. Its AWS detector calls STS GetCallerIdentity, which returns the account and principal without changing anything. A verified result is close to certain. An unverified result means the provider rejected the credential, and an unknown result means the check could not complete.
Verification has two costs. It needs both halves of a split credential: an AWS key ID authenticates nothing without its 40-character secret access key. It also makes a real authenticated request, which appears in the provider's logs and is available to anyone holding the string, including an attacker.
Figure 1 shows a candidate string passing through four gates. Gate 1, format, applies a prefix keyword and regex such as ghp_[0-9A-Za-z]{36}; it runs locally and rejects almost all of the text in a repository. Gate 2, checksum, recomputes the CRC32 over the 30-character body and compares it with the last six base62 characters; it is also local and it rejects typos and random lookalikes such as line 2 of our demo. Gate 3, entropy and context, applies only to generic secrets with no fixed format; it rejects low-randomness strings such as changeme_changeme_changeme at 2.93 bits per character, and it is the main source of false positives. Gate 4, verification, makes one authenticated API call per candidate, for example GET /user for GitHub or STS GetCallerIdentity for AWS, and sorts survivors into verified, unverified, or unknown. Only gate 4 tells you a credential is live, and only gate 4 touches the network.
How does GitHub secret scanning work?
#GitHub secret scanning applies the same ideas server-side, across everything GitHub hosts for a repository. According to GitHub's documentation, it scans the entire git history on all branches, plus issue descriptions and comments, pull request titles and comments, Discussions, wikis, and secret gists, and it rescans when new secret types are added.
GitHub secret scanning has three distinct mechanisms:
- Partner alerts. For providers in GitHub's partner program, a match is sent directly to the provider, which can revoke it. Partner secrets "are reported directly to the provider and aren't displayed in your repository alerts." npm has revoked tokens automatically when it finds them in published packages since January 2019, and later extended that to tokens pushed to GitHub.
- User alerts. Matches appear on the repository's security tab, optionally with a validity check that contacts the issuer.
- Push protection. Supported secrets are blocked at
git push, before they enter history. GitHub turned push protection on by default for all user accounts in early 2024, and at the time it detected more than 200 token types from more than 180 providers.
As of October 2026, scanning is free for public repositories. Private and internal repositories owned by an organization need the paid GitHub Secret Protection product.
The boundary is the important part. GitHub secret scanning sees what is pushed to GitHub. It does not see the tarball npm publish builds from your working directory, a CI log stored in another system, a container image pushed to a registry, or the ~/.npmrc on a developer laptop.
Deleted secrets stay in history and in packages
#Deleting a secret from a file removes it from the working tree, not from the repository. Git stores every committed version of every file as an immutable object, and a later commit that deletes a line only adds a new version. Continuing the demo:
$ git log --format='%h %s'
0d580bf remove hardcoded token
93c7e47 add config
$ python3 scan.py config.js
$ git log -p --all | python3 scan.py /dev/stdin
/dev/stdin:12 github-pat KEEP ghp_SocketGl…
/dev/stdin:13 github-pat DROP checksum mismatch
/dev/stdin:14 npm-token KEEP npm_SocketGl…
…
/dev/stdin:32 github-pat KEEP ghp_SocketGl…
/dev/stdin:33 github-pat DROP checksum mismatch
/dev/stdin:34 npm-token KEEP npm_SocketGl…
…The working tree scan finds nothing. The history scan finds each string twice: once in the diff that removed it, and once in the commit that added it. A scanner that reads only the checked-out files reports the repository clean. TruffleHog's filesystem mode is built for directories; its separate git mode exists to walk history.
Attackers know this. In October 2026, GhostAction committed a workflow named security-audit.yml into 346 repositories from two compromised accounts. The new half of its payload checks out with fetch-depth: 0, which fetches every branch and tag, then pipes git log -p --all through 13 regular expressions for cloud and AI service credentials. For every AWS key ID it finds, it also captures the surrounding lines, because the matching secret access key usually sits one or two lines away. By October 9, we had identified more than 500 accounts that had committed the workflow.
Packages are a second, separate copy. npm builds a tarball from your directory, not from git, and its ignore rules have a trap: if a .npmignore file exists, npm stops reading .gitignore. On npm 12.2.1:
$ cat .gitignore
.env
node_modules
$ npm pack --dry-run
npm notice Tarball Contents
npm notice 19B index.js
npm notice 66B package.json
npm notice total files: 2
$ echo 'test/' > .npmignore
$ npm pack --dry-run
npm notice Tarball Contents
npm notice 51B .env
npm notice 19B index.js
npm notice 66B package.json
npm notice total files: 3Adding a one-line .npmignore to exclude tests silently publishes the .env file that .gitignore was keeping out. npm always excludes .npmrc, according to its package.json documentation, but it has no such rule for .env. The file never touches git, so no repository scanner will ever see it. npm has scanned published packages for its own tokens since 2019, which covers the npm token in this file but not an AWS key or a database password next to it. An allowlist in the files field of package.json is safer than any ignore file, because it fails closed.
Attackers run the same scanner
#The Shai-Hulud worm treated secret scanning as its reconnaissance stage. In the September 2025 wave, a bundle.js payload fetched a TruffleHog binary for the host platform, searched the filesystem for tokens and cloud credentials, validated stolen npm tokens against npm's whoami endpoint, and planted a GitHub Actions workflow that posted ${{ toJSON(secrets) }} to a webhook. The November 2025 wave, Sha1-Hulud: The Second Coming, cached the binary in ~/.truffler-cache and ran this:
trufflehog filesystem $HOME --jsonSocket Basics, our CI security scanner, runs secret scanning with the same tool. This is the command it builds, from its public source:
trufflehog filesystem --json --include-detectors=all --fail-on-scan-errors \
--results=verified,unknown <workspace files>Defender: Socket Basics in CI
trufflehog filesystem --json --include-detectors=all --results=verified,unknown <workspace files>
- Runs on
- A CI runner, per pull request
- Reads
- Committed or changed files
- Verifies
- Yes
- Results go to
- A PR comment or alert
- Next step
- Revoke the credential
Attacker: Sha1-Hulud payload
trufflehog filesystem $HOME --json
- Runs on
- Every machine that installed the package
- Reads
- The whole home directory, including ~/.npmrc, cloud credentials, and SSH keys
- Verifies
- Yes, TruffleHog verifies by default
- Results go to
- A public repo on the victim's account
- Next step
- Republish the victim's packages
Same binary, same subcommand, same verification. The only difference is the directory and who reads the output.
Figure 2 puts the two invocations side by side, and the table below carries the same comparison.
| Aspect | Defender (Socket Basics in CI) | Attacker (Sha1-Hulud payload) |
|---|---|---|
| Command | trufflehog filesystem --json --include-detectors=all --results=verified,unknown | trufflehog filesystem $HOME --json |
| Runs on | A CI runner, against the workspace or changed files | Every developer machine and CI runner that installed the package |
| Reads | Files committed or about to be committed | The whole home directory, including ~/.npmrc, cloud credentials, and SSH keys |
| Verification | Yes, results limited to verified and unknown | Yes, TruffleHog verifies by default |
| Output goes to | A pull request comment or alert | A public GitHub repository on the victim's own account |
| Next step | Revoke the credential | Republish the victim's packages with the worm |
The difference is not the tool. It is where the scan runs. A repository scanner looks for secrets that should not be in code. The worm looks for secrets that are supposed to be on the machine: the npm token in ~/.npmrc is stored exactly where npm expects it, and it is invisible to every repository and push-time control. Repository secret scanning catches accidental disclosure. It does nothing against code that runs inside your trust boundary with your permissions.
The scale of that second problem is now measurable. After the August 2026 arrests of two alleged members, the Australian Federal Police estimated that TeamPCP, which ran the 2026 Shai-Hulud waves, enabled the theft of more than 500,000 credentials. Security scanners are targets too. TeamPCP has been linked to the compromises of the Trivy GitHub Action and of Checkmarx KICS, tools that run inside CI with broad access to secrets.
Code running inside your trust boundary is a malware problem, and it is solved before install, not after. Credential theft by a dependency is an infostealer running with your privileges. npm v12 now ships with install scripts off by default, which closes the lifecycle hook path both 2025 Shai-Hulud waves used: a postinstall script in September and a preinstall script in November. Code that runs at require() time can still read process.env. Socket's Environment variable access alert flags packages whose code reads process.env, os.environ, and their equivalents, so a reviewer can ask why a color library needs NPM_TOKEN before it is installed. Most packages that read environment variables are benign, so the alert is a signal for review, not a verdict.
Token formats are a contract with scanners
#Every secret scanner depends on the issuer keeping its token format stable, and issuers change formats. On April 24, 2026, GitHub announced that GitHub App installation tokens (ghs_) would move to a variable-length ghs_APPID_JWT format, about 520 characters long instead of 40. GitHub's guidance was direct: "treat tokens as opaque strings and avoid validating them against hardcoded patterns."
Composer had validated exactly that way. In the Composer token leak, the new format reached GitHub Actions, Composer rejected the token, and Composer printed it in full to stderr, which landed in Actions logs. Packagist urged an immediate update to Composer 2.9.8 or 2.2.28 LTS on May 13, 2026, and GitHub temporarily rolled the format back. GitHub completed the rollout on October 2, 2026, and will ignore the opt-out header after November 30, 2026. Its migration checklist explicitly names "logging and secret redaction rules that only match the legacy token pattern."
Secret scanners are redaction rules with a different output. We built a synthetic token in the documented ghs_APPID_JWT shape, with a 6-digit app ID and an RS256-sized JWT, 526 characters in total, and ran the default GitHub App token rules from Gitleaks and TruffleHog against it, using Go's standard regexp package, which implements the RE2 syntax both tools use:
package main
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
)
func main() {
b := base64.RawURLEncoding
hdr := b.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
pl := b.EncodeToString([]byte(`{"iss":"123456","inst":"98765432","exp":1792860000,"note":"socket-glossary-example-not-a-real-token"}`))
sig := b.EncodeToString([]byte(strings.Repeat("\xfb\xff", 128)))
tok := "ghs_123456_" + hdr + "." + pl + "." + sig
fmt.Printf("token length: %d\n\n", len(tok))
rules := map[string]string{
"gitleaks github-app-token (Oct 2026)": `(?:ghu|ghs)_[0-9a-zA-Z]{36}`,
"trufflehog github v2 (Oct 2026)": `\b((?:ghp|gho|ghu|ghs|ghr|github_pat)_[a-zA-Z0-9_]{36,255})\b`,
}
for _, name := range []string{"gitleaks github-app-token (Oct 2026)", "trufflehog github v2 (Oct 2026)"} {
m := regexp.MustCompile(rules[name]).FindString(tok)
if m == "" {
fmt.Printf("%-38s no match\n", name)
} else {
fmt.Printf("%-38s matched %d of %d chars, ends %q\n", name, len(m), len(tok), m[len(m)-8:])
}
}
}$ go run main.go
token length: 526
gitleaks github-app-token (Oct 2026) no match
trufflehog github v2 (Oct 2026) matched 47 of 526 chars, ends "IkpXVCJ9"Gitleaks' rule, (?:ghu|ghs)_[0-9a-zA-Z]{36}, fails because the app ID is followed by an underscore. TruffleHog's rule allows underscores but stops at the first . of the JWT and caps the match at 255 characters, so it captures the prefix and the JWT header, which can never verify. A truncated token comes back unverified, and a scanner configured to report only verified and unknown results, as Socket Basics is by default, will not surface it. Neither result is a finding a person would act on. Installation tokens expire within an hour, which limits the damage from this particular gap. The general point stands: when an issuer changes a token format, scanners, log maskers, and validation code are blind until someone updates a regular expression.
Opaque tokens are the right advice for applications and the wrong advice for detection. The two goals conflict, and issuers should resolve it on their side. A token format change should ship with a published detection pattern and a test vector before the rollout begins, the same way GitHub published the prefix scheme in 2021.
What should you do when a scanner finds a secret?
#When a scanner finds a secret, revoke the credential first, then investigate. Removing the string from code, rewriting git history, or deleting a log does not make a leaked credential safe, and GitHub's documentation calls history rewriting optional once the credential is revoked. After revocation, read the provider's audit logs for the exposure window: CloudTrail for AWS, the npm package version history, the GitHub security log.
Rotation has to be atomic. Before the second Trivy compromise in March 2026, Trivy's maintainers had rotated credentials after a first breach, but the rotation was not fully atomic, and the attacker kept access to newly issued credentials. That residual access let them force-push 75 of 76 version tags of aquasecurity/trivy-action. Revoke every credential the compromised environment could reach in one step, before issuing replacements, or the replacements are compromised too.
Where you place the scanner decides what it can catch.
| Placement | Catches | Misses | Cost |
|---|---|---|---|
| Pre-commit hook on the developer machine | Secrets before they enter local history | Anything when the hook is skipped with --no-verify or not installed | Fast, but opt-in per clone |
| Push protection on the git host | Supported, high-confidence token types before they reach the remote | Generic secrets, unsupported formats, anything not pushed to that host | Free for public GitHub repos; bypassable by the pusher |
| Pull request scan in CI | New secrets in changed files, with verification | History, package tarballs, logs | Minutes per PR; needs triage of unverified hits |
| One-time full history scan | Secrets buried in old commits, branches, and tags | New leaks after the scan | Slow on large repos; findings are all old and need revocation |
| Partner revocation by the issuer | Supported tokens in public places, revoked automatically | Private repos, other hosts, generic secrets | None to you |
| Short-lived, scoped credentials | Limits the value of every leak above | Theft during the credential's lifetime | Engineering work to remove long-lived tokens |
The verdict: run push protection and a pull request scan everywhere, run one full history scan per repository and revoke what it finds, and spend the remaining effort on the last row. Detection reduces the time a credential is exposed. Only short lifetimes reduce what a stolen credential is worth.
npm has moved in that direction. Trusted publishing replaces long-lived npm tokens in CI with short-lived OIDC credentials, and in npm's May 2026 token invalidation, npm revoked every granular access token with write access that bypassed two-factor authentication, effective May 19. Short-lived credentials still have to exist somewhere for a few minutes, and that is long enough. In May 2026 the attackers behind the TanStack compromise took no long-lived token at all. They extracted an OIDC token from the GitHub Actions runner's process memory and published through TanStack's own trusted publisher binding. Short lifetimes shrink the window. They do not close it, which is why CI/CD security and secret management are separate disciplines from finding strings in files.
The open problem is that some of the most effective secret scanning of 2026 did not read files at all. The Trivy payload read /proc/<pid>/mem of the Runner.Worker process and searched the raw memory for "isSecret":true, the marker GitHub Actions uses for secrets in its own heap. That is secret scanning against RAM, run by the attacker, with no defender-side equivalent. A secret that lives in memory long enough to be used lives long enough to be stolen. The defensible goal is not zero leaked secrets. It is a world where each leaked secret authorizes as little as possible, for as short a time as possible.
