@quantakrypto/core 0.4.2 → 0.4.3

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 (132) 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.js.map +1 -1
  7. package/dist/changed.js.map +1 -1
  8. package/dist/codemods/config-toggle.js.map +1 -1
  9. package/dist/codemods/registry.js.map +1 -1
  10. package/dist/comments.d.ts +26 -0
  11. package/dist/comments.d.ts.map +1 -1
  12. package/dist/comments.js +141 -1
  13. package/dist/comments.js.map +1 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/cwe.js.map +1 -1
  16. package/dist/dependencies.d.ts +3 -2
  17. package/dist/dependencies.d.ts.map +1 -1
  18. package/dist/dependencies.js +138 -2
  19. package/dist/dependencies.js.map +1 -1
  20. package/dist/detect-utils.d.ts +14 -3
  21. package/dist/detect-utils.d.ts.map +1 -1
  22. package/dist/detect-utils.js +32 -4
  23. package/dist/detect-utils.js.map +1 -1
  24. package/dist/detectors/c.d.ts.map +1 -1
  25. package/dist/detectors/c.js +190 -2
  26. package/dist/detectors/c.js.map +1 -1
  27. package/dist/detectors/csharp.d.ts.map +1 -1
  28. package/dist/detectors/csharp.js +141 -3
  29. package/dist/detectors/csharp.js.map +1 -1
  30. package/dist/detectors/go.d.ts +5 -3
  31. package/dist/detectors/go.d.ts.map +1 -1
  32. package/dist/detectors/go.js +187 -3
  33. package/dist/detectors/go.js.map +1 -1
  34. package/dist/detectors/java.d.ts.map +1 -1
  35. package/dist/detectors/java.js +168 -3
  36. package/dist/detectors/java.js.map +1 -1
  37. package/dist/detectors/pem.d.ts.map +1 -1
  38. package/dist/detectors/pem.js +49 -1
  39. package/dist/detectors/pem.js.map +1 -1
  40. package/dist/detectors/python.d.ts.map +1 -1
  41. package/dist/detectors/python.js +74 -3
  42. package/dist/detectors/python.js.map +1 -1
  43. package/dist/detectors/ruby.d.ts +9 -5
  44. package/dist/detectors/ruby.d.ts.map +1 -1
  45. package/dist/detectors/ruby.js +122 -2
  46. package/dist/detectors/ruby.js.map +1 -1
  47. package/dist/detectors/rust.d.ts +8 -3
  48. package/dist/detectors/rust.d.ts.map +1 -1
  49. package/dist/detectors/rust.js +185 -3
  50. package/dist/detectors/rust.js.map +1 -1
  51. package/dist/detectors/source.d.ts.map +1 -1
  52. package/dist/detectors/source.js +117 -7
  53. package/dist/detectors/source.js.map +1 -1
  54. package/dist/detectors/stateful-hbs.d.ts +22 -0
  55. package/dist/detectors/stateful-hbs.d.ts.map +1 -0
  56. package/dist/detectors/stateful-hbs.js +139 -0
  57. package/dist/detectors/stateful-hbs.js.map +1 -0
  58. package/dist/errors.js.map +1 -1
  59. package/dist/evidence.d.ts +48 -0
  60. package/dist/evidence.d.ts.map +1 -0
  61. package/dist/evidence.js +76 -0
  62. package/dist/evidence.js.map +1 -0
  63. package/dist/index.d.ts +4 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +4 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/inventory.d.ts +2 -0
  68. package/dist/inventory.d.ts.map +1 -1
  69. package/dist/inventory.js +27 -1
  70. package/dist/inventory.js.map +1 -1
  71. package/dist/parallel.js.map +1 -1
  72. package/dist/patch-policy.js.map +1 -1
  73. package/dist/redact.d.ts.map +1 -1
  74. package/dist/redact.js +39 -0
  75. package/dist/redact.js.map +1 -1
  76. package/dist/registry.d.ts +2 -2
  77. package/dist/registry.d.ts.map +1 -1
  78. package/dist/registry.js +4 -2
  79. package/dist/registry.js.map +1 -1
  80. package/dist/remediate-pipeline.d.ts +3 -0
  81. package/dist/remediate-pipeline.d.ts.map +1 -1
  82. package/dist/remediate-pipeline.js +50 -0
  83. package/dist/remediate-pipeline.js.map +1 -1
  84. package/dist/remediate-request.js.map +1 -1
  85. package/dist/remediation.d.ts +9 -3
  86. package/dist/remediation.d.ts.map +1 -1
  87. package/dist/remediation.js +17 -5
  88. package/dist/remediation.js.map +1 -1
  89. package/dist/report.d.ts +9 -0
  90. package/dist/report.d.ts.map +1 -1
  91. package/dist/report.js +31 -0
  92. package/dist/report.js.map +1 -1
  93. package/dist/scan-worker.js.map +1 -1
  94. package/dist/scan.d.ts.map +1 -1
  95. package/dist/scan.js +8 -1
  96. package/dist/scan.js.map +1 -1
  97. package/dist/severity.js.map +1 -1
  98. package/dist/triage.js.map +1 -1
  99. package/dist/types.d.ts +1 -1
  100. package/dist/types.d.ts.map +1 -1
  101. package/dist/types.js.map +1 -1
  102. package/dist/verify.js.map +1 -1
  103. package/dist/version.d.ts +1 -1
  104. package/dist/version.js +1 -1
  105. package/dist/version.js.map +1 -1
  106. package/dist/walk.js.map +1 -1
  107. package/dist/worktree.js.map +1 -1
  108. package/package.json +1 -1
  109. package/src/comments.ts +140 -1
  110. package/src/dependencies.ts +143 -4
  111. package/src/detect-utils.ts +33 -4
  112. package/src/detectors/c.ts +202 -2
  113. package/src/detectors/csharp.ts +154 -3
  114. package/src/detectors/go.ts +209 -6
  115. package/src/detectors/java.ts +189 -3
  116. package/src/detectors/pem.ts +53 -1
  117. package/src/detectors/python.ts +82 -3
  118. package/src/detectors/ruby.ts +138 -7
  119. package/src/detectors/rust.ts +211 -7
  120. package/src/detectors/source.ts +149 -6
  121. package/src/detectors/stateful-hbs.ts +177 -0
  122. package/src/evidence.ts +124 -0
  123. package/src/index.ts +5 -1
  124. package/src/inventory.ts +31 -1
  125. package/src/redact.ts +41 -0
  126. package/src/registry.ts +4 -2
  127. package/src/remediate-pipeline.ts +55 -0
  128. package/src/remediation.ts +23 -8
  129. package/src/report.ts +41 -2
  130. package/src/scan.ts +13 -1
  131. package/src/types.ts +1 -1
  132. package/src/version.ts +1 -1
@@ -1,15 +1,20 @@
1
1
  /**
2
2
  * Source-code detector for classical asymmetric cryptography in Rust. Covers the
3
3
  * common crypto crates: `rsa`, `ring`, the `*-dalek` family (ed25519/x25519),
4
- * and the RustCrypto elliptic-curve crates (`p256`/`p384`/`k256`). Lexical, same
5
- * strategy as the other packs; the `::` path syntax makes the signals precise.
4
+ * the RustCrypto elliptic-curve crates (`p256`/`p384`/`k256`), and the `openssl`
5
+ * crate bindings. Lexical, same strategy as the other packs; the `::` path
6
+ * syntax makes the signals precise.
6
7
  *
7
8
  * HNDL: RSA encryption and (EC/X)DH key agreement are harvest-now-decrypt-later
8
- * exposed (hndl:true); ECDSA / Ed25519 signatures are hndl:false but forgeable.
9
+ * exposed (hndl:true); ECDSA / DSA / Ed25519 signatures are hndl:false but
10
+ * forgeable. `openssl::EcKey` keygen is ambiguous (feeds ECDSA + ECDH),
11
+ * classified conservatively as key-exchange-capable (hndl:true). Also flags
12
+ * Rust TLS certificate-validation bypasses (reqwest / rustls) — not quantum but
13
+ * mirrors the JS tlsDetector's cert-validation category.
9
14
  */
10
- import type { Detector, Finding, RuleMeta } from "../types.js";
15
+ import type { AlgorithmFamily, Detector, Finding, RuleMeta } from "../types.js";
11
16
  import { RUST_EXTENSIONS, eachMatch, findingFromRule, hasExtension } from "../detect-utils.js";
12
- import { CWE_BROKEN_CRYPTO } from "../cwe.js";
17
+ import { CWE_BROKEN_CRYPTO, CWE_CERT_VALIDATION } from "../cwe.js";
13
18
 
14
19
  // `rsa` crate + ring RsaKeyPair. `::` after the type marks an associated-fn call.
15
20
  const RE_RUST_RSA = /\b(?:RsaPrivateKey|RsaPublicKey|RsaKeyPair)::/g;
@@ -22,6 +27,31 @@ const RE_RUST_ECDH = /\becdh::EphemeralSecret\b|\bagreement::ECDH_P(?:256|384)\b
22
27
  const RE_RUST_ED25519 = /\bed25519_dalek::(?:SigningKey|Keypair|SecretKey)\b|\bEd25519KeyPair::/g;
23
28
  // X25519 key agreement (dalek).
24
29
  const RE_RUST_X25519 = /\bx25519_dalek::(?:EphemeralSecret|StaticSecret)\b/g;
30
+ // openssl crate bindings — the `Type::generate(` / `Dh::` call forms, distinct
31
+ // from the ring / RustCrypto path syntax above (audit F5/F9 false-negatives).
32
+ const RE_RUST_OPENSSL_RSA = /\bRsa::generate\s*\(/g;
33
+ const RE_RUST_OPENSSL_EC = /\bEcKey::generate\s*\(/g;
34
+ const RE_RUST_OPENSSL_DSA = /\bDsa::generate\s*\(/g;
35
+ const RE_RUST_OPENSSL_DH = /\bDh::/g;
36
+ // ring X25519 agreement (the ECDH_P* forms are handled by RE_RUST_ECDH above).
37
+ const RE_RUST_RING_X25519 = /\bagreement::X25519\b/g;
38
+ // Bare (unqualified) constructors: a braced `use x25519_dalek::{EphemeralSecret}`
39
+ // / `use ed25519_dalek::{SigningKey}` strips the `::` path prefix, defeating the
40
+ // path-qualified rules above. The `(?<![:\w])` lookbehind keeps these from
41
+ // double-matching the qualified forms (e.g. `p256::ecdsa::SigningKey::random`).
42
+ const RE_RUST_BARE_X25519 = /(?<![:\w])EphemeralSecret::new\s*\(/g;
43
+ const RE_RUST_BARE_SIGNINGKEY = /(?<![:\w])SigningKey::(?:generate|random)\s*\(/g;
44
+ // jsonwebtoken crate `Algorithm` enum variants selecting a classical JWT signer:
45
+ // RSASSA-PKCS1 (RS*), RSASSA-PSS (PS*), ECDSA (ES*), and Ed25519 (EdDSA). The
46
+ // `\s*` between `Algorithm` and `::` catches the variant split across lines /
47
+ // routed through a helper (audit token_policy false-negative); the required
48
+ // suffix keeps it off unrelated `Algorithm::` uses (e.g. `HS256` HMAC, which is
49
+ // symmetric and not quantum-vulnerable, or `Algorithm::new`).
50
+ const RE_RUST_JWT_ALG = /\bAlgorithm\s*::(RS|PS|ES)(?:256|384|512)\b|\bAlgorithm\s*::EdDSA\b/g;
51
+ // Rust TLS certificate-validation bypass: reqwest `danger_accept_invalid_certs`
52
+ // and the rustls `.dangerous()` escape hatch. Mirrors the JS tlsDetector.
53
+ const RE_RUST_TLS_ACCEPT_INVALID = /\bdanger_accept_invalid_certs\s*\(\s*true/g;
54
+ const RE_RUST_TLS_DANGEROUS = /\.dangerous\s*\(\s*\)/g;
25
55
 
26
56
  const RULE_RUST_RSA: RuleMeta = {
27
57
  id: "rust-rsa",
@@ -78,13 +108,148 @@ const RULE_RUST_X25519: RuleMeta = {
78
108
  title: "Rust X25519 key agreement",
79
109
  description: "x25519-dalek EphemeralSecret/StaticSecret",
80
110
  category: "key-exchange",
81
- severity: "low",
111
+ severity: "medium",
82
112
  confidence: "high",
83
113
  algorithm: "X25519",
84
114
  hndl: true,
85
115
  cwe: CWE_BROKEN_CRYPTO,
86
116
  message: "X25519 (Rust) is modern but still classical key agreement — harvest-now-decrypt-later.",
87
117
  };
118
+ const RULE_RUST_OPENSSL_RSA: RuleMeta = {
119
+ id: "rust-openssl-rsa",
120
+ title: "Rust openssl RSA key generation",
121
+ description: "openssl crate Rsa::generate",
122
+ category: "kem",
123
+ severity: "high",
124
+ confidence: "high",
125
+ algorithm: "RSA",
126
+ hndl: true,
127
+ cwe: CWE_BROKEN_CRYPTO,
128
+ message:
129
+ "Generates a classical RSA key pair via the Rust `openssl` crate — not quantum-safe and RSA encryption is HNDL-exposed.",
130
+ };
131
+ const RULE_RUST_OPENSSL_EC: RuleMeta = {
132
+ id: "rust-openssl-ec",
133
+ title: "Rust openssl EC key generation",
134
+ description: "openssl crate EcKey::generate",
135
+ category: "key-exchange",
136
+ severity: "high",
137
+ confidence: "high",
138
+ algorithm: "ECDH",
139
+ hndl: true,
140
+ cwe: CWE_BROKEN_CRYPTO,
141
+ message:
142
+ "Generates a classical EC key pair via the Rust `openssl` crate. EC keys feed BOTH ECDSA signatures and ECDH key agreement; the ECDH path is harvest-now-decrypt-later exposed.",
143
+ remediation:
144
+ "For key agreement: hybrid X25519MLKEM768 (ML-KEM-768). For signatures: ML-DSA-65 (FIPS 204).",
145
+ };
146
+ const RULE_RUST_OPENSSL_DSA: RuleMeta = {
147
+ id: "rust-openssl-dsa",
148
+ title: "Rust openssl DSA key/usage",
149
+ description: "openssl crate Dsa::generate",
150
+ category: "signature",
151
+ severity: "high",
152
+ confidence: "high",
153
+ algorithm: "DSA",
154
+ hndl: false,
155
+ cwe: CWE_BROKEN_CRYPTO,
156
+ message:
157
+ "Classical DSA via the Rust `openssl` crate is deprecated and forgeable by a quantum attacker.",
158
+ remediation: "Rotate off DSA and migrate to ML-DSA-65 (FIPS 204).",
159
+ };
160
+ const RULE_RUST_OPENSSL_DH: RuleMeta = {
161
+ id: "rust-openssl-dh",
162
+ title: "Rust openssl Diffie-Hellman key exchange",
163
+ description: "openssl crate Dh params / key generation",
164
+ category: "key-exchange",
165
+ severity: "high",
166
+ confidence: "high",
167
+ algorithm: "DH",
168
+ hndl: true,
169
+ cwe: CWE_BROKEN_CRYPTO,
170
+ message:
171
+ "Finite-field Diffie-Hellman via the Rust `openssl` crate is broken by Shor's algorithm (harvest-now-decrypt-later).",
172
+ };
173
+ const RULE_RUST_RING_X25519: RuleMeta = {
174
+ id: "rust-ring-x25519",
175
+ title: "Rust ring X25519 key agreement",
176
+ description: "ring agreement::X25519",
177
+ category: "key-exchange",
178
+ severity: "medium",
179
+ confidence: "high",
180
+ algorithm: "X25519",
181
+ hndl: true,
182
+ cwe: CWE_BROKEN_CRYPTO,
183
+ message:
184
+ "X25519 key agreement via ring (Rust) is modern but still classical — harvest-now-decrypt-later.",
185
+ };
186
+ const RULE_RUST_BARE_X25519: RuleMeta = {
187
+ id: "rust-x25519-bare",
188
+ title: "Rust X25519 key agreement (unqualified)",
189
+ description: "bare EphemeralSecret::new (x25519-dalek imported via `use`)",
190
+ category: "key-exchange",
191
+ severity: "medium",
192
+ confidence: "medium",
193
+ algorithm: "X25519",
194
+ hndl: true,
195
+ cwe: CWE_BROKEN_CRYPTO,
196
+ message:
197
+ "X25519 key agreement (x25519-dalek, imported unqualified) is modern but still classical — harvest-now-decrypt-later.",
198
+ };
199
+ const RULE_RUST_BARE_SIGNINGKEY: RuleMeta = {
200
+ id: "rust-signingkey-bare",
201
+ title: "Rust signature key (unqualified)",
202
+ description: "bare SigningKey::generate/random (ed25519-dalek / k256 via `use`)",
203
+ category: "signature",
204
+ severity: "medium",
205
+ confidence: "medium",
206
+ algorithm: "unknown",
207
+ hndl: false,
208
+ cwe: CWE_BROKEN_CRYPTO,
209
+ message:
210
+ "Classical signature key from an unqualified `SigningKey` (ed25519-dalek Ed25519 / k256 ECDSA) — forgeable by a quantum attacker.",
211
+ remediation: "ML-DSA-65 (FIPS 204) or SLH-DSA (FIPS 205)",
212
+ };
213
+ const RULE_RUST_JWT_ALGORITHM: RuleMeta = {
214
+ id: "rust-jwt-algorithm",
215
+ title: "Rust jsonwebtoken classical signature algorithm",
216
+ description: "jsonwebtoken Algorithm::{RS,PS,ES}* / Algorithm::EdDSA enum variant",
217
+ category: "signature",
218
+ severity: "high",
219
+ confidence: "high",
220
+ // Representative family; refined per-finding (RS*/PS* → RSA, ES* → ECDSA, EdDSA).
221
+ algorithm: "RSA",
222
+ hndl: false,
223
+ cwe: CWE_BROKEN_CRYPTO,
224
+ message:
225
+ "Selects a classical JWT signature algorithm (jsonwebtoken RS*/PS*/ES*/EdDSA), forgeable by a quantum attacker.",
226
+ remediation: "ML-DSA-65 (FIPS 204) or SLH-DSA (FIPS 205)",
227
+ };
228
+ const RULE_RUST_TLS_ACCEPT_INVALID: RuleMeta = {
229
+ id: "rust-tls-accept-invalid-certs",
230
+ title: "Rust TLS certificate verification disabled",
231
+ description: "reqwest danger_accept_invalid_certs(true)",
232
+ category: "tls",
233
+ severity: "high",
234
+ confidence: "high",
235
+ hndl: false,
236
+ cwe: CWE_CERT_VALIDATION,
237
+ message:
238
+ "danger_accept_invalid_certs(true) disables TLS certificate verification in reqwest (MITM risk).",
239
+ remediation: "Remove danger_accept_invalid_certs(true); verify certificates properly.",
240
+ };
241
+ const RULE_RUST_TLS_DANGEROUS: RuleMeta = {
242
+ id: "rust-tls-rustls-dangerous",
243
+ title: "Rust rustls dangerous certificate config",
244
+ description: "rustls ClientConfig .dangerous() escape hatch",
245
+ category: "tls",
246
+ severity: "high",
247
+ confidence: "medium",
248
+ hndl: false,
249
+ cwe: CWE_CERT_VALIDATION,
250
+ message: "rustls `.dangerous()` opts into disabling certificate verification (MITM risk).",
251
+ remediation: "Avoid the dangerous() escape hatch; keep the default certificate verifier.",
252
+ };
88
253
 
89
254
  /** Detects classical asymmetric crypto in Rust (rsa, ring, dalek, p256/k256). */
90
255
  export const rustDetector: Detector = {
@@ -92,7 +257,23 @@ export const rustDetector: Detector = {
92
257
  description: "Classical asymmetric crypto in Rust (rsa, ring, *-dalek, p256/k256)",
93
258
  scope: "source",
94
259
  language: "rust",
95
- rules: [RULE_RUST_RSA, RULE_RUST_ECDSA, RULE_RUST_ECDH, RULE_RUST_ED25519, RULE_RUST_X25519],
260
+ rules: [
261
+ RULE_RUST_RSA,
262
+ RULE_RUST_ECDSA,
263
+ RULE_RUST_ECDH,
264
+ RULE_RUST_ED25519,
265
+ RULE_RUST_X25519,
266
+ RULE_RUST_OPENSSL_RSA,
267
+ RULE_RUST_OPENSSL_EC,
268
+ RULE_RUST_OPENSSL_DSA,
269
+ RULE_RUST_OPENSSL_DH,
270
+ RULE_RUST_RING_X25519,
271
+ RULE_RUST_BARE_X25519,
272
+ RULE_RUST_BARE_SIGNINGKEY,
273
+ RULE_RUST_JWT_ALGORITHM,
274
+ RULE_RUST_TLS_ACCEPT_INVALID,
275
+ RULE_RUST_TLS_DANGEROUS,
276
+ ],
96
277
  appliesTo: (f) => hasExtension(f, RUST_EXTENSIONS),
97
278
  detect({ file, content }): Finding[] {
98
279
  const findings: Finding[] = [];
@@ -107,6 +288,29 @@ export const rustDetector: Detector = {
107
288
  add(RE_RUST_ECDH, RULE_RUST_ECDH);
108
289
  add(RE_RUST_ED25519, RULE_RUST_ED25519);
109
290
  add(RE_RUST_X25519, RULE_RUST_X25519);
291
+ add(RE_RUST_OPENSSL_RSA, RULE_RUST_OPENSSL_RSA);
292
+ add(RE_RUST_OPENSSL_EC, RULE_RUST_OPENSSL_EC);
293
+ add(RE_RUST_OPENSSL_DSA, RULE_RUST_OPENSSL_DSA);
294
+ add(RE_RUST_OPENSSL_DH, RULE_RUST_OPENSSL_DH);
295
+ add(RE_RUST_RING_X25519, RULE_RUST_RING_X25519);
296
+ add(RE_RUST_BARE_X25519, RULE_RUST_BARE_X25519);
297
+ add(RE_RUST_BARE_SIGNINGKEY, RULE_RUST_BARE_SIGNINGKEY);
298
+ // jsonwebtoken Algorithm enum variants — resolve the family per match: the
299
+ // RS*/PS* variants are RSASSA (RSA), ES* is ECDSA, EdDSA is Ed25519.
300
+ eachMatch(RE_RUST_JWT_ALG, content, (m) => {
301
+ const prefix = m[1];
302
+ const algorithm: AlgorithmFamily =
303
+ prefix === undefined ? "EdDSA" : prefix === "ES" ? "ECDSA" : "RSA";
304
+ findings.push(
305
+ findingFromRule(
306
+ RULE_RUST_JWT_ALGORITHM,
307
+ { file, content, index: m.index, matchLength: m[0].length },
308
+ { algorithm },
309
+ ),
310
+ );
311
+ });
312
+ add(RE_RUST_TLS_ACCEPT_INVALID, RULE_RUST_TLS_ACCEPT_INVALID);
313
+ add(RE_RUST_TLS_DANGEROUS, RULE_RUST_TLS_DANGEROUS);
110
314
  return findings;
111
315
  },
112
316
  };
@@ -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,
@@ -280,8 +281,8 @@ const nodeCryptoDetector: Detector = {
280
281
  },
281
282
  dsa: { algo: "DSA", cat: "signature", sev: "high", hndl: false, label: "DSA" },
282
283
  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" },
284
+ x25519: { algo: "X25519", cat: "key-exchange", sev: "medium", hndl: true, label: "X25519" },
285
+ x448: { algo: "X448", cat: "key-exchange", sev: "medium", hndl: true, label: "X448" },
285
286
  ed25519: { algo: "EdDSA", cat: "signature", sev: "low", hndl: false, label: "Ed25519" },
286
287
  ed448: { algo: "EdDSA", cat: "signature", sev: "low", hndl: false, label: "Ed448" },
287
288
  };
@@ -623,6 +624,11 @@ const libraryDetector: Detector = {
623
624
  /* JWT / JOSE / COSE algorithm strings */
624
625
  /* -------------------------------------------------------------------------- */
625
626
 
627
+ // JOSE RSA key-transport algorithms (JWE `alg`): quoted RSA-OAEP / RSA-OAEP-256
628
+ // /384/512 / RSA1_5. Classical RSA *encryption* (key transport) — distinct from
629
+ // the RS*/PS* *signature* tokens above, and a harvest-now-decrypt-later surface.
630
+ const RE_JOSE_KEM = /['"`](RSA-OAEP(?:-(?:256|384|512))?|RSA1_5)['"`]/g;
631
+
626
632
  const RULE_JWT_ALG: RuleMeta = {
627
633
  id: "jwt-classical-alg",
628
634
  title: "Classical JWT/JOSE algorithm",
@@ -650,6 +656,20 @@ const RULE_JOSE_ECDH: RuleMeta = {
650
656
  "JOSE ECDH-ES performs classical ECDH key agreement — harvest-now-decrypt-later exposed.",
651
657
  remediation: "Track IETF PQC JOSE/COSE; adopt hybrid X25519MLKEM768 KEM-based encryption.",
652
658
  };
659
+ const RULE_JOSE_RSA_OAEP: RuleMeta = {
660
+ id: "jose-rsa-oaep",
661
+ title: "JOSE RSA key-transport algorithm",
662
+ description: "JWE RSA-OAEP / RSA-OAEP-256/384/512 / RSA1_5 key encryption",
663
+ category: "key-exchange",
664
+ severity: "high",
665
+ confidence: "medium",
666
+ algorithm: "RSA",
667
+ hndl: true,
668
+ cwe: CWE_BROKEN_CRYPTO,
669
+ message:
670
+ "JOSE RSA key transport (RSA-OAEP / RSA1_5) is classical RSA encryption — harvest-now-decrypt-later exposed.",
671
+ remediation: "Track IETF PQC JOSE/COSE; adopt hybrid X25519MLKEM768 KEM-based encryption.",
672
+ };
653
673
 
654
674
  /**
655
675
  * Detects classical signature algorithm identifiers used by JWT/JOSE, plus
@@ -664,7 +684,7 @@ const jwtDetector: Detector = {
664
684
  // signal in JS/TS or Python (e.g. PyJWT `algorithm="RS256"`), so this detector
665
685
  // is un-gated from JS-only to the JWT host surfaces.
666
686
  language: "any",
667
- rules: [RULE_JWT_ALG, RULE_JOSE_ECDH],
687
+ rules: [RULE_JWT_ALG, RULE_JOSE_ECDH, RULE_JOSE_RSA_OAEP],
668
688
  appliesTo: (f) => hasExtension(f, JWT_HOST_EXTENSIONS),
669
689
  detect({ file, content }): Finding[] {
670
690
  const findings: Finding[] = [];
@@ -703,6 +723,20 @@ const jwtDetector: Detector = {
703
723
  );
704
724
  });
705
725
 
726
+ // JOSE RSA key transport (RSA-OAEP / RSA1_5) — classical RSA encryption, HNDL.
727
+ eachMatch(RE_JOSE_KEM, content, (m) => {
728
+ findings.push(
729
+ findingFromRule(
730
+ RULE_JOSE_RSA_OAEP,
731
+ { file, content, index: m.index, matchLength: m[0].length },
732
+ {
733
+ title: `JOSE RSA key transport ${m[1]}`,
734
+ message: `JOSE "${m[1]}" is classical RSA key transport — harvest-now-decrypt-later exposed.`,
735
+ },
736
+ ),
737
+ );
738
+ });
739
+
706
740
  return findings;
707
741
  },
708
742
  };
@@ -810,6 +844,13 @@ const tlsDetector: Detector = {
810
844
  const RE_SSH_PUBKEY = /\b(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-nistp(?:256|384|521))\b/g;
811
845
  const RE_CERT_SIG_ALG =
812
846
  /\b(sha(?:1|256|384|512)WithRSAEncryption|ecdsa-with-SHA(?:1|256|384|512)|rsassaPss|dsaWithSHA(?:1|256))\b/g;
847
+ // Classical SSH key-exchange algorithm identifiers: finite-field DH groups
848
+ // (diffie-hellman-group{1,14,15,16,17,18} + group-exchange), ECDH over NIST
849
+ // curves (ecdh-sha2-nistp*), and Curve25519 (curve25519-sha256). All are
850
+ // Shor-broken key agreement — the harvest-now-decrypt-later surface a lexical
851
+ // scan misses when only the `crypto/*` API is matched, not the negotiated kex.
852
+ const RE_SSH_KEX =
853
+ /\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;
813
854
 
814
855
  const RULE_SSH_PUBKEY: RuleMeta = {
815
856
  id: "ssh-public-key",
@@ -823,7 +864,8 @@ const RULE_SSH_PUBKEY: RuleMeta = {
823
864
  cwe: CWE_BROKEN_CRYPTO,
824
865
  sensitive: true,
825
866
  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).",
867
+ remediation:
868
+ "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
869
  };
828
870
  const RULE_CERT_SIG_ALG: RuleMeta = {
829
871
  id: "cert-signature-algorithm",
@@ -839,6 +881,21 @@ const RULE_CERT_SIG_ALG: RuleMeta = {
839
881
  "A classical certificate signature algorithm (RSA/ECDSA/DSA) is a quantum forgery surface.",
840
882
  remediation: "Plan re-issuance with PQC-capable CAs as ML-DSA certificate profiles mature.",
841
883
  };
884
+ const RULE_SSH_KEX: RuleMeta = {
885
+ id: "ssh-kex-classical",
886
+ title: "Classical SSH key exchange",
887
+ description: "diffie-hellman-group* / group-exchange / ecdh-sha2-* / curve25519-sha256 kex",
888
+ category: "key-exchange",
889
+ severity: "medium",
890
+ confidence: "medium",
891
+ algorithm: "unknown",
892
+ hndl: true,
893
+ cwe: CWE_BROKEN_CRYPTO,
894
+ message:
895
+ "A classical SSH key-exchange algorithm (finite-field DH / ECDH / X25519) is harvest-now-decrypt-later exposed.",
896
+ remediation:
897
+ "Prefer the mlkem768x25519-sha256 KEX (ML-KEM-768 hybrid, OpenSSH 10 default); sntrup761x25519 is an acceptable interim.",
898
+ };
842
899
 
843
900
  /**
844
901
  * Detects classical SSH public keys (`authorized_keys` / `known_hosts` lines)
@@ -851,8 +908,10 @@ const sshCertDetector: Detector = {
851
908
  description: "SSH public keys and TLS/X.509 certificate signature algorithms in config",
852
909
  scope: "config",
853
910
  language: "any",
854
- rules: [RULE_SSH_PUBKEY, RULE_CERT_SIG_ALG],
855
- appliesTo: () => true,
911
+ rules: [RULE_SSH_PUBKEY, RULE_CERT_SIG_ALG, RULE_SSH_KEX],
912
+ // Skip prose/docs: a changelog or README that merely mentions `ssh-rsa` in a
913
+ // sentence is not crypto config. PEM material is caught by its own detector.
914
+ appliesTo: (f) => !hasExtension(f, DOC_EXTENSIONS),
856
915
  detect({ file, content }): Finding[] {
857
916
  const findings: Finding[] = [];
858
917
 
@@ -900,6 +959,89 @@ const sshCertDetector: Detector = {
900
959
  );
901
960
  });
902
961
 
962
+ // SSH key-exchange algorithm identifiers (finite-field DH / ECDH / X25519).
963
+ eachMatch(RE_SSH_KEX, content, (m) => {
964
+ const tok = m[1];
965
+ const algorithm: Finding["algorithm"] = tok.startsWith("diffie-hellman")
966
+ ? "DH"
967
+ : tok.startsWith("ecdh")
968
+ ? "ECDH"
969
+ : "X25519";
970
+ findings.push(
971
+ findingFromRule(
972
+ RULE_SSH_KEX,
973
+ { file, content, index: m.index, matchLength: m[0].length },
974
+ {
975
+ title: `Classical SSH key exchange (${tok})`,
976
+ algorithm,
977
+ message: `SSH key-exchange "${tok}" is classical (${algorithm}) — harvest-now-decrypt-later exposed.`,
978
+ },
979
+ ),
980
+ );
981
+ });
982
+
983
+ return findings;
984
+ },
985
+ };
986
+
987
+ /* -------------------------------------------------------------------------- */
988
+ /* Classical TLS key-exchange cipher suites (language-agnostic config) */
989
+ /* -------------------------------------------------------------------------- */
990
+
991
+ // Classical KEX cipher-suite identifiers, in both the OpenSSL (`ECDHE-RSA-…`) and
992
+ // IANA (`TLS_ECDHE_RSA_WITH_…`) spellings, with `-` or `_` separators. These name
993
+ // a Shor-broken key exchange (ECDHE/DHE), so they are the harvest-now surface a
994
+ // PQC scanner should flag — distinct from the legacy-*version* / weak-cipher rules
995
+ // above. Case-sensitive on purpose: cipher-suite tokens are upper-case constants,
996
+ // so prose like "ecdhe rsa" does not misfire. No trailing \b so the IANA
997
+ // `TLS_ECDHE_RSA_WITH_…` form (a `_` follows `RSA`) still matches.
998
+ const RE_TLS_CLASSICAL_KEX = /\b(?:TLS_)?(?:ECDHE|ECDH|DHE)[-_](?:RSA|ECDSA|DSS)/g;
999
+
1000
+ const RULE_TLS_CLASSICAL_KEX: RuleMeta = {
1001
+ id: "tls-classical-kex",
1002
+ title: "Classical TLS key-exchange cipher suite",
1003
+ description: "ECDHE / DHE cipher suites negotiate Shor-broken key exchange",
1004
+ category: "tls",
1005
+ severity: "medium",
1006
+ confidence: "medium",
1007
+ algorithm: "unknown",
1008
+ hndl: true,
1009
+ cwe: CWE_BROKEN_CRYPTO,
1010
+ message:
1011
+ "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.",
1012
+ remediation:
1013
+ "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.",
1014
+ };
1015
+
1016
+ /**
1017
+ * Detects classical TLS key-exchange cipher suites in any text/config file
1018
+ * (OpenSSL cipher lists, IANA suite constants in Go/Java/C#/Rust, `.properties`,
1019
+ * etc.). Language-agnostic because the suite identifiers are the same tokens
1020
+ * everywhere — this is the cross-language TLS gap the legacy-version rule misses.
1021
+ */
1022
+ const tlsClassicalKexDetector: Detector = {
1023
+ id: "tls-cipher-suite",
1024
+ description: "Classical TLS key-exchange cipher suites (ECDHE/DHE) in any config",
1025
+ scope: "config",
1026
+ language: "any",
1027
+ rules: [RULE_TLS_CLASSICAL_KEX],
1028
+ appliesTo: (f) => !hasExtension(f, DOC_EXTENSIONS),
1029
+ detect({ file, content }): Finding[] {
1030
+ const findings: Finding[] = [];
1031
+ eachMatch(RE_TLS_CLASSICAL_KEX, content, (m) => {
1032
+ const tok = m[0];
1033
+ const algorithm: Finding["algorithm"] = tok.includes("ECDH") ? "ECDH" : "DH";
1034
+ findings.push(
1035
+ findingFromRule(
1036
+ RULE_TLS_CLASSICAL_KEX,
1037
+ { file, content, index: m.index, matchLength: m[0].length },
1038
+ {
1039
+ algorithm,
1040
+ message: `Classical TLS key-exchange suite "${tok}…" (${algorithm}) is harvest-now-decrypt-later exposed.`,
1041
+ },
1042
+ ),
1043
+ );
1044
+ });
903
1045
  return findings;
904
1046
  },
905
1047
  };
@@ -912,4 +1054,5 @@ export const sourceDetectors: Detector[] = [
912
1054
  jwtDetector,
913
1055
  tlsDetector,
914
1056
  sshCertDetector,
1057
+ tlsClassicalKexDetector,
915
1058
  ];
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Config / source-agnostic detector: stateful hash-based signatures
3
+ * (NIST SP 800-208) — LMS, HSS, XMSS and XMSSMT.
4
+ *
5
+ * These schemes ARE quantum-safe and NIST-approved (SP 800-208, for firmware /
6
+ * secure-boot signing), so this is NOT a "broken crypto" finding. The hazard it
7
+ * flags is STATE MANAGEMENT: each private key holds a bounded set of one-time
8
+ * key indices, and reusing an index is catastrophic (it enables signature
9
+ * forgery). The detector therefore surfaces the usage so a reviewer can confirm
10
+ * rigorous state handling exists — or migrate to a stateless scheme.
11
+ *
12
+ * Like {@link pemDetector}, this is a `scope: "config"`, `language: "any"`
13
+ * detector that runs on every text file, because the distinctive LMS/XMSS
14
+ * parameter strings and library tokens (`LMS_SHA256_M32_H10`, `pyhsslms`,
15
+ * `XMSS-SHA2_10_256`, …) appear identically across languages, config, and docs.
16
+ * Every rule's metadata lives in the {@link RuleMeta} declaration below; the
17
+ * regexes are tightly bounded and `\b`-anchored to stay high-signal.
18
+ */
19
+ import type { Detector, Finding, RuleMeta } from "../types.js";
20
+ import { eachMatch, findingFromRule } from "../detect-utils.js";
21
+ import { CWE_RISKY_PRIMITIVE } from "../cwe.js";
22
+
23
+ /**
24
+ * Shared remediation for every stateful-HBS finding: these are approved but
25
+ * STATEFUL, so the reviewer must confirm state management before trusting them.
26
+ */
27
+ const STATEFUL_HBS_REMEDIATION =
28
+ "LMS/HSS/XMSS/XMSSMT are NIST-approved (SP 800-208) but STATEFUL: the signer " +
29
+ "must NEVER reuse a one-time key index (reuse enables signature forgery). Use " +
30
+ "only with rigorous, crash-safe state management; otherwise prefer the " +
31
+ "stateless ML-DSA (FIPS 204) or SLH-DSA (FIPS 205).";
32
+
33
+ /** A stateful-HBS rule: its catalog metadata plus the token regex that triggers it. */
34
+ interface HbsRule {
35
+ /** Global regex matching the distinctive token. */
36
+ re: RegExp;
37
+ meta: RuleMeta;
38
+ }
39
+
40
+ const HBS_RULES: HbsRule[] = [
41
+ {
42
+ // LMS parameter set, e.g. LMS_SHA256_M32_H10 (RFC 8554 / SP 800-208).
43
+ re: /\bLMS_SHA256_[MN]\d+_[HW]\d+\b/g,
44
+ meta: {
45
+ id: "stateful-hbs-lms-param",
46
+ title: "LMS parameter set (stateful hash-based signature)",
47
+ description: "LMS/HSS one-time-signature parameter string (SP 800-208)",
48
+ category: "signature",
49
+ severity: "medium",
50
+ confidence: "high",
51
+ algorithm: "unknown",
52
+ hndl: false,
53
+ cwe: CWE_RISKY_PRIMITIVE,
54
+ message:
55
+ "LMS parameter set — NIST-approved (SP 800-208) but STATEFUL: reusing a one-time key index is catastrophic.",
56
+ remediation: STATEFUL_HBS_REMEDIATION,
57
+ },
58
+ },
59
+ {
60
+ // HSS keygen (hierarchical LMS), e.g. pyhsslms.hss_generate_private_key(...).
61
+ re: /\bhss_generate_private_key\b/g,
62
+ meta: {
63
+ id: "stateful-hbs-hss-keygen",
64
+ title: "HSS private-key generation (stateful hash-based signature)",
65
+ description: "HSS (hierarchical LMS) private-key generation call (SP 800-208)",
66
+ category: "signature",
67
+ severity: "medium",
68
+ confidence: "high",
69
+ algorithm: "unknown",
70
+ hndl: false,
71
+ cwe: CWE_RISKY_PRIMITIVE,
72
+ message:
73
+ "HSS private-key generation — NIST-approved (SP 800-208) but STATEFUL: never reuse a one-time key index.",
74
+ remediation: STATEFUL_HBS_REMEDIATION,
75
+ },
76
+ },
77
+ {
78
+ // pyhsslms — the Python LMS/HSS library import token.
79
+ re: /\bpyhsslms\b/g,
80
+ meta: {
81
+ id: "stateful-hbs-pyhsslms",
82
+ title: "pyhsslms library (stateful LMS/HSS signatures)",
83
+ description: "Reference to the pyhsslms LMS/HSS library (SP 800-208)",
84
+ category: "signature",
85
+ severity: "medium",
86
+ confidence: "high",
87
+ algorithm: "unknown",
88
+ hndl: false,
89
+ cwe: CWE_RISKY_PRIMITIVE,
90
+ message:
91
+ "pyhsslms (LMS/HSS) — NIST-approved (SP 800-208) but STATEFUL: the signer must never reuse a one-time key index.",
92
+ remediation: STATEFUL_HBS_REMEDIATION,
93
+ },
94
+ },
95
+ {
96
+ // XMSS parameter set, e.g. XMSS-SHA2_10_256 (RFC 8391 / SP 800-208).
97
+ re: /\bXMSS-SHA2_\d+_256\b/g,
98
+ meta: {
99
+ id: "stateful-hbs-xmss-param",
100
+ title: "XMSS parameter set (stateful hash-based signature)",
101
+ description: "XMSS one-time-signature parameter string (SP 800-208)",
102
+ category: "signature",
103
+ severity: "medium",
104
+ confidence: "high",
105
+ algorithm: "unknown",
106
+ hndl: false,
107
+ cwe: CWE_RISKY_PRIMITIVE,
108
+ message:
109
+ "XMSS parameter set — NIST-approved (SP 800-208) but STATEFUL: reusing a one-time key index is catastrophic.",
110
+ remediation: STATEFUL_HBS_REMEDIATION,
111
+ },
112
+ },
113
+ {
114
+ // XMSSMT (multi-tree XMSS) parameter set, e.g. XMSSMT-SHA2_20/2_256.
115
+ re: /\bXMSSMT-SHA2_\d+\b/g,
116
+ meta: {
117
+ id: "stateful-hbs-xmssmt-param",
118
+ title: "XMSSMT parameter set (stateful hash-based signature)",
119
+ description: "XMSSMT (multi-tree XMSS) parameter string (SP 800-208)",
120
+ category: "signature",
121
+ severity: "medium",
122
+ confidence: "high",
123
+ algorithm: "unknown",
124
+ hndl: false,
125
+ cwe: CWE_RISKY_PRIMITIVE,
126
+ message:
127
+ "XMSSMT parameter set — NIST-approved (SP 800-208) but STATEFUL: never reuse a one-time key index.",
128
+ remediation: STATEFUL_HBS_REMEDIATION,
129
+ },
130
+ },
131
+ {
132
+ // XMSS keypair generation, e.g. xmss_keypair(...) (liboqs / xmss reference).
133
+ re: /\bxmss_keypair\b/g,
134
+ meta: {
135
+ id: "stateful-hbs-xmss-keypair",
136
+ title: "XMSS keypair generation (stateful hash-based signature)",
137
+ description: "XMSS keypair-generation call (SP 800-208)",
138
+ category: "signature",
139
+ severity: "medium",
140
+ confidence: "high",
141
+ algorithm: "unknown",
142
+ hndl: false,
143
+ cwe: CWE_RISKY_PRIMITIVE,
144
+ message:
145
+ "XMSS keypair generation — NIST-approved (SP 800-208) but STATEFUL: the signer must never reuse a one-time key index.",
146
+ remediation: STATEFUL_HBS_REMEDIATION,
147
+ },
148
+ },
149
+ ];
150
+
151
+ /** Detects stateful hash-based signature (SP 800-208) usage in arbitrary files. */
152
+ export const statefulHbsDetector: Detector = {
153
+ id: "stateful-hbs",
154
+ description:
155
+ "Stateful hash-based signatures (NIST SP 800-208: LMS / HSS / XMSS / XMSSMT) in any file",
156
+ scope: "config",
157
+ language: "any",
158
+ rules: HBS_RULES.map((r) => r.meta),
159
+ // Applies to every text file; the walker already filters out binaries.
160
+ appliesTo: () => true,
161
+ detect({ file, content }): Finding[] {
162
+ const findings: Finding[] = [];
163
+ for (const rule of HBS_RULES) {
164
+ eachMatch(rule.re, content, (m) => {
165
+ findings.push(
166
+ findingFromRule(rule.meta, {
167
+ file,
168
+ content,
169
+ index: m.index,
170
+ matchLength: m[0].length,
171
+ }),
172
+ );
173
+ });
174
+ }
175
+ return findings;
176
+ },
177
+ };