secretscan-mcp
by 0xmortuex
README.md
# secretscan-mcp
**Let your AI agent find secrets you committed by mistake, in your files and in your git history, without the secrets ever reaching the agent.**
<!-- mcp-name: io.github.0xmortuex/secretscan-mcp -->
> **What's different here.** Other secret-scanning MCP servers either call a vendor's cloud API
> ([GitGuardian's ggmcp](https://github.com/GitGuardian/ggmcp)), only look at the working tree
> ([mcp-secret-sentinel](https://github.com/AleBrito124356/mcp-secret-sentinel)), or are generic scanners.
> secretscan-mcp is **offline** (one dependency, `mcp`; it shells out to your own `git`), it **walks git history**
> and tells you which commit added and removed each secret, it ends with a **provider-specific rotation and
> clean-up checklist**, and its safety claim is **structural and tested**: no code path can return a raw secret,
> and a test launches the server over stdio and checks that no planted secret, or 16-character slice of one,
> appears in any tool output or error.
It finds API keys, tokens, private keys, passwords in URLs and `.env` mistakes, in the working tree and in every
commit you let it walk. Then it tells the agent what to do: rotate first, look for misuse, clean up, and (optionally,
as text only) how to rewrite history.
Companion to the tutorial [secretscan-mcp-the-hard-way](https://github.com/0xmortuex/secretscan-mcp-the-hard-way),
which builds a minimal version of this server from scratch.
## What it looks like
Real output (the secrets are fake values generated by the test suite):
```
> scan_tree("C:\code\demo")
Scanned 2 files (git repository: .gitignore respected; skipped 0 binary, 0 over 1 MB). 2 finding(s).
HIGH f-15ab4258b891b540 aws-access-key-id .env:1 AKIA... (20 chars)
HIGH f-b5d779ed381cbf95 github-token app/settings.py:2 ghp_... (40 chars)
> scan_history("C:\code\demo")
Scanned 3 commit(s). 3 distinct secret(s) found: 1 removed from the files but STILL IN HISTORY, 2 still present in the newest scanned commit.
REMOVED HIGH f-8d09a993cf035308 stripe-live-key old.py:1 sk_l... (36 chars)
added in d98048147385 (2026-11-06), removed in 8996cdd170d6 (2026-11-06)
PRESENT HIGH f-15ab4258b891b540 aws-access-key-id .env:1 AKIA... (20 chars)
added in e7274b88dc45 (2026-11-06)
> explain_finding("f-8d09a993cf035308")
Stripe live secret or restricted key (stripe-live-key, severity high)
How to rotate / revoke:
1. Dashboard > Developers > API keys > the key's menu > Roll key. Choose an expiry for the old key ...
Rewriting history does not un-leak a secret. Once a commit was pushed, assume it was copied ...
```
## Install
You need Python 3.10+ and `git` on your PATH.
**Claude Code**
```bash
claude mcp add secretscan -- uvx secretscan-mcp
```
**Claude Desktop** (add to `claude_desktop_config.json`)
```json
{
"mcpServers": {
"secretscan": {
"command": "uvx",
"args": ["secretscan-mcp"]
}
}
}
```
Or with pip: `pip install secretscan-mcp`, then run `secretscan-mcp`.
## Try asking
- *"Scan C:\code\myapp for committed secrets."*
- *"Has anything secret ever been committed to this repo, even if it was deleted later?"*
- *"Is my .env file ignored and untracked?"*
- *"I leaked a GitHub token in an old commit. What exactly do I do?"*
- *"Give me a remediation checklist for this repo, in order."*
- *"Show me what I'd put in .secretscan-ignore to accept the current findings."*
## Tools
| Tool | What it does |
|------|--------------|
| `scan_tree(path)` | Scan the files `git add .` would pick up (tracked plus untracked-not-ignored), so `.gitignore` is respected. Skips binaries, files over 1 MB, lockfiles, vendored directories; never follows symlinks. |
| `scan_history(path, max_commits=500, all_branches=False)` | Stream `git log -p`, report each distinct secret once with the commit that added it and the one that removed it. `REMOVED` = gone from the files, still in history. Says when older commits were not scanned. |
| `check_gitignore(path)` | Are `.env`, `*.pem`, `*.key`, `id_rsa`, `credentials.json`, `*.tfvars` ... ignored and untracked? Reports tracked ones (with `git rm --cached` as text), untracked-but-not-ignored ones, and ignore-rule gaps. |
| `explain_finding(id)` | What the credential is, step-by-step rotation at the provider (AWS, GitHub, GitLab, Slack, Stripe, Google, Anthropic, OpenAI, npm, PyPI, SendGrid, JWT, URL passwords, private keys), how to check for misuse, and why history rewriting does not un-leak it. |
| `suggest_remediation(path)` | Scans tree and history and returns an ordered checklist: rotate, check for misuse, remove from the tree, optionally rewrite history (`git filter-repo` and BFG commands as **text only**), prevent recurrence. |
| `make_baseline(path)` | The lines to put in `.secretscan-ignore` to accept current findings (fingerprints only). It does not write the file. |
### What it detects
AWS access key IDs and secret keys; GitHub (classic and fine-grained), GitLab, Slack (tokens and webhooks), Stripe (live
and test), Google API, Anthropic, OpenAI, npm, PyPI and SendGrid keys; JWTs (the header must decode as JSON); PEM
private key blocks; passwords embedded in URLs; and generic assignments such as `API_TOKEN = "..."` that pass entropy,
character-class, length and placeholder filters.
**False-positive control** is part of the design: hashes, UUIDs, base64 images, lorem ipsum, `os.environ[...]` and
`${VAR}` references, `password: str`, `changeme`-style placeholders, lockfile integrity hashes and identifier-like values
are all in the test corpus and must produce zero findings.
### The ignore file (baseline)
`<repo root>/.secretscan-ignore` holds one fingerprint per line, `#` starts a comment:
```
f-b5d779ed381cbf95 # test token in the docs, already revoked
```
A fingerprint is a 16-hex-character SHA-256 prefix of the rule and the value. It identifies a finding without
containing it. A line that is not a fingerprint is an error, and the error never echoes the line, in case someone
pasted a raw secret.
## Security
- **Findings are always redacted.** A finding is: type, file, line, commit, fingerprint, and the value as first 4
characters plus length. Secrets shorter than 16 characters show no characters at all. For provider tokens the 4
characters are the non-secret type marker (`AKIA`, `ghp_`).
- **No tool returns a raw secret.** `Finding` has no field that can hold one; the raw value exists only inside the
detector, long enough to compute the fingerprint and the preview. The source line is never printed.
- **A last line of defence.** Every tool result and every error message is re-checked against the high-confidence
rules. If anything matches (for example, a file *named* like a token), the tool refuses to return it. Error
messages never echo user input that could be a secret (unknown ids, ignore-file lines).
- **Read-only.** The server never writes a file, makes no network connection, and runs only `git ls-files`, `git log`,
`git check-ignore` and `git rev-parse`, as argument lists (never a shell). `core.fsmonitor` is switched off and
`--no-ext-diff --no-textconv` are used, so scanning an untrusted clone does not run its code.
- **Remediation is text.** `git filter-repo` and BFG commands are printed for you to run. History rewriting needs the
secret text, and the server will not print it, so the replacements file is yours to fill in.
- **Fingerprints** are truncated hashes of the value. For high-entropy tokens that is safe to share. A weak,
guessable password could in principle be confirmed by hashing guesses, so do not publish the ignore file for repos
that hold such values.
- The honest limit: **a leak is a leak.** Removing a secret from history does not make it safe. Rotate it.
## Limits
- Pattern-based. No finding does not prove there are no secrets; a finding is not proof of a live credential (nothing
is verified against the provider, because that needs the network).
- Generic detection needs a digit in the value and, for non-password names, 16+ characters. Pass-phrases made of words
only, and short passwords under a non-password name, are not detected. UUID-valued secrets are skipped by design.
- Lockfiles, minified files, images, `node_modules`, `vendor`, `.venv` and files over 1 MB are skipped, in the tree and in history.
- History status (`PRESENT`/`REMOVED`) is tracked per distinct value across the scanned window; a value moved between
files within one commit counts as unchanged. Only the newest `max_commits` are walked.
- `.gitignore` is applied through git, so a directory that is not a repository is scanned without it (the output says so).
## Tests
```bash
pip install ".[test]" "ruff==0.16.0" "mypy==2.3.0"
ruff check src tests && mypy --strict src
pytest tests
```
The tests build real git repositories in temp directories and plant fake secrets assembled at test time (the only
literal is AWS's documented dummy `AKIAIOSFODNN7EXAMPLE`). They cover detection for every rule, a corpus of benign
look-alikes, history detection of a secret added then deleted, moves, odd file names, `--all`, truncation, the
baseline, and a stdio test that launches the installed server, drives all six tools over MCP, and asserts that no
planted value or 16-character slice of one appears in any output or error.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues