@quantakrypto/core 0.3.0 → 0.4.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.
Files changed (166) hide show
  1. package/dist/agent-types.d.ts +48 -0
  2. package/dist/agent-types.d.ts.map +1 -0
  3. package/dist/agent-types.js +2 -0
  4. package/dist/agent-types.js.map +1 -0
  5. package/dist/cache.d.ts +27 -0
  6. package/dist/cache.d.ts.map +1 -0
  7. package/dist/cache.js +93 -0
  8. package/dist/cache.js.map +1 -0
  9. package/dist/cbom.d.ts.map +1 -1
  10. package/dist/cbom.js +26 -1
  11. package/dist/cbom.js.map +1 -1
  12. package/dist/changed.d.ts.map +1 -1
  13. package/dist/changed.js +14 -2
  14. package/dist/changed.js.map +1 -1
  15. package/dist/codemods/config-toggle.d.ts +3 -0
  16. package/dist/codemods/config-toggle.d.ts.map +1 -0
  17. package/dist/codemods/config-toggle.js +26 -0
  18. package/dist/codemods/config-toggle.js.map +1 -0
  19. package/dist/codemods/registry.d.ts +26 -0
  20. package/dist/codemods/registry.d.ts.map +1 -0
  21. package/dist/codemods/registry.js +8 -0
  22. package/dist/codemods/registry.js.map +1 -0
  23. package/dist/comments.d.ts +45 -0
  24. package/dist/comments.d.ts.map +1 -0
  25. package/dist/comments.js +164 -0
  26. package/dist/comments.js.map +1 -0
  27. package/dist/config.d.ts +1 -1
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +7 -0
  30. package/dist/config.js.map +1 -1
  31. package/dist/dependencies.d.ts +31 -13
  32. package/dist/dependencies.d.ts.map +1 -1
  33. package/dist/dependencies.js +460 -40
  34. package/dist/dependencies.js.map +1 -1
  35. package/dist/detect-utils.d.ts +44 -1
  36. package/dist/detect-utils.d.ts.map +1 -1
  37. package/dist/detect-utils.js +113 -17
  38. package/dist/detect-utils.js.map +1 -1
  39. package/dist/detectors/c.d.ts +20 -0
  40. package/dist/detectors/c.d.ts.map +1 -0
  41. package/dist/detectors/c.js +104 -0
  42. package/dist/detectors/c.js.map +1 -0
  43. package/dist/detectors/csharp.d.ts +15 -0
  44. package/dist/detectors/csharp.d.ts.map +1 -0
  45. package/dist/detectors/csharp.js +76 -0
  46. package/dist/detectors/csharp.js.map +1 -0
  47. package/dist/detectors/go.d.ts +25 -0
  48. package/dist/detectors/go.d.ts.map +1 -0
  49. package/dist/detectors/go.js +132 -0
  50. package/dist/detectors/go.js.map +1 -0
  51. package/dist/detectors/java.d.ts +25 -0
  52. package/dist/detectors/java.d.ts.map +1 -0
  53. package/dist/detectors/java.js +200 -0
  54. package/dist/detectors/java.js.map +1 -0
  55. package/dist/detectors/python.d.ts +26 -0
  56. package/dist/detectors/python.d.ts.map +1 -0
  57. package/dist/detectors/python.js +171 -0
  58. package/dist/detectors/python.js.map +1 -0
  59. package/dist/detectors/ruby.d.ts +15 -0
  60. package/dist/detectors/ruby.d.ts.map +1 -0
  61. package/dist/detectors/ruby.js +75 -0
  62. package/dist/detectors/ruby.js.map +1 -0
  63. package/dist/detectors/rust.d.ts +13 -0
  64. package/dist/detectors/rust.d.ts.map +1 -0
  65. package/dist/detectors/rust.js +94 -0
  66. package/dist/detectors/rust.js.map +1 -0
  67. package/dist/detectors/source.d.ts.map +1 -1
  68. package/dist/detectors/source.js +70 -22
  69. package/dist/detectors/source.js.map +1 -1
  70. package/dist/index.d.ts +18 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +15 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/parallel.d.ts +4 -0
  75. package/dist/parallel.d.ts.map +1 -1
  76. package/dist/parallel.js +59 -8
  77. package/dist/parallel.js.map +1 -1
  78. package/dist/patch-policy.d.ts +22 -0
  79. package/dist/patch-policy.d.ts.map +1 -0
  80. package/dist/patch-policy.js +21 -0
  81. package/dist/patch-policy.js.map +1 -0
  82. package/dist/redact.d.ts +19 -0
  83. package/dist/redact.d.ts.map +1 -0
  84. package/dist/redact.js +77 -0
  85. package/dist/redact.js.map +1 -0
  86. package/dist/registry.d.ts +13 -3
  87. package/dist/registry.d.ts.map +1 -1
  88. package/dist/registry.js +31 -4
  89. package/dist/registry.js.map +1 -1
  90. package/dist/remediate-pipeline.d.ts +41 -0
  91. package/dist/remediate-pipeline.d.ts.map +1 -0
  92. package/dist/remediate-pipeline.js +38 -0
  93. package/dist/remediate-pipeline.js.map +1 -0
  94. package/dist/remediate-request.d.ts +21 -0
  95. package/dist/remediate-request.d.ts.map +1 -0
  96. package/dist/remediate-request.js +28 -0
  97. package/dist/remediate-request.js.map +1 -0
  98. package/dist/remediation.d.ts +10 -0
  99. package/dist/remediation.d.ts.map +1 -1
  100. package/dist/remediation.js +21 -8
  101. package/dist/remediation.js.map +1 -1
  102. package/dist/report.d.ts.map +1 -1
  103. package/dist/report.js +16 -1
  104. package/dist/report.js.map +1 -1
  105. package/dist/scan-worker.js +6 -2
  106. package/dist/scan-worker.js.map +1 -1
  107. package/dist/scan.d.ts +5 -4
  108. package/dist/scan.d.ts.map +1 -1
  109. package/dist/scan.js +52 -29
  110. package/dist/scan.js.map +1 -1
  111. package/dist/triage.d.ts +27 -0
  112. package/dist/triage.d.ts.map +1 -0
  113. package/dist/triage.js +33 -0
  114. package/dist/triage.js.map +1 -0
  115. package/dist/types.d.ts +53 -3
  116. package/dist/types.d.ts.map +1 -1
  117. package/dist/verify.d.ts +30 -0
  118. package/dist/verify.d.ts.map +1 -0
  119. package/dist/verify.js +65 -0
  120. package/dist/verify.js.map +1 -0
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/dist/walk.d.ts +7 -0
  124. package/dist/walk.d.ts.map +1 -1
  125. package/dist/walk.js +85 -11
  126. package/dist/walk.js.map +1 -1
  127. package/dist/worktree.d.ts +6 -0
  128. package/dist/worktree.d.ts.map +1 -0
  129. package/dist/worktree.js +43 -0
  130. package/dist/worktree.js.map +1 -0
  131. package/package.json +1 -1
  132. package/src/agent-types.ts +52 -0
  133. package/src/cache.ts +120 -0
  134. package/src/cbom.ts +27 -1
  135. package/src/changed.ts +13 -4
  136. package/src/codemods/config-toggle.ts +35 -0
  137. package/src/codemods/registry.ts +31 -0
  138. package/src/comments.ts +180 -0
  139. package/src/config.ts +8 -0
  140. package/src/dependencies.ts +483 -51
  141. package/src/detect-utils.ts +123 -15
  142. package/src/detectors/c.ts +132 -0
  143. package/src/detectors/csharp.ts +98 -0
  144. package/src/detectors/go.ts +167 -0
  145. package/src/detectors/java.ts +235 -0
  146. package/src/detectors/python.ts +212 -0
  147. package/src/detectors/ruby.ts +97 -0
  148. package/src/detectors/rust.ts +112 -0
  149. package/src/detectors/source.ts +69 -21
  150. package/src/index.ts +45 -1
  151. package/src/parallel.ts +63 -6
  152. package/src/patch-policy.ts +42 -0
  153. package/src/redact.ts +92 -0
  154. package/src/registry.ts +32 -4
  155. package/src/remediate-pipeline.ts +81 -0
  156. package/src/remediate-request.ts +52 -0
  157. package/src/remediation.ts +27 -11
  158. package/src/report.ts +27 -1
  159. package/src/scan-worker.ts +6 -2
  160. package/src/scan.ts +62 -31
  161. package/src/triage.ts +58 -0
  162. package/src/types.ts +66 -3
  163. package/src/verify.ts +82 -0
  164. package/src/version.ts +1 -1
  165. package/src/walk.ts +81 -12
  166. package/src/worktree.ts +45 -0
@@ -29,6 +29,7 @@
29
29
  import type { Detector, Finding, RuleMeta } from "../types.js";
30
30
  import {
31
31
  JS_TS_EXTENSIONS,
32
+ JWT_HOST_EXTENSIONS,
32
33
  eachMatch,
33
34
  findingFromRule,
34
35
  hasExtension,
@@ -40,31 +41,41 @@ import { CWE_BROKEN_CRYPTO, CWE_CERT_VALIDATION, CWE_WEAK_STRENGTH } from "../cw
40
41
  /* Precompiled regexes (module scope — never recreated per file) */
41
42
  /* -------------------------------------------------------------------------- */
42
43
 
44
+ // `rsa-pss` is listed before `rsa` so the alternation consumes the full token
45
+ // (ordered alternation would otherwise match `rsa` and reject the `-pss` tail).
43
46
  const RE_GENERATE_KEYPAIR =
44
- /generateKeyPair(?:Sync)?\s*\(\s*['"`](rsa|ec|dsa|dh|x25519|x448|ed25519|ed448)['"`]/g;
47
+ /generateKeyPair(?:Sync)?\s*\(\s*['"`](rsa-pss|rsa|ec|dsa|dh|x25519|x448|ed25519|ed448)['"`]/g;
45
48
  const RE_CREATE_SIGN_VERIFY = /create(?:Sign|Verify)\s*\(/g;
46
- // One-shot crypto.sign/verify(algorithm, data, key). Anchored so it doesn't
47
- // fire inside identifiers like `assign(` or `createSign(` (which the dedicated
48
- // createSign/createVerify rule handles). The first argument is either a quoted
49
- // digest-algorithm string (RSA/ECDSA) or `null` Node's EdDSA one-shot form is
50
- // `crypto.sign(null, data, edKey)`, which must still be detected.
49
+ // One-shot crypto.sign/verify(algorithm, data, key). A LOOKBEHIND (not a
50
+ // consumed char) anchors it so it doesn't fire inside identifiers like `assign(`
51
+ // or `createSign(` (handled by the dedicated createSign/createVerify rule)
52
+ // consuming the preceding char used to push the match onto the previous line,
53
+ // corrupting the reported line/snippet and SARIF/baseline fingerprints. The
54
+ // first argument is either a quoted digest-algorithm string (RSA/ECDSA) or
55
+ // `null` — Node's EdDSA one-shot form is `crypto.sign(null, data, edKey)`.
51
56
  const RE_ONESHOT_SIGN_VERIFY =
52
- /(?:^|[^.\w])(?:crypto\.)?(sign|verify)\s*\(\s*(?:['"`][\w.-]+['"`]|null)\s*,/g;
57
+ /(?<![.\w])(?:crypto\.)?(sign|verify)\s*\(\s*(?:['"`][\w.-]+['"`]|null)\s*,/g;
53
58
  const RE_CREATE_DH = /createDiffieHellman(?:Group)?\s*\(/g;
54
59
  const RE_GET_DH = /getDiffieHellman\s*\(\s*['"`](modp\d+)['"`]\s*\)/g;
55
60
  const RE_CREATE_ECDH = /createECDH\s*\(/g;
56
61
  const RE_RSA_ENCRYPT = /(?:crypto\.)?(?:publicEncrypt|privateDecrypt)\s*\(/g;
57
62
  const RE_DH_KEYOBJECT = /(?:crypto\.)?diffieHellman\s*\(\s*\{/g;
58
63
 
59
- // WebCrypto.
60
- const RE_WEBCRYPTO_ALGO = /\b(RSA-OAEP|RSA-PSS|RSASSA-PKCS1-v1_5|ECDH|ECDSA)\b/gi;
64
+ // WebCrypto. Includes the newer curve algorithms (Ed25519/Ed448 signatures,
65
+ // X25519/X448 key agreement) shipping in modern SubtleCrypto implementations.
66
+ const RE_WEBCRYPTO_ALGO =
67
+ /\b(RSA-OAEP|RSA-PSS|RSASSA-PKCS1-v1_5|ECDH|ECDSA|Ed25519|Ed448|X25519|X448)\b/gi;
61
68
  const RE_SUBTLE_CALL =
62
69
  /subtle\s*\.\s*(generateKey|importKey|exportKey|deriveKey|deriveBits|sign|verify|encrypt|decrypt|wrapKey|unwrapKey)\s*\(/g;
63
70
 
64
71
  // Libraries.
65
72
  const RE_FORGE_RSA = /pki\.rsa\.generateKeyPair\s*\(/g;
66
73
  const RE_FORGE_ED25519 = /forge\.ed25519\b/g;
67
- const RE_ELLIPTIC_EC = /new\s+(?:elliptic\.)?ec\s*\(/gi;
74
+ // Require a curve-like first argument so `new EC("request-scope")` (a non-crypto
75
+ // `EC` class) is not flagged; the elliptic library is always constructed with a
76
+ // named curve (`new EC('secp256k1')`, `new EC('p256')`, …).
77
+ const RE_ELLIPTIC_EC =
78
+ /new\s+(?:elliptic\.)?ec\s*\(\s*['"`](?:sec[pt]|prime|nistp|curve|ed25519|ed448|brainpool|p-?(?:192|224|256|384|521)|x25519|x448)/gi;
68
79
  const RE_JSRSASIGN_KEYGEN = /KEYUTIL\.generateKeypair\s*\(/g;
69
80
  const RE_JSRSASIGN_SIGN = /KJUR\.crypto\.(?:Signature|ECDSA)\b/g;
70
81
  const RE_NODE_RSA = /new\s+NodeRSA\s*\(/g;
@@ -237,6 +248,18 @@ const nodeCryptoDetector: Detector = {
237
248
  }
238
249
  > = {
239
250
  rsa: { algo: "RSA", cat: "kem", sev: "high", hndl: true, label: "RSA" },
251
+ // RSA-PSS is signature-only, so classify it as a (forgeable) signature
252
+ // rather than a KEM — no HNDL confidentiality exposure.
253
+ "rsa-pss": {
254
+ algo: "RSA",
255
+ cat: "signature",
256
+ sev: "high",
257
+ hndl: false,
258
+ label: "RSA-PSS",
259
+ message:
260
+ "Generates a classical RSA-PSS signing key, which is forgeable by a quantum attacker.",
261
+ remediation: "ML-DSA-65 (FIPS 204) or SLH-DSA (FIPS 205)",
262
+ },
240
263
  // EC keys feed BOTH ECDSA (sign) and ECDH (key agreement). ECDH is
241
264
  // HNDL-exposed, so classify conservatively as key-exchange-capable and
242
265
  // surface both concerns rather than asserting signature-only (P0-4).
@@ -406,15 +429,36 @@ const webCryptoDetector: Detector = {
406
429
  eachMatch(RE_WEBCRYPTO_ALGO, content, (m) => {
407
430
  if (!nearSortedCall(callIndexes, m.index, 400)) return;
408
431
  const name = m[1].toUpperCase();
409
- const isKem = name === "RSA-OAEP";
410
- const isEcdh = name === "ECDH";
411
- const algorithm: Finding["algorithm"] = name.startsWith("RSA")
412
- ? "RSA"
413
- : isEcdh
414
- ? "ECDH"
415
- : "ECDSA";
416
- const category: Finding["category"] = isEcdh ? "key-exchange" : isKem ? "kem" : "signature";
417
- const hndl = isKem || isEcdh;
432
+ // Classify by algorithm: RSA-OAEP is KEM (HNDL); ECDH/X25519/X448 are key
433
+ // agreement (HNDL); RSA-PSS/RSASSA/ECDSA/Ed25519/Ed448 are signatures.
434
+ let algorithm: Finding["algorithm"];
435
+ let category: Finding["category"];
436
+ let hndl: boolean;
437
+ let severity: Finding["severity"] | undefined;
438
+ if (name.startsWith("RSA")) {
439
+ algorithm = "RSA";
440
+ const isKem = name === "RSA-OAEP";
441
+ category = isKem ? "kem" : "signature";
442
+ hndl = isKem;
443
+ } else if (name === "ECDH") {
444
+ algorithm = "ECDH";
445
+ category = "key-exchange";
446
+ hndl = true;
447
+ } else if (name === "X25519" || name === "X448") {
448
+ algorithm = name === "X448" ? "X448" : "X25519";
449
+ category = "key-exchange";
450
+ hndl = true;
451
+ severity = "low"; // modern but classical
452
+ } else if (name === "ED25519" || name === "ED448") {
453
+ algorithm = "EdDSA";
454
+ category = "signature";
455
+ hndl = false;
456
+ severity = "low";
457
+ } else {
458
+ algorithm = "ECDSA";
459
+ category = "signature";
460
+ hndl = false;
461
+ }
418
462
  findings.push(
419
463
  findingFromRule(
420
464
  RULE_WEBCRYPTO,
@@ -424,6 +468,7 @@ const webCryptoDetector: Detector = {
424
468
  category,
425
469
  algorithm,
426
470
  hndl,
471
+ ...(severity ? { severity } : {}),
427
472
  message: `WebCrypto algorithm "${m[1]}" is classical asymmetric crypto and not quantum-safe.`,
428
473
  },
429
474
  ),
@@ -607,9 +652,12 @@ const jwtDetector: Detector = {
607
652
  id: "jwt-jose",
608
653
  description: "Classical JWT/JOSE algorithms (RS/PS/ES/EdDSA) and ECDH-ES key agreement",
609
654
  scope: "source",
610
- language: "js",
655
+ // Language-agnostic evidence: a quoted "RS256"/"ES256" alg token is the same
656
+ // signal in JS/TS or Python (e.g. PyJWT `algorithm="RS256"`), so this detector
657
+ // is un-gated from JS-only to the JWT host surfaces.
658
+ language: "any",
611
659
  rules: [RULE_JWT_ALG, RULE_JOSE_ECDH],
612
- appliesTo: (f) => hasExtension(f, JS_TS_EXTENSIONS),
660
+ appliesTo: (f) => hasExtension(f, JWT_HOST_EXTENSIONS),
613
661
  detect({ file, content }): Finding[] {
614
662
  const findings: Finding[] = [];
615
663
 
package/src/index.ts CHANGED
@@ -18,6 +18,41 @@ export type { SarifLog } from "./report.js";
18
18
  // Core orchestration + built-in detector set.
19
19
  export { scan, detectors, detectFile, compareFindings } from "./scan.js";
20
20
 
21
+ // Snippet-level fix verification (shared by MCP verify_fix + remediation).
22
+ export { verifyFix, languageToExtension } from "./verify.js";
23
+ export type { VerifyResult } from "./verify.js";
24
+
25
+ // Agent-plane shared types + the context redactor (offline; reused by MCP).
26
+ export type {
27
+ ContextLevel,
28
+ RedactedContext,
29
+ TriageVerdict,
30
+ Patch,
31
+ FixProposal,
32
+ } from "./agent-types.js";
33
+ export { buildContext, renderPreflight } from "./redact.js";
34
+ export { TRIAGE_RUBRIC, TRIAGE_VERDICT_SCHEMA, buildTriageRequest } from "./triage.js";
35
+ export type { TriageRequest } from "./triage.js";
36
+ export {
37
+ REMEDIATE_RUBRIC,
38
+ FIX_REQUEST_SCHEMA,
39
+ buildRemediateRequest,
40
+ } from "./remediate-request.js";
41
+ export type { RemediateRequest } from "./remediate-request.js";
42
+ export { checkPatchPolicy } from "./patch-policy.js";
43
+ export type { PolicyContext, PolicyDecision } from "./patch-policy.js";
44
+ export { withWorktree } from "./worktree.js";
45
+ export { codemodRegistry, codemodFor } from "./codemods/registry.js";
46
+ export type { Codemod } from "./codemods/registry.js";
47
+ export { configToggleCodemod } from "./codemods/config-toggle.js";
48
+ export { remediateFindings } from "./remediate-pipeline.js";
49
+ export type {
50
+ RemediateOptions,
51
+ RemediationResult,
52
+ VerifiedPatch,
53
+ RejectedPatch,
54
+ } from "./remediate-pipeline.js";
55
+
21
56
  // Scan cancellation / work-budget errors.
22
57
  export { AbortError, BudgetExceededError } from "./errors.js";
23
58
 
@@ -50,11 +85,20 @@ export type { QuantakryptoFileConfig, LoadConfigResult } from "./config.js";
50
85
  // Filesystem walker (relative POSIX paths, default ignores, size/binary filters).
51
86
  export { walkFiles, isBinaryPath, isGeneratedPath, looksMinified } from "./walk.js";
52
87
 
88
+ // Analyzable-language coverage (which source languages the scanner inspects).
89
+ export {
90
+ isAnalyzableSource,
91
+ ANALYZABLE_SOURCE_EXTENSIONS,
92
+ ANALYZABLE_LANGUAGES_LABEL,
93
+ } from "./detect-utils.js";
94
+
53
95
  // Inventory + readiness score.
54
96
  export { buildInventory } from "./inventory.js";
55
97
 
56
98
  // Vulnerable-dependency database (the manifest scanner is used internally by scan()).
57
- export { vulnerableDependencies } from "./dependencies.js";
99
+ // `DEP_VULNERABLE_RULE` is the generic catalog entry for dependency findings
100
+ // (which don't come from a Detector, so aren't in the registry's rule catalog).
101
+ export { vulnerableDependencies, DEP_VULNERABLE_RULE, isManifestFile } from "./dependencies.js";
58
102
 
59
103
  // Severity utilities (ordering, threshold, SARIF level) — shared across tools.
60
104
  export { SEVERITY_ORDER, severityRank, meetsThreshold, sarifLevel } from "./severity.js";
package/src/parallel.ts CHANGED
@@ -18,8 +18,10 @@ import type { Worker as NodeWorker } from "node:worker_threads";
18
18
 
19
19
  import type { Finding, ParallelScanOptions, ScanResult } from "./types.js";
20
20
  import { walkFiles } from "./walk.js";
21
+ import { isAnalyzableSource } from "./detect-utils.js";
21
22
  import { buildInventory } from "./inventory.js";
22
23
  import { compareFindings, filterExplicitFileList, scan } from "./scan.js";
24
+ import { AbortError, BudgetExceededError } from "./errors.js";
23
25
  import { VERSION } from "./version.js";
24
26
 
25
27
  /** One unit of work dispatched to a worker. */
@@ -31,6 +33,10 @@ export interface ScanChunk {
31
33
  export interface ChunkResult {
32
34
  findings: Finding[];
33
35
  filesScanned: number;
36
+ /** Files that couldn't be read in this chunk (optional; older workers omit it). */
37
+ unreadable?: number;
38
+ /** Files skipped as minified in this chunk (optional; older workers omit it). */
39
+ skippedMinified?: number;
34
40
  }
35
41
 
36
42
  const DEFAULT_PARALLEL_THRESHOLD_BYTES = 2 * 1024 * 1024;
@@ -74,12 +80,16 @@ export function chunkByBytes(files: readonly SizedFile[], chunkBytes: number): S
74
80
  export function mergeChunkResults(results: readonly ChunkResult[]): ChunkResult {
75
81
  const findings: Finding[] = [];
76
82
  let filesScanned = 0;
83
+ let unreadable = 0;
84
+ let skippedMinified = 0;
77
85
  for (const r of results) {
78
86
  for (const f of r.findings) findings.push(f);
79
87
  filesScanned += r.filesScanned;
88
+ unreadable += r.unreadable ?? 0;
89
+ skippedMinified += r.skippedMinified ?? 0;
80
90
  }
81
91
  findings.sort(compareFindings);
82
- return { findings, filesScanned };
92
+ return { findings, filesScanned, unreadable, skippedMinified };
83
93
  }
84
94
 
85
95
  /** Resolve the worker count (>= 1). */
@@ -162,14 +172,29 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
162
172
  const rootStat = await stat(options.root);
163
173
  const baseDir = rootStat.isFile() ? path.dirname(options.root) : options.root;
164
174
 
165
- // Single-file roots and the override-detectors path always run serially:
166
- // detectors may not be structured-cloneable across the worker boundary.
167
- if (rootStat.isFile() || options.detectors) {
175
+ // Single-file roots, the override-detectors path, and the scan cache always
176
+ // run serially: detectors may not be structured-cloneable across the worker
177
+ // boundary, and the cache read/write is owned by the in-process `scan()`.
178
+ if (rootStat.isFile() || options.detectors || options.cacheFile) {
168
179
  return scan(options);
169
180
  }
170
181
 
171
182
  const files = await enumerateFiles(options, baseDir);
172
183
 
184
+ // Budget + cancellation parity with the serial path. The parallel path
185
+ // enumerates upfront, so budgets are enforced against the whole file set
186
+ // before any worker is dispatched.
187
+ if (options.signal?.aborted) throw new AbortError();
188
+ if (typeof options.maxFiles === "number" && files.length > options.maxFiles) {
189
+ throw new BudgetExceededError(`maxFiles budget exceeded (limit: ${options.maxFiles}).`);
190
+ }
191
+ if (typeof options.maxBytes === "number") {
192
+ const totalBytes = files.reduce((n, f) => n + f.size, 0);
193
+ if (totalBytes > options.maxBytes) {
194
+ throw new BudgetExceededError(`maxBytes budget exceeded (limit: ${options.maxBytes}).`);
195
+ }
196
+ }
197
+
173
198
  if (!shouldParallelize(options, files)) {
174
199
  // In-process: reuse the exact serial path over the same file list.
175
200
  return scan({ ...options, files: files.map((f) => f.rel) });
@@ -206,9 +231,12 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
206
231
  chunks,
207
232
  concurrency,
208
233
  options.onFile,
234
+ options.signal,
209
235
  );
210
- } catch {
211
- // Any worker failure safe fallback to the serial path.
236
+ } catch (err) {
237
+ // Cancellation / budget overflow must propagate, not silently degrade.
238
+ if (err instanceof AbortError || err instanceof BudgetExceededError) throw err;
239
+ // Any other worker failure → safe fallback to the serial path.
212
240
  return scan({ ...options, files: files.map((f) => f.rel) });
213
241
  }
214
242
 
@@ -216,10 +244,21 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
216
244
  const inventory = buildInventory(merged.findings);
217
245
  const finishedAt = new Date();
218
246
 
247
+ // Coverage: count analyzable-source files in the enumerated set. Computed from
248
+ // the file list (not per-worker) to avoid worker-boundary plumbing; on the
249
+ // parallel path this can include a minified analyzable file the workers
250
+ // skipped, but that is vanishingly rare and never under-reports coverage.
251
+ const analyzedFiles = files.reduce((n, f) => (isAnalyzableSource(f.rel) ? n + 1 : n), 0);
252
+
219
253
  return {
220
254
  root: options.root,
221
255
  findings: merged.findings,
222
256
  filesScanned: merged.filesScanned,
257
+ analyzedFiles,
258
+ diagnostics: {
259
+ unreadable: merged.unreadable ?? 0,
260
+ skippedMinified: merged.skippedMinified ?? 0,
261
+ },
223
262
  inventory,
224
263
  startedAt: startedAt.toISOString(),
225
264
  finishedAt: finishedAt.toISOString(),
@@ -243,6 +282,7 @@ function runPool(
243
282
  chunks: ScanChunk[],
244
283
  concurrency: number,
245
284
  onFile?: (file: string) => void,
285
+ signal?: AbortSignal,
246
286
  ): Promise<ChunkResult[]> {
247
287
  return new Promise((resolve, reject) => {
248
288
  const results: ChunkResult[] = new Array(chunks.length);
@@ -251,10 +291,27 @@ function runPool(
251
291
  let failed = false;
252
292
  const workers: Array<NodeWorker> = [];
253
293
 
294
+ const onAbort = (): void => {
295
+ if (failed) return;
296
+ failed = true;
297
+ cleanup();
298
+ reject(new AbortError());
299
+ };
300
+
254
301
  const cleanup = () => {
302
+ if (signal) signal.removeEventListener("abort", onAbort);
255
303
  for (const w of workers) void w.terminate();
256
304
  };
257
305
 
306
+ // Cooperative cancellation: stop dispatching and tear down on abort.
307
+ if (signal) {
308
+ if (signal.aborted) {
309
+ reject(new AbortError());
310
+ return;
311
+ }
312
+ signal.addEventListener("abort", onAbort, { once: true });
313
+ }
314
+
258
315
  const dispatch = (w: NodeWorker): void => {
259
316
  if (failed) return;
260
317
  if (next >= chunks.length) {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Patch-policy allowlist. A remediation patch may only edit a file that already
3
+ * has a finding, or add dependencies to a manifest. Everything else — CI config,
4
+ * lockfiles, env/secret files, unrelated source — is denied. This is the second
5
+ * safety gate (the first is `verify_fix`): even a "verified" patch is dropped if
6
+ * it strays outside the sanctioned surface.
7
+ */
8
+ import type { Patch } from "./agent-types.js";
9
+
10
+ /** Files a remediation may write to. */
11
+ export interface PolicyContext {
12
+ /** Relative paths (posix) that contain at least one finding. */
13
+ findingFiles: Set<string>;
14
+ /** Relative manifest paths (package.json, requirements.txt, …). */
15
+ manifestFiles: Set<string>;
16
+ }
17
+
18
+ /**
19
+ * Paths a patch may NEVER touch, regardless of anything else: version control,
20
+ * CI, dependency lockfiles, and anything that looks like a secret/env file.
21
+ */
22
+ const DENY_RE =
23
+ /(^|\/)(\.github|\.git|node_modules)\/|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|Gemfile\.lock|poetry\.lock)$|(^|\/)\.env(\.[^/]*)?$|\.(pem|key|p12|pfx)$/i;
24
+
25
+ export interface PolicyDecision {
26
+ allowed: boolean;
27
+ reason?: string;
28
+ }
29
+
30
+ /** Decide whether `patch` may be applied under `ctx`. */
31
+ export function checkPatchPolicy(patch: Patch, ctx: PolicyContext): PolicyDecision {
32
+ const p = patch.path.replace(/\\/g, "/");
33
+ if (DENY_RE.test(p)) {
34
+ return { allowed: false, reason: `patch touches a protected path (${p})` };
35
+ }
36
+ if (ctx.findingFiles.has(p)) return { allowed: true };
37
+ if (ctx.manifestFiles.has(p)) return { allowed: true };
38
+ return {
39
+ allowed: false,
40
+ reason: `patch edits ${p}, which has no finding and is not a dependency manifest`,
41
+ };
42
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Context redactor: turn a finding + its file into the payload the LLM sees,
3
+ * bounded to a chosen {@link ContextLevel} and with secrets ALWAYS stripped.
4
+ *
5
+ * This is a hard privacy boundary and lives in `@quantakrypto/core` (offline) so
6
+ * the deterministic MCP plane redacts identically to the networked CLI. A
7
+ * `sensitive` finding (the match IS key material) never emits code at any level.
8
+ */
9
+ import type { Finding } from "./types.js";
10
+ import type { ContextLevel, RedactedContext } from "./agent-types.js";
11
+
12
+ /** Lines of context on each side of the match at `snippet` level. */
13
+ const SNIPPET_RADIUS = 8;
14
+
15
+ /**
16
+ * PEM blocks and long unbroken base64 runs (≥120 chars) are treated as secret
17
+ * material and masked, even inside otherwise-shareable code.
18
+ */
19
+ const SECRET_RE =
20
+ /-----BEGIN [A-Z0-9 ]+-----[\s\S]*?-----END [A-Z0-9 ]+-----|[A-Za-z0-9+/]{120,}={0,2}/g;
21
+
22
+ function stripSecrets(text: string): { text: string; redacted: boolean } {
23
+ let redacted = false;
24
+ const out = text.replace(SECRET_RE, () => {
25
+ redacted = true;
26
+ return "«redacted-secret»";
27
+ });
28
+ return { text: out, redacted };
29
+ }
30
+
31
+ /** Best-effort enclosing brace/colon block around a 0-based line index. */
32
+ function enclosingBlock(lines: string[], idx: number): string {
33
+ let start = idx;
34
+ while (start > 0 && !/[{:]\s*$/.test(lines[start - 1] ?? "")) start--;
35
+ let end = idx;
36
+ let depth = 0;
37
+ for (let i = start; i < lines.length; i++) {
38
+ const line = lines[i] ?? "";
39
+ depth += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
40
+ end = i;
41
+ if (i > idx && depth <= 0) break;
42
+ }
43
+ return lines.slice(start, end + 1).join("\n");
44
+ }
45
+
46
+ /**
47
+ * Build the redacted context for `finding` at `level`. `fileContent` is the full
48
+ * text of the file the finding lives in (unused at `metadata` level). Secrets
49
+ * are always removed; a `sensitive` finding yields `code: null`.
50
+ */
51
+ export function buildContext(
52
+ finding: Finding,
53
+ level: ContextLevel,
54
+ fileContent: string,
55
+ ): RedactedContext {
56
+ const meta = {
57
+ ruleId: finding.ruleId,
58
+ algorithm: finding.algorithm,
59
+ severity: finding.severity,
60
+ hndl: finding.hndl,
61
+ file: finding.location.file,
62
+ line: finding.location.line,
63
+ message: finding.message,
64
+ };
65
+ // Sensitive findings never emit code, at any level.
66
+ if (finding.sensitive) return { level, meta, code: null, redactedSecret: true };
67
+ if (level === "metadata") return { level, meta, code: null, redactedSecret: false };
68
+
69
+ const lines = fileContent.split("\n");
70
+ let code: string;
71
+ if (level === "file") {
72
+ code = fileContent;
73
+ } else if (level === "function") {
74
+ code = enclosingBlock(lines, finding.location.line - 1);
75
+ } else {
76
+ const i = finding.location.line - 1;
77
+ code = lines.slice(Math.max(0, i - SNIPPET_RADIUS), i + SNIPPET_RADIUS + 1).join("\n");
78
+ }
79
+ const { text, redacted } = stripSecrets(code);
80
+ return { level, meta, code: text, redactedSecret: redacted };
81
+ }
82
+
83
+ /** Render the exact payload text a `--dry-run` preflight would send. */
84
+ export function renderPreflight(contexts: RedactedContext[]): string {
85
+ return contexts
86
+ .map((c) => {
87
+ const flags = `level=${c.level}${c.redactedSecret ? ", secret-redacted" : ""}`;
88
+ const head = `[${c.meta.severity}] ${c.meta.ruleId} ${c.meta.file}:${c.meta.line} (${flags})`;
89
+ return c.code ? `${head}\n${c.code}` : head;
90
+ })
91
+ .join("\n\n---\n\n");
92
+ }
package/src/registry.ts CHANGED
@@ -12,6 +12,13 @@
12
12
  */
13
13
  import type { Detector, DetectorScope, RuleMeta } from "./types.js";
14
14
  import { sourceDetectors } from "./detectors/source.js";
15
+ import { pythonDetector } from "./detectors/python.js";
16
+ import { goDetector } from "./detectors/go.js";
17
+ import { javaDetector } from "./detectors/java.js";
18
+ import { csharpDetector } from "./detectors/csharp.js";
19
+ import { rustDetector } from "./detectors/rust.js";
20
+ import { rubyDetector } from "./detectors/ruby.js";
21
+ import { cDetector } from "./detectors/c.js";
15
22
  import { pemDetector } from "./detectors/pem.js";
16
23
 
17
24
  /** Normalised scope of a detector (defaults to "source" when undeclared). */
@@ -102,8 +109,29 @@ export class DetectorRegistry {
102
109
  }
103
110
 
104
111
  /**
105
- * The default registry, preloaded with the built-in detectors in run order:
106
- * the JS/TS source + config detectors, then the language-agnostic PEM detector.
107
- * The manifest (dependency) scanner is handled separately by `scan()`.
112
+ * The built-in detectors, in run order: the JS/TS source + config detectors,
113
+ * then the per-language detectors (Python, Go, Java, C#, Rust, Ruby, C/C++),
114
+ * then the language-agnostic PEM detector. The manifest (dependency) scanner is
115
+ * handled separately by `scan()`.
116
+ *
117
+ * This is the single source of truth for the default detector set: both
118
+ * {@link defaultRegistry} and the public `detectors` export (re-exported from
119
+ * `scan.ts`) are built from it, so the two can never drift out of sync.
120
+ */
121
+ export const builtinDetectors: Detector[] = [
122
+ ...sourceDetectors,
123
+ pythonDetector,
124
+ goDetector,
125
+ javaDetector,
126
+ csharpDetector,
127
+ rustDetector,
128
+ rubyDetector,
129
+ cDetector,
130
+ pemDetector,
131
+ ];
132
+
133
+ /**
134
+ * The default registry, preloaded with {@link builtinDetectors}. Used by
135
+ * `scan()` whenever `options.detectors` is not supplied.
108
136
  */
109
- export const defaultRegistry = new DetectorRegistry([...sourceDetectors, pemDetector]);
137
+ export const defaultRegistry = new DetectorRegistry(builtinDetectors);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The remediation pipeline: turn candidate patches (from codemods or the LLM)
3
+ * into VERIFIED, in-policy patches. Pure and deterministic — the verify gate
4
+ * runs `verifyFix` on the patched content in memory, so this needs no
5
+ * filesystem or git. Callers (the `qremediate` CLI) decide what to DO with the
6
+ * verified patches (print a diff, write them, or open a PR in a worktree).
7
+ *
8
+ * Two gates, both must pass:
9
+ * 1. patch-policy — the patch may only touch sanctioned files.
10
+ * 2. verify_fix — the patched content clears the target finding, adds no
11
+ * new finding type, and nets fewer findings than before.
12
+ */
13
+ import type { Finding } from "./types.js";
14
+ import type { Patch } from "./agent-types.js";
15
+ import { checkPatchPolicy } from "./patch-policy.js";
16
+ import type { PolicyContext } from "./patch-policy.js";
17
+ import { verifyFix } from "./verify.js";
18
+
19
+ export interface VerifiedPatch {
20
+ finding: Finding;
21
+ patch: Patch;
22
+ }
23
+ export interface RejectedPatch {
24
+ finding: Finding;
25
+ reason: string;
26
+ }
27
+ export interface RemediationResult {
28
+ applied: VerifiedPatch[];
29
+ rejected: RejectedPatch[];
30
+ }
31
+
32
+ export interface RemediateOptions {
33
+ /** Current content of the file a finding lives in. */
34
+ readContent: (finding: Finding) => Promise<string> | string;
35
+ /** Produce a candidate patch for a finding (codemod or LLM), or null. */
36
+ patchSource: (finding: Finding, content: string) => Promise<Patch | null> | Patch | null;
37
+ /** Which files a patch is allowed to touch. */
38
+ policy: PolicyContext;
39
+ }
40
+
41
+ /** True when `after` is a strict improvement over `before` for `finding`. */
42
+ function passesVerify(before: Finding[], after: Finding[], finding: Finding): boolean {
43
+ const targetGone = !after.some((x) => x.ruleId === finding.ruleId);
44
+ const noNewRuleTypes = after.every((x) => before.some((b) => b.ruleId === x.ruleId));
45
+ return targetGone && noNewRuleTypes && after.length < before.length;
46
+ }
47
+
48
+ /**
49
+ * Run each finding through patchSource → policy → verify, collecting the patches
50
+ * that survive both gates and the reasons the rest were dropped.
51
+ */
52
+ export async function remediateFindings(
53
+ findings: readonly Finding[],
54
+ opts: RemediateOptions,
55
+ ): Promise<RemediationResult> {
56
+ const applied: VerifiedPatch[] = [];
57
+ const rejected: RejectedPatch[] = [];
58
+
59
+ for (const finding of findings) {
60
+ const content = await opts.readContent(finding);
61
+ const patch = await opts.patchSource(finding, content);
62
+ if (!patch) {
63
+ rejected.push({ finding, reason: "no deterministic fix available" });
64
+ continue;
65
+ }
66
+ const decision = checkPatchPolicy(patch, opts.policy);
67
+ if (!decision.allowed) {
68
+ rejected.push({ finding, reason: decision.reason ?? "rejected by patch policy" });
69
+ continue;
70
+ }
71
+ const before = verifyFix(content, { filename: finding.location.file }).findings;
72
+ const after = verifyFix(patch.newContent, { filename: patch.path }).findings;
73
+ if (!passesVerify(before, after, finding)) {
74
+ rejected.push({ finding, reason: "patch did not pass the verify_fix gate" });
75
+ continue;
76
+ }
77
+ applied.push({ finding, patch });
78
+ }
79
+
80
+ return { applied, rejected };
81
+ }