ecdsa-scan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/package.json +43 -0
- package/src/index.js +212 -0
- package/src/lib/glob.js +62 -0
- package/src/lib/mask.js +199 -0
- package/src/lib/text.js +86 -0
- package/src/report.js +298 -0
- package/src/rules/_shared.js +113 -0
- package/src/rules/crypto-inventory.js +35 -0
- package/src/rules/curve-mixing.js +71 -0
- package/src/rules/hardcoded-private-key.js +66 -0
- package/src/rules/index.js +42 -0
- package/src/rules/insecure-nonce-source.js +80 -0
- package/src/rules/jwt-alg-from-token.js +53 -0
- package/src/rules/jwt-decode-without-verification.js +72 -0
- package/src/rules/jwt-verify-missing-algorithms.js +99 -0
- package/src/rules/key-file-outside-tests.js +56 -0
- package/src/rules/non-constant-time-comparison.js +99 -0
- package/src/rules/secp256k1-low-s.js +46 -0
- package/src/rules/signature-encoding.js +83 -0
- package/src/rules/tls-verification-disabled.js +75 -0
- package/src/rules/unchecked-verification-result.js +60 -0
- package/src/rules/unvalidated-public-key-point.js +72 -0
- package/src/rules/weak-signature-hash.js +83 -0
- package/src/scan.js +308 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ecdsa.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# `ecdsa-scan`
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/ecdsa-scan)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
Static analysis for code that creates and checks **digital signatures**.
|
|
8
|
+
|
|
9
|
+
Secret scanners already find committed keys. This tool looks for the layer above
|
|
10
|
+
that: the places where signature code is *subtly wrong* — a JWT verified without
|
|
11
|
+
pinning the algorithm, a P-256 key generated in a file that derives Ethereum
|
|
12
|
+
addresses, an `r‖s` signature built by hand without padding, a verification whose
|
|
13
|
+
boolean result is thrown away.
|
|
14
|
+
|
|
15
|
+
It is **read-only** (it never rewrites your files), has **zero dependencies**,
|
|
16
|
+
needs **no build step**, and runs on Node 20+.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
$ ecdsa scan ./src
|
|
20
|
+
|
|
21
|
+
src/auth/session.ts
|
|
22
|
+
42:18 confirmed jwt-verify-missing-algorithms JWT verified without an explicit algorithm allow-list
|
|
23
|
+
│ const claims = jwt.verify(token, publicKey);
|
|
24
|
+
`jwt.verify(token, publicKey)` does not pass `algorithms`, so the token header
|
|
25
|
+
decides how the signature is checked.
|
|
26
|
+
Why it matters: A JWT names its own algorithm in the header, so a verifier that
|
|
27
|
+
does not pin the accepted algorithms lets the token choose how it is checked …
|
|
28
|
+
Fix:
|
|
29
|
+
jwt.verify(token, publicKey, { algorithms: ["ES256"] })
|
|
30
|
+
Reference: https://datatracker.ietf.org/doc/html/rfc8725#section-3.1
|
|
31
|
+
|
|
32
|
+
Summary 214 files scanned, 3 findings (1 confirmed, 2 advisory)
|
|
33
|
+
Exit code 1: 1 confirmed finding.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Install and run
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# no install
|
|
40
|
+
npx ecdsa-scan .
|
|
41
|
+
|
|
42
|
+
# from a clone of this repository
|
|
43
|
+
node cli/src/index.js scan .
|
|
44
|
+
|
|
45
|
+
# global (installs two equivalent binaries: `ecdsa-scan` and `ecdsa`)
|
|
46
|
+
npm install -g ecdsa-scan && ecdsa scan .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
ecdsa scan [path] scan a directory or a single file (default: .)
|
|
53
|
+
ecdsa rules list the rules and their default confidence
|
|
54
|
+
ecdsa --help | --version
|
|
55
|
+
|
|
56
|
+
--json machine-readable report on stdout
|
|
57
|
+
--sarif [file] write SARIF 2.1.0 (default: ecdsa-scan.sarif, "-" for stdout)
|
|
58
|
+
--min-confidence <level> confirmed | suspected | advisory (default: advisory)
|
|
59
|
+
--ignore <glob> skip paths matching a glob; repeatable
|
|
60
|
+
--no-color plain output (also honours NO_COLOR)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ecdsa scan ./src --min-confidence suspected
|
|
67
|
+
ecdsa scan . --ignore "**/*.test.ts" --ignore examples
|
|
68
|
+
ecdsa scan . --sarif results.sarif # upload to GitHub Code Scanning
|
|
69
|
+
ecdsa scan . --json | jq '.inventory' # the CBOM seed
|
|
70
|
+
ecdsa scan . --json | jq '.findings[] | select(.confidence=="confirmed")'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Languages:** JavaScript, TypeScript, Python, Go (`.js .mjs .cjs .jsx .ts .tsx
|
|
74
|
+
.mts .cts .py .go`), plus key files (`.pem .key .p8 .p12 .pfx .jks`, `id_rsa`,
|
|
75
|
+
`id_ecdsa`, …).
|
|
76
|
+
**Skipped automatically:** `node_modules`, `.git`, `dist`, `build`, `out`,
|
|
77
|
+
`vendor`, `.next`, `.venv`, `__pycache__`, `target`, `coverage` and similar.
|
|
78
|
+
|
|
79
|
+
### Exit codes
|
|
80
|
+
|
|
81
|
+
| Code | Meaning |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `0` | no **confirmed** findings (suspected/advisory findings do not fail a build) |
|
|
84
|
+
| `1` | at least one confirmed finding — use this to gate CI |
|
|
85
|
+
| `2` | usage error, or the path could not be read |
|
|
86
|
+
|
|
87
|
+
### Three levels of confidence
|
|
88
|
+
|
|
89
|
+
Findings are graded, and the grade is the contract with you:
|
|
90
|
+
|
|
91
|
+
| Level | Meaning | What to do |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| **confirmed** | The pattern is a defect regardless of surrounding code. | Fix it. |
|
|
94
|
+
| **suspected** | Very likely wrong; the surrounding code decides. | Read the finding, then fix or dismiss. |
|
|
95
|
+
| **advisory** | Worth a look. Legitimate code matches here. | Treat as a question, not a verdict. |
|
|
96
|
+
|
|
97
|
+
Some rules move a finding between levels based on context — a private key in a
|
|
98
|
+
`test/fixtures/` path is reported as advisory rather than confirmed, and a
|
|
99
|
+
`jwt.decode()` whose result feeds a role check is upgraded from advisory to
|
|
100
|
+
suspected.
|
|
101
|
+
|
|
102
|
+
## Rules
|
|
103
|
+
|
|
104
|
+
14 defect rules and one inventory collector. `ecdsa rules` prints the same list.
|
|
105
|
+
|
|
106
|
+
| Rule | Default | Severity | What it finds |
|
|
107
|
+
|---|---|---|---|
|
|
108
|
+
| `jwt-verify-missing-algorithms` | confirmed | high | `jwt.verify` / `jwtVerify` without `algorithms:`, PyJWT `decode` without `algorithms=`, `jwt.Parse` without `WithValidMethods`; also empty or `"none"` lists |
|
|
109
|
+
| `jwt-decode-without-verification` | suspected | high | `jwt.decode`, `decodeJwt`, `jwtDecode`, PyJWT `verify_signature: False` — upgraded when role/user/permission identifiers follow the call |
|
|
110
|
+
| `jwt-alg-from-token` | confirmed | high | The algorithm list is built from the token's own header (`algorithms: [header.alg]`, `get_unverified_header`) |
|
|
111
|
+
| `curve-mixing` | advisory | medium | A P-256 key created in Ethereum/Bitcoin code (suspected); secp256k1 and P-256 handled in one module (advisory) |
|
|
112
|
+
| `signature-encoding` | suspected | medium | `r`/`s` concatenated without zero-padding to the field size; Node `crypto.sign`/`verify` without `dsaEncoding` in JWS code (advisory) |
|
|
113
|
+
| `secp256k1-low-s` | advisory | low | secp256k1 signing/verification with no mention of low-S canonicalisation |
|
|
114
|
+
| `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) |
|
|
115
|
+
| `hardcoded-private-key` | confirmed | high | PEM private key blocks in source; 64-hex literals assigned to `privateKey`/`secret`/`signingKey`-style names (suspected) |
|
|
116
|
+
| `key-file-outside-tests` | suspected | high | `.pem`/`.key`/`.p12`/`id_ecdsa` files with private key material outside `test/`, `fixtures/`, `examples/` |
|
|
117
|
+
| `non-constant-time-comparison` | suspected | medium | Signatures/MACs/digests compared with `==`, `===`, `.equals()`, `bytes.Equal` instead of a constant-time helper |
|
|
118
|
+
| `unchecked-verification-result` | suspected | high | Boolean-returning verification (`crypto.verify`, `ecdsa.VerifyASN1`, …) called as a bare statement |
|
|
119
|
+
| `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) |
|
|
120
|
+
| `unvalidated-public-key-point` | advisory | medium | Public keys built from raw x/y coordinates with no on-curve check; hand-rolled curve arithmetic |
|
|
121
|
+
| `tls-verification-disabled` | confirmed | high | `rejectUnauthorized: false`, `NODE_TLS_REJECT_UNAUTHORIZED=0`, `verify=False` on HTTP clients, `ssl.CERT_NONE`, `InsecureSkipVerify: true` |
|
|
122
|
+
| `crypto-inventory` | — | — | Not a defect rule: collects libraries, algorithms, curves and signing operations per file |
|
|
123
|
+
|
|
124
|
+
Every finding carries a plain-English explanation, a fix example and a link to
|
|
125
|
+
the relevant RFC or guidance.
|
|
126
|
+
|
|
127
|
+
### Inventory (the CBOM seed)
|
|
128
|
+
|
|
129
|
+
`--json` includes an `inventory` section: which crypto libraries, algorithms,
|
|
130
|
+
curves and signing operations appear in which files. This is the raw material
|
|
131
|
+
for a cryptographic bill of materials, and for answering "where do we depend on
|
|
132
|
+
ECDSA?" before the NIST 2030/2035 deadlines.
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"inventory": {
|
|
137
|
+
"libraries": [{ "name": "jose", "detail": "npm", "files": ["src/auth/jwt.ts"] }],
|
|
138
|
+
"algorithms": [{ "name": "ES256", "files": ["src/auth/jwt.ts"] }],
|
|
139
|
+
"curves": [{ "name": "P-256", "files": ["src/auth/jwt.ts"] }],
|
|
140
|
+
"operations": [{ "name": "verify", "files": ["src/auth/jwt.ts"] }]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## GitHub Action
|
|
146
|
+
|
|
147
|
+
A complete workflow: scan on every push and pull request, publish the findings
|
|
148
|
+
to GitHub Code Scanning as pull-request annotations.
|
|
149
|
+
|
|
150
|
+
```yaml
|
|
151
|
+
# .github/workflows/ecdsa-scan.yml
|
|
152
|
+
name: ecdsa-scan
|
|
153
|
+
|
|
154
|
+
on:
|
|
155
|
+
push:
|
|
156
|
+
branches: [main]
|
|
157
|
+
pull_request:
|
|
158
|
+
|
|
159
|
+
permissions:
|
|
160
|
+
contents: read
|
|
161
|
+
security-events: write # required by upload-sarif
|
|
162
|
+
|
|
163
|
+
jobs:
|
|
164
|
+
scan:
|
|
165
|
+
runs-on: ubuntu-latest
|
|
166
|
+
steps:
|
|
167
|
+
- uses: actions/checkout@v4
|
|
168
|
+
|
|
169
|
+
- uses: actions/setup-node@v4
|
|
170
|
+
with:
|
|
171
|
+
node-version: 20
|
|
172
|
+
|
|
173
|
+
- name: ECDSA signature scan
|
|
174
|
+
run: npx ecdsa-scan . --sarif ecdsa.sarif
|
|
175
|
+
continue-on-error: true # keep the upload step running on findings
|
|
176
|
+
|
|
177
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
178
|
+
with:
|
|
179
|
+
sarif_file: ecdsa.sarif
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Confidence maps to SARIF levels: confirmed → `error`, suspected → `warning`,
|
|
183
|
+
advisory → `note`. Each rule ships its explanation, fix and reference in the
|
|
184
|
+
SARIF `help` field, so the annotation in a pull request is self-contained.
|
|
185
|
+
|
|
186
|
+
To fail the job on confirmed findings instead of only annotating, drop
|
|
187
|
+
`continue-on-error` — the scanner exits `1` when a confirmed finding exists.
|
|
188
|
+
A ready-made composite action lives in [`action.yml`](./action.yml).
|
|
189
|
+
|
|
190
|
+
## Pre-commit
|
|
191
|
+
|
|
192
|
+
With [pre-commit](https://pre-commit.com):
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
# .pre-commit-config.yaml
|
|
196
|
+
repos:
|
|
197
|
+
- repo: local
|
|
198
|
+
hooks:
|
|
199
|
+
- id: ecdsa-scan
|
|
200
|
+
name: ecdsa-scan (signature defects)
|
|
201
|
+
entry: npx --yes ecdsa-scan
|
|
202
|
+
args: ["--min-confidence", "suspected"]
|
|
203
|
+
language: system
|
|
204
|
+
pass_filenames: false # the scanner walks the tree itself
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Or as a plain Git hook:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# .git/hooks/pre-commit
|
|
211
|
+
#!/bin/sh
|
|
212
|
+
npx --yes ecdsa-scan . || exit 1
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Only **confirmed** findings exit non-zero, so a pre-commit hook blocks real
|
|
216
|
+
defects without nagging about advisory matches.
|
|
217
|
+
|
|
218
|
+
## How it works
|
|
219
|
+
|
|
220
|
+
1. Walk the tree, skipping dependency and build directories.
|
|
221
|
+
2. For each file build three views of the source, all with identical byte
|
|
222
|
+
offsets: the raw text; a copy with **comments blanked**; and a copy with the
|
|
223
|
+
**contents of strings, template literals, regexes and JSX prose blanked** too.
|
|
224
|
+
3. Run every rule that applies to the file's language. Rules match structure
|
|
225
|
+
against the masked views (so documentation and code samples never become
|
|
226
|
+
findings) and read literal values from the raw view when the value is the
|
|
227
|
+
point — a PEM header, `createSign("sha1")`, a curve name.
|
|
228
|
+
4. Sort, de-duplicate and format.
|
|
229
|
+
|
|
230
|
+
There is no parser and no type information. That is a deliberate trade-off: the
|
|
231
|
+
tool runs on any repository instantly, in any state, without installing its
|
|
232
|
+
dependencies or compiling anything.
|
|
233
|
+
|
|
234
|
+
## Limitations — read this
|
|
235
|
+
|
|
236
|
+
This is pattern matching over text, not program analysis. Specifically:
|
|
237
|
+
|
|
238
|
+
- **False positives happen.** A module that legitimately supports several curves
|
|
239
|
+
matches `curve-mixing`; a signature-debugging tool legitimately calls
|
|
240
|
+
`jwt.decode`. That is why every rule has a confidence level, why only
|
|
241
|
+
*confirmed* findings fail the build, and why advisory findings are phrased as
|
|
242
|
+
questions.
|
|
243
|
+
- **False negatives happen, and they are worse.** Anything indirect is invisible:
|
|
244
|
+
a wrapper function (`verifyToken()` defined in another file), an algorithm read
|
|
245
|
+
from configuration, a key type decided at runtime, a defect expressed through
|
|
246
|
+
a library this tool has never heard of.
|
|
247
|
+
- **Weak randomness is only partly detectable.** A biased nonce produced by a
|
|
248
|
+
custom PRNG three modules away looks exactly like correct code. Statistical
|
|
249
|
+
nonce problems are found by looking at *signatures*, not at source.
|
|
250
|
+
- **No cross-file analysis, no data flow, no taint tracking.** Each file is
|
|
251
|
+
judged on its own.
|
|
252
|
+
- **Comment and literal masking is heuristic.** Unusual formatting (a regex the
|
|
253
|
+
lexer misreads, a template literal containing real logic in `${…}`) can hide a
|
|
254
|
+
finding.
|
|
255
|
+
- **It does not check your cryptography is correct** — only that certain
|
|
256
|
+
well-known mistakes are absent. Passing this scan is not an audit, and no
|
|
257
|
+
finding count implies a security level.
|
|
258
|
+
|
|
259
|
+
Treat the output as a prioritised reading list for a human reviewer.
|
|
260
|
+
|
|
261
|
+
## Development
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
node --test test/*.test.js # 78 tests, no dependencies
|
|
265
|
+
node src/index.js scan .. # dogfood: scan the repository above
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`test/fixtures/bad/` holds a defective example per rule and
|
|
269
|
+
`test/fixtures/good/` the corrected version of the same code; the suite asserts
|
|
270
|
+
that every rule fires on the first and stays silent on the second. The `.pem`
|
|
271
|
+
fixtures contain placeholder text, not usable keys.
|
|
272
|
+
|
|
273
|
+
### Adding a rule
|
|
274
|
+
|
|
275
|
+
Create `src/rules/<id>.js` exporting one object and register it in
|
|
276
|
+
`src/rules/index.js`:
|
|
277
|
+
|
|
278
|
+
```js
|
|
279
|
+
export default {
|
|
280
|
+
id: "my-rule", // kebab-case; becomes the SARIF ruleId
|
|
281
|
+
title: "One line, human",
|
|
282
|
+
severity: "high", // high | medium | low
|
|
283
|
+
confidence: "suspected", // default level for this rule's findings
|
|
284
|
+
languages: ["js", "ts", "python", "go"],
|
|
285
|
+
why: "Two or three sentences: what is wrong and why it matters.",
|
|
286
|
+
fix: "A short corrected snippet.",
|
|
287
|
+
docs: "https://…", // RFC or authoritative guidance
|
|
288
|
+
match(ctx) {
|
|
289
|
+
return [...ctx.matchAll(/pattern/g)]
|
|
290
|
+
.filter((m) => !ctx.isMasked(m.index))
|
|
291
|
+
.map((m) => ({ index: m.index, message: "What is wrong here." }));
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The context gives you `ctx.text` (raw), `ctx.code` (comments blanked),
|
|
297
|
+
`ctx.structure` (literals blanked), `ctx.isMasked(index)`, `ctx.matchAll`,
|
|
298
|
+
`ctx.findCalls`, `ctx.window`, `ctx.lineOf` and `ctx.isTestPath`. Add fixtures
|
|
299
|
+
under `test/fixtures/bad/` and `test/fixtures/good/` and a row in the `CASES`
|
|
300
|
+
table in `test/rules.test.js` — the suite fails if a rule has no fixture.
|
|
301
|
+
|
|
302
|
+
Prefer a lower confidence over a louder rule: a scanner people mute is worth
|
|
303
|
+
nothing.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ecdsa-scan",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Static analysis for digital-signature code: JWT algorithm confusion, curve mix-ups, signature-encoding bugs, weak nonces, hardcoded keys and disabled certificate checks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://ecdsa.com/scanner",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+ssh://git@bitbucket.org/hub2026/ecdsa.git",
|
|
11
|
+
"directory": "cli"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.0.0"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"ecdsa-scan": "src/index.js",
|
|
18
|
+
"ecdsa": "src/index.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test/*.test.js",
|
|
27
|
+
"scan": "node ./src/index.js scan"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ecdsa",
|
|
31
|
+
"jwt",
|
|
32
|
+
"signature",
|
|
33
|
+
"static-analysis",
|
|
34
|
+
"security",
|
|
35
|
+
"linter",
|
|
36
|
+
"sarif",
|
|
37
|
+
"cbom",
|
|
38
|
+
"cryptography",
|
|
39
|
+
"secp256k1",
|
|
40
|
+
"p-256",
|
|
41
|
+
"post-quantum"
|
|
42
|
+
]
|
|
43
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ecdsa scan — static analysis for digital-signature code.
|
|
3
|
+
//
|
|
4
|
+
// Read-only by design: the scanner opens files and writes nothing except the
|
|
5
|
+
// report you ask for (--sarif writes the report file itself).
|
|
6
|
+
|
|
7
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
8
|
+
import { writeFile } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { pathToFileURL } from "node:url";
|
|
12
|
+
import { parseArgs } from "node:util";
|
|
13
|
+
import { CONFIDENCE_LEVELS, filterByConfidence, scan } from "./scan.js";
|
|
14
|
+
import { formatJson, formatSarif, formatText, TOOL_VERSION } from "./report.js";
|
|
15
|
+
import { defectRules, rules as allRules } from "./rules/index.js";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_SARIF_PATH = "ecdsa-scan.sarif";
|
|
18
|
+
|
|
19
|
+
const OPTIONS = {
|
|
20
|
+
json: { type: "boolean", default: false },
|
|
21
|
+
sarif: { type: "string" },
|
|
22
|
+
"min-confidence": { type: "string", default: "advisory" },
|
|
23
|
+
ignore: { type: "string", multiple: true, default: [] },
|
|
24
|
+
"no-color": { type: "boolean", default: false },
|
|
25
|
+
help: { type: "boolean", short: "h", default: false },
|
|
26
|
+
version: { type: "boolean", short: "v", default: false },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function helpText() {
|
|
30
|
+
return `ecdsa-scan — find digital-signature defects in source code
|
|
31
|
+
|
|
32
|
+
USAGE
|
|
33
|
+
ecdsa-scan [path] scan a directory or a single file (default: .)
|
|
34
|
+
ecdsa-scan rules list the rules and their default confidence
|
|
35
|
+
ecdsa-scan --help | --version
|
|
36
|
+
(installed as both \`ecdsa-scan\` and \`ecdsa\`; \`ecdsa scan [path]\` works too)
|
|
37
|
+
|
|
38
|
+
OPTIONS
|
|
39
|
+
--json machine-readable report on stdout
|
|
40
|
+
--sarif [file] write SARIF 2.1.0 (default: ${DEFAULT_SARIF_PATH}, "-" for stdout)
|
|
41
|
+
--min-confidence <level> confirmed | suspected | advisory (default: advisory)
|
|
42
|
+
--ignore <glob> skip paths matching a glob; repeatable
|
|
43
|
+
--no-color plain output (also honours NO_COLOR)
|
|
44
|
+
-h, --help this text
|
|
45
|
+
-v, --version print version
|
|
46
|
+
|
|
47
|
+
CONFIDENCE
|
|
48
|
+
confirmed an unambiguous defect — fix it
|
|
49
|
+
suspected very likely wrong, but the surrounding code decides
|
|
50
|
+
advisory worth a look; expect legitimate matches here
|
|
51
|
+
|
|
52
|
+
EXIT CODES
|
|
53
|
+
0 no confirmed findings
|
|
54
|
+
1 at least one confirmed finding (use this to gate CI)
|
|
55
|
+
2 usage error, or the path could not be read
|
|
56
|
+
|
|
57
|
+
EXAMPLES
|
|
58
|
+
ecdsa scan
|
|
59
|
+
ecdsa scan ./src --min-confidence suspected
|
|
60
|
+
ecdsa scan . --ignore "**/*.test.ts" --ignore examples
|
|
61
|
+
ecdsa scan . --sarif results.sarif # upload to GitHub Code Scanning
|
|
62
|
+
ecdsa scan . --json | jq '.inventory'
|
|
63
|
+
|
|
64
|
+
Languages: JavaScript, TypeScript, Python, Go (plus key files: .pem/.key/.p12).
|
|
65
|
+
Skipped automatically: node_modules, .git, dist, build, vendor, .next, and more.
|
|
66
|
+
Always: static pattern matching. It misses things, and it flags correct code.
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rulesText() {
|
|
71
|
+
const lines = ["ID CONFIDENCE SEVERITY TITLE"];
|
|
72
|
+
for (const rule of allRules) {
|
|
73
|
+
const confidence = rule.kind === "inventory" ? "n/a" : rule.confidence;
|
|
74
|
+
const severity = rule.kind === "inventory" ? "n/a" : rule.severity;
|
|
75
|
+
lines.push(`${rule.id.padEnd(35)} ${confidence.padEnd(11)} ${severity.padEnd(9)} ${rule.title}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(`${defectRules.length} defect rules + 1 inventory collector.`);
|
|
79
|
+
lines.push("Some rules downgrade or upgrade a finding's confidence based on the surrounding code.");
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `--sarif` may be used without a value; parseArgs would reject that, so the
|
|
85
|
+
* default path is injected before parsing.
|
|
86
|
+
*/
|
|
87
|
+
function normalizeArgv(argv) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (let i = 0; i < argv.length; i++) {
|
|
90
|
+
out.push(argv[i]);
|
|
91
|
+
if (argv[i] === "--sarif") {
|
|
92
|
+
const next = argv[i + 1];
|
|
93
|
+
// `-` means stdout and is a real value; any other flag means the value
|
|
94
|
+
// was omitted, so the default file name is inserted.
|
|
95
|
+
if (next === undefined || (next.startsWith("-") && next !== "-")) out.push(DEFAULT_SARIF_PATH);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function colorEnabled(values) {
|
|
102
|
+
if (values["no-color"] || process.env.NO_COLOR) return false;
|
|
103
|
+
return Boolean(process.stdout.isTTY);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = parseArgs({ args: normalizeArgv(argv), options: OPTIONS, allowPositionals: true });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
process.stderr.write(`ecdsa: ${err.message}\n\nRun \`ecdsa --help\`.\n`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
const { values, positionals } = parsed;
|
|
115
|
+
|
|
116
|
+
if (values.version) {
|
|
117
|
+
process.stdout.write(`${TOOL_VERSION}\n`);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let command = positionals[0] ?? (values.help ? "help" : "scan");
|
|
122
|
+
let targetArg = positionals[1];
|
|
123
|
+
// `ecdsa-scan .` / `ecdsa-scan ./src`: an existing path in command position
|
|
124
|
+
// implies the scan command, so the published binary works without a verb.
|
|
125
|
+
if (!["scan", "rules", "help"].includes(command) && existsSync(path.resolve(command))) {
|
|
126
|
+
targetArg = command;
|
|
127
|
+
command = "scan";
|
|
128
|
+
}
|
|
129
|
+
if (command === "help" || values.help) {
|
|
130
|
+
process.stdout.write(helpText());
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
if (command === "rules") {
|
|
134
|
+
process.stdout.write(`${rulesText()}\n`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
if (command !== "scan") {
|
|
138
|
+
process.stderr.write(`ecdsa: unknown command "${command}". Run \`ecdsa --help\`.\n`);
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const minConfidence = values["min-confidence"];
|
|
143
|
+
if (!CONFIDENCE_LEVELS.includes(minConfidence)) {
|
|
144
|
+
process.stderr.write(
|
|
145
|
+
`ecdsa: --min-confidence must be one of ${CONFIDENCE_LEVELS.join(", ")} (got "${minConfidence}").\n`
|
|
146
|
+
);
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const target = path.resolve(targetArg ?? ".");
|
|
151
|
+
let result;
|
|
152
|
+
try {
|
|
153
|
+
result = await scan(target, { ignore: values.ignore });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
process.stderr.write(`ecdsa: cannot scan ${target}: ${err.message}\n`);
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
result.findings = filterByConfidence(result.findings, minConfidence);
|
|
160
|
+
const confirmed = result.findings.filter((f) => f.confidence === "confirmed").length;
|
|
161
|
+
|
|
162
|
+
if (values.sarif) {
|
|
163
|
+
const sarif = formatSarif(result);
|
|
164
|
+
if (values.sarif === "-") {
|
|
165
|
+
process.stdout.write(`${sarif}\n`);
|
|
166
|
+
} else {
|
|
167
|
+
const sarifPath = path.resolve(values.sarif);
|
|
168
|
+
try {
|
|
169
|
+
await writeFile(sarifPath, `${sarif}\n`, "utf8");
|
|
170
|
+
} catch (err) {
|
|
171
|
+
process.stderr.write(`ecdsa: cannot write ${sarifPath}: ${err.message}\n`);
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
if (!values.json) process.stderr.write(`SARIF written to ${sarifPath}\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (values.json) {
|
|
179
|
+
process.stdout.write(`${formatJson(result)}\n`);
|
|
180
|
+
} else if (values.sarif !== "-") {
|
|
181
|
+
process.stdout.write(
|
|
182
|
+
`${formatText(result, { color: colorEnabled(values), width: process.stdout.columns ?? 100 })}\n`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return confirmed > 0 ? 1 : 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Only run when executed directly, so tests can import main(). argv[1] is
|
|
190
|
+
// resolved through realpath because npm installs the bin as a symlink in
|
|
191
|
+
// node_modules/.bin (and /tmp-style symlinked paths exist too): node resolves
|
|
192
|
+
// the real file for import.meta.url while argv[1] keeps the symlink.
|
|
193
|
+
function isMainModule() {
|
|
194
|
+
if (!process.argv[1]) return false;
|
|
195
|
+
try {
|
|
196
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (isMainModule()) {
|
|
203
|
+
main().then(
|
|
204
|
+
(code) => {
|
|
205
|
+
process.exitCode = code;
|
|
206
|
+
},
|
|
207
|
+
(err) => {
|
|
208
|
+
process.stderr.write(`ecdsa: unexpected failure: ${err?.stack ?? err}\n`);
|
|
209
|
+
process.exitCode = 2;
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
}
|
package/src/lib/glob.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Minimal glob matching for --ignore. Supports `*`, `**`, `?` and `{a,b}`.
|
|
2
|
+
// Patterns are matched against the path relative to the scan root (always with
|
|
3
|
+
// forward slashes); a pattern without a slash also matches a bare file name,
|
|
4
|
+
// which is what people expect from `--ignore "*.test.js"`.
|
|
5
|
+
|
|
6
|
+
function toRegExpSource(pattern) {
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
9
|
+
const ch = pattern[i];
|
|
10
|
+
if (ch === "*") {
|
|
11
|
+
if (pattern[i + 1] === "*") {
|
|
12
|
+
// `**/` swallows any number of directories, including none.
|
|
13
|
+
if (pattern[i + 2] === "/") {
|
|
14
|
+
out += "(?:.*/)?";
|
|
15
|
+
i += 2;
|
|
16
|
+
} else {
|
|
17
|
+
out += ".*";
|
|
18
|
+
i += 1;
|
|
19
|
+
}
|
|
20
|
+
} else {
|
|
21
|
+
out += "[^/]*";
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (ch === "?") {
|
|
26
|
+
out += "[^/]";
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === "{") {
|
|
30
|
+
const close = pattern.indexOf("}", i);
|
|
31
|
+
if (close !== -1) {
|
|
32
|
+
const options = pattern.slice(i + 1, close).split(",");
|
|
33
|
+
out += `(?:${options.map((o) => toRegExpSource(o)).join("|")})`;
|
|
34
|
+
i = close;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
out += ch.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compile ignore patterns into a predicate over the root-relative posix path.
|
|
45
|
+
* A pattern also matches everything below a directory it names.
|
|
46
|
+
*/
|
|
47
|
+
export function compileIgnore(patterns) {
|
|
48
|
+
const compiled = (patterns ?? []).filter(Boolean).map((pattern) => {
|
|
49
|
+
const clean = pattern.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
50
|
+
const src = toRegExpSource(clean);
|
|
51
|
+
return {
|
|
52
|
+
pattern,
|
|
53
|
+
bare: !clean.includes("/"),
|
|
54
|
+
// The pattern itself, or the pattern used as a directory prefix.
|
|
55
|
+
re: new RegExp(`^(?:${src})(?:/.*)?$`),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
return (relPath) => {
|
|
59
|
+
const base = relPath.split("/").pop();
|
|
60
|
+
return compiled.some((c) => c.re.test(relPath) || (c.bare && c.re.test(base)));
|
|
61
|
+
};
|
|
62
|
+
}
|