跳到主要内容
ecdsa.com

ecdsa-scan · 免费 CLI · 零依赖 · Node 20+

找出代码中每一处签名的位置——以及其中的错误

机密扫描工具能找出已提交到仓库的密钥。ecdsa-scan 关注的是更上一层:暗藏错误的签名代码——验证 JWT 时未固定算法、在以太坊代码中生成 P-256 密钥、手工拼接 r‖s 签名却未做填充、验证结果的布尔值被直接丢弃。只读,无需构建步骤,无需配置。支持 JavaScript、TypeScript、Python 和 Go。

$ npx ecdsa-scan .

无需安装,无需账号。只有出现 confirmed 级别的检测结果时才返回退出码 1——可直接用作 CI 门禁。全局安装:npm i -g ecdsa-scan

分级且附带解释的检测结果

每项检测结果都带有置信度级别、用浅显英文写成的机制说明、修正后的代码片段,以及相关 RFC 的链接。绝不只给出一条不加解释的模式匹配结果。

用于代码扫描的 SARIF

只需一个命令行选项,即可生成供 GitHub Code Scanning 使用的 SARIF 2.1.0:confirmed → error、suspected → warning、advisory → note——每条注释中都嵌入了修复方法。

免费获得 CBOM 种子数据

JSON 报告会清点哪些密码库、算法、曲线和签名操作出现在哪些文件中——这正是绘制后量子迁移地图的原始素材。

扫描结果示例

真实输出,原样摘自 ecdsa-scan 0.1.0 扫描其自带测试夹具集中两个文件的结果。每项检测结果都会指出机制、给出修复方法并引用相应标准。扫描器本身以英文输出报告。

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.

三个置信度级别

检测结果是分级的,而级别就是我们与你之间的约定。被用户屏蔽的扫描器毫无价值,因此只有明确无误的缺陷才会让构建失败。

confirmed

无论周围代码如何,该模式都是缺陷。

修复它——并据此设置 CI 门禁:只有 confirmed 级别的检测结果会导致非零退出码。

suspected

很可能有错;取决于周围的代码。

阅读检测结果,然后修复或忽略。

advisory

值得一看——正常代码也可能在这里匹配。

把它当作疑问,而不是定论。

15 条规则

14 条缺陷规则和 1 个清单收集器。ecdsa-scan rules 会输出同样的列表。有些规则会根据上下文调整检测结果的级别——位于 test/fixtures/ 下的私钥属于 advisory,而不是 confirmed。

规则置信度严重程度检测内容
jwt-verify-missing-algorithmsconfirmed高未传 algorithms: 的 jwt.verify / jwtVerify、未传 algorithms= 的 PyJWT decode、未用 WithValidMethods 的 jwt.Parse——以及空列表或“none”列表。
jwt-alg-from-tokenconfirmed高用令牌自身头部构造的验证算法列表(algorithms: [header.alg]、get_unverified_header)。
jwt-decode-without-verificationsuspected高jwt.decode、decodeJwt、jwtDecode、PyJWT 的 verify_signature: False——当结果被 role/user/permission 之类的标识符使用时,级别会提升。
insecure-nonce-sourceconfirmed高密钥或 nonce 相关代码附近出现 Math.random()、Python random、math/rand;由调用方提供的 k、extraEntropy、deterministic: false。
hardcoded-private-keyconfirmed高源代码中的 PEM 私钥块;赋值给 privateKey/secret/signingKey 之类名称的 64 个十六进制字符的字面量。
key-file-outside-testssuspected高位于 test/、fixtures/ 和 examples/ 目录之外、包含私钥材料的 .pem/.key/.p12/id_ecdsa 文件。
unchecked-verification-resultsuspected高返回布尔值的验证函数(crypto.verify、ecdsa.VerifyASN1 等)作为独立语句调用,其结果被丢弃。
weak-signature-hashconfirmed高签名路径中使用 SHA-1 或 MD5:createSign("sha1")、hashes.SHA1()、x509.SHA1WithRSA;签名代码其他位置出现的 SHA-1 摘要。
tls-verification-disabledconfirmed高rejectUnauthorized: false、NODE_TLS_REJECT_UNAUTHORIZED=0、HTTP 客户端上的 verify=False、ssl.CERT_NONE、InsecureSkipVerify: true。
signature-encodingsuspected中手工拼接 r 和 s 时没有用零填充到域的长度;JWS 代码中调用 Node crypto.sign/verify 时未指定 dsaEncoding。
non-constant-time-comparisonsuspected中用 ==、===、.equals() 或 bytes.Equal 而不是常量时间比较函数来比较签名、MAC 或摘要。
curve-mixingadvisory中在以太坊/比特币代码中创建 P-256 密钥;在同一模块中同时处理 secp256k1 和 P-256。
unvalidated-public-key-pointadvisory中由原始 x/y 坐标构造公钥却不检查点是否在曲线上;手写的曲线运算。
secp256k1-low-sadvisory低secp256k1 签名或验证中看不到 low-S 规范化——对区块链代码而言,这是延展性方面的问题。
crypto-inventory不适用不适用不是缺陷规则:按文件收集密码库、算法、曲线和签名操作——即 CBOM 种子数据。

表中为默认置信度;单项检测结果可能因周围代码而被升级或降级。

CI 集成

只有存在 confirmed 级别的缺陷时,扫描器才以退出码 1 退出,因此可以接入任何流水线而不会带来噪音问题。SARIF 报告会把检测结果转换为 pull request 注释,每条注释都附带各自的修复方法和参考资料。

GitHub Actions

在推送和 PR 时扫描,并将检测结果发布到 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

出现 confirmed 级别的检测结果时让 PR 步骤失败,并将 SARIF 保存为制品。

# 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

局限性——务必阅读

这是对文本的模式匹配,而不是程序分析。它能换来什么、要付出什么代价,下面如实说明:

  • 会有误报。

    合理地支持多种曲线的模块会匹配 curve-mixing 规则;调试工具调用 jwt.decode 也是正当的。正因如此,每项检测结果都带有置信度级别,只有 confirmed 级别的检测结果会让构建失败,advisory 级别的检测结果也以疑问的形式表述。

  • 也会有漏报,而且漏报更糟。

    任何间接的东西都看不到:在另一个文件中定义的包装函数、从配置中读取的算法、在运行时才决定的密钥类型、通过扫描器从未见过的库体现出来的缺陷。

  • 弱随机性只能部分检测出来。

    由隔着三个模块的自定义 PRNG 生成的有偏 nonce,看起来与正确的代码一模一样。nonce 的统计性问题要通过分析签名来发现,而不是分析源代码。

  • 没有跨文件分析,没有数据流分析,也没有污点追踪。

    每个文件都单独判断。这是付出的代价,换来的是在任何仓库、任何状态下都能立即运行,无需安装其依赖,也无需编译任何东西。

  • 对注释和字面量的屏蔽是启发式的。

    不寻常的格式——词法分析器误读的正则表达式、包含实际逻辑的模板字面量——可能会让检测结果被漏掉。

  • 通过扫描不等于做过审计。

    扫描器检查的是有没有某些众所周知的错误,而不是你的密码学实现是否正确。请把输出当作一份按优先级排好的阅读清单,交给人工审查者。

路线图

扫描器只是第一步——签名资产清单平台即将推出。

一次扫描能告诉你,代码目前在哪些地方签名。我们正在构建的平台会让这张地图持续更新:一份从仓库到密钥再到环境的清单——有哪些密钥、它们签名什么、使用哪些算法,以及在 NIST 后量子草案的截止日期之前有哪些必须迁移:2035年后禁用经典签名,2030年后弃用 RSA-2048 等 112 位安全强度的参数集。