ecdsa-scan
v0.1.1
Published
Static analysis for digital-signature code: JWT algorithm confusion, curve mix-ups, signature-encoding bugs, weak nonces, hardcoded keys and disabled certificate checks. Part of the ecdsa.com signature toolkit — https://ecdsa.com
Maintainers
Readme
ecdsa-scan
Made by ecdsa.com — free, local-first tools to verify, debug and understand digital signatures. Scanner docs and demo: ecdsa.com/scanner.
Static analysis for code that creates and checks digital signatures.
Secret scanners already find committed keys. This tool looks for the layer above
that: the places where signature code is subtly wrong — a JWT verified without
pinning the algorithm, a P-256 key generated in a file that derives Ethereum
addresses, an r‖s signature built by hand without padding, a verification whose
boolean result is thrown away.
It is read-only (it never rewrites your files), has zero dependencies, needs no build step, and runs on Node 20+.
$ ecdsa scan ./src
src/auth/session.ts
42: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 …
Fix:
jwt.verify(token, publicKey, { algorithms: ["ES256"] })
Reference: https://datatracker.ietf.org/doc/html/rfc8725#section-3.1
Summary 214 files scanned, 3 findings (1 confirmed, 2 advisory)
Exit code 1: 1 confirmed finding.Install and run
# no install
npx ecdsa-scan .
# from a clone of this repository
node cli/src/index.js scan .
# global (installs two equivalent binaries: `ecdsa-scan` and `ecdsa`)
npm install -g ecdsa-scan && ecdsa scan .Usage
ecdsa scan [path] scan a directory or a single file (default: .)
ecdsa rules list the rules and their default confidence
ecdsa --help | --version
--json machine-readable report on stdout
--sarif [file] write SARIF 2.1.0 (default: ecdsa-scan.sarif, "-" for stdout)
--min-confidence <level> confirmed | suspected | advisory (default: advisory)
--ignore <glob> skip paths matching a glob; repeatable
--no-color plain output (also honours NO_COLOR)Examples:
ecdsa scan ./src --min-confidence suspected
ecdsa scan . --ignore "**/*.test.ts" --ignore examples
ecdsa scan . --sarif results.sarif # upload to GitHub Code Scanning
ecdsa scan . --json | jq '.inventory' # the CBOM seed
ecdsa scan . --json | jq '.findings[] | select(.confidence=="confirmed")'Languages: JavaScript, TypeScript, Python, Go (.js .mjs .cjs .jsx .ts .tsx
.mts .cts .py .go), plus key files (.pem .key .p8 .p12 .pfx .jks, id_rsa,
id_ecdsa, …).
Skipped automatically: node_modules, .git, dist, build, out,
vendor, .next, .venv, __pycache__, target, coverage and similar.
Exit codes
| Code | Meaning |
|---|---|
| 0 | no confirmed findings (suspected/advisory findings do not fail a build) |
| 1 | at least one confirmed finding — use this to gate CI |
| 2 | usage error, or the path could not be read |
Three levels of confidence
Findings are graded, and the grade is the contract with you:
| Level | Meaning | What to do | |---|---|---| | confirmed | The pattern is a defect regardless of surrounding code. | Fix it. | | suspected | Very likely wrong; the surrounding code decides. | Read the finding, then fix or dismiss. | | advisory | Worth a look. Legitimate code matches here. | Treat as a question, not a verdict. |
Some rules move a finding between levels based on context — a private key in a
test/fixtures/ path is reported as advisory rather than confirmed, and a
jwt.decode() whose result feeds a role check is upgraded from advisory to
suspected.
Rules
14 defect rules and one inventory collector. ecdsa rules prints the same list.
| Rule | Default | Severity | What it finds |
|---|---|---|---|
| jwt-verify-missing-algorithms | confirmed | high | jwt.verify / jwtVerify without algorithms:, PyJWT decode without algorithms=, jwt.Parse without WithValidMethods; also empty or "none" lists |
| jwt-decode-without-verification | suspected | high | jwt.decode, decodeJwt, jwtDecode, PyJWT verify_signature: False — upgraded when role/user/permission identifiers follow the call |
| jwt-alg-from-token | confirmed | high | The algorithm list is built from the token's own header (algorithms: [header.alg], get_unverified_header) |
| curve-mixing | advisory | medium | A P-256 key created in Ethereum/Bitcoin code (suspected); secp256k1 and P-256 handled in one module (advisory) |
| signature-encoding | suspected | medium | r/s concatenated without zero-padding to the field size; Node crypto.sign/verify without dsaEncoding in JWS code (advisory) |
| secp256k1-low-s | advisory | low | secp256k1 signing/verification with no mention of low-S canonicalisation |
| insecure-nonce-source | confirmed | high | Math.random() / Python random / math/rand next to key or nonce material (confirmed); caller-supplied k, extraEntropy, deterministic: false (suspected) |
| hardcoded-private-key | confirmed | high | PEM private key blocks in source; 64-hex literals assigned to privateKey/secret/signingKey-style names (suspected) |
| key-file-outside-tests | suspected | high | .pem/.key/.p12/id_ecdsa files with private key material outside test/, fixtures/, examples/ |
| non-constant-time-comparison | suspected | medium | Signatures/MACs/digests compared with ==, ===, .equals(), bytes.Equal instead of a constant-time helper |
| unchecked-verification-result | suspected | high | Boolean-returning verification (crypto.verify, ecdsa.VerifyASN1, …) called as a bare statement |
| weak-signature-hash | confirmed | high | SHA-1/MD5 in a signing path (createSign("sha1"), hashes.SHA1(), x509.SHA1WithRSA); SHA-1 digests elsewhere in signing code (advisory) |
| unvalidated-public-key-point | advisory | medium | Public keys built from raw x/y coordinates with no on-curve check; hand-rolled curve arithmetic |
| tls-verification-disabled | confirmed | high | rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0, verify=False on HTTP clients, ssl.CERT_NONE, InsecureSkipVerify: true |
| crypto-inventory | — | — | Not a defect rule: collects libraries, algorithms, curves and signing operations per file |
Every finding carries a plain-English explanation, a fix example and a link to the relevant RFC or guidance.
Inventory (the CBOM seed)
--json includes an inventory section: which crypto libraries, algorithms,
curves and signing operations appear in which files. This is the raw material
for a cryptographic bill of materials, and for answering "where do we depend on
ECDSA?" before the NIST 2030/2035 deadlines.
{
"inventory": {
"libraries": [{ "name": "jose", "detail": "npm", "files": ["src/auth/jwt.ts"] }],
"algorithms": [{ "name": "ES256", "files": ["src/auth/jwt.ts"] }],
"curves": [{ "name": "P-256", "files": ["src/auth/jwt.ts"] }],
"operations": [{ "name": "verify", "files": ["src/auth/jwt.ts"] }]
}
}GitHub Action
A complete workflow: scan on every push and pull request, publish the findings to GitHub Code Scanning as pull-request annotations.
# .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.sarifConfidence maps to SARIF levels: confirmed → error, suspected → warning,
advisory → note. Each rule ships its explanation, fix and reference in the
SARIF help field, so the annotation in a pull request is self-contained.
To fail the job on confirmed findings instead of only annotating, drop
continue-on-error — the scanner exits 1 when a confirmed finding exists.
A ready-made composite action lives in action.yml.
Pre-commit
With pre-commit:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: ecdsa-scan
name: ecdsa-scan (signature defects)
entry: npx --yes ecdsa-scan
args: ["--min-confidence", "suspected"]
language: system
pass_filenames: false # the scanner walks the tree itselfOr as a plain Git hook:
# .git/hooks/pre-commit
#!/bin/sh
npx --yes ecdsa-scan . || exit 1Only confirmed findings exit non-zero, so a pre-commit hook blocks real defects without nagging about advisory matches.
How it works
- Walk the tree, skipping dependency and build directories.
- For each file build three views of the source, all with identical byte offsets: the raw text; a copy with comments blanked; and a copy with the contents of strings, template literals, regexes and JSX prose blanked too.
- Run every rule that applies to the file's language. Rules match structure
against the masked views (so documentation and code samples never become
findings) and read literal values from the raw view when the value is the
point — a PEM header,
createSign("sha1"), a curve name. - Sort, de-duplicate and format.
There is no parser and no type information. That is a deliberate trade-off: the tool runs on any repository instantly, in any state, without installing its dependencies or compiling anything.
Limitations — read this
This is pattern matching over text, not program analysis. Specifically:
- False positives happen. A module that legitimately supports several curves
matches
curve-mixing; a signature-debugging tool legitimately callsjwt.decode. That is why every rule has 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 (
verifyToken()defined in another file), an algorithm read from configuration, a key type decided at runtime, a defect expressed through a library this tool 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.
- Comment and literal masking is heuristic. Unusual formatting (a regex the
lexer misreads, a template literal containing real logic in
${…}) can hide a finding. - It does not check your cryptography is correct — only that certain well-known mistakes are absent. Passing this scan is not an audit, and no finding count implies a security level.
Treat the output as a prioritised reading list for a human reviewer.
Development
node --test test/*.test.js # 78 tests, no dependencies
node src/index.js scan .. # dogfood: scan the repository abovetest/fixtures/bad/ holds a defective example per rule and
test/fixtures/good/ the corrected version of the same code; the suite asserts
that every rule fires on the first and stays silent on the second. The .pem
fixtures contain placeholder text, not usable keys.
Adding a rule
Create src/rules/<id>.js exporting one object and register it in
src/rules/index.js:
export default {
id: "my-rule", // kebab-case; becomes the SARIF ruleId
title: "One line, human",
severity: "high", // high | medium | low
confidence: "suspected", // default level for this rule's findings
languages: ["js", "ts", "python", "go"],
why: "Two or three sentences: what is wrong and why it matters.",
fix: "A short corrected snippet.",
docs: "https://…", // RFC or authoritative guidance
match(ctx) {
return [...ctx.matchAll(/pattern/g)]
.filter((m) => !ctx.isMasked(m.index))
.map((m) => ({ index: m.index, message: "What is wrong here." }));
},
};The context gives you ctx.text (raw), ctx.code (comments blanked),
ctx.structure (literals blanked), ctx.isMasked(index), ctx.matchAll,
ctx.findCalls, ctx.window, ctx.lineOf and ctx.isTestPath. Add fixtures
under test/fixtures/bad/ and test/fixtures/good/ and a row in the CASES
table in test/rules.test.js — the suite fails if a rule has no fixture.
Prefer a lower confidence over a louder rule: a scanner people mute is worth nothing.
