Open source · Go · zero dependencies

Secrets don't disappear when you delete them.

A secret scanner for the three places credentials actually hide: old git commits, Docker image layers, and your working tree. Regex for the keys you can name, Shannon entropy for the ones you can't.

$ go install github.com/SpenceChakabva/shhscan@latest
View source ↗
~/client-app — shhscan

The problem

A clean working tree
is not a clean repo.

You committed a key, noticed, and moved it to an environment variable. The file looks fine now. But git kept the old version, and anyone who clones the repo gets the whole history.

config.pycommit 4fa5ef02
+AWS_SECRET_ACCESS_KEY = "hT8kLm2pQvR9wXzA4bC7nE1dF6gH0jK3sL5uV8wY"
AWS_SECRET_ACCESS_KEY = "hT8kLm2pQvR9wXzA4bC7nE1dF6gH0jK3sL5uV8wY"
+AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET"]
The removal in a later commit changes nothing. The secret still lives in 4fa5ef02 — exactly where shhscan finds it.

Three sources

Look where secrets hide.

01 / GIT

Commit history

Walks every commit on every branch and scans the added lines of each diff — not just the files you have checked out today.

$ shhscan git .
02 / FILES

Filesystem

Recurses a directory tree, skips node_modules and binaries, and scans each text file line by line.

$ shhscan fs ./src
03 / IMAGE

Docker layers

Unpacks a docker save tarball and scans inside each layer — including a key baked into an early layer and "removed" later.

$ shhscan docker image.tar

How detection works

Two detectors, one pass.

Regex catches secrets with a recognisable shape. Entropy catches the shapeless rest. You need both.

regex rules

The keys you can name

High-signal patterns anchored to provider-specific prefixes, so they rarely fire on ordinary text. An AWS key starts with AKIA; a GitHub token with ghp_.

AWSGitHubGitLabStripeGoogle SlackSendGridTwilionpmJWTprivate keys
shannon entropy

The keys you can't

No regex matches a random 40-character secret with no prefix. So measure its randomness instead — bits per character:

H = −Σ p(x)·log2 p(x)

Flag base64 runs above 4.5 and hex runs above 3.0 bits/char. Try it on real strings:

The pipeline

From source to exit code.

SOURCES
git historyevery commit
filesystemtree walk
docker layerssave tarball
SCANNER
regex
entropy
allowlist · drop UUIDs, hashes, EXAMPLE
de-duplicate · report once
redact · ghp_••••••3bC6
OUTPUT
human reportgrouped findings
--jsonfor CI
0clean
1found

Drop-in for CI

A non-zero exit is the whole point.

No config, no server, no parsing. Findings fail the build. Add it to a pipeline or a pre-commit hook in one line.

.github/workflows/ci.yml
# fail the build if any secret is in the history
- name: Scan for secrets
  run: |
    go install github.com/SpenceChakabva/shhscan@latest
    shhscan git . # exits 1 on a finding → build fails

Honest limitations

What I learned building it.

Rebuilding a scanner from scratch — instead of running gitleaks — was the point. You only understand a detector's blind spots once you've written one.

01

No live verification

shhscan says a string looks like a secret. trufflehog goes further and calls the provider to check it's still live. That's the higher bar — and the obvious next iteration.

02

Entropy is blunt

Randomness flags real secrets and every UUID and content hash alike. The allowlist does the real work; newer tools are already moving past raw Shannon entropy.

03

Line-oriented

A key split across lines or built at runtime from concatenated strings walks straight past a line scanner. Knowing that is the difference between a control and theatre.