Every security fix that crypto wallet developers have quietly shipped, collected from their own public repositories. 16,854 fixes across 174 wallets, hardware firmwares, smart-contract accounts and signing libraries.
Almost none of them have a CVE. That is the point.
The file holds 34,526 rows. The other 17,672 were read by two independent classifiers
that both said "not a security fix", and they are kept — labelled refuted, not deleted —
because what a broad gate over-admits is worth being able to see. Start from
df[df.security_verdict != "refuted"].
Across all 181 repositories in this registry, the total number of published security advisories is 16. 167 repositories have published zero.
Every one of those 16 belongs to an npm library or a US-based company. npm publishes
advisories because downstream projects run npm audit; US companies publish them
because they have disclosure policies. Trezor, Ledger, Coldcard, BitBox, Keystone,
OneKey, Electrum, BlueWallet, Sparrow, Wasabi, Monero, Keplr and imToken have published
none between them.
That is not a safety ranking. Reading it as one inverts the causality. Advisory counts measure whether a project has a disclosure habit, not whether it had bugs.
Here is what was actually in one of those zero-advisory repositories. Judging all 20,650 of Trezor's firmware commits surfaced 1,956 security fixes, including:
- a hardcoded signature bypass —
signature_valid = sectruesitting in the firmware verification path with aTODO-removecomment - signing nonces derived from the private key (RFC 6979), replaced with a hardware random source, because the deterministic derivation leaked key material
- nonce bias in Ed25519 multisig signing — the nonce was run through a function that clamps bits, forcing the value into a predictable subset
- an entropy failure that returned an ignorable boolean instead of halting the device, so a caller could proceed with weak randomness
None of these have a CVE. If you had checked Trezor's advisory page, you would have seen nothing, and concluded nothing was wrong.
The wallet ecosystem's own advisories and the fixes it ships quietly were read the same way — an LLM over the diff, one taxonomy of 23 defect mechanisms, no knowledge of which side a row came from.
Of the 1,325 rows carrying an advisory id or a graded severity — the strongest evidence tier in this corpus — 51 (4%) are a defect in the wallet's own custody path. The rest:
| rows | |
|---|---|
| a defect in the wallet's own custody path | 51 |
| following a CVE in a third-party dependency | 146 |
| not a defect fix at all | 1,128 |
That last group is security process work — reviewers added to CODEOWNERS, code annotated with CVE references, a fuzz harness for an already-fixed CVE — plus real bugs outside custody, like a server-side cursor leak. The keyword-free sweep of sixteen repositories found 4,349 custody fixes that reached a released version. A ratio of 85 : 1.
The composition comparison does hold, on the right denominator and with a test. Of the rows on each side whose defect mechanism could be identified — 194 with an advisory, 4,059 without — six differences survive Fisher's exact test with a Holm correction across all 22 mechanisms:
| mechanism | with an advisory | no CVE/GHSA | Holm p |
|---|---|---|---|
| a CVE in a dependency, bumped | 77.3% (150) | 0.5% (22) | 4e-218 |
| input length and bounds checking | 4.1% (8) | 16.5% (670) | 2e-6 |
| authorisation of the caller | 1.5% (3) | 12.6% (511) | 3e-7 |
| what is signed differs from what is shown | 1.5% (3) | 12.5% (509) | 4e-7 |
| key derivation and storage | 2.1% (4) | 11.5% (467) | 2e-4 |
| key left in memory | 0% (0) | 6.3% (255) | 4e-4 |
Three quarters of the disclosed record is somebody else's CVE. What a wallet publishes an advisory for is overwhelmingly a dependency it bumped; its own custody logic — the bytes it signs against the bytes it displays, who is allowed to call what, where the key lives — is where the undisclosed fixes concentrate. The other sixteen mechanisms do not separate the two populations and are not claimed to.
A correction. An earlier version of this section claimed five mechanisms never appear in an advisory, then withdrew the composition comparison entirely as underpowered. The first claim was wrong — 7 of 22 mechanisms are absent from a 51-row sample where 7.4 absences are expected by chance. The retraction went too far: it came from dividing by all 1,325 advisory rows, 1,131 of which repair no defect. On the mechanism-bearing rows the comparison is testable, and six differences survive correction for testing all 22.
Of the 4,349 fixes recovered by reading every commit of sixteen widely-used wallets and verified to have reached a released version:
| fixes | what actually goes wrong | |
|---|---|---|
signing |
1,303 | a signature ends up valid over something you never agreed to |
key_material |
1,034 | the seed or key leaks, is weakly generated, or is left in memory |
ui_deception |
493 | you approve the wrong thing because the screen told you something false |
firmware |
458 | boot verification, PIN handling, or the trusted display on a hardware wallet |
transport |
339 | the channel between a dapp and your wallet lets in an origin it should not |
memory |
190 | memory corruption in firmware or native crypto code |
platform |
176 | an OS or browser escape reaches the key store |
approval |
102 | spend authority is obtained without ever touching your key |
Signing and UI deception together outweigh key leakage. The common mental model — "keep your seed phrase safe and you are fine" — does not match where the bugs are. Most of these fixes are about a wallet signing or displaying something other than what you believed you approved, while your seed stayed exactly where it was supposed to be.
| File | Size | What |
|---|---|---|
data/keywordless_sweep_wave1.csv |
1.6 MB | start here. 4,608 fixes from ten mass-market wallets, one row each, with the reason it was judged a security fix |
data/wallet_vulns.parquet |
95 MB | the full corpus — 34,526 rows, all columns, before/after code inline |
data/wallet_vulns.preview.csv |
6 MB | key columns only, browsable in the GitHub UI |
data/raw/train.classified.parquet |
31 MB | pre-filter snapshot, for reproducing the curation |
data/silent_mechanisms.csv |
2.0 MB | all 5,457 sweep fixes labelled by defect mechanism, plus on_default / in_release / dup_subject; the figures read the 4,349 that shipped and are not duplicates |
data/wave1_mechanisms.csv |
1.7 MB | the wave-1 subset, kept as published |
data/advisory_mechanisms.csv |
403 KB | all 1,325 advisory-bearing rows, read from the diff, with the verdict and its reason |
data/mechanism_comparison.csv |
— | fig2 as a table: per mechanism, both sides' share, the difference, and Fisher/Holm p — generated by scripts/mechanism_comparison.py from the same rows the figure reads |
data/mechanisms.csv |
1.1 MB | every mechanism label, keyed by commit URL — the source of the corpus column |
data/silent_fix_llm.csv |
31 MB | every classifier verdict, including the negatives |
data/manifest.json |
— | per-stage drop counts and redaction tally |
import pandas as pd
df = pd.read_parquet("data/wallet_vulns.parquet")
df[df.security_verdict != "refuted"] # 16,854 — start here
df[df.contest == "all-commits"] # 4,676 read from diffs
df[df.authority_tier.isin(["A_authoritative", "B_corroborated"])] # 22,884 broad
df[df.mechanism == "signed-differs-from-shown"] # by what went wrongThe full CSV export is not committed — at 305 MB it exceeds GitHub's file limit.
pd.read_parquet(...).to_csv(...) regenerates it.
| rows | |
|---|---|
| raw snapshot | 94,388 |
| curated | 34,526 |
└ survives both classifiers (security_verdict != "refuted") |
16,854 |
| by tier | A_authoritative 1,421 · A_dependency 628 · B_corroborated 20,992 · C_candidate 11,485 |
| by tier, not refuted | A_authoritative 175 · A_dependency 19 · B_corroborated 12,525 · C_candidate 4,135 |
| by severity | Critical 1 · High 228 · Medium 846 · Low 49 · Info 11,650 · Unrated 21,752 |
| with a STRIDE category | 8,705 |
| with a CWE-Top-25 id | 8,241 |
| found by the LLM classifier alone | 7,057 |
| └ from the keyword-free sweep | 4,676 |
security_verdict |
assessed 15,174 · refuted 17,672 · unassessed 1,680 |
with a defect mechanism |
7,537 across 22 kinds · 2,732 read but unattributable · 24,257 unread |
97% of rows are Info or Unrated because nobody ever graded them. Unrated means no grader existed, not low impact.
A_dependency (628 rows) carries a real advisory about someone else's code — the
repo bumping rails or rubyzip after a CVE. Genuine, and not a custody bug, so it sits
outside the strongest slice. Left mixed in, the top tier partly measured whether
Dependabot runs on a repo.
label says which part of the custody chain a fix touched; mechanism says what went
wrong there — a signature checked against the wrong bytes, a length never validated, a
key left in a place another app can read. It is the column to group by when the question
is "what should I be careful about", and the 22 values are in
data/mechanism_comparison.csv.
Only a row an LLM has read can carry one, so the rest say unclassified rather than
guessing. Two things the column does not hide: rows found by reading whole histories land
in other 5.1% of the time, while rows inherited from the keyword crawl do so 20.6% of
the time — a thinner record makes a weaker label. And a row judged not to be a security
fix is 90% other, which is correct: there is no defect to attribute.
security_verdict records what two independent LLM passes concluded. refuted means both
denied it is a security fix, and those 17,672 rows stay in the file rather than being
deleted, so what the gate over-admitted stays inspectable. 1,680 rows are still
unassessed: 634 could not be read at all (263 whose diff would not fetch, 74 with no
diff, 276 whose two answer fields contradicted each other, 21 never reached), and the rest
predate the columns the join needs. They are neither confirmed nor denied and say so.
Do not read the tier names as quality. Every row the gate had admitted without a
reader — 23,258 of them — has now been read. 85.8% came back as not a security fix.
That is the whole reason refuted is 17,672 rows and the corpus's usable size is 16,854
rather than 34,526.
| tier | assessed | refuted | share refuted |
|---|---|---|---|
A_authoritative |
84 | 1,246 | 94% |
A_dependency |
7 | 609 | 99% |
B_corroborated |
11,303 | 8,938 | 44% |
C_candidate |
3,780 | 6,879 | 65% |
The rate is high because this is exactly the population that keyword and heuristic signals
admitted and no reader ever checked, and it lands where an independent measurement said it
would: keyword-gated rows survive a STRIDE check 24.6% of the time. A_authoritative at
94% is the same fact as the advisory census above — an advisory id mostly marks security
process work, not a defect fix.
Two checks that the verdict is not just noise. All 4,676 keyword-free rows survive, none
refuted — they were admitted because a reader said yes in the first place, so a filter that
threw them out would be broken. And B_corroborated, the tier built from two independent
signals stacking, is the only tier that survives more often than not.
CVE and GHSA are used only to calibrate. The corpus comes from commit history, three ways:
- Advisory backlinking — advisory → fixing commit. Precise, and bounded by the coverage this project exists to escape.
- Keyword-gated commit mining — custody vocabulary across each repo's history, with per-repo search terms and word-boundary matching.
- Reading every commit — no keyword at all. Judged by an LLM over the diff.
The third recovers what the second cannot, by construction: a fix whose message says "cleanup" matches no keyword. Measured against each other on the same repositories, rows recovered without keywords survive an independent security check 69% of the time against 24.6% for keyword-gated rows. Keywords buy precision by spending recall.
How much recall: of the 5,406 fixes recovered by reading 13 repositories end to end,
4,352 — 81% — contain none of the crawler's 26 search terms anywhere in the commit
message. Per repository the share runs from 59% (metamask-snaps) to 85%
(trezor-firmware, monero, safe-contracts). And the test is generous to keywords:
GitHub's commit search indexes the message, while this matched subject and body, so
the real miss rate is at least this high.
ethers shows what the misses look like. 42 of its 80 fixes have a title with no
security word in it, several of them literally admin: updated dist files, and under
that title sit: splitSignature inverting the recovery parameter so a signature
recovers the wrong signer; BIP-39 entropy derived with the wrong wordlist for
non-English mnemonics; a mnemonic not normalised before derivation, so the same phrase
typed with different spacing yields different keys; IDNA validation admitting unassigned
code points, allowing ENS homograph spoofing; and an ENS name resolving to the zero
address being treated as valid, sending funds to 0x0.
Method: docs/silent_fix_detection.md ·
limits: docs/limitations.md
In scope when a defect there can cost you funds, key material, or signing authority —
wider than "a wallet app". It includes the firmware holding your seed, the contract
holding your balance, the MPC library sharding your key, and libraries like ethers,
viem, bitcoinjs-lib, wallet-core and WalletConnect, where one defect is
simultaneously a bug in a hundred wallets.
181 repositories (collection/wallets.py):
| by category | by custody model | ||
|---|---|---|---|
| wallet SDK / library | 50 | self-custody | 65 |
| browser extension | 26 | library (no custody) | 55 |
| mobile | 24 | smart-contract account | 27 |
| smart-contract account | 22 | MPC / threshold / seedless | 19 |
| MPC / TSS / seedless | 19 | hardware (secure element) | 15 |
| desktop | 17 | ||
| hardware firmware | 10 | ||
| node wallet | 7 | ||
| connection infra | 6 |
Closed-source wallets — Phantom, Exodus, Binance Web3, OKX, Bitget, SafePal, exchange custodians — publish no commit history, so none of their silent fixes is observable.
It means no CVE or GHSA record exists for the fix. It does not mean nobody was told.
The sweep's clearest case is BTCPay Server's
TOTP 2FA bypass: a TOTP-only
account could be driven through the entire Greenfield API with just an email and
password, because the Basic-auth handler tested Fido2Credentials.Any() instead of
whether two-factor was enabled at all. It was patched in an emergency 2.4.2 release under
active exploitation, and covered in the trade press. BTCPay's GitHub advisory list is
nonetheless empty, so every automated tool that watches advisories saw nothing.
That is the failure this corpus measures: not silence toward users, but absence from the record a scanner can read. Either way, the fix is not in the feed your dependency bot subscribes to.
Two custody models get explicit coverage:
- Seedless / embedded (Privy, Web3Auth, Openfort, Para, thirdweb, Magic, Turnkey, Dfns) — you sign in with email or OAuth and never see a mnemonic; the key is split across device, provider and recovery factor. The question stops being "can the seed leak" and becomes "can an attacker assemble a quorum of shares". Most vendors keep the product closed and publish only the cryptographic core, which is exactly the part where a defect is catastrophic.
- Passkey / biometric (Coinbase Smart Wallet,
webauthn-sol,p256-verifier, Clave, passkey-kit) — signing rests on a platform authenticator released by Face ID or Touch ID. A mis-parsedclientDataJSONor an unchecked user-verification flag is a signing bypass with no key leak at all, which is why the WebAuthn verification libraries wallets embed are in the registry too.
Sixteen mass-market repositories done. Every eligible commit judged, no keyword consulted.
| repo | commits judged | fixes found | rate |
|---|---|---|---|
| trezor/trezor-firmware | 20,650 | 1,956 | 9.5% |
| spesmilo/electrum | 15,311 | 962 | 6.3% |
| LedgerHQ/app-ethereum | 2,640 | 459 | 17.4% |
| trustwallet/wallet-core | 4,815 | 365 | 7.6% |
| MetaMask/snaps | 3,325 | 194 | 5.8% |
| RabbyHub/Rabby | 4,489 | 186 | 4.1% |
| bitcoinjs/bitcoinjs-lib | 2,096 | 165 | 7.9% |
| WalletConnect (monorepo) | 4,095 | 143 | 3.5% |
| sparrowwallet/sparrow | 1,938 | 103 | 5.3% |
| safe-fndn/safe-smart-account | 845 | 75 | 8.9% |
| monero-project/monero | 6,791 | 457 | 6.7% |
| btcpayserver/btcpayserver | 6,137 | 261 | 4.3% |
| ethers-io/ethers.js | 2,207 | 80 | 3.6% |
| wevm/viem | 4,269 | 67 | 1.6% |
| monero-project/monero-gui | 837 | 19 | 2.3% |
| wevm/wagmi | 2,046 | 15 | 0.7% |
| total | 82,491 | 5,507 | 6.7% |
Each repository's most common failure class matches what that repository is for —
transport for the dapp-to-wallet channel, contract for the smart account, approval
for the system that grants third-party code wallet rights, signing and firmware for
the hardware signers. The classifier is never told which repository it is reading.
The second wave was chosen to test whether popularity predicts yield. It does not.
The three most-depended-on libraries in the registry — viem, wagmi and ethers —
returned 1.6%, 0.7% and 3.6%, while monero, a wallet that holds keys itself, returned
6.7%. wagmi's 15 fixes in 2,046 commits is the lowest of anything swept. A hooks layer
over a signing library has little of its own to get wrong; the rate tracks how close the
code sits to keys and signatures, not how many projects import it.
Rate is the wrong way to rank the queue. Electrum's 6.3% is unremarkable, but across 15,311 commits it produced 962 fixes — 21% of everything found. What matters is rate × history length. Neither does the keyword-era rate predict the sweep rate: Sparrow looked like 15.4% by keyword and came in at 5.3% swept, because a keyword rate measures purity after filtering, not how much is there.
36 tier-1 repositories remain, roughly 540,000 commits. Ordering, measured yield and real cost per repo:
uv run python scripts/repo_priority.py # the table
uv run python scripts/repo_priority.py --top 20 --slugsDo not take that list top to bottom. brave/brave-core is 60,169 commits at a 2.3%
rate; MetaMask/metamask-extension is 42,387 at 3.6%. Both cost more than the entire
first wave and return less. Prefer repositories where a defect reaches custody directly —
hardware firmware, signing libraries, long-lived Bitcoin wallets.
Popularity is the wrong predictor, and wagmi is the cleanest demonstration: 2,046
commits read, 15 fixes, 0.7% — the lowest of any repository swept, against 9.5% for
trezor-firmware. It is a React hooks layer over a signing library, so almost nothing in
it can lose your funds by itself. What raises the rate is proximity to keys and to
signing, not how many projects depend on the package.
Before more repositories, though: 24,031 rows already in the corpus have never been read
by any classifier, which is why security_verdict != "refuted" barely filters and why
two thirds of the corpus has no mechanism. Reading those costs no new cloning — all but
264 sit in repositories already on disk — and it makes both columns usable. Measured
throughput is ~40 rows a minute, so the full pass is several hours:
uv run python collection/llm_classify_fixes.py --apply --tier all \
--in <unassessed rows>.parquet --pred-cache scratchpad_crawl/pred_cache.json \
--apply-out assessed.csv --workers 5 \
--engine openai --model glm-5.2 \
--base-url https://ollama.com/v1 --api-key-env OLLAMA_API_KEYThen fold the verdicts back in and rebuild. Those rows are already candidates, so only
the verdicts merge — --extra-verdicts exists for exactly this and adds no new rows:
uv run python scripts/merge_keywordless.py --extra-verdicts assessed.csv --writeRun it after a sweep, not beside one: both draw on the same quota, and two passes competing spend their time on backoff instead of judging.
export OLLAMA_API_KEY=...
bash scripts/keywordless_sweep.sh <slug> [<slug> ...]Nothing is ever judged twice: verdicts are cached per commit URL in
scratchpad_crawl/pred_cache.json (121,000+ entries), so re-entering a finished
repository costs nothing and an interrupted sweep picks up where it stopped. Slugs come
from collection/wallets.py and are validated before the first model call.
The judging endpoint is rate-limited. Check it before a long run:
curl -s -o /dev/null -w "%{http_code}\n" https://ollama.com/v1/chat/completions \
-H "Authorization: Bearer $OLLAMA_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"glm-5.2","messages":[{"role":"user","content":"ok"}]}'429 means the quota is spent. The sweep backs off and resumes on its own, but it will
crawl until the window resets. A quota-interrupted pass still yields its output:
uv run python collection/llm_classify_fixes.py --apply --csv-only \
--in <enumerated.parquet> --tier all \
--apply-out out.csv --pred-cache scratchpad_crawl/pred_cache.jsonAlso open:
refutedrows are not yet excluded from the strongest slice. Doing that fairly needs the assessed share above 28%.- Backport detection is recorded but unused.
is_backportmarks a commit whose patch appears on more than one branch. Measured lift over three repositories: 1.74×, 0.94×, 0.45×. It does not generalise and does not order any work.
The curated table derives deterministically from the raw snapshot — no network, no keys:
uv run python pipeline/build_security_dataset.py \
--in data/raw/train.classified.parquet --out data/wallet_vulns.parquet
uv run --extra dev python -m pytest tests/ -q # 144 testsCuration and labels from an existing crawl, without re-crawling:
bash scripts/finalize.sh # stages 4-10
SKIP_LABELS=1 bash scripts/finalize.sh # gate only, fully offlineRe-collecting the raw snapshot is network-bound, roughly 24 hours for all 181 repositories, dominated by GitHub's 30 requests/minute search limit:
MODE=full TIER=3 bash collection/run_pipeline.sh| Stage | Drops | Why |
|---|---|---|
| T0 | 7 | repositories outside collection/wallets.py. The registry is the only authority on scope, and this runs whichever crawler produced the row |
| T2 | 7,377 | CI, docs and dependency-bump work, decided on the title |
| T2c | 1,138 | version bumps whose package name is custody vocabulary (@metamask/eth-hd-keyring, @scure/bip39) |
| T2d | 7,403 | author-declared build:/ci:/test:/docs: work, unless it cites an advisory or is genuine build integrity |
| GATE | 44,149 | no independent security signal fired |
This corpus quotes commit text verbatim and collects security fixes, so a commit whose
purpose was "remove the hardcoded test seed" contains the seed. Every published column is
masked to XXXXXXX before writing (pipeline/redact.py):
mnemonics, xprv/WIF/raw-hex private keys, PEM blocks, cloud tokens. Commit SHAs,
lockfile hashes and public keys stay intact — masking those breaks fix_commit joins.
Masking runs on the gate's input, before scoring, so the row that ships is the row
that was scored. This build masked 69 mnemonics, 21 raw hex private keys, 2 xprv, 1 WIF
key and 12 cloud tokens. Most are canonical test vectors — the BIP-39 abandon … about
mnemonic is in nearly every wallet's test suite — but the pass does not try to tell live
credentials from dead ones. Counts are in data/manifest.json.
pre_fix_code / post_fix_code carry the before/after hunks inline for 93% of rows,
files_changed for 96%, fix_commit for 100%. Inline code is capped at 8 KB and 12
files per row; without a cap one monorepo commit contributed megabytes and the parquet
write hit Arrow's 2 GB column limit.
Every row's label names the part of the custody chain that broke:
key:seed-mnemonic 3,422 · key:storage 2,983 · network-io 2,760 ·
sign:encoding-malleability 1,536 · build-ci 1,264 · test 1,175 · key:derivation 1,007
build-ci and test rows are meta-work that survived the gate. They are labelled as
such so they can be excluded.
The is_backport column in scratchpad_crawl/allcommits/*.parquet is not usable for
the repositories swept before 2026-08-12. It was meant to mark a fix cherry-picked to a
maintenance branch, on the reasoning that backporting is work a team only does when users
on an old release cannot wait. Detecting it renders every diff in the history, which on a
large repository does not finish, and the column was initialised to False — so a
timeout and a repository that genuinely backports nothing produced identical output. Nine
of twelve repositories report a zero of unknown meaning. Only bitcoinjs-lib (84),
ledger-app-eth (695) and wallet-core (448) are real counts.
It is now boolean with NA for "not determined", and the attempt is capped at 90
seconds rather than 420. A commit-count cut does not predict which repositories can
finish — ethers has 2,901 non-merge commits and still cannot render its history, because
what decides it is total diff bytes, and ethers carries generated bundles. The signal itself did not
survive testing either: measured lift over the base silent-fix rate was 1.74x, 0.94x and
0.45x on three repositories — no consistent direction — so nothing in the corpus depends
on it.
docs/figures/ holds the three figures behind the findings above, regenerated from
the committed tables so they cannot drift from the data:
| Figure | Shows |
|---|---|
fig1_ratio.png |
disclosed advisories against fixes shipped without one |
fig2_compare.png |
defect-mechanism composition of the two records, ordered by the difference, significant rows marked |
fig3_stack.png |
software type against defect location, with column totals and the signing-vs-key grouping |
docs/poster_examples.md holds five concrete fixes with their
source diffs, one per mechanism that fig2 finds significant. Each is verified to have
been shipped before it was fixed — the commit is on the default branch, and a release
tag exists that contains its parent but not the commit itself. Without that check a "fix"
cannot be told apart from a developer tidying their own branch:
scripts/check_shipped.py applies it, and three of the first
five candidates failed it.
That check found the corpus overcounts, and the figures now correct for it.
Enumeration walks git log --all, so commits on unmerged branches are judged too. Of the
sweep's 5,457 fixes, 892 never reached a released version and 475 more are a squash-merged
pull request counted a second time under its own SHA. The three figures read the remaining
4,349, and every count above is that population. The disclosed-to-silent ratio is
85 : 1, not the 107 : 1 published earlier; the direction of every finding is unchanged
and the counts were about a fifth high.
The flags live in the data, not in a filter buried in the plotting code:
scripts/mark_reachable.py adds on_default, in_release
and dup_subject to a mechanism table, and no row is deleted — a fix that never shipped
is still evidence about how the project works.
Two things that test does not do. It leaves rows it cannot test at NA and keeps them:
1,155 advisory rows are identified by a pull-request URL, which contains no SHA to walk
from, and every one of the 170 advisory rows that is testable did reach a release — so
the advisory side of every comparison is unfiltered and unaffected. And git log --all is
still the right way to enumerate: a fix on a maintenance branch is a real fix, which is why
in_release rescues 397 rows that trunk never saw.
data/wallet_vulns.parquet does not carry these columns yet. Applying them there means
giving the gate a dependency on local clones, which it does not have today.
uv run --with matplotlib --with numpy --with uharfbuzz --with fonttools \
--with pandas --with pyarrow --with scipy python scripts/poster_figures.pyThere were five figures; there are three. A yield-vs-history scatter was cut for
describing the collection process rather than the finding, and a figure contrasting
signing defects with key leakage was folded into fig3 as a bracketed column group and
a totals row — the same claim, read off one table instead of asserted in a second.
Titles state what the figure shows and its scope; interpretation goes in the subtitle or a note, never in the title. Labels are Japanese, worded for a security team that does not work on wallets. Two terms carry the whole set, defined once as constants at the top of the script so three figures cannot end up naming three populations:
| サイレント修正(CVE・GHSA 登録なし) | the silent side |
| 公開修正(CVE・GHSA 登録あり) | the disclosed side |
Four characters each, so the pair reads as one axis. The definition always rides along on
first appearance, for a reason: every fix in this corpus is a public commit. 公開 here
means the advisory record is public, not the code, and without the parenthetical a reader
could take サイレント修正 to mean the change itself was hidden. Deliberately not 公表されず
(undisclosed) either — BTCPay's 2FA bypass was announced loudly and is still absent from
every advisory feed.
Mechanism and column names use the vocabulary a corporate security function already has —
入力値の検証不備, 権限チェックの欠落, 鍵の生成・保管の不備, 画面表示の偽装, 送金権限の付与,
外部ライブラリの脆弱性対応 — rather than the taxonomy's wallet-internal English. Every
number is read from data/, never typed in.
scripts/poster_figures.py imports style.py from the repository root, so it needs
PYTHONPATH=. when run from anywhere other than that root.
collection/ wallets.py (registry) · wallet_vocab.py (threat vocabulary)
enumerate_commits.py — keyword-free whole-history enumeration
llm_classify_fixes.py — the silent-fix judge
local_diffs.py · gh_rate.py · crawlers · run_pipeline.sh
pipeline/ build_security_dataset.py — deterministic gate, tiering, verdicts
enrich_labels.py — label / root_cause / attack_path / pre+post code
redact.py — credential masking
scripts/ keywordless_sweep.sh — sweep repositories one at a time
repo_priority.py — stars × measured yield × cost
finalize.sh — rebuild from an existing crawl
tests/ quality gates, plus one regression test per bug that shipped
docs/ method · limitations · per-slice yield
Built with the same methodology as
NyxFoundation/ethereum-vuln-dataset,
retargeted from a protocol threat model to a custody one.
Data: CC-BY-4.0, from each wallet's own public repository.
Code under collection/, pipeline/ and scripts/: MIT.