@quantakrypto/core 0.4.2 → 0.4.4

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.
Files changed (223) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +38 -10
  3. package/dist/agent-types.js.map +1 -1
  4. package/dist/baseline.js.map +1 -1
  5. package/dist/cache.js.map +1 -1
  6. package/dist/cbom-merge.d.ts +9 -0
  7. package/dist/cbom-merge.d.ts.map +1 -0
  8. package/dist/cbom-merge.js +99 -0
  9. package/dist/cbom-merge.js.map +1 -0
  10. package/dist/cbom.js.map +1 -1
  11. package/dist/changed.js.map +1 -1
  12. package/dist/codemods/config-toggle.js.map +1 -1
  13. package/dist/codemods/registry.js.map +1 -1
  14. package/dist/comments.d.ts +29 -2
  15. package/dist/comments.d.ts.map +1 -1
  16. package/dist/comments.js +178 -18
  17. package/dist/comments.js.map +1 -1
  18. package/dist/config.js.map +1 -1
  19. package/dist/cwe.js.map +1 -1
  20. package/dist/dependencies.d.ts +3 -2
  21. package/dist/dependencies.d.ts.map +1 -1
  22. package/dist/dependencies.js +138 -2
  23. package/dist/dependencies.js.map +1 -1
  24. package/dist/detect-utils.d.ts +19 -4
  25. package/dist/detect-utils.d.ts.map +1 -1
  26. package/dist/detect-utils.js +43 -6
  27. package/dist/detect-utils.js.map +1 -1
  28. package/dist/detectors/c.d.ts +4 -2
  29. package/dist/detectors/c.d.ts.map +1 -1
  30. package/dist/detectors/c.js +382 -3
  31. package/dist/detectors/c.js.map +1 -1
  32. package/dist/detectors/cicd.d.ts +27 -0
  33. package/dist/detectors/cicd.d.ts.map +1 -0
  34. package/dist/detectors/cicd.js +116 -0
  35. package/dist/detectors/cicd.js.map +1 -0
  36. package/dist/detectors/cloud-kms.d.ts +21 -0
  37. package/dist/detectors/cloud-kms.d.ts.map +1 -0
  38. package/dist/detectors/cloud-kms.js +57 -0
  39. package/dist/detectors/cloud-kms.js.map +1 -0
  40. package/dist/detectors/cloudformation.d.ts +30 -0
  41. package/dist/detectors/cloudformation.d.ts.map +1 -0
  42. package/dist/detectors/cloudformation.js +170 -0
  43. package/dist/detectors/cloudformation.js.map +1 -0
  44. package/dist/detectors/csharp.d.ts.map +1 -1
  45. package/dist/detectors/csharp.js +141 -3
  46. package/dist/detectors/csharp.js.map +1 -1
  47. package/dist/detectors/database.d.ts +17 -0
  48. package/dist/detectors/database.d.ts.map +1 -0
  49. package/dist/detectors/database.js +60 -0
  50. package/dist/detectors/database.js.map +1 -0
  51. package/dist/detectors/dnssec.d.ts +55 -0
  52. package/dist/detectors/dnssec.d.ts.map +1 -0
  53. package/dist/detectors/dnssec.js +107 -0
  54. package/dist/detectors/dnssec.js.map +1 -0
  55. package/dist/detectors/elixir.d.ts +20 -0
  56. package/dist/detectors/elixir.d.ts.map +1 -0
  57. package/dist/detectors/elixir.js +195 -0
  58. package/dist/detectors/elixir.js.map +1 -0
  59. package/dist/detectors/go.d.ts +5 -3
  60. package/dist/detectors/go.d.ts.map +1 -1
  61. package/dist/detectors/go.js +187 -3
  62. package/dist/detectors/go.js.map +1 -1
  63. package/dist/detectors/java.d.ts.map +1 -1
  64. package/dist/detectors/java.js +169 -4
  65. package/dist/detectors/java.js.map +1 -1
  66. package/dist/detectors/jose.d.ts +20 -0
  67. package/dist/detectors/jose.d.ts.map +1 -0
  68. package/dist/detectors/jose.js +62 -0
  69. package/dist/detectors/jose.js.map +1 -0
  70. package/dist/detectors/jwk.d.ts +25 -0
  71. package/dist/detectors/jwk.d.ts.map +1 -0
  72. package/dist/detectors/jwk.js +95 -0
  73. package/dist/detectors/jwk.js.map +1 -0
  74. package/dist/detectors/k8s.d.ts +17 -0
  75. package/dist/detectors/k8s.d.ts.map +1 -0
  76. package/dist/detectors/k8s.js +85 -0
  77. package/dist/detectors/k8s.js.map +1 -0
  78. package/dist/detectors/mesh.d.ts +35 -0
  79. package/dist/detectors/mesh.d.ts.map +1 -0
  80. package/dist/detectors/mesh.js +109 -0
  81. package/dist/detectors/mesh.js.map +1 -0
  82. package/dist/detectors/messaging.d.ts +16 -0
  83. package/dist/detectors/messaging.d.ts.map +1 -0
  84. package/dist/detectors/messaging.js +72 -0
  85. package/dist/detectors/messaging.js.map +1 -0
  86. package/dist/detectors/pem.d.ts.map +1 -1
  87. package/dist/detectors/pem.js +50 -2
  88. package/dist/detectors/pem.js.map +1 -1
  89. package/dist/detectors/php.d.ts +24 -0
  90. package/dist/detectors/php.d.ts.map +1 -0
  91. package/dist/detectors/php.js +167 -0
  92. package/dist/detectors/php.js.map +1 -0
  93. package/dist/detectors/python.d.ts.map +1 -1
  94. package/dist/detectors/python.js +144 -3
  95. package/dist/detectors/python.js.map +1 -1
  96. package/dist/detectors/ruby.d.ts +9 -5
  97. package/dist/detectors/ruby.d.ts.map +1 -1
  98. package/dist/detectors/ruby.js +122 -2
  99. package/dist/detectors/ruby.js.map +1 -1
  100. package/dist/detectors/rust.d.ts +8 -3
  101. package/dist/detectors/rust.d.ts.map +1 -1
  102. package/dist/detectors/rust.js +253 -3
  103. package/dist/detectors/rust.js.map +1 -1
  104. package/dist/detectors/secrets.d.ts +21 -0
  105. package/dist/detectors/secrets.d.ts.map +1 -0
  106. package/dist/detectors/secrets.js +79 -0
  107. package/dist/detectors/secrets.js.map +1 -0
  108. package/dist/detectors/source.d.ts.map +1 -1
  109. package/dist/detectors/source.js +305 -52
  110. package/dist/detectors/source.js.map +1 -1
  111. package/dist/detectors/stateful-hbs.d.ts +22 -0
  112. package/dist/detectors/stateful-hbs.d.ts.map +1 -0
  113. package/dist/detectors/stateful-hbs.js +142 -0
  114. package/dist/detectors/stateful-hbs.js.map +1 -0
  115. package/dist/detectors/terraform.d.ts +21 -0
  116. package/dist/detectors/terraform.d.ts.map +1 -0
  117. package/dist/detectors/terraform.js +123 -0
  118. package/dist/detectors/terraform.js.map +1 -0
  119. package/dist/detectors/vpn.d.ts +22 -0
  120. package/dist/detectors/vpn.d.ts.map +1 -0
  121. package/dist/detectors/vpn.js +99 -0
  122. package/dist/detectors/vpn.js.map +1 -0
  123. package/dist/errors.js.map +1 -1
  124. package/dist/evidence.d.ts +53 -0
  125. package/dist/evidence.d.ts.map +1 -0
  126. package/dist/evidence.js +81 -0
  127. package/dist/evidence.js.map +1 -0
  128. package/dist/index.d.ts +9 -2
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +10 -2
  131. package/dist/index.js.map +1 -1
  132. package/dist/inventory.d.ts +2 -0
  133. package/dist/inventory.d.ts.map +1 -1
  134. package/dist/inventory.js +27 -1
  135. package/dist/inventory.js.map +1 -1
  136. package/dist/parallel.js.map +1 -1
  137. package/dist/patch-policy.js.map +1 -1
  138. package/dist/policy.d.ts +67 -0
  139. package/dist/policy.d.ts.map +1 -0
  140. package/dist/policy.js +115 -0
  141. package/dist/policy.js.map +1 -0
  142. package/dist/redact.d.ts.map +1 -1
  143. package/dist/redact.js +39 -0
  144. package/dist/redact.js.map +1 -1
  145. package/dist/registry.d.ts +2 -2
  146. package/dist/registry.d.ts.map +1 -1
  147. package/dist/registry.js +34 -2
  148. package/dist/registry.js.map +1 -1
  149. package/dist/remediate-pipeline.d.ts +3 -0
  150. package/dist/remediate-pipeline.d.ts.map +1 -1
  151. package/dist/remediate-pipeline.js +71 -0
  152. package/dist/remediate-pipeline.js.map +1 -1
  153. package/dist/remediate-request.js.map +1 -1
  154. package/dist/remediation.d.ts +9 -3
  155. package/dist/remediation.d.ts.map +1 -1
  156. package/dist/remediation.js +17 -5
  157. package/dist/remediation.js.map +1 -1
  158. package/dist/report.d.ts +9 -0
  159. package/dist/report.d.ts.map +1 -1
  160. package/dist/report.js +38 -0
  161. package/dist/report.js.map +1 -1
  162. package/dist/scan-worker.js.map +1 -1
  163. package/dist/scan.d.ts.map +1 -1
  164. package/dist/scan.js +8 -1
  165. package/dist/scan.js.map +1 -1
  166. package/dist/severity.js.map +1 -1
  167. package/dist/standards.d.ts +96 -0
  168. package/dist/standards.d.ts.map +1 -0
  169. package/dist/standards.js +107 -0
  170. package/dist/standards.js.map +1 -0
  171. package/dist/triage.js.map +1 -1
  172. package/dist/types.d.ts +2 -2
  173. package/dist/types.d.ts.map +1 -1
  174. package/dist/types.js.map +1 -1
  175. package/dist/verify.js.map +1 -1
  176. package/dist/version.d.ts +1 -1
  177. package/dist/version.js +1 -1
  178. package/dist/version.js.map +1 -1
  179. package/dist/walk.js.map +1 -1
  180. package/dist/worktree.js.map +1 -1
  181. package/package.json +1 -1
  182. package/src/cbom-merge.ts +116 -0
  183. package/src/comments.ts +183 -18
  184. package/src/dependencies.ts +144 -4
  185. package/src/detect-utils.ts +47 -6
  186. package/src/detectors/c.ts +411 -5
  187. package/src/detectors/cicd.ts +158 -0
  188. package/src/detectors/cloud-kms.ts +87 -0
  189. package/src/detectors/cloudformation.ts +215 -0
  190. package/src/detectors/csharp.ts +154 -3
  191. package/src/detectors/database.ts +88 -0
  192. package/src/detectors/dnssec.ts +186 -0
  193. package/src/detectors/elixir.ts +237 -0
  194. package/src/detectors/go.ts +209 -6
  195. package/src/detectors/java.ts +191 -4
  196. package/src/detectors/jose.ts +94 -0
  197. package/src/detectors/jwk.ts +128 -0
  198. package/src/detectors/k8s.ts +112 -0
  199. package/src/detectors/mesh.ts +154 -0
  200. package/src/detectors/messaging.ts +98 -0
  201. package/src/detectors/pem.ts +54 -2
  202. package/src/detectors/php.ts +225 -0
  203. package/src/detectors/python.ts +153 -3
  204. package/src/detectors/ruby.ts +138 -7
  205. package/src/detectors/rust.ts +282 -7
  206. package/src/detectors/secrets.ts +114 -0
  207. package/src/detectors/source.ts +382 -72
  208. package/src/detectors/stateful-hbs.ts +180 -0
  209. package/src/detectors/terraform.ts +154 -0
  210. package/src/detectors/vpn.ts +131 -0
  211. package/src/evidence.ts +135 -0
  212. package/src/index.ts +15 -1
  213. package/src/inventory.ts +31 -1
  214. package/src/policy.ts +180 -0
  215. package/src/redact.ts +41 -0
  216. package/src/registry.ts +34 -2
  217. package/src/remediate-pipeline.ts +78 -0
  218. package/src/remediation.ts +23 -8
  219. package/src/report.ts +48 -2
  220. package/src/scan.ts +13 -1
  221. package/src/standards.ts +186 -0
  222. package/src/types.ts +3 -1
  223. package/src/version.ts +1 -1
@@ -29,6 +29,7 @@
29
29
  import type { Detector, Finding, RuleMeta } from "../types.js";
30
30
  import {
31
31
  JS_TS_EXTENSIONS,
32
+ DOC_EXTENSIONS,
32
33
  JWT_HOST_EXTENSIONS,
33
34
  eachMatch,
34
35
  findingFromRule,
@@ -45,6 +46,135 @@ import { CWE_BROKEN_CRYPTO, CWE_CERT_VALIDATION, CWE_WEAK_STRENGTH } from "../cw
45
46
  // (ordered alternation would otherwise match `rsa` and reject the `-pss` tail).
46
47
  const RE_GENERATE_KEYPAIR =
47
48
  /generateKeyPair(?:Sync)?\s*\(\s*['"`](rsa-pss|rsa|ec|dsa|dh|x25519|x448|ed25519|ed448)['"`]/g;
49
+
50
+ /** Per-key-type classification for `generateKeyPair(Sync)('<type>', …)`. Hoisted
51
+ * to module scope so the direct matcher AND the import-alias pass (below) share
52
+ * one source of truth for how each key type is classified. */
53
+ interface KeygenInfo {
54
+ algo: Finding["algorithm"];
55
+ cat: Finding["category"];
56
+ sev: Finding["severity"];
57
+ hndl: boolean;
58
+ label: string;
59
+ message?: string;
60
+ remediation?: string;
61
+ }
62
+ const KEYGEN_INFO: Record<string, KeygenInfo> = {
63
+ rsa: { algo: "RSA", cat: "kem", sev: "high", hndl: true, label: "RSA" },
64
+ // RSA-PSS is signature-only, so classify it as a (forgeable) signature
65
+ // rather than a KEM — no HNDL confidentiality exposure.
66
+ "rsa-pss": {
67
+ algo: "RSA",
68
+ cat: "signature",
69
+ sev: "high",
70
+ hndl: false,
71
+ label: "RSA-PSS",
72
+ message: "Generates a classical RSA-PSS signing key, which is forgeable by a quantum attacker.",
73
+ remediation: "ML-DSA-65 (FIPS 204) or SLH-DSA (FIPS 205)",
74
+ },
75
+ // EC keys feed BOTH ECDSA (sign) and ECDH (key agreement). ECDH is
76
+ // HNDL-exposed, so classify conservatively as key-exchange-capable and
77
+ // surface both concerns rather than asserting signature-only (P0-4).
78
+ ec: {
79
+ algo: "ECDH",
80
+ cat: "key-exchange",
81
+ sev: "high",
82
+ hndl: true,
83
+ label: "EC (ECDSA/ECDH)",
84
+ message:
85
+ "Generates a classical EC key pair. EC keys feed BOTH ECDSA signatures " +
86
+ "and ECDH key agreement; the ECDH path is harvest-now-decrypt-later exposed.",
87
+ remediation:
88
+ "For key agreement: hybrid X25519MLKEM768 (ML-KEM-768). For signatures: ML-DSA-65 (FIPS 204).",
89
+ },
90
+ dsa: { algo: "DSA", cat: "signature", sev: "high", hndl: false, label: "DSA" },
91
+ dh: { algo: "DH", cat: "key-exchange", sev: "high", hndl: true, label: "Diffie-Hellman" },
92
+ x25519: { algo: "X25519", cat: "key-exchange", sev: "medium", hndl: true, label: "X25519" },
93
+ x448: { algo: "X448", cat: "key-exchange", sev: "medium", hndl: true, label: "X448" },
94
+ ed25519: { algo: "EdDSA", cat: "signature", sev: "low", hndl: false, label: "Ed25519" },
95
+ ed448: { algo: "EdDSA", cat: "signature", sev: "low", hndl: false, label: "Ed448" },
96
+ };
97
+
98
+ /** Emit a keygen finding for `generateKeyPair(Sync)('<type>')` at [index]. Shared
99
+ * by the direct matcher and the import-alias pass so both classify identically. */
100
+ function pushKeygenFinding(
101
+ findings: Finding[],
102
+ rawType: string,
103
+ file: string,
104
+ content: string,
105
+ index: number,
106
+ matchLength: number,
107
+ ): void {
108
+ const info = KEYGEN_INFO[rawType.toLowerCase()];
109
+ if (!info) return;
110
+ findings.push(
111
+ findingFromRule(
112
+ RULE_NODE_KEYGEN,
113
+ { file, content, index, matchLength },
114
+ {
115
+ title: `${info.label} key generation`,
116
+ category: info.cat,
117
+ severity: info.sev,
118
+ algorithm: info.algo,
119
+ hndl: info.hndl,
120
+ message:
121
+ info.message ??
122
+ `Generates a classical ${info.label} key pair, which is not quantum-safe.`,
123
+ ...(info.remediation ? { remediation: info.remediation } : {}),
124
+ },
125
+ ),
126
+ );
127
+ }
128
+
129
+ /** Canonical Node-crypto call names whose import-aliases we resolve. */
130
+ const ALIASABLE: readonly string[] = [
131
+ "generateKeyPairSync",
132
+ "generateKeyPair",
133
+ "createECDH",
134
+ "createDiffieHellman",
135
+ "createDiffieHellmanGroup",
136
+ ];
137
+
138
+ /**
139
+ * Collect import-aliases for the canonical crypto calls we resolve, so
140
+ * `import { generateKeyPairSync as gk } from 'node:crypto'` (or the CommonJS
141
+ * `const { generateKeyPairSync: gk } = require('crypto')`) is followed when `gk`
142
+ * is called later. Returns Map<canonical, alias[]>. An alias equal to its own
143
+ * canonical name is skipped (already matched directly). Precision-safe: the
144
+ * alias is only ever bound to a known crypto symbol, so a later `gk(` call is
145
+ * unambiguously that symbol.
146
+ */
147
+ function collectCryptoAliases(content: string): Map<string, string[]> {
148
+ const out = new Map<string, string[]>();
149
+ const add = (canonical: string, alias: string): void => {
150
+ if (!alias || alias === canonical) return;
151
+ const list = out.get(canonical) ?? [];
152
+ if (!list.includes(alias)) list.push(alias);
153
+ out.set(canonical, list);
154
+ };
155
+ // ESM named imports: `import { A as B, C as D } from '...'`.
156
+ const esm = /import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"][^'"]*['"]/g;
157
+ for (let m = esm.exec(content); m; m = esm.exec(content)) {
158
+ const spec = /([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)/g;
159
+ for (let s = spec.exec(m[1]); s; s = spec.exec(m[1])) {
160
+ if (ALIASABLE.includes(s[1])) add(s[1], s[2]);
161
+ }
162
+ }
163
+ // CommonJS destructure-rename: `const { A: B } = require('...')`.
164
+ const cjs = /(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\s*\(\s*['"][^'"]*['"]\s*\)/g;
165
+ for (let m = cjs.exec(content); m; m = cjs.exec(content)) {
166
+ const spec = /([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)/g;
167
+ for (let s = spec.exec(m[1]); s; s = spec.exec(m[1])) {
168
+ if (ALIASABLE.includes(s[1])) add(s[1], s[2]);
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /** Escape a string for safe interpolation into a dynamically-built RegExp. */
175
+ function escapeRe(s: string): string {
176
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
177
+ }
48
178
  const RE_CREATE_SIGN_VERIFY = /create(?:Sign|Verify)\s*\(/g;
49
179
  // One-shot crypto.sign/verify(algorithm, data, key). A LOOKBEHIND (not a
50
180
  // consumed char) anchors it so it doesn't fire inside identifiers like `assign(`
@@ -81,7 +211,7 @@ const RE_JSRSASIGN_SIGN = /KJUR\.crypto\.(?:Signature|ECDSA)\b/g;
81
211
  const RE_NODE_RSA = /new\s+NodeRSA\s*\(/g;
82
212
  // secp256k1 — direct @noble/secp256k1 / secp256k1-style API usage in source.
83
213
  const RE_SECP256K1 =
84
- /\b(?:secp(?:256k1)?|secp)\s*\.\s*(?:sign|verify|getPublicKey|getSharedSecret|ecdh|recoverPublicKey)\s*\(/g;
214
+ /\b(?:secp(?:256k1)?|secp)\s*\.\s*(sign|verify|getPublicKey|getSharedSecret|ecdh|recoverPublicKey)\s*\(/g;
85
215
 
86
216
  // JWT/JOSE.
87
217
  const RE_JWT_ALG = /['"`](RS(?:256|384|512)|PS(?:256|384|512)|ES(?:256|384|512|256K)|EdDSA)['"`]/g;
@@ -237,74 +367,54 @@ const nodeCryptoDetector: Detector = {
237
367
 
238
368
  // generateKeyPair(Sync)('rsa' | 'ec' | 'dsa' | 'dh' | 'x25519' | 'ed25519', ...)
239
369
  eachMatch(RE_GENERATE_KEYPAIR, content, (m) => {
240
- const type = m[1].toLowerCase();
241
- const map: Record<
242
- string,
243
- {
244
- algo: Finding["algorithm"];
245
- cat: Finding["category"];
246
- sev: Finding["severity"];
247
- hndl: boolean;
248
- label: string;
249
- message?: string;
250
- remediation?: string;
251
- }
252
- > = {
253
- rsa: { algo: "RSA", cat: "kem", sev: "high", hndl: true, label: "RSA" },
254
- // RSA-PSS is signature-only, so classify it as a (forgeable) signature
255
- // rather than a KEM — no HNDL confidentiality exposure.
256
- "rsa-pss": {
257
- algo: "RSA",
258
- cat: "signature",
259
- sev: "high",
260
- hndl: false,
261
- label: "RSA-PSS",
262
- message:
263
- "Generates a classical RSA-PSS signing key, which is forgeable by a quantum attacker.",
264
- remediation: "ML-DSA-65 (FIPS 204) or SLH-DSA (FIPS 205)",
265
- },
266
- // EC keys feed BOTH ECDSA (sign) and ECDH (key agreement). ECDH is
267
- // HNDL-exposed, so classify conservatively as key-exchange-capable and
268
- // surface both concerns rather than asserting signature-only (P0-4).
269
- ec: {
270
- algo: "ECDH",
271
- cat: "key-exchange",
272
- sev: "high",
273
- hndl: true,
274
- label: "EC (ECDSA/ECDH)",
275
- message:
276
- "Generates a classical EC key pair. EC keys feed BOTH ECDSA signatures " +
277
- "and ECDH key agreement; the ECDH path is harvest-now-decrypt-later exposed.",
278
- remediation:
279
- "For key agreement: hybrid X25519MLKEM768 (ML-KEM-768). For signatures: ML-DSA-65 (FIPS 204).",
280
- },
281
- dsa: { algo: "DSA", cat: "signature", sev: "high", hndl: false, label: "DSA" },
282
- dh: { algo: "DH", cat: "key-exchange", sev: "high", hndl: true, label: "Diffie-Hellman" },
283
- x25519: { algo: "X25519", cat: "key-exchange", sev: "low", hndl: true, label: "X25519" },
284
- x448: { algo: "X448", cat: "key-exchange", sev: "low", hndl: true, label: "X448" },
285
- ed25519: { algo: "EdDSA", cat: "signature", sev: "low", hndl: false, label: "Ed25519" },
286
- ed448: { algo: "EdDSA", cat: "signature", sev: "low", hndl: false, label: "Ed448" },
287
- };
288
- const info = map[type];
289
- findings.push(
290
- findingFromRule(
291
- RULE_NODE_KEYGEN,
292
- { file, content, index: m.index, matchLength: m[0].length },
293
- {
294
- title: `${info.label} key generation`,
295
- category: info.cat,
296
- severity: info.sev,
297
- algorithm: info.algo,
298
- hndl: info.hndl,
299
- message:
300
- info.message ??
301
- `Generates a classical ${info.label} key pair, which is not quantum-safe.`,
302
- ...(info.remediation ? { remediation: info.remediation } : {}),
303
- },
304
- ),
305
- );
370
+ pushKeygenFinding(findings, m[1], file, content, m.index, m[0].length);
306
371
  });
307
372
 
373
+ // Import-alias resolution: follow `import { generateKeyPairSync as gk }` (and
374
+ // the CommonJS destructure-rename) so an aliased call still detects. Only the
375
+ // keygen / ECDH / DH constructors are resolved — their classification is
376
+ // unambiguous. The alias regexes run on the ORIGINAL content, so locations
377
+ // stay exact; it fires only when the alias was explicitly bound to a known
378
+ // crypto symbol, so precision is unaffected.
379
+ const aliases = collectCryptoAliases(content);
380
+ for (const [canonical, names] of aliases) {
381
+ for (const alias of names) {
382
+ const a = escapeRe(alias);
383
+ if (canonical === "generateKeyPairSync" || canonical === "generateKeyPair") {
384
+ const re = new RegExp(
385
+ `\\b${a}\\s*\\(\\s*['"\`](rsa-pss|rsa|ec|dsa|dh|x25519|x448|ed25519|ed448)['"\`]`,
386
+ "g",
387
+ );
388
+ eachMatch(re, content, (m) =>
389
+ pushKeygenFinding(findings, m[1], file, content, m.index, m[0].length),
390
+ );
391
+ } else if (canonical === "createECDH") {
392
+ eachMatch(new RegExp(`\\b${a}\\s*\\(`, "g"), content, (m) =>
393
+ findings.push(
394
+ findingFromRule(RULE_NODE_ECDH, {
395
+ file,
396
+ content,
397
+ index: m.index,
398
+ matchLength: m[0].length,
399
+ }),
400
+ ),
401
+ );
402
+ } else {
403
+ // createDiffieHellman / createDiffieHellmanGroup.
404
+ eachMatch(new RegExp(`\\b${a}\\s*\\(`, "g"), content, (m) =>
405
+ findings.push(
406
+ findingFromRule(RULE_NODE_DH, {
407
+ file,
408
+ content,
409
+ index: m.index,
410
+ matchLength: m[0].length,
411
+ }),
412
+ ),
413
+ );
414
+ }
415
+ }
416
+ }
417
+
308
418
  // createSign / createVerify — RSA / ECDSA / DSA signatures.
309
419
  eachMatch(RE_CREATE_SIGN_VERIFY, content, (m) => {
310
420
  findings.push(
@@ -610,7 +720,27 @@ const libraryDetector: Detector = {
610
720
  add(RE_FORGE_RSA, RULE_FORGE_RSA); // node-forge: pki.rsa.generateKeyPair(...)
611
721
  add(RE_FORGE_ED25519, RULE_FORGE_ED25519); // node-forge: forge.ed25519.*
612
722
  add(RE_ELLIPTIC_EC, RULE_ELLIPTIC_EC); // elliptic: new EC('secp256k1')
613
- add(RE_SECP256K1, RULE_SECP256K1); // secp.sign / getPublicKey / getSharedSecret
723
+ // secp256k1: classify per method getSharedSecret/ecdh are key AGREEMENT
724
+ // (ECDH, harvest-now-decrypt-later); sign/verify/getPublicKey/recover are
725
+ // ECDSA signatures (not HNDL). The old rule flagged everything as ECDSA.
726
+ eachMatch(RE_SECP256K1, content, (m) => {
727
+ const kex = m[1] === "getSharedSecret" || m[1] === "ecdh";
728
+ findings.push(
729
+ findingFromRule(
730
+ RULE_SECP256K1,
731
+ { file, content, index: m.index, matchLength: m[0].length },
732
+ kex
733
+ ? {
734
+ title: "secp256k1 ECDH key agreement",
735
+ category: "key-exchange",
736
+ algorithm: "ECDH",
737
+ hndl: true,
738
+ message: `secp256k1 ECDH key agreement (.${m[1]}()) is classical and harvest-now-decrypt-later exposed.`,
739
+ }
740
+ : undefined,
741
+ ),
742
+ );
743
+ });
614
744
  add(RE_JSRSASIGN_KEYGEN, RULE_JSRSASIGN_KEYGEN); // jsrsasign: KEYUTIL.generateKeypair(...)
615
745
  add(RE_JSRSASIGN_SIGN, RULE_JSRSASIGN_SIGN); // jsrsasign: KJUR.crypto.*
616
746
  add(RE_NODE_RSA, RULE_NODE_RSA_LIB); // node-rsa: new NodeRSA(...)
@@ -623,6 +753,11 @@ const libraryDetector: Detector = {
623
753
  /* JWT / JOSE / COSE algorithm strings */
624
754
  /* -------------------------------------------------------------------------- */
625
755
 
756
+ // JOSE RSA key-transport algorithms (JWE `alg`): quoted RSA-OAEP / RSA-OAEP-256
757
+ // /384/512 / RSA1_5. Classical RSA *encryption* (key transport) — distinct from
758
+ // the RS*/PS* *signature* tokens above, and a harvest-now-decrypt-later surface.
759
+ const RE_JOSE_KEM = /['"`](RSA-OAEP(?:-(?:256|384|512))?|RSA1_5)['"`]/g;
760
+
626
761
  const RULE_JWT_ALG: RuleMeta = {
627
762
  id: "jwt-classical-alg",
628
763
  title: "Classical JWT/JOSE algorithm",
@@ -650,6 +785,20 @@ const RULE_JOSE_ECDH: RuleMeta = {
650
785
  "JOSE ECDH-ES performs classical ECDH key agreement — harvest-now-decrypt-later exposed.",
651
786
  remediation: "Track IETF PQC JOSE/COSE; adopt hybrid X25519MLKEM768 KEM-based encryption.",
652
787
  };
788
+ const RULE_JOSE_RSA_OAEP: RuleMeta = {
789
+ id: "jose-rsa-oaep",
790
+ title: "JOSE RSA key-transport algorithm",
791
+ description: "JWE RSA-OAEP / RSA-OAEP-256/384/512 / RSA1_5 key encryption",
792
+ category: "key-exchange",
793
+ severity: "high",
794
+ confidence: "medium",
795
+ algorithm: "RSA",
796
+ hndl: true,
797
+ cwe: CWE_BROKEN_CRYPTO,
798
+ message:
799
+ "JOSE RSA key transport (RSA-OAEP / RSA1_5) is classical RSA encryption — harvest-now-decrypt-later exposed.",
800
+ remediation: "Track IETF PQC JOSE/COSE; adopt hybrid X25519MLKEM768 KEM-based encryption.",
801
+ };
653
802
 
654
803
  /**
655
804
  * Detects classical signature algorithm identifiers used by JWT/JOSE, plus
@@ -664,7 +813,7 @@ const jwtDetector: Detector = {
664
813
  // signal in JS/TS or Python (e.g. PyJWT `algorithm="RS256"`), so this detector
665
814
  // is un-gated from JS-only to the JWT host surfaces.
666
815
  language: "any",
667
- rules: [RULE_JWT_ALG, RULE_JOSE_ECDH],
816
+ rules: [RULE_JWT_ALG, RULE_JOSE_ECDH, RULE_JOSE_RSA_OAEP],
668
817
  appliesTo: (f) => hasExtension(f, JWT_HOST_EXTENSIONS),
669
818
  detect({ file, content }): Finding[] {
670
819
  const findings: Finding[] = [];
@@ -703,6 +852,20 @@ const jwtDetector: Detector = {
703
852
  );
704
853
  });
705
854
 
855
+ // JOSE RSA key transport (RSA-OAEP / RSA1_5) — classical RSA encryption, HNDL.
856
+ eachMatch(RE_JOSE_KEM, content, (m) => {
857
+ findings.push(
858
+ findingFromRule(
859
+ RULE_JOSE_RSA_OAEP,
860
+ { file, content, index: m.index, matchLength: m[0].length },
861
+ {
862
+ title: `JOSE RSA key transport ${m[1]}`,
863
+ message: `JOSE "${m[1]}" is classical RSA key transport — harvest-now-decrypt-later exposed.`,
864
+ },
865
+ ),
866
+ );
867
+ });
868
+
706
869
  return findings;
707
870
  },
708
871
  };
@@ -810,6 +973,48 @@ const tlsDetector: Detector = {
810
973
  const RE_SSH_PUBKEY = /\b(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-nistp(?:256|384|521))\b/g;
811
974
  const RE_CERT_SIG_ALG =
812
975
  /\b(sha(?:1|256|384|512)WithRSAEncryption|ecdsa-with-SHA(?:1|256|384|512)|rsassaPss|dsaWithSHA(?:1|256))\b/g;
976
+ // Classical SSH key-exchange algorithm identifiers: finite-field DH groups
977
+ // (diffie-hellman-group{1,14,15,16,17,18} + group-exchange), ECDH over NIST
978
+ // curves (ecdh-sha2-nistp*), and Curve25519 (curve25519-sha256). All are
979
+ // Shor-broken key agreement — the harvest-now-decrypt-later surface a lexical
980
+ // scan misses when only the `crypto/*` API is matched, not the negotiated kex.
981
+ const RE_SSH_KEX =
982
+ /\b(diffie-hellman-group(?:1|14|15|16|17|18)(?:-sha1|-sha256|-sha512)?|diffie-hellman-group-exchange-sha(?:1|256)|ecdh-sha2-nistp(?:256|384|521)|curve25519-sha256)\b/g;
983
+
984
+ // A bare key-type token (`ssh-rsa`, `ssh-ed25519`, …) is real SSH crypto only
985
+ // when it is EITHER (a) followed by base64 key material — an actual
986
+ // authorized_keys / known_hosts entry — OR (b) one of ≥2 DISTINCT ssh key/host-key
987
+ // algorithm tokens on the same line — an algorithm-preference list
988
+ // (`HostKeyAlgorithms ssh-ed25519,ecdsa-sha2-nistp256,ssh-rsa`, `%w[…]`, …). A
989
+ // lone token, e.g. a UI label / i18n value like `"ssh-rsa": "ssh-rsa"`, is
990
+ // neither, so it must NOT be flagged. `RE_SSH_ALGO_TOKEN` is a separate (cloned-
991
+ // on-use) copy so counting per line never disturbs the outer `eachMatch` state.
992
+ const RE_SSH_ALGO_TOKEN = /\b(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-nistp(?:256|384|521))\b/g;
993
+ /** How far around a token we look for a sibling algorithm on the same line. A
994
+ * real `HostKeyAlgorithms` list is short; bounding the window keeps the per-token
995
+ * work O(1) so a pathological single huge line can't make detection quadratic. */
996
+ const SSH_LINE_WINDOW = 512;
997
+ function isRealSshKeyOrAlgoList(content: string, index: number, matchLen: number): boolean {
998
+ // (a) base64 key material immediately after the token → a real SSH public key.
999
+ if (/^\s+[A-Za-z0-9+/]{20,}/.test(content.slice(index + matchLen, index + matchLen + 80))) {
1000
+ return true;
1001
+ }
1002
+ // (b) ≥2 distinct ssh key/host-key algorithm tokens on the same line → a list.
1003
+ // Bound both the back-scan for the line start and the window itself so the work
1004
+ // is constant per token and never crosses a line boundary (i18n entries live on
1005
+ // separate lines, so a lone repeated token stays a single distinct token).
1006
+ const from = Math.max(0, index - SSH_LINE_WINDOW);
1007
+ const pre = content.slice(from, index);
1008
+ const preNl = pre.lastIndexOf("\n");
1009
+ const lineStart = preNl === -1 ? from : from + preNl + 1;
1010
+ const to = Math.min(content.length, index + matchLen + SSH_LINE_WINDOW);
1011
+ const post = content.slice(index, to);
1012
+ const postNl = post.indexOf("\n");
1013
+ const lineEnd = postNl === -1 ? to : index + postNl;
1014
+ const distinct = new Set<string>();
1015
+ for (const t of content.slice(lineStart, lineEnd).matchAll(RE_SSH_ALGO_TOKEN)) distinct.add(t[1]);
1016
+ return distinct.size >= 2;
1017
+ }
813
1018
 
814
1019
  const RULE_SSH_PUBKEY: RuleMeta = {
815
1020
  id: "ssh-public-key",
@@ -823,7 +1028,8 @@ const RULE_SSH_PUBKEY: RuleMeta = {
823
1028
  cwe: CWE_BROKEN_CRYPTO,
824
1029
  sensitive: true,
825
1030
  message: "A classical SSH public key is forgeable by a quantum attacker.",
826
- remediation: "Plan migration to PQC-capable SSH (e.g. sntrup761x25519 KEX, PQC host keys).",
1031
+ remediation:
1032
+ "Plan migration to PQC-capable SSH: prefer the mlkem768x25519-sha256 KEX (ML-KEM-768 hybrid, OpenSSH 10's default since Apr 2025); sntrup761x25519 is an acceptable interim. Rotate to PQC host keys as they land.",
827
1033
  };
828
1034
  const RULE_CERT_SIG_ALG: RuleMeta = {
829
1035
  id: "cert-signature-algorithm",
@@ -839,6 +1045,21 @@ const RULE_CERT_SIG_ALG: RuleMeta = {
839
1045
  "A classical certificate signature algorithm (RSA/ECDSA/DSA) is a quantum forgery surface.",
840
1046
  remediation: "Plan re-issuance with PQC-capable CAs as ML-DSA certificate profiles mature.",
841
1047
  };
1048
+ const RULE_SSH_KEX: RuleMeta = {
1049
+ id: "ssh-kex-classical",
1050
+ title: "Classical SSH key exchange",
1051
+ description: "diffie-hellman-group* / group-exchange / ecdh-sha2-* / curve25519-sha256 kex",
1052
+ category: "key-exchange",
1053
+ severity: "medium",
1054
+ confidence: "medium",
1055
+ algorithm: "unknown",
1056
+ hndl: true,
1057
+ cwe: CWE_BROKEN_CRYPTO,
1058
+ message:
1059
+ "A classical SSH key-exchange algorithm (finite-field DH / ECDH / X25519) is harvest-now-decrypt-later exposed.",
1060
+ remediation:
1061
+ "Prefer the mlkem768x25519-sha256 KEX (ML-KEM-768 hybrid, OpenSSH 10 default); sntrup761x25519 is an acceptable interim.",
1062
+ };
842
1063
 
843
1064
  /**
844
1065
  * Detects classical SSH public keys (`authorized_keys` / `known_hosts` lines)
@@ -851,13 +1072,18 @@ const sshCertDetector: Detector = {
851
1072
  description: "SSH public keys and TLS/X.509 certificate signature algorithms in config",
852
1073
  scope: "config",
853
1074
  language: "any",
854
- rules: [RULE_SSH_PUBKEY, RULE_CERT_SIG_ALG],
855
- appliesTo: () => true,
1075
+ rules: [RULE_SSH_PUBKEY, RULE_CERT_SIG_ALG, RULE_SSH_KEX],
1076
+ // Skip prose/docs: a changelog or README that merely mentions `ssh-rsa` in a
1077
+ // sentence is not crypto config. PEM material is caught by its own detector.
1078
+ appliesTo: (f) => !hasExtension(f, DOC_EXTENSIONS),
856
1079
  detect({ file, content }): Finding[] {
857
1080
  const findings: Finding[] = [];
858
1081
 
859
1082
  // SSH public keys: ssh-rsa AAAA…, ecdsa-sha2-nistp256 …, ssh-ed25519 …
860
1083
  eachMatch(RE_SSH_PUBKEY, content, (m) => {
1084
+ // Skip a bare token that is neither a real key nor an algorithm list (e.g.
1085
+ // a `"ssh-rsa": "ssh-rsa"` i18n label) — see `isRealSshKeyOrAlgoList`.
1086
+ if (!isRealSshKeyOrAlgoList(content, m.index, m[0].length)) return;
861
1087
  const tok = m[1];
862
1088
  const algorithm: Finding["algorithm"] = tok.startsWith("ssh-rsa")
863
1089
  ? "RSA"
@@ -900,6 +1126,89 @@ const sshCertDetector: Detector = {
900
1126
  );
901
1127
  });
902
1128
 
1129
+ // SSH key-exchange algorithm identifiers (finite-field DH / ECDH / X25519).
1130
+ eachMatch(RE_SSH_KEX, content, (m) => {
1131
+ const tok = m[1];
1132
+ const algorithm: Finding["algorithm"] = tok.startsWith("diffie-hellman")
1133
+ ? "DH"
1134
+ : tok.startsWith("ecdh")
1135
+ ? "ECDH"
1136
+ : "X25519";
1137
+ findings.push(
1138
+ findingFromRule(
1139
+ RULE_SSH_KEX,
1140
+ { file, content, index: m.index, matchLength: m[0].length },
1141
+ {
1142
+ title: `Classical SSH key exchange (${tok})`,
1143
+ algorithm,
1144
+ message: `SSH key-exchange "${tok}" is classical (${algorithm}) — harvest-now-decrypt-later exposed.`,
1145
+ },
1146
+ ),
1147
+ );
1148
+ });
1149
+
1150
+ return findings;
1151
+ },
1152
+ };
1153
+
1154
+ /* -------------------------------------------------------------------------- */
1155
+ /* Classical TLS key-exchange cipher suites (language-agnostic config) */
1156
+ /* -------------------------------------------------------------------------- */
1157
+
1158
+ // Classical KEX cipher-suite identifiers, in both the OpenSSL (`ECDHE-RSA-…`) and
1159
+ // IANA (`TLS_ECDHE_RSA_WITH_…`) spellings, with `-` or `_` separators. These name
1160
+ // a Shor-broken key exchange (ECDHE/DHE), so they are the harvest-now surface a
1161
+ // PQC scanner should flag — distinct from the legacy-*version* / weak-cipher rules
1162
+ // above. Case-sensitive on purpose: cipher-suite tokens are upper-case constants,
1163
+ // so prose like "ecdhe rsa" does not misfire. No trailing \b so the IANA
1164
+ // `TLS_ECDHE_RSA_WITH_…` form (a `_` follows `RSA`) still matches.
1165
+ const RE_TLS_CLASSICAL_KEX = /\b(?:TLS_)?(?:ECDHE|ECDH|DHE)[-_](?:RSA|ECDSA|DSS)/g;
1166
+
1167
+ const RULE_TLS_CLASSICAL_KEX: RuleMeta = {
1168
+ id: "tls-classical-kex",
1169
+ title: "Classical TLS key-exchange cipher suite",
1170
+ description: "ECDHE / DHE cipher suites negotiate Shor-broken key exchange",
1171
+ category: "tls",
1172
+ severity: "medium",
1173
+ confidence: "medium",
1174
+ algorithm: "unknown",
1175
+ hndl: true,
1176
+ cwe: CWE_BROKEN_CRYPTO,
1177
+ message:
1178
+ "Classical TLS key exchange (ECDHE/DHE) is harvest-now-decrypt-later exposed — the session key can be recorded now and recovered by a quantum attacker.",
1179
+ remediation:
1180
+ "Adopt a PQC-hybrid TLS 1.3 key exchange (e.g. X25519MLKEM768) as your stack and peers support it; keep classical suites only as a transitional fallback.",
1181
+ };
1182
+
1183
+ /**
1184
+ * Detects classical TLS key-exchange cipher suites in any text/config file
1185
+ * (OpenSSL cipher lists, IANA suite constants in Go/Java/C#/Rust, `.properties`,
1186
+ * etc.). Language-agnostic because the suite identifiers are the same tokens
1187
+ * everywhere — this is the cross-language TLS gap the legacy-version rule misses.
1188
+ */
1189
+ const tlsClassicalKexDetector: Detector = {
1190
+ id: "tls-cipher-suite",
1191
+ description: "Classical TLS key-exchange cipher suites (ECDHE/DHE) in any config",
1192
+ scope: "config",
1193
+ language: "any",
1194
+ rules: [RULE_TLS_CLASSICAL_KEX],
1195
+ appliesTo: (f) => !hasExtension(f, DOC_EXTENSIONS),
1196
+ detect({ file, content }): Finding[] {
1197
+ const findings: Finding[] = [];
1198
+ eachMatch(RE_TLS_CLASSICAL_KEX, content, (m) => {
1199
+ const tok = m[0];
1200
+ const algorithm: Finding["algorithm"] = tok.includes("ECDH") ? "ECDH" : "DH";
1201
+ findings.push(
1202
+ findingFromRule(
1203
+ RULE_TLS_CLASSICAL_KEX,
1204
+ { file, content, index: m.index, matchLength: m[0].length },
1205
+ {
1206
+ algorithm,
1207
+ message: `Classical TLS key-exchange suite "${tok}…" (${algorithm}) is harvest-now-decrypt-later exposed.`,
1208
+ },
1209
+ ),
1210
+ );
1211
+ });
903
1212
  return findings;
904
1213
  },
905
1214
  };
@@ -912,4 +1221,5 @@ export const sourceDetectors: Detector[] = [
912
1221
  jwtDetector,
913
1222
  tlsDetector,
914
1223
  sshCertDetector,
1224
+ tlsClassicalKexDetector,
915
1225
  ];