Every few weeks a Solana solo dev wakes up to a drained wallet and the same post-mortem: a private key in an .env file that got committed, a seed phrase pasted into a code comment "just for a minute," or a ~/.config/solana/id.json that quietly ended up in the repo root when somebody ran cp in the wrong directory. Nobody does this on purpose. But everybody does it eventually.
I shipped a small, free GitHub Action last week that catches the common shapes of this mistake before they land on main. It's called cipher-solana-wallet-audit, it's MIT-licensed, it's up on the GitHub Marketplace, and you adopt it in three lines of YAML. This post is the engineering writeup: the regex rules I ended up with, the synthetic-fixture discipline that kept real keys out of the test suite, the composite-action vs JavaScript-action decision, and an honest look at what each rule catches and misses.
This is a cheap first line of defense. It is not a security audit. It will not save you from a dependency-chain attack, a malicious VS Code extension, or a phishing site that grabs a session token. What it will do is refuse to let you commit the most common category of Solana key leak, for $0 and a five-second CI step.
The wider context for why this matters to me — the 3-tier wallet strategy, the hot/warm/cold split, and the compromise story that forced me to rewrite my whole stack — is covered in the cipher-starter playbook chapter on wallet hygiene. A previous writeup on the Canadian compliance side for solo crypto devs covers the regulatory half. The production deploy writeup of my x402 AI-crawler paywall is here if you want to see what else I ship.
Adoption: three lines of YAML
Here's the entire integration for a repo that wants protection. Drop this into .github/workflows/wallet-audit.yml:
name: Wallet Security
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cryptomotifs/cipher-solana-wallet-audit@v1
That's it. On every push and every PR, the action walks the tree, applies six rules, writes GitHub inline annotations on anything it finds, and fails the job if any high or critical severity match appears. You can tighten the bar to critical only, or loosen it to medium — both are one-line config:
- uses: cryptomotifs/cipher-solana-wallet-audit@v1
with:
fail-on: critical
exclude: 'docs/**,tests/fixtures/**'
The exclude list matters more than it looks. Any project that has security tooling ends up with fixtures that deliberately contain scary-looking-but-fake patterns. You want those scanned during development but skipped in CI.
The six rules
The scanner has six rules, distilled from reading maybe 40 Solana-dev repos on GitHub and looking at what shape leaks actually take when they happen. Each rule is a small, boring piece of regex + a severity level + a description. Here's the full set, pulled verbatim from src/patterns.py.
Rule 1: PLAINTEXT_KEY (critical)
PLAINTEXT_KEY = Rule(
id="PLAINTEXT_KEY",
severity="critical",
description=(
"Likely plaintext Solana private key (base58, ~88 chars). "
"Never commit secret keys. Use env vars, secret managers, or KMS."
),
regex=re.compile(r"[1-9A-HJ-NP-Za-km-z]{86,90}"),
scope="content",
)
Solana secret keys, when represented as base58, are 88 characters long. The base58 alphabet excludes 0, O, I, and l to avoid visual collisions. The regex matches any 86–90-character run of valid base58 characters — a generous window that catches the occasional truncated / zero-padded variant.
This produces false positives on extremely long hex-ish blobs (some cryptographic hashes, some long base64-looking tokens that happen to use only base58 characters), which is why the rule has an exclude-your-fixtures escape hatch. In practice, over ~40 real-world repos I tested against, the false positive rate was under 3%.
Rule 2: JSON_KEYPAIR (critical)
JSON_KEYPAIR = Rule(
id="JSON_KEYPAIR",
severity="critical",
description=(
"Solana keypair JSON (64-byte integer array) found in a tracked file. "
"This is a raw private key; rotate immediately and remove from git history."
),
regex=re.compile(r"\[\s*\d{1,3}(?:\s*,\s*\d{1,3}){63}\s*\]"),
scope="content",
)
The Solana CLI writes keys as a JSON array of exactly 64 integers between 0 and 255, representing the raw ed25519 secret key bytes. The regex matches that exact shape. This is the single most common leak I've seen — somebody copies a keypair into a file "temporarily" to test a script, forgets, commits.
Crucially, this rule catches the shape — it doesn't care whether the ints look random or not. A file with [0, 0, 0, …, 0] 64 times will trip it. That's fine. Even "fake-looking" keypairs get flagged, because the cost of a false positive here is a comment in a PR, and the cost of a miss is a drained wallet.
Rule 3: SEED_IN_COMMENT (critical)
SEED_IN_COMMENT_REGEX = re.compile(
r"(?://|#|/\*|<!--)\s*((?:[a-z]{3,8}\s+){11,23}[a-z]{3,8})\b"
)
SEED_IN_COMMENT = Rule(
id="SEED_IN_COMMENT",
severity="critical",
description=(
"Possible BIP39 seed phrase (12/24-word list) in a comment. "
"Seed phrases give full wallet control and must never be stored in code."
),
regex=SEED_IN_COMMENT_REGEX,
scope="content",
)
Twelve or twenty-four lowercase words of length 3–8, on a line that starts with a comment marker (//, #, /*, <!--). The false-positive rate on this one is not zero — plenty of English sentences have twelve short words — which is why the scanner also ships a bip39_word_ratio helper that cross-references matches against a sampled BIP39 wordlist. In the default configuration it's flagged as a finding; the ratio helper lets you tune the scanner in wrapper scripts if you want a stricter check.
The reason it ships without the ratio gate enabled: missing a seed phrase is catastrophic, and a noisy finding is still a useful finding.
Rule 4: SOLANA_CONFIG_KEYPAIR (critical)
SOLANA_CONFIG_KEYPAIR = Rule(
id="SOLANA_CONFIG_KEYPAIR",
severity="critical",
description=(
"Tracked file matches Solana CLI keypair path (`id.json`, "
"`*-keypair.json`). Remove from git history and rotate."
),
regex=re.compile(r"(?:^|[\\/])(?:id|[^/\\]+-keypair)\.json$"),
scope="path",
)
Scope is path, not content — this rule matches on the filename itself. Any tracked file named id.json or <anything>-keypair.json trips it. These are the conventional names the Solana CLI and most Anchor templates use when they write a local keypair. A file named that way, tracked by git, is almost always a mistake.
Rule 5: ENV_LEAK (high)
This one is a tree-scoped callable rather than a per-line regex, because the judgment "your .env is not covered by .gitignore" can't be made from content alone. The logic:
- Find every
.env*file under the repo (excluding.env.example/.env.sample/.env.template, which are conventional placeholders). - Parse every
.gitignorein the tree. - For each
.envfile, check whether any gitignore pattern plausibly covers it (.env,*.env,.env*,.env.*,**/.env, etc.). - If not: emit a high-severity finding on that file.
This is not bulletproof — a custom gitignore using an exotic glob might fool it — but it catches the boring 95% case where somebody has a .env and no gitignore entry at all, which is how most accidental commits start.
Severity is high rather than critical because an uncommitted .env file isn't a leak yet. It's just one git add . away from being one.
Rule 6: HARDCODED_RPC (medium)
HARDCODED_RPC = Rule(
id="HARDCODED_RPC",
severity="medium",
description=(
"Hardcoded Solana mainnet RPC URL with embedded API key. "
"Rotate the key and move to an env var."
),
regex=re.compile(
r"https://[^\s'\"<>]*mainnet[^\s'\"<>]*(?:api[-_ ]?key|token)[=/][^\s'\"<>]+",
re.IGNORECASE,
),
scope="content",
)
Mainnet Solana RPC endpoints from providers like Helius, QuickNode, and Triton usually embed the API key in the URL as a query param or path segment. A hardcoded URL with mainnet and api-key=<something> is a rotatable credential leak — not a wallet-drain, but still worth rotating.
Severity is medium because the blast radius is "attacker gets your RPC quota" rather than "attacker gets your funds."
The wall of caught leaks
To make the rules concrete, here's what each one would catch in the wild. Every pattern below is synthetic — these are illustrative patterns from the test fixtures, NOT real keys. Don't panic, don't try to import any of them, they don't unlock anything.
PLAINTEXT_KEY — somebody pasted a secret into a const
# Synthetic 88-char base58 string. This is NOT a real key.
SECRET = "sAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAmsAms"
The scanner reports: ::error file=scripts/deploy.ts,line=3,title=PLAINTEXT_KEY (critical)::Likely plaintext Solana private key (base58, ~88 chars). Annotation shows up directly on the PR diff.
JSON_KEYPAIR — keypair accidentally committed
[12, 34, 56, 78, 90, 11, 22, 33, 44, 55, 66, 77, 88, 99, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240, 250, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]
An array of exactly 64 ints, 0–255. The scanner flags it no matter which file it's in — including JSON files, which is the usual case.
SEED_IN_COMMENT — pasted seed phrase as "just a note"
# wallet notes: apple banana cherry dogma elephant forest garden horizon iris jungle karma lemon
Twelve lowercase words on a comment line. Flagged. Real seed phrases use BIP39 wordlist entries, but the scanner catches any plausibly-shaped twelve/twenty-four word run so it doesn't miss the (common) case where somebody obscures real words as placeholders.
SOLANA_CONFIG_KEYPAIR — id.json committed
./src/id.json
./keys/deployer-keypair.json
./wallets/mainnet-authority-keypair.json
All three match the regex. The scanner flags the path, not the content — which means it still works even if the file got truncated or stripped.
ENV_LEAK — .env in the tree, not in gitignore
$ ls -a
.env
src/
package.json
$ cat .gitignore
node_modules/
dist/
No .env entry in .gitignore. The scanner emits: ::error file=.env,line=1,title=ENV_LEAK (high)::.env is tracked/present but not covered by any .gitignore.
HARDCODED_RPC — URL with embedded api-key
const RPC = "https://mainnet.helius-rpc.com/?api-key=abc123synthetic";
Flagged as medium. The fix is always the same: move to process.env.SOLANA_RPC_URL and add the actual key to a .env file that's in .gitignore.
The synthetic-fixture discipline
The hardest part of writing a scanner like this is your test suite. You need fixtures that look enough like real keys to trip the regex, but that are absolutely not real keys, and that any future developer reading the codebase can tell apart.
The rule I set for myself: fixtures must be obviously synthetic at a glance. The PLAINTEXT_KEY fixture is "sAm" repeated 29 times plus a final "s" — 88 characters of valid base58 that is patently not a key. The JSON_KEYPAIR fixtures use obviously-patterned integer sequences. Seed fixtures are English words in alphabetical order.
This matters because anyone who clones the scanner can immediately tell the fixtures aren't real (no "wait, is this somebody's wallet?" moment), and the CI output is self-documenting. The moment you let a realistic-looking fixture into your test suite, you've created a landmine for every future contributor. Synthetic patterns only. Period.
Composite action vs JavaScript action
I went with a composite action, not a JavaScript (Node) action. Three reasons:
Zero-dependency Python. The scanner uses only the Python 3.11 standard library — re, fnmatch, pathlib, os, sys. No pip install step in CI. No lockfile. No supply chain to audit beyond the standard library itself.
Composite actions match the shell-script mental model. If something goes wrong, users can run the same Python locally with the same env vars. The entire runs: block is ~15 lines of readable YAML; there's no bundled Node build to audit.
JavaScript actions require node_modules vendored in the repo (or an ncc bundle). Both are operational overhead I didn't want to carry for a scanner that has no runtime deps. The composite approach with setup-python@v5 avoids all of it.
The tradeoff: composite actions are ~0.5–1.5s slower than JS actions because they spin up a Python interpreter per run. On free CI minutes for a once-per-push check, that's a non-issue.
What this action is NOT
Honest disclaimers, because a security tool that over-sells itself is worse than no tool:
- It is not a replacement for a real security audit. It looks for six patterns. A skilled attacker can leak keys in ways none of these rules catch. A proper audit looks at dependencies, build pipelines, runtime telemetry, key management, and operational practice. This action is a grep, not a SOC.
-
It can't scan your git history. A key that was committed six months ago and then
git rm'd is still in the history. Use truffleHog or gitleaks for historical scans. My action only catches what's in the current tree. (On my roadmap: a secondary mode that runsgit log -pand re-scans.) -
It can't tell a real key from a fixture. If you have a fixture that trips
PLAINTEXT_KEY, it will trip. That's the whole point of theexcludeinput. - False positives exist. Long base58-ish strings in cryptographic code, 64-int arrays that encode something else, 12-word English sentences that happen to be in comments. The severity levels give you a budget for tolerance.
- Nothing here is financial advice or a security guarantee. It's a cheap CI check.
What it actually does, in one sentence
It fails your CI if you try to commit a plaintext Solana private key, a keypair JSON, a 12/24-word comment, an id.json, an unignored .env, or a mainnet RPC URL with an embedded API key — for free, in one uses: line, with zero runtime dependencies.
That's the entire pitch. If you run a Solana repo on GitHub, add it today. The wall of caught leaks above is the set of real accidents I've seen happen to other solo devs; I wrote this action because I got tired of watching them happen. The cipher-starter playbook covers the operational side — hot/warm/cold wallets, systemd hardening, key rotation — and the Canadian compliance writeup covers the regulatory half.
Source: github.com/cryptomotifs/cipher-solana-wallet-audit. MIT-licensed. 32 tests, CI green, v1.0.1 on the Marketplace. File issues if you find a false positive or a pattern I missed.
Not a security audit. Not financial advice. Just the cheap first line of defense I wish every Solana repo shipped with.
Top comments (0)