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.
@@ -0,0 +1,56 @@
1
+ // Rule: key files living outside test/fixture/example directories.
2
+
3
+ import path from "node:path";
4
+
5
+ const PRIVATE_PEM = /-----BEGIN\s+(?:[A-Z0-9]+\s+)*PRIVATE KEY-----/;
6
+ const OPENSSH_PRIVATE = /-----BEGIN OPENSSH PRIVATE KEY-----/;
7
+ const BINARY_KEYSTORE = new Set([".p12", ".pfx", ".jks", ".p8", ".pk8"]);
8
+
9
+ export default {
10
+ id: "key-file-outside-tests",
11
+ title: "Key file stored outside test/fixture directories",
12
+ severity: "high",
13
+ confidence: "suspected",
14
+ languages: ["keyfile"],
15
+ why:
16
+ "A `.pem`, `.key`, `id_ecdsa` or `.p12` file that sits next to application code is almost always a real key that was committed by accident — throwaway keys normally live under `test/`, `fixtures/` or `examples/`. " +
17
+ "Anyone with read access to the repository, including CI logs and forks, gets the key. " +
18
+ "If the file really is a disposable fixture, move it into a test directory so the intent is obvious to both humans and scanners.",
19
+ fix: [
20
+ "# keep keys out of the tree",
21
+ "git rm --cached path/to/private.key",
22
+ "echo '*.key' >> .gitignore",
23
+ "# then rotate the key — a committed key must be considered public",
24
+ ].join("\n"),
25
+ docs: "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning",
26
+
27
+ match(ctx) {
28
+ if (ctx.isTestPath) return [];
29
+ const ext = path.extname(ctx.relPath).toLowerCase();
30
+ const base = path.basename(ctx.relPath);
31
+
32
+ if (BINARY_KEYSTORE.has(ext)) {
33
+ return [
34
+ {
35
+ line: 1,
36
+ column: 1,
37
+ confidence: "suspected",
38
+ snippet: base,
39
+ message: `\`${base}\` is a key store (${ext}) checked in outside any test directory. Confirm what it holds; if it contains a private key or a certificate chain with a key, rotate it and remove the file from git history.`,
40
+ },
41
+ ];
42
+ }
43
+
44
+ if (!PRIVATE_PEM.test(ctx.text)) return [];
45
+ const m = ctx.text.match(PRIVATE_PEM);
46
+ return [
47
+ {
48
+ index: m.index,
49
+ confidence: "suspected",
50
+ message: OPENSSH_PRIVATE.test(ctx.text)
51
+ ? `\`${base}\` is an OpenSSH private key stored outside any test directory. Rotate it and remove it from the repository.`
52
+ : `\`${base}\` contains a PEM private key and is stored outside any test/fixture directory. Rotate the key, then remove the file from git history.`,
53
+ },
54
+ ];
55
+ },
56
+ };
@@ -0,0 +1,99 @@
1
+ // Rule: signatures/MACs compared with ordinary equality operators.
2
+
3
+ import { CODE_LANGS, condense } from "./_shared.js";
4
+
5
+ const COMPARISON =
6
+ /([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*|\s*\[\s*["'][^"']*["']\s*\])*)\s*(===|!==|==|!=)\s*([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*|\s*\[\s*["'][^"']*["']\s*\])*|["'][^"'\n]*["'])/g;
7
+
8
+ const SENSITIVE = /(signature|\bsig\b|sig[A-Z_]|mac\b|hmac|digest|checksum|\btag\b|authtag|token|otp\b|secret|password|passwd|apikey|api_key|hash)/i;
9
+ /** Suffixes that mean "this is metadata about the value, not the value". */
10
+ const METADATA = /(name|alg|algorithm|type|kind|format|encoding|id|len|length|count|status|method|mode|version|header|prefix|scheme|size|index|url|path)$/i;
11
+ /**
12
+ * Property names that are components or descriptions of a signature rather than
13
+ * the signature itself: `sig.r !== sig.s`, `found.hash !== baseline.hash`.
14
+ * Comparing those is ordinary logic, not a timing leak.
15
+ */
16
+ const GENERIC_PROPERTY = /^(?:r|s|x|y|v|hash|curve|alg|type|kind|format|encoding|name|id|mode|method|version|status|scheme|label)$/i;
17
+ const NON_VALUE = /^(?:null|undefined|true|false|None|nil|NaN|0|""|''|-1)$/;
18
+ const SAFE_CALL = /timingSafeEqual|compare_digest|ConstantTimeCompare|hmac\.Equal|subtle\.ConstantTime|crypto_verify|sodium_memcmp/;
19
+
20
+ const BUFFER_EQUALS = /\b([A-Za-z_$][\w$]*(?:signature|sig|mac|digest|tag|token|hmac)[\w$]*)\s*\.\s*equals\s*\(/i;
21
+ const GO_BYTES_EQUAL = /\b(?:bytes\.Equal|reflect\.DeepEqual)\b/;
22
+
23
+ function looksLikeBlobLiteral(side) {
24
+ if (!/^["']/.test(side)) return true; // not a literal at all
25
+ const value = side.slice(1, -1);
26
+ return value.length >= 16 && /^[A-Za-z0-9+/=_-]+$/.test(value);
27
+ }
28
+
29
+ export default {
30
+ id: "non-constant-time-comparison",
31
+ title: "Signature or MAC compared with a variable-time operator",
32
+ severity: "medium",
33
+ confidence: "suspected",
34
+ languages: CODE_LANGS,
35
+ why:
36
+ "`==`, `===` and `bytes.Equal` stop at the first differing byte, so the time they take reveals how many leading bytes of a guess were correct. " +
37
+ "Over many requests that timing signal lets an attacker reconstruct a valid MAC or signature byte by byte, without ever knowing the key. " +
38
+ "Compare secrets with a constant-time helper instead.",
39
+ fix: [
40
+ "// Node",
41
+ "crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(actual, 'hex'));",
42
+ "",
43
+ "# Python",
44
+ "hmac.compare_digest(expected, actual)",
45
+ "",
46
+ "// Go",
47
+ "hmac.Equal(expected, actual) // or subtle.ConstantTimeCompare(a, b) == 1",
48
+ ].join("\n"),
49
+ docs: "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html",
50
+
51
+ match(ctx) {
52
+ const out = [];
53
+
54
+ for (const m of ctx.matchAll(COMPARISON)) {
55
+ if (ctx.isMasked(m.index)) continue;
56
+ const line = ctx.lineOf(m.index);
57
+ if (SAFE_CALL.test(line)) continue;
58
+ const left = m[1].replace(/\s+/g, "");
59
+ const right = m[3].replace(/\s+/g, "");
60
+ if (NON_VALUE.test(left) || NON_VALUE.test(right)) continue;
61
+ if (/\.(length|size|byteLength)$/.test(left) || /\.(length|size|byteLength)$/.test(right)) continue;
62
+ if (/\btypeof\b/.test(line)) continue;
63
+
64
+ const leftName = left.split(/[.[\]"']+/).filter(Boolean).pop() ?? "";
65
+ const rightName = right.split(/[.[\]"']+/).filter(Boolean).pop() ?? "";
66
+ const leftSensitive = SENSITIVE.test(left) && !METADATA.test(leftName) && !GENERIC_PROPERTY.test(leftName);
67
+ const rightSensitive = SENSITIVE.test(right) && !METADATA.test(rightName) && !GENERIC_PROPERTY.test(rightName);
68
+ if (!leftSensitive && !rightSensitive) continue;
69
+ if (!looksLikeBlobLiteral(m[3]) || !looksLikeBlobLiteral(m[1])) continue;
70
+
71
+ out.push({
72
+ index: m.index,
73
+ confidence: "suspected",
74
+ message: `\`${condense(m[0], 70)}\` compares signature/MAC material with \`${m[2]}\`, which short-circuits on the first differing byte and leaks how much of a guess was right.`,
75
+ });
76
+ }
77
+
78
+ for (const m of ctx.matchAll(new RegExp(BUFFER_EQUALS.source, "gi"))) {
79
+ out.push({
80
+ index: m.index,
81
+ confidence: "suspected",
82
+ message: `\`${m[1]}.equals(...)\` is a plain byte comparison, not a constant-time one. Use \`crypto.timingSafeEqual\` for signature and MAC values.`,
83
+ });
84
+ }
85
+
86
+ if (ctx.lang === "go") {
87
+ for (const call of ctx.findCalls(new RegExp(GO_BYTES_EQUAL.source, "g"))) {
88
+ if (!SENSITIVE.test(call.args)) continue;
89
+ out.push({
90
+ index: call.index,
91
+ confidence: "suspected",
92
+ message: `\`${call.callee}(${condense(call.args, 50)})\` compares signature/MAC bytes in variable time. Use \`hmac.Equal\` or \`subtle.ConstantTimeCompare\`.`,
93
+ });
94
+ }
95
+ }
96
+
97
+ return out;
98
+ },
99
+ };
@@ -0,0 +1,46 @@
1
+ // Rule: secp256k1 signing/verification with no sign of low-S normalization.
2
+
3
+ import { CODE_LANGS, K1_RE } from "./_shared.js";
4
+
5
+ const SIGN_OP =
6
+ /\.(?:sign|signSync|signAsync|verify|verifySync|signRecoverable|recoverPublicKey)\s*\(|\bSign\s*\(|\bVerify\s*\(|\bsign_digest\w*\s*\(/;
7
+
8
+ const NORMALIZED =
9
+ /lowS|low_s|lows\b|normalize|normalise|canonical|toCompactRawBytes|hasHighS|halfOrder|HALF_CURVE_ORDER|HALF_ORDER|malleab|isHigh|highS|N\s*-\s*s\b|n\s*-\s*s\b|CURVE\.n\s*-|order\s*-\s*s\b|signatureNormalize/i;
10
+
11
+ export default {
12
+ id: "secp256k1-low-s",
13
+ title: "secp256k1 signatures without visible low-S normalization",
14
+ severity: "low",
15
+ confidence: "advisory",
16
+ languages: CODE_LANGS,
17
+ why:
18
+ "For every valid ECDSA signature (r, s) the pair (r, n − s) is equally valid, so the same message has two encodings — signature malleability. " +
19
+ "Bitcoin, Ethereum and most chain tooling reject the high-S form, and code that stores or compares signature bytes can be tricked by the duplicate. " +
20
+ "This rule is a reminder, not a verdict: many libraries (noble-secp256k1, libsecp256k1, ethers) normalize by default, in which case there is nothing to fix.",
21
+ fix: [
22
+ "// @noble/curves normalizes on signing; be explicit when verifying too",
23
+ "const sig = secp256k1.sign(msgHash, priv); // low-S by default",
24
+ "secp256k1.verify(sig, msgHash, pub, { lowS: true });",
25
+ "",
26
+ "# python-ecdsa",
27
+ "sig = sk.sign_digest_deterministic(digest, sigencode=util.sigencode_der_canonize)",
28
+ ].join("\n"),
29
+ docs: "https://github.com/bitcoin/bips/blob/master/bip-0146.mediawiki",
30
+
31
+ match(ctx) {
32
+ // Identifiers only: "secp256k1" inside documentation or a code sample does
33
+ // not mean this module signs anything.
34
+ if (!K1_RE.test(ctx.structure)) return [];
35
+ if (NORMALIZED.test(ctx.code)) return [];
36
+ const m = ctx.structure.match(new RegExp(SIGN_OP.source));
37
+ if (!m) return [];
38
+ return [
39
+ {
40
+ index: m.index,
41
+ message:
42
+ "This module signs or verifies with secp256k1 but never mentions low-S normalization (`lowS`, `normalize`, `n - s`). If the library does not canonicalize for you, half of the signatures produced here will be rejected by chain tooling, and both forms of the same signature will be accepted on verification.",
43
+ },
44
+ ];
45
+ },
46
+ };
@@ -0,0 +1,83 @@
1
+ // Rule: hand-built r‖s signatures and ambiguous Node signature encodings.
2
+
3
+ import { CODE_LANGS, JS_LANGS, condense } from "./_shared.js";
4
+
5
+ const R_NAME = "(?:r|R|rBuf|rBytes|rBuffer|rHex|sigR|sig_r|r_bytes|r_hex)";
6
+ const S_NAME = "(?:s|S|sBuf|sBytes|sBuffer|sHex|sigS|sig_s|s_bytes|s_hex)";
7
+
8
+ const CONCAT_PATTERNS = [
9
+ new RegExp(`Buffer\\.concat\\(\\s*\\[\\s*${R_NAME}\\s*,\\s*${S_NAME}\\s*[,\\]]`),
10
+ new RegExp(`\\b${R_NAME}\\.toString\\(\\s*(?:["']hex["']|16)\\s*\\)\\s*\\+\\s*${S_NAME}\\.toString\\(`),
11
+ new RegExp(`\\b${R_NAME}Hex\\s*\\+\\s*${S_NAME}Hex\\b`),
12
+ new RegExp(`\\$\\{\\s*${R_NAME}\\.toString\\(\\s*16\\s*\\)\\s*\\}\\$\\{\\s*${S_NAME}\\.toString\\(\\s*16\\s*\\)\\s*\\}`),
13
+ new RegExp(`long_to_bytes\\(\\s*${R_NAME}\\s*\\)\\s*\\+\\s*long_to_bytes\\(`),
14
+ new RegExp(`\\bformat\\(\\s*${R_NAME}\\s*,\\s*["']x["']\\s*\\)\\s*\\+\\s*format\\(`),
15
+ new RegExp(`\\bhex\\(\\s*${R_NAME}\\s*\\)\\s*\\[\\s*2\\s*:\\s*\\]\\s*\\+`),
16
+ new RegExp(`append\\(\\s*${R_NAME}\\.Bytes\\(\\)\\s*,\\s*${S_NAME}\\.Bytes\\(\\)\\.\\.\\.`),
17
+ new RegExp(`\\b${R_NAME}\\.Bytes\\(\\)\\s*\\.\\.\\.`),
18
+ ];
19
+
20
+ /** Anything that means "the halves are padded to the field size". */
21
+ const PADDING_RE =
22
+ /padStart|padEnd|zeroPad|leftPad|hexZeroPad|toBeHex|numberToBytesBE|toArrayLike\s*\(\s*Buffer\s*,\s*["']be["']\s*,\s*\d+|setLength|to_bytes\s*\(\s*\d+|\.zfill\(|\.rjust\(|FillBytes|fillBytes|Uint8Array\(\s*(?:32|48|66)\s*\)/;
23
+
24
+ const NODE_SIGN_VERIFY = /\bcrypto\s*\.\s*(?:sign|verify)\b/;
25
+ const JWS_CONTEXT = /\b(jws|jwt|JWS|JWT|base64url|base64Url|webauthn|WebAuthn|COSE|P1363|p1363|ieee-?p1363)\b/;
26
+
27
+ export default {
28
+ id: "signature-encoding",
29
+ title: "Hand-built r‖s signature or unspecified DER/raw encoding",
30
+ severity: "medium",
31
+ confidence: "suspected",
32
+ languages: CODE_LANGS,
33
+ why:
34
+ "An ECDSA signature is two integers, r and s, and the raw (P1363/JOSE) form requires each of them to be zero-padded to the exact field size — 32 bytes for P-256 and secp256k1. " +
35
+ "Concatenating `r.toString(16) + s.toString(16)` or `Buffer.concat([r, s])` drops leading zero bytes roughly once in every 256 signatures, which produces a 63-byte signature and an 'invalid signature' bug that only shows up in production. " +
36
+ "The mirror image of the problem is Node's `crypto.sign`/`crypto.verify`, which default to DER while JWS expects raw.",
37
+ fix: [
38
+ "// pad each half to the field size",
39
+ "const size = 32; // P-256 / secp256k1",
40
+ "const raw = Buffer.concat([",
41
+ " Buffer.from(r.toString(16).padStart(size * 2, '0'), 'hex'),",
42
+ " Buffer.from(s.toString(16).padStart(size * 2, '0'), 'hex'),",
43
+ "]);",
44
+ "",
45
+ "// or let Node do the conversion",
46
+ "crypto.verify(null, data, { key, dsaEncoding: 'ieee-p1363' }, signature);",
47
+ ].join("\n"),
48
+ docs: "https://www.rfc-editor.org/rfc/rfc7518#section-3.4",
49
+
50
+ match(ctx) {
51
+ const out = [];
52
+
53
+ for (const re of CONCAT_PATTERNS) {
54
+ for (const m of ctx.matchAll(new RegExp(re.source, "g"))) {
55
+ if (ctx.isMasked(m.index)) continue; // code sample inside a string
56
+ const around = ctx.window(m.index, 3, 2);
57
+ if (PADDING_RE.test(around)) continue;
58
+ out.push({
59
+ index: m.index,
60
+ confidence: "suspected",
61
+ message: `\`${condense(m[0], 60)}\` joins r and s without padding them to the field size. Signatures whose r or s starts with a zero byte come out too short and fail verification.`,
62
+ });
63
+ }
64
+ }
65
+
66
+ // Node's crypto.sign/verify default to DER; JOSE and WebAuthn want raw.
67
+ if (JS_LANGS.includes(ctx.lang) && JWS_CONTEXT.test(ctx.code)) {
68
+ for (const call of ctx.findCalls(new RegExp(NODE_SIGN_VERIFY.source, "g"))) {
69
+ if (ctx.isMasked(call.index)) continue;
70
+ if (call.args.trim() === "") continue; // `crypto.sign()` in prose, not a call
71
+ if (/dsaEncoding/.test(call.args)) continue;
72
+ out.push({
73
+ index: call.index,
74
+ confidence: "advisory",
75
+ message:
76
+ "`crypto.sign`/`crypto.verify` default to DER-encoded ECDSA signatures, but this file also deals with JWS/base64url data, which uses the raw r‖s form. Set `dsaEncoding: 'ieee-p1363'` (or convert explicitly) so the two never get mixed up.",
77
+ });
78
+ }
79
+ }
80
+
81
+ return out;
82
+ },
83
+ };
@@ -0,0 +1,75 @@
1
+ // Rule: certificate verification switched off.
2
+
3
+ import { CODE_LANGS, condense } from "./_shared.js";
4
+
5
+ const JS_PATTERNS = [
6
+ { re: /\brejectUnauthorized\s*:\s*false\b/, what: "`rejectUnauthorized: false`" },
7
+ { re: /NODE_TLS_REJECT_UNAUTHORIZED["'\]]*\s*=\s*["']?0/, what: "`NODE_TLS_REJECT_UNAUTHORIZED = 0`" },
8
+ { re: /\bstrictSSL\s*:\s*false\b/, what: "`strictSSL: false`" },
9
+ { re: /\bcheckServerIdentity\s*:\s*(?:\([^)]*\)|function\s*\([^)]*\))\s*(?:=>)?\s*(?:\{\s*\}|undefined|null|true)/, what: "a `checkServerIdentity` stub that accepts every host" },
10
+ { re: /\bsecureProtocol\s*:\s*["']SSLv[23]/, what: "an obsolete SSL protocol" },
11
+ ];
12
+
13
+ const PY_PATTERNS = [
14
+ { re: /\bssl\._create_unverified_context\s*\(/, what: "`ssl._create_unverified_context()`" },
15
+ { re: /\bverify_mode\s*=\s*ssl\.CERT_NONE\b|\bssl\.CERT_NONE\b/, what: "`ssl.CERT_NONE`" },
16
+ { re: /\bcheck_hostname\s*=\s*False\b/, what: "`check_hostname = False`" },
17
+ ];
18
+
19
+ const GO_PATTERNS = [{ re: /\bInsecureSkipVerify\s*:\s*true\b/, what: "`InsecureSkipVerify: true`" }];
20
+
21
+ const PY_VERIFY_FALSE = /(?:^|[\s,(])verify\s*=\s*False\b/;
22
+ /** HTTP clients: `verify=False` there is a TLS switch, not a JWT one. */
23
+ const HTTP_CLIENT = /\b(?:requests|httpx|session|s|client)\s*\.\s*(?:get|post|put|patch|delete|head|options|request|send)\s*\(|\brequests\.\w+\(|\bhttpx\.\w+\(/;
24
+
25
+ export default {
26
+ id: "tls-verification-disabled",
27
+ title: "Certificate verification disabled",
28
+ severity: "high",
29
+ confidence: "confirmed",
30
+ languages: CODE_LANGS,
31
+ why:
32
+ "Turning off certificate validation keeps the encryption but removes the identity check, so any machine on the path can present its own certificate and read or rewrite the traffic. " +
33
+ "The usual motive is a self-signed certificate in staging, and the setting then survives into production. " +
34
+ "Trust the specific CA or certificate instead of disabling the check.",
35
+ fix: [
36
+ "// Node: trust your own CA rather than nobody",
37
+ "const agent = new https.Agent({ ca: fs.readFileSync('internal-ca.pem') });",
38
+ "",
39
+ "# Python",
40
+ "requests.get(url, verify='/etc/ssl/internal-ca.pem')",
41
+ "",
42
+ "// Go",
43
+ "tls.Config{RootCAs: pool} // never InsecureSkipVerify: true",
44
+ ].join("\n"),
45
+ docs: "https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html",
46
+
47
+ match(ctx) {
48
+ const out = [];
49
+ const groups = { js: JS_PATTERNS, ts: JS_PATTERNS, python: PY_PATTERNS, go: GO_PATTERNS };
50
+ for (const pattern of groups[ctx.lang] ?? []) {
51
+ for (const m of ctx.matchAll(new RegExp(pattern.re.source, "g"))) {
52
+ if (ctx.isMasked(m.index)) continue; // prose or a scanner pattern, not code
53
+ out.push({
54
+ index: m.index,
55
+ message: `${pattern.what} disables certificate validation — the connection is encrypted but the peer is no longer authenticated, which is exactly what a man-in-the-middle needs.`,
56
+ });
57
+ }
58
+ }
59
+
60
+ if (ctx.lang === "python") {
61
+ for (const m of ctx.matchAll(new RegExp(PY_VERIFY_FALSE.source, "gm"))) {
62
+ if (ctx.isMasked(m.index)) continue;
63
+ const before = ctx.code.slice(Math.max(0, m.index - 200), m.index);
64
+ if (!HTTP_CLIENT.test(before)) continue; // `verify=False` on jwt.decode is a different rule
65
+ if (/\bjwt\b/.test(before)) continue;
66
+ out.push({
67
+ index: m.index,
68
+ message: `\`${condense(m[0], 30)}\` on an HTTP request disables TLS certificate validation. Point \`verify\` at your CA bundle instead of switching the check off.`,
69
+ });
70
+ }
71
+ }
72
+
73
+ return out;
74
+ },
75
+ };
@@ -0,0 +1,60 @@
1
+ // Rule: the boolean returned by a verification call is thrown away.
2
+
3
+ import { JS_LANGS, condense } from "./_shared.js";
4
+
5
+ // Only APIs that RETURN a boolean are listed. Libraries that throw on failure
6
+ // (jsonwebtoken, jose, python-cryptography) are correct to call as a statement,
7
+ // so they are deliberately excluded — this rule must not punish good code.
8
+ const JS_BOOLEAN_VERIFY =
9
+ /\b(?:crypto|secp256k1|k256|p256|p384|p521|ed25519|ecdsa|nacl\.sign\.detached|curve|verifier|publicKey|pubKey|key)\s*\.\s*verify\b|\b(?:verifySignature|isValidSignature|checkSignature|verifyDigest)\b/;
10
+ const GO_BOOLEAN_VERIFY = /\b(?:ecdsa\.Verify(?:ASN1)?|ed25519\.Verify|secp256k1\.Verify|btcec\.Verify)\b/;
11
+
12
+ const THROWING_LIBS = /\b(?:jwt|jose|jsonwebtoken)\s*\.\s*verify\b|\bjwtVerify\b|\bcompactVerify\b/;
13
+
14
+ /** The call must stand alone as a statement — not assigned, returned or tested. */
15
+ function isBareStatement(code, call) {
16
+ let start = call.index - 1;
17
+ while (start >= 0 && !";{}\n".includes(code[start])) start--;
18
+ const prefix = code.slice(start + 1, call.index).trim();
19
+ if (!/^(?:await\s+|void\s+)?$/.test(prefix)) return false;
20
+ const after = code.slice(call.end + 1, call.end + 40);
21
+ return /^\s*;?\s*(?:\r?\n|$)/.test(after);
22
+ }
23
+
24
+ export default {
25
+ id: "unchecked-verification-result",
26
+ title: "Verification result is never checked",
27
+ severity: "high",
28
+ confidence: "suspected",
29
+ languages: [...JS_LANGS, "go"],
30
+ why:
31
+ "`crypto.verify`, `secp256k1.verify` and `ecdsa.Verify` report failure by returning `false`, not by throwing. " +
32
+ "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. " +
33
+ "The result must control a branch: an `if`, an early return, or a thrown error.",
34
+ fix: [
35
+ "// bad",
36
+ "crypto.verify(null, data, publicKey, signature);",
37
+ "",
38
+ "// good",
39
+ "if (!crypto.verify(null, data, publicKey, signature)) {",
40
+ " throw new Error('invalid signature');",
41
+ "}",
42
+ ].join("\n"),
43
+ docs: "https://nodejs.org/api/crypto.html#cryptoverifyalgorithm-data-key-signature-callback",
44
+
45
+ match(ctx) {
46
+ const out = [];
47
+ const calleeRe = ctx.lang === "go" ? GO_BOOLEAN_VERIFY : JS_BOOLEAN_VERIFY;
48
+ for (const call of ctx.findCalls(new RegExp(calleeRe.source, "g"))) {
49
+ if (ctx.isMasked(call.index)) continue; // a code sample, not a call
50
+ if (call.args.trim() === "") continue;
51
+ if (THROWING_LIBS.test(call.callee)) continue;
52
+ if (!isBareStatement(ctx.code, call)) continue;
53
+ out.push({
54
+ index: call.index,
55
+ message: `\`${condense(call.callee, 40)}(...)\` returns a boolean that is discarded here, so a failed signature check changes nothing. Wrap it in a condition and reject on \`false\`.`,
56
+ });
57
+ }
58
+ return out;
59
+ },
60
+ };
@@ -0,0 +1,72 @@
1
+ // Rule: public keys built from raw coordinates without an on-curve check, and
2
+ // hand-rolled curve arithmetic.
3
+
4
+ import { CODE_LANGS, condense } from "./_shared.js";
5
+
6
+ const VALIDATED = /\.validate\s*\(|assertValidity|isOnCurve|IsOnCurve|is_on_curve|validatePoint|checkPoint|verifyPoint/;
7
+
8
+ const RAW_POINT_BUILDERS = [
9
+ { re: /\bkeyFromPublic\s*\(/, what: "elliptic `keyFromPublic`" },
10
+ { re: /\b(?:ProjectivePoint|Point|ExtendedPoint)\s*\.\s*fromAffine\s*\(/, what: "`Point.fromAffine`" },
11
+ { re: /\bnew\s+ecdsa\.PublicKey\b|\becdsa\.PublicKey\s*\{/, what: "a literal `ecdsa.PublicKey{X, Y}`" },
12
+ { re: /\bEllipticCurvePublicNumbers\s*\(/, what: "`EllipticCurvePublicNumbers`" },
13
+ { re: /\bVerifyingKey\.from_public_point\s*\(/, what: "`VerifyingKey.from_public_point`" },
14
+ ];
15
+
16
+ /** Signs that the file implements curve arithmetic by hand. */
17
+ const HANDROLLED = /\b(?:pointAdd|point_add|pointDouble|point_double|pointMultiply|pointMul|scalarMult|scalar_mult|modInverse|mod_inverse|modPow|mod_pow|invert)\s*\(/;
18
+
19
+ export default {
20
+ id: "unvalidated-public-key-point",
21
+ title: "Public key built from raw coordinates without an on-curve check",
22
+ severity: "medium",
23
+ confidence: "advisory",
24
+ languages: CODE_LANGS,
25
+ why:
26
+ "When a public key arrives as raw x/y coordinates (a JWK, a database column, an API parameter), the receiver must check that the point actually lies on the expected curve and is not the point at infinity. " +
27
+ "A point from a different, weaker curve turns the subsequent scalar multiplication into an invalid-curve attack that can leak bits of the private key. " +
28
+ "High-level libraries usually validate for you — this rule flags the places worth confirming, not proven defects.",
29
+ fix: [
30
+ "// elliptic: validate what you import",
31
+ "const key = ec.keyFromPublic({ x, y }, 'hex');",
32
+ "if (!key.validate().result) throw new Error('point is not on the curve');",
33
+ "",
34
+ "// noble: assertValidity after building a point from coordinates",
35
+ "const point = secp256k1.Point.fromAffine({ x, y });",
36
+ "point.assertValidity();",
37
+ "",
38
+ "// Go",
39
+ "if !curve.IsOnCurve(x, y) { return errors.New(\"point not on curve\") }",
40
+ ].join("\n"),
41
+ docs: "https://csrc.nist.gov/pubs/sp/800/56/a/r3/final",
42
+
43
+ match(ctx) {
44
+ const out = [];
45
+ const fileValidates = VALIDATED.test(ctx.code);
46
+
47
+ if (!fileValidates) {
48
+ for (const builder of RAW_POINT_BUILDERS) {
49
+ for (const m of ctx.matchAll(new RegExp(builder.re.source, "g"))) {
50
+ if (ctx.isMasked(m.index)) continue;
51
+ out.push({
52
+ index: m.index,
53
+ confidence: "advisory",
54
+ message: `A public key is built from raw coordinates via ${builder.what} and nothing in this file checks that the point is on the curve. If those coordinates can come from a request, a database row or a JWK, add an explicit validity check.`,
55
+ });
56
+ break; // one reminder per builder is enough
57
+ }
58
+ }
59
+ }
60
+
61
+ const handrolled = ctx.structure.match(new RegExp(HANDROLLED.source));
62
+ if (handrolled && /\b(?:curve|ecdsa|secp|p256|generator|\bGx\b|\bGy\b)/i.test(ctx.structure)) {
63
+ out.push({
64
+ index: handrolled.index,
65
+ confidence: "advisory",
66
+ message: `This file implements curve arithmetic by hand (\`${condense(handrolled[0], 30)}\`). Hand-written field and point operations are where invalid-curve handling, non-constant-time branches and edge cases (point at infinity, s = 0) usually go wrong — prefer a reviewed library unless this is deliberately an educational implementation.`,
67
+ });
68
+ }
69
+
70
+ return out;
71
+ },
72
+ };
@@ -0,0 +1,83 @@
1
+ // Rule: SHA-1 or MD5 used as the digest of a signature.
2
+
3
+ import { CODE_LANGS, condense } from "./_shared.js";
4
+
5
+ const CONFIRMED_PATTERNS = [
6
+ /\bcreate(?:Sign|Verify)\s*\(\s*["'](?:sha-?1|md5|RSA-SHA1|RSA-MD5|ecdsa-with-SHA1)["']/i,
7
+ /["'](?:sha1WithRSA(?:Encryption)?|md5WithRSA(?:Encryption)?|ecdsa-with-SHA1|sha1WithRSASignature)["']/i,
8
+ /\bx509\.(?:SHA1WithRSA|ECDSAWithSHA1|MD5WithRSA|MD2WithRSA)\b/,
9
+ /\bSign\w*\([^)]{0,160}?\bcrypto\.(?:SHA1|MD5)\b/,
10
+ /\b(?:sign|verify)\s*\([^)]{0,200}?\bhashes\.(?:SHA1|MD5)\s*\(/s,
11
+ /\bsignature_algorithm\s*=\s*["']?(?:SHA1|MD5)/i,
12
+ ];
13
+
14
+ const ADVISORY_PATTERNS = [
15
+ /\bcreateHash\s*\(\s*["'](?:sha-?1|md5)["']\s*\)/i,
16
+ /\bhashlib\.(?:sha1|md5)\s*\(/,
17
+ /\bsha1\.(?:New|Sum)\s*\(|\bmd5\.(?:New|Sum)\s*\(/,
18
+ /\bhashes\.(?:SHA1|MD5)\s*\(/,
19
+ ];
20
+
21
+ const SIGNING_CONTEXT = /\b(sign|signature|signing|verify|certificate|csr|x509|jws|jwt)\b/i;
22
+
23
+ // Lookup tables that merely *name* legacy algorithms — OID maps in certificate
24
+ // parsers, label dictionaries, UI drop-downs — are documentation, not usage.
25
+ const LOOKUP_TABLE_LINE = /\b\d+\.\d+\.\d+(?:\.\d+)+\b|\b(?:label|name|title|display|description|text|oid)\s*:/i;
26
+
27
+ export default {
28
+ id: "weak-signature-hash",
29
+ title: "SHA-1 or MD5 used for a signature",
30
+ severity: "high",
31
+ confidence: "confirmed",
32
+ languages: CODE_LANGS,
33
+ why:
34
+ "A signature is only as strong as the hash it covers, and both MD5 and SHA-1 have practical chosen-prefix collisions (SHA-1 since SHAttered/2017, for a few tens of thousands of dollars of compute). " +
35
+ "With a collision an attacker gets one signature that is valid for two different documents, which breaks certificates, software updates and any signed message. " +
36
+ "NIST disallowed SHA-1 for digital signatures in 2013 and retired it entirely at the end of 2030.",
37
+ fix: [
38
+ "// Node",
39
+ "const signer = crypto.createSign('sha256');",
40
+ "",
41
+ "# Python (cryptography)",
42
+ "key.sign(data, ec.ECDSA(hashes.SHA256()))",
43
+ "",
44
+ "// Go",
45
+ "sum := sha256.Sum256(data)",
46
+ "sig, err := ecdsa.SignASN1(rand.Reader, key, sum[:])",
47
+ ].join("\n"),
48
+ docs: "https://csrc.nist.gov/news/2022/nist-transitioning-away-from-sha-1-for-all-apps",
49
+
50
+ match(ctx) {
51
+ const out = [];
52
+ const reported = new Set();
53
+
54
+ for (const re of CONFIRMED_PATTERNS) {
55
+ for (const m of ctx.matchAll(new RegExp(re.source, `${re.flags.replace("g", "")}g`))) {
56
+ if (ctx.isMasked(m.index)) continue;
57
+ if (LOOKUP_TABLE_LINE.test(ctx.lineOf(m.index))) continue;
58
+ reported.add(m.index);
59
+ out.push({
60
+ index: m.index,
61
+ confidence: "confirmed",
62
+ message: `\`${condense(m[0], 70)}\` signs or verifies with a broken digest. Collisions in SHA-1/MD5 let one signature cover two different messages.`,
63
+ });
64
+ }
65
+ }
66
+
67
+ if (!SIGNING_CONTEXT.test(ctx.code)) return out;
68
+ for (const re of ADVISORY_PATTERNS) {
69
+ for (const m of ctx.matchAll(new RegExp(re.source, "gi"))) {
70
+ if (ctx.isMasked(m.index)) continue;
71
+ if (LOOKUP_TABLE_LINE.test(ctx.lineOf(m.index))) continue;
72
+ if ([...reported].some((i) => Math.abs(i - m.index) < 80)) continue;
73
+ out.push({
74
+ index: m.index,
75
+ confidence: "advisory",
76
+ message: `\`${condense(m[0], 50)}\` computes a SHA-1/MD5 digest in a file that also signs or verifies data. If this digest ever feeds a signature, it must be replaced with SHA-256; for non-security uses (ETags, cache keys, legacy interop) it is fine.`,
77
+ });
78
+ }
79
+ }
80
+
81
+ return out;
82
+ },
83
+ };