ecdsa.com

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.

demo-app — npx ecdsa-scan .
$ 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.

confirmed

The pattern is a defect regardless of surrounding code.

Fix it — and gate CI on it: only confirmed findings exit non-zero.

suspected

Very likely wrong; the surrounding code decides.

Read the finding, then fix or dismiss.

advisory

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.

RuleConfidenceSeverityWhat it finds
jwt-verify-missing-algorithmsconfirmedhighjwt.verify / jwtVerify without algorithms:, PyJWT decode without algorithms=, jwt.Parse without WithValidMethods — plus empty or "none" lists.
jwt-alg-from-tokenconfirmedhighThe verification algorithm list built from the token's own header (algorithms: [header.alg], get_unverified_header).
jwt-decode-without-verificationsuspectedhighjwt.decode, decodeJwt, jwtDecode, PyJWT verify_signature: False — upgraded when role/user/permission identifiers consume the result.
insecure-nonce-sourceconfirmedhighMath.random(), Python random, math/rand next to key or nonce material; caller-supplied k, extraEntropy, deterministic: false.
hardcoded-private-keyconfirmedhighPEM private-key blocks in source; 64-hex literals assigned to privateKey/secret/signingKey-style names.
key-file-outside-testssuspectedhigh.pem/.key/.p12/id_ecdsa files containing private-key material outside test/, fixtures/ and examples/ directories.
unchecked-verification-resultsuspectedhighBoolean-returning verification (crypto.verify, ecdsa.VerifyASN1, …) called as a bare statement, its result discarded.
weak-signature-hashconfirmedhighSHA-1 or MD5 in a signing path: createSign("sha1"), hashes.SHA1(), x509.SHA1WithRSA; SHA-1 digests elsewhere in signing code.
tls-verification-disabledconfirmedhighrejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED=0, verify=False on HTTP clients, ssl.CERT_NONE, InsecureSkipVerify: true.
signature-encodingsuspectedmediumr and s concatenated by hand without zero-padding to the field size; Node crypto.sign/verify without dsaEncoding in JWS code.
non-constant-time-comparisonsuspectedmediumSignatures, MACs or digests compared with ==, ===, .equals() or bytes.Equal instead of a constant-time helper.
curve-mixingadvisorymediumA P-256 key created in Ethereum/Bitcoin code; secp256k1 and P-256 handled in the same module.
unvalidated-public-key-pointadvisorymediumPublic keys built from raw x/y coordinates with no on-curve check; hand-rolled curve arithmetic.
secp256k1-low-sadvisorylowsecp256k1 signing or verification with no visible low-S normalization — a malleability question for blockchain code.
crypto-inventoryn/an/aNot 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.sarif

Bitbucket 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.sarif

Limitations — 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.