ecdsa-scan · free CLI · zero dependencies · Node 20+
Find every place your code signs — and what's wrong there.
Secret scanners find committed keys. ecdsa-scan looks for the layer above that: signature code that is subtly wrong — a JWT verified without pinning the algorithm, a P-256 key generated in Ethereum code, an r‖s signature built by hand without padding, a verification whose boolean result is thrown away. Read-only, no build step, no configuration. JavaScript, TypeScript, Python and Go.
$ npx ecdsa-scan .No install, no account. Exit code 1 only on confirmed findings — ready to gate CI. Global install: npm i -g ecdsa-scan
Graded, explained findings
Every finding carries a confidence level, a plain-English explanation of the mechanism, a corrected snippet and a link to the relevant RFC. No bare pattern matches.
SARIF for code scanning
One flag produces SARIF 2.1.0 for GitHub Code Scanning: confirmed → error, suspected → warning, advisory → note — with the fix embedded in each annotation.
A CBOM seed for free
The JSON report inventories which crypto libraries, algorithms, curves and signing operations appear in which files — the raw material for the post-quantum migration map.
What a scan looks like
Real output, captured verbatim from ecdsa-scan 0.1.0 scanning two files from its own test-fixture suite. Every finding names the mechanism, shows the fix and cites the standard.
$ npx ecdsa-scan . src/auth/session.js 6:18 confirmed jwt-verify-missing-algorithms JWT verified without an explicit algorithm allow-list │ const claims = jwt.verify(token, publicKey); `jwt.verify(token, publicKey)` does not pass `algorithms`, so the token header decides how the signature is checked. Why it matters: A JWT names its own algorithm in the header, so a verifier that does not pin the accepted algorithms lets the token choose how it is checked. The classic result is algorithm confusion: an attacker re-signs the token with HS256 using your RSA/EC public key as the HMAC secret, or supplies alg:none, and verification succeeds. Always pass the exact algorithms your issuer uses. Fix: jsonwebtoken: jwt.verify(token, publicKey, { algorithms: ["ES256"] }) jose: await jwtVerify(token, key, { algorithms: ["ES256"] }) PyJWT: jwt.decode(token, key, algorithms=["ES256"]) golang-jwt: jwt.Parse(s, keyFunc, jwt.WithValidMethods([]string{"ES256"})) Reference: https://datatracker.ietf.org/doc/html/rfc8725#section-3.1 11:29 confirmed jwt-verify-missing-algorithms JWT verified without an explicit algorithm allow-list │ const { payload } = await jwtVerify(token, key); `jwtVerify(token, key)` does not pass `algorithms`, so the token header decides how the signature is checked. 17:10 confirmed jwt-verify-missing-algorithms JWT verified without an explicit algorithm allow-list │ return jwt.verify(token, key, { algorithms: [] }); `jwt.verify` accepts an empty algorithm list or "none" — every token, including unsigned ones, will pass. src/webhooks/verify.js 6:3 suspected unchecked-verification-result Verification result is never checked │ crypto.verify(null, data, publicKey, signature); `crypto.verify(...)` returns a boolean that is discarded here, so a failed signature check changes nothing. Wrap it in a condition and reject on `false`. Why it matters: `crypto.verify`, `secp256k1.verify` and `ecdsa.Verify` report failure by returning `false`, not by throwing. Calling one as a bare statement means an invalid signature is indistinguishable from a valid one and execution simply continues — the check exists in the code but does nothing. The result must control a branch: an `if`, an early return, or a thrown error. Fix: // bad crypto.verify(null, data, publicKey, signature); // good if (!crypto.verify(null, data, publicKey, signature)) { throw new Error('invalid signature'); } Reference: https://nodejs.org/api/crypto.html#cryptoverifyalgorithm-data-key-signature-callback Inventory library: jose (1), jsonwebtoken (1), node:crypto (1) operation: verify (2) Summary 2 files scanned, 4 findings (3 confirmed, 1 suspected) Exit code 1: 3 confirmed findings. Static pattern analysis: it can miss defects and can flag correct code. Treat advisory findings as questions, not verdicts.
Three levels of confidence
Findings are graded, and the grade is the contract with you. A scanner people mute is worth nothing, so only unambiguous defects can fail a build.
The pattern is a defect regardless of surrounding code.
Fix it — and gate CI on it: only confirmed findings exit non-zero.
Very likely wrong; the surrounding code decides.
Read the finding, then fix or dismiss.
Worth a look — legitimate code matches here too.
Treat as a question, not a verdict.
The 15 rules
Fourteen defect rules and one inventory collector. ecdsa-scan rules prints the same list. Some rules move a finding between levels based on context — a private key under test/fixtures/ is advisory, not confirmed.
| Rule | Confidence | Severity | What it finds |
|---|---|---|---|
| jwt-verify-missing-algorithms | confirmed | high | jwt.verify / jwtVerify without algorithms:, PyJWT decode without algorithms=, jwt.Parse without WithValidMethods — plus empty or "none" lists. |
| jwt-alg-from-token | confirmed | high | The verification algorithm list built from the token's own header (algorithms: [header.alg], get_unverified_header). |
| jwt-decode-without-verification | suspected | high | jwt.decode, decodeJwt, jwtDecode, PyJWT verify_signature: False — upgraded when role/user/permission identifiers consume the result. |
| insecure-nonce-source | confirmed | high | Math.random(), Python random, math/rand next to key or nonce material; caller-supplied k, extraEntropy, deterministic: false. |
| hardcoded-private-key | confirmed | high | PEM private-key blocks in source; 64-hex literals assigned to privateKey/secret/signingKey-style names. |
| key-file-outside-tests | suspected | high | .pem/.key/.p12/id_ecdsa files containing private-key material outside test/, fixtures/ and examples/ directories. |
| unchecked-verification-result | suspected | high | Boolean-returning verification (crypto.verify, ecdsa.VerifyASN1, …) called as a bare statement, its result discarded. |
| weak-signature-hash | confirmed | high | SHA-1 or MD5 in a signing path: createSign("sha1"), hashes.SHA1(), x509.SHA1WithRSA; SHA-1 digests elsewhere in signing code. |
| tls-verification-disabled | confirmed | high | rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0, verify=False on HTTP clients, ssl.CERT_NONE, InsecureSkipVerify: true. |
| signature-encoding | suspected | medium | r and s concatenated by hand without zero-padding to the field size; Node crypto.sign/verify without dsaEncoding in JWS code. |
| non-constant-time-comparison | suspected | medium | Signatures, MACs or digests compared with ==, ===, .equals() or bytes.Equal instead of a constant-time helper. |
| curve-mixing | advisory | medium | A P-256 key created in Ethereum/Bitcoin code; secp256k1 and P-256 handled in the same module. |
| unvalidated-public-key-point | advisory | medium | Public keys built from raw x/y coordinates with no on-curve check; hand-rolled curve arithmetic. |
| secp256k1-low-s | advisory | low | secp256k1 signing or verification with no visible low-S normalization — a malleability question for blockchain code. |
| crypto-inventory | n/a | n/a | Not a defect rule: collects the crypto libraries, algorithms, curves and signing operations per file — the CBOM seed. |
Default confidence shown; individual findings can be upgraded or downgraded by their surrounding code.
CI integration
The scanner exits 1 only when a confirmed defect exists, so it slots into any pipeline without a noise problem. The SARIF report turns findings into pull-request annotations, each carrying its own fix and reference.
GitHub Actions
Scan on push and PR, publish findings to GitHub Code Scanning.
# .github/workflows/ecdsa-scan.yml
name: ecdsa-scan
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
security-events: write # required by upload-sarif
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: ECDSA signature scan
run: npx ecdsa-scan . --sarif ecdsa.sarif
continue-on-error: true # keep the upload step running on findings
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ecdsa.sarifBitbucket Pipelines
Fail the PR step on confirmed findings, keep the SARIF as an artifact.
# bitbucket-pipelines.yml
pipelines:
pull-requests:
"**":
- step:
name: ECDSA signature scan
image: node:20
script:
# exits 1 on confirmed findings and fails the step
- npx ecdsa-scan . --sarif ecdsa.sarif
artifacts:
- ecdsa.sarifLimitations — read this
This is pattern matching over text, not program analysis. It is honest about what that buys and what it costs:
False positives happen.
A module that legitimately supports several curves matches curve-mixing; a debugging tool legitimately calls jwt.decode. That is why every finding carries a confidence level, why only confirmed findings fail the build, and why advisory findings are phrased as questions.
False negatives happen, and they are worse.
Anything indirect is invisible: a wrapper function defined in another file, an algorithm read from configuration, a key type decided at runtime, a defect expressed through a library the scanner has never heard of.
Weak randomness is only partly detectable.
A biased nonce produced by a custom PRNG three modules away looks exactly like correct code. Statistical nonce problems are found by looking at signatures, not at source.
No cross-file analysis, no data flow, no taint tracking.
Each file is judged on its own. That is the price of running instantly on any repository, in any state, without installing its dependencies or compiling anything.
Comment and literal masking is heuristic.
Unusual formatting — a regex the lexer misreads, a template literal containing real logic — can hide a finding.
Passing the scan is not an audit.
The scanner checks that certain well-known mistakes are absent, not that your cryptography is correct. Treat the output as a prioritised reading list for a human reviewer.
Roadmap
The scanner is the first step — the signing inventory platform is coming.
A scan tells you where your code signs today. The platform we are building keeps that map alive: one inventory from repository to key to environment — which keys exist, what they sign, which algorithms they use, and what has to migrate before the NIST 2030/2035 post-quantum deadlines.