@quantakrypto/core 0.2.2 → 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 (173) 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 +70 -2
  36. package/dist/detect-utils.d.ts.map +1 -1
  37. package/dist/detect-utils.js +139 -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/pem.d.ts +4 -0
  56. package/dist/detectors/pem.d.ts.map +1 -1
  57. package/dist/detectors/pem.js +113 -91
  58. package/dist/detectors/pem.js.map +1 -1
  59. package/dist/detectors/python.d.ts +26 -0
  60. package/dist/detectors/python.d.ts.map +1 -0
  61. package/dist/detectors/python.js +171 -0
  62. package/dist/detectors/python.js.map +1 -0
  63. package/dist/detectors/ruby.d.ts +15 -0
  64. package/dist/detectors/ruby.d.ts.map +1 -0
  65. package/dist/detectors/ruby.js +75 -0
  66. package/dist/detectors/ruby.js.map +1 -0
  67. package/dist/detectors/rust.d.ts +13 -0
  68. package/dist/detectors/rust.d.ts.map +1 -0
  69. package/dist/detectors/rust.js +94 -0
  70. package/dist/detectors/rust.js.map +1 -0
  71. package/dist/detectors/source.d.ts +9 -2
  72. package/dist/detectors/source.d.ts.map +1 -1
  73. package/dist/detectors/source.js +435 -286
  74. package/dist/detectors/source.js.map +1 -1
  75. package/dist/index.d.ts +19 -1
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +16 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/parallel.d.ts +4 -0
  80. package/dist/parallel.d.ts.map +1 -1
  81. package/dist/parallel.js +61 -8
  82. package/dist/parallel.js.map +1 -1
  83. package/dist/patch-policy.d.ts +22 -0
  84. package/dist/patch-policy.d.ts.map +1 -0
  85. package/dist/patch-policy.js +21 -0
  86. package/dist/patch-policy.js.map +1 -0
  87. package/dist/redact.d.ts +19 -0
  88. package/dist/redact.d.ts.map +1 -0
  89. package/dist/redact.js +77 -0
  90. package/dist/redact.js.map +1 -0
  91. package/dist/registry.d.ts +29 -4
  92. package/dist/registry.d.ts.map +1 -1
  93. package/dist/registry.js +62 -4
  94. package/dist/registry.js.map +1 -1
  95. package/dist/remediate-pipeline.d.ts +41 -0
  96. package/dist/remediate-pipeline.d.ts.map +1 -0
  97. package/dist/remediate-pipeline.js +38 -0
  98. package/dist/remediate-pipeline.js.map +1 -0
  99. package/dist/remediate-request.d.ts +21 -0
  100. package/dist/remediate-request.d.ts.map +1 -0
  101. package/dist/remediate-request.js +28 -0
  102. package/dist/remediate-request.js.map +1 -0
  103. package/dist/remediation.d.ts +10 -0
  104. package/dist/remediation.d.ts.map +1 -1
  105. package/dist/remediation.js +21 -8
  106. package/dist/remediation.js.map +1 -1
  107. package/dist/report.d.ts +9 -1
  108. package/dist/report.d.ts.map +1 -1
  109. package/dist/report.js +72 -24
  110. package/dist/report.js.map +1 -1
  111. package/dist/scan-worker.js +7 -3
  112. package/dist/scan-worker.js.map +1 -1
  113. package/dist/scan.d.ts +6 -5
  114. package/dist/scan.d.ts.map +1 -1
  115. package/dist/scan.js +59 -30
  116. package/dist/scan.js.map +1 -1
  117. package/dist/triage.d.ts +27 -0
  118. package/dist/triage.d.ts.map +1 -0
  119. package/dist/triage.js +33 -0
  120. package/dist/triage.js.map +1 -0
  121. package/dist/types.d.ts +106 -3
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/verify.d.ts +30 -0
  124. package/dist/verify.d.ts.map +1 -0
  125. package/dist/verify.js +65 -0
  126. package/dist/verify.js.map +1 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/walk.d.ts +7 -0
  130. package/dist/walk.d.ts.map +1 -1
  131. package/dist/walk.js +85 -11
  132. package/dist/walk.js.map +1 -1
  133. package/dist/worktree.d.ts +6 -0
  134. package/dist/worktree.d.ts.map +1 -0
  135. package/dist/worktree.js +43 -0
  136. package/dist/worktree.js.map +1 -0
  137. package/package.json +1 -1
  138. package/src/agent-types.ts +52 -0
  139. package/src/cache.ts +120 -0
  140. package/src/cbom.ts +27 -1
  141. package/src/changed.ts +13 -4
  142. package/src/codemods/config-toggle.ts +35 -0
  143. package/src/codemods/registry.ts +31 -0
  144. package/src/comments.ts +180 -0
  145. package/src/config.ts +8 -0
  146. package/src/dependencies.ts +483 -51
  147. package/src/detect-utils.ts +180 -16
  148. package/src/detectors/c.ts +132 -0
  149. package/src/detectors/csharp.ts +98 -0
  150. package/src/detectors/go.ts +167 -0
  151. package/src/detectors/java.ts +235 -0
  152. package/src/detectors/pem.ts +124 -105
  153. package/src/detectors/python.ts +212 -0
  154. package/src/detectors/ruby.ts +97 -0
  155. package/src/detectors/rust.ts +112 -0
  156. package/src/detectors/source.ts +535 -364
  157. package/src/index.ts +47 -2
  158. package/src/parallel.ts +72 -7
  159. package/src/patch-policy.ts +42 -0
  160. package/src/redact.ts +92 -0
  161. package/src/registry.ts +71 -5
  162. package/src/remediate-pipeline.ts +81 -0
  163. package/src/remediate-request.ts +52 -0
  164. package/src/remediation.ts +27 -11
  165. package/src/report.ts +106 -24
  166. package/src/scan-worker.ts +18 -7
  167. package/src/scan.ts +74 -29
  168. package/src/triage.ts +58 -0
  169. package/src/types.ts +120 -3
  170. package/src/verify.ts +82 -0
  171. package/src/version.ts +1 -1
  172. package/src/walk.ts +81 -12
  173. package/src/worktree.ts +45 -0
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
 
@@ -25,8 +60,9 @@ export { AbortError, BudgetExceededError } from "./errors.js";
25
60
  export { scanParallel, mergeChunkResults, chunkByBytes } from "./parallel.js";
26
61
  export type { ScanChunk, ChunkResult, SizedFile } from "./parallel.js";
27
62
 
28
- // Detector registry (plugin point) + helpers.
63
+ // Detector registry (plugin point) + helpers + the rule catalog.
29
64
  export { DetectorRegistry, defaultRegistry, detectorScope } from "./registry.js";
65
+ export type { RuleCatalogEntry } from "./registry.js";
30
66
 
31
67
  // Canonical baseline (shared by qScan + the Action).
32
68
  export {
@@ -49,11 +85,20 @@ export type { QuantakryptoFileConfig, LoadConfigResult } from "./config.js";
49
85
  // Filesystem walker (relative POSIX paths, default ignores, size/binary filters).
50
86
  export { walkFiles, isBinaryPath, isGeneratedPath, looksMinified } from "./walk.js";
51
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
+
52
95
  // Inventory + readiness score.
53
96
  export { buildInventory } from "./inventory.js";
54
97
 
55
98
  // Vulnerable-dependency database (the manifest scanner is used internally by scan()).
56
- 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";
57
102
 
58
103
  // Severity utilities (ordering, threshold, SARIF level) — shared across tools.
59
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) });
@@ -191,6 +216,8 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
191
216
  config: options.config !== false,
192
217
  deps: options.dependencies !== false,
193
218
  scanMinified: options.scanMinified === true,
219
+ // Plain string array — structured-cloneable, so it crosses the worker boundary.
220
+ disabledRules: options.disabledRules,
194
221
  };
195
222
 
196
223
  let results: ChunkResult[];
@@ -204,9 +231,12 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
204
231
  chunks,
205
232
  concurrency,
206
233
  options.onFile,
234
+ options.signal,
207
235
  );
208
- } catch {
209
- // 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.
210
240
  return scan({ ...options, files: files.map((f) => f.rel) });
211
241
  }
212
242
 
@@ -214,10 +244,21 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
214
244
  const inventory = buildInventory(merged.findings);
215
245
  const finishedAt = new Date();
216
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
+
217
253
  return {
218
254
  root: options.root,
219
255
  findings: merged.findings,
220
256
  filesScanned: merged.filesScanned,
257
+ analyzedFiles,
258
+ diagnostics: {
259
+ unreadable: merged.unreadable ?? 0,
260
+ skippedMinified: merged.skippedMinified ?? 0,
261
+ },
221
262
  inventory,
222
263
  startedAt: startedAt.toISOString(),
223
264
  finishedAt: finishedAt.toISOString(),
@@ -231,10 +272,17 @@ function runPool(
231
272
  entry: string,
232
273
  execArgv: string[] | undefined,
233
274
  baseDir: string,
234
- toggles: { source: boolean; config: boolean; deps: boolean; scanMinified: boolean },
275
+ toggles: {
276
+ source: boolean;
277
+ config: boolean;
278
+ deps: boolean;
279
+ scanMinified: boolean;
280
+ disabledRules?: string[];
281
+ },
235
282
  chunks: ScanChunk[],
236
283
  concurrency: number,
237
284
  onFile?: (file: string) => void,
285
+ signal?: AbortSignal,
238
286
  ): Promise<ChunkResult[]> {
239
287
  return new Promise((resolve, reject) => {
240
288
  const results: ChunkResult[] = new Array(chunks.length);
@@ -243,10 +291,27 @@ function runPool(
243
291
  let failed = false;
244
292
  const workers: Array<NodeWorker> = [];
245
293
 
294
+ const onAbort = (): void => {
295
+ if (failed) return;
296
+ failed = true;
297
+ cleanup();
298
+ reject(new AbortError());
299
+ };
300
+
246
301
  const cleanup = () => {
302
+ if (signal) signal.removeEventListener("abort", onAbort);
247
303
  for (const w of workers) void w.terminate();
248
304
  };
249
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
+
250
315
  const dispatch = (w: NodeWorker): void => {
251
316
  if (failed) return;
252
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
@@ -10,8 +10,15 @@
10
10
  * To add a language or detector, see the "Adding a detector / language" section
11
11
  * of the package README.
12
12
  */
13
- import type { Detector, DetectorScope } from "./types.js";
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). */
@@ -19,6 +26,12 @@ export function detectorScope(d: Detector): DetectorScope {
19
26
  return d.scope ?? "source";
20
27
  }
21
28
 
29
+ /** A rule plus the detector that emits it — the result of {@link DetectorRegistry.forRule}. */
30
+ export interface RuleCatalogEntry {
31
+ rule: RuleMeta;
32
+ detector: Detector;
33
+ }
34
+
22
35
  /**
23
36
  * An ordered, id-indexed collection of detectors. Registration order is
24
37
  * preserved by {@link all} for deterministic scan output. Ids must be unique.
@@ -57,6 +70,38 @@ export class DetectorRegistry {
57
70
  return this.order.map((id) => this.byId.get(id)!);
58
71
  }
59
72
 
73
+ /**
74
+ * The flattened rule catalog: every {@link RuleMeta} declared by every
75
+ * registered detector, in detector-registration then in-detector order. This
76
+ * is the single source of truth for rule metadata consumed by SARIF
77
+ * `rules[]`, the MCP `explain_finding` resolver, and per-rule enable/disable.
78
+ * Duplicate rule ids across detectors throw (ids are globally unique).
79
+ */
80
+ ruleCatalog(): RuleMeta[] {
81
+ const out: RuleMeta[] = [];
82
+ const seen = new Set<string>();
83
+ for (const det of this.all()) {
84
+ for (const rule of det.rules ?? []) {
85
+ if (seen.has(rule.id)) {
86
+ throw new Error(`duplicate rule id in catalog: ${rule.id}`);
87
+ }
88
+ seen.add(rule.id);
89
+ out.push(rule);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /** Resolve a rule id to its {@link RuleMeta} and the detector that emits it. */
96
+ forRule(ruleId: string): RuleCatalogEntry | undefined {
97
+ for (const det of this.all()) {
98
+ for (const rule of det.rules ?? []) {
99
+ if (rule.id === ruleId) return { rule, detector: det };
100
+ }
101
+ }
102
+ return undefined;
103
+ }
104
+
60
105
  /** A shallow copy of this registry (useful to extend the defaults). */
61
106
  clone(): DetectorRegistry {
62
107
  return new DetectorRegistry(this.all());
@@ -64,8 +109,29 @@ export class DetectorRegistry {
64
109
  }
65
110
 
66
111
  /**
67
- * The default registry, preloaded with the built-in detectors in run order:
68
- * the JS/TS source + config detectors, then the language-agnostic PEM detector.
69
- * 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.
70
136
  */
71
- 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
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Remediation rubric, fix schema, and request-bundle builder — offline and
3
+ * deterministic, shared by `@quantakrypto/agent` (which prompts a model with
4
+ * them) and the MCP `remediate_findings` tool (which hands the bundle to the
5
+ * host agent). Keeping them here means the MCP server stays offline/key-free.
6
+ */
7
+ import type { Finding } from "./types.js";
8
+ import type { ContextLevel, RedactedContext } from "./agent-types.js";
9
+ import { buildContext } from "./redact.js";
10
+
11
+ /** The system rubric for a fix proposal. */
12
+ export const REMEDIATE_RUBRIC =
13
+ "You are a post-quantum cryptography migration engineer. Given the FULL content " +
14
+ "of one source file plus a finding describing classical (quantum-vulnerable) " +
15
+ "cryptography in it, return the FULL corrected file content that removes the " +
16
+ "flagged usage, migrating to a post-quantum or hybrid construction " +
17
+ "(ML-KEM-768 / ML-DSA-65, hybrid X25519MLKEM768) where a safe replacement " +
18
+ "exists. Change as little as possible; preserve all other code and formatting " +
19
+ "exactly. If you cannot safely fix it, return newContent identical to the " +
20
+ "input. NEVER invent or alter secrets/keys. After proposing, VERIFY with the " +
21
+ "verify_fix tool and keep only fixes that clear the finding.";
22
+
23
+ /** JSON Schema every fix proposal must satisfy. */
24
+ export const FIX_REQUEST_SCHEMA: Record<string, unknown> = {
25
+ type: "object",
26
+ required: ["path", "newContent", "explanation"],
27
+ properties: {
28
+ path: { type: "string" },
29
+ newContent: { type: "string" },
30
+ explanation: { type: "string" },
31
+ },
32
+ };
33
+
34
+ /** A remediation request bundle for the host agent. */
35
+ export interface RemediateRequest {
36
+ instructions: string;
37
+ schema: Record<string, unknown>;
38
+ contexts: RedactedContext[];
39
+ }
40
+
41
+ /** Build a remediation request bundle (offline; metadata level unless `readContent`). */
42
+ export function buildRemediateRequest(
43
+ findings: readonly Finding[],
44
+ level: ContextLevel = "metadata",
45
+ readContent?: (finding: Finding) => string,
46
+ ): RemediateRequest {
47
+ const effectiveLevel: ContextLevel = readContent ? level : "metadata";
48
+ const contexts = findings.map((f) =>
49
+ buildContext(f, effectiveLevel, readContent ? readContent(f) : ""),
50
+ );
51
+ return { instructions: REMEDIATE_RUBRIC, schema: FIX_REQUEST_SCHEMA, contexts };
52
+ }
@@ -96,6 +96,31 @@ export function remediationText(algorithm: AlgorithmFamily): string {
96
96
  return REMEDIATIONS[algorithm].recommendation;
97
97
  }
98
98
 
99
+ /**
100
+ * True when a family's PQC replacement is a KEM — i.e. it's used for
101
+ * confidentiality / key-agreement (and is therefore harvest-now-decrypt-later
102
+ * exposed). RSA is in both this set and {@link isSignatureFamily}. Single
103
+ * source of truth for the family→target taxonomy used by tier guidance and the
104
+ * multi-family remediation composer.
105
+ */
106
+ export function isConfidentialityFamily(algorithm: AlgorithmFamily): boolean {
107
+ return (
108
+ algorithm === "RSA" ||
109
+ algorithm === "ECDH" ||
110
+ algorithm === "DH" ||
111
+ algorithm === "X25519" ||
112
+ algorithm === "X448" ||
113
+ algorithm === "ECIES"
114
+ );
115
+ }
116
+
117
+ /** True when a family's PQC replacement is a signature scheme (ML-DSA / SLH-DSA). */
118
+ export function isSignatureFamily(algorithm: AlgorithmFamily): boolean {
119
+ return (
120
+ algorithm === "RSA" || algorithm === "ECDSA" || algorithm === "EdDSA" || algorithm === "DSA"
121
+ );
122
+ }
123
+
99
124
  /* -------------------------------------------------------------------------- */
100
125
  /* Security-tier guidance (CNSA 2.0 Category 5) + stateful HBS (SP 800-208) */
101
126
  /* -------------------------------------------------------------------------- */
@@ -135,14 +160,7 @@ export function remediationForTier(
135
160
  const base = REMEDIATIONS[algorithm];
136
161
  const params = TIER_PARAMS[tier];
137
162
  // Confidentiality families lean on the KEM; signature families on the signer.
138
- const isConfidentiality =
139
- algorithm === "RSA" ||
140
- algorithm === "ECDH" ||
141
- algorithm === "DH" ||
142
- algorithm === "X25519" ||
143
- algorithm === "X448" ||
144
- algorithm === "ECIES";
145
- const primary = isConfidentiality ? params.kem : params.signature;
163
+ const primary = isConfidentialityFamily(algorithm) ? params.kem : params.signature;
146
164
  return {
147
165
  algorithm,
148
166
  recommendation: `${base.recommendation} — ${tier}: ${primary}`,
@@ -167,7 +185,5 @@ export const STATEFUL_HBS_NOTE =
167
185
  /** True when stateful HBS (SP 800-208) is a reasonable alternative for a family. */
168
186
  export function statefulHbsApplies(algorithm: AlgorithmFamily): boolean {
169
187
  // Signature families only — LMS/XMSS are signatures, not KEMs.
170
- return (
171
- algorithm === "RSA" || algorithm === "ECDSA" || algorithm === "EdDSA" || algorithm === "DSA"
172
- );
188
+ return isSignatureFamily(algorithm);
173
189
  }