@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/report.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  * or a human-readable text summary. No third-party dependencies — ANSI colour
4
4
  * is emitted with raw escape codes and is off by default.
5
5
  */
6
- import type { Finding, ScanResult, Severity } from "./types.js";
6
+ import type { Finding, RuleMeta, ScanResult, Severity } from "./types.js";
7
7
  import { VERSION } from "./version.js";
8
8
  import { SEVERITY_ORDER, sarifLevel } from "./severity.js";
9
+ import { ANALYZABLE_LANGUAGES_LABEL } from "./detect-utils.js";
9
10
 
10
11
  /** Minimal SARIF 2.1.0 log shape (kept permissive on purpose). */
11
12
  export interface SarifLog {
@@ -23,6 +24,14 @@ export interface ReportOptions {
23
24
  * snippet there IS the sensitive value.
24
25
  */
25
26
  redactSnippets?: boolean;
27
+ /**
28
+ * Full rule catalog to advertise in SARIF `tool.driver.rules[]`, even for
29
+ * rules that produced no finding in this run. Pass
30
+ * `defaultRegistry.ruleCatalog()`. When omitted, only the rules that actually
31
+ * fired are emitted (the historical behaviour). SARIF-only; ignored by
32
+ * {@link toJson}.
33
+ */
34
+ catalog?: RuleMeta[];
26
35
  }
27
36
 
28
37
  const SARIF_SCHEMA =
@@ -56,40 +65,88 @@ function sarifRank(severity: Severity): number {
56
65
  }
57
66
  }
58
67
 
68
+ /** Build a SARIF `rules[]` entry from a rule's severity/title/message/etc. */
69
+ function sarifRule(spec: {
70
+ id: string;
71
+ title: string;
72
+ message: string;
73
+ severity: Severity;
74
+ category: string;
75
+ algorithm?: string;
76
+ hndl: boolean;
77
+ cwe?: string;
78
+ remediation?: string;
79
+ }): Record<string, unknown> {
80
+ return {
81
+ id: spec.id,
82
+ name: spec.id,
83
+ shortDescription: { text: spec.title },
84
+ fullDescription: { text: spec.message },
85
+ defaultConfiguration: { level: sarifLevel(spec.severity), rank: sarifRank(spec.severity) },
86
+ ...(spec.remediation ? { help: { text: `Remediation: ${spec.remediation}` } } : {}),
87
+ properties: {
88
+ category: spec.category,
89
+ ...(spec.algorithm ? { algorithm: spec.algorithm } : {}),
90
+ hndl: spec.hndl,
91
+ ...(spec.cwe ? { cwe: spec.cwe, "security-severity": securitySeverity(spec.severity) } : {}),
92
+ ...(spec.cwe ? { tags: ["security", spec.cwe] } : {}),
93
+ },
94
+ ...(spec.cwe
95
+ ? {
96
+ relationships: [
97
+ { target: { id: spec.cwe, toolComponent: { name: "CWE" } }, kinds: ["relevant"] },
98
+ ],
99
+ }
100
+ : {}),
101
+ };
102
+ }
103
+
59
104
  /** Serialize a scan result as SARIF 2.1.0. */
60
105
  export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
61
106
  const redactSnippets = opts?.redactSnippets ?? false;
62
- // Build the unique rule set (one rule per ruleId encountered) and collect the
63
- // set of CWE taxa referenced by any rule.
107
+ // Build the rule set and collect the CWE taxa referenced by any rule. When a
108
+ // full catalog is supplied, advertise every rule (even ones that didn't fire);
109
+ // otherwise emit one rule per ruleId encountered (the historical behaviour).
64
110
  const ruleIndex = new Map<string, number>();
65
111
  const rules: Array<Record<string, unknown>> = [];
66
112
  const cweTaxa = new Set<string>();
113
+
114
+ for (const r of opts?.catalog ?? []) {
115
+ if (ruleIndex.has(r.id)) continue;
116
+ if (r.cwe) cweTaxa.add(r.cwe);
117
+ ruleIndex.set(r.id, rules.length);
118
+ rules.push(
119
+ sarifRule({
120
+ id: r.id,
121
+ title: r.title,
122
+ message: r.message,
123
+ severity: r.severity,
124
+ category: r.category,
125
+ algorithm: r.algorithm,
126
+ hndl: r.hndl,
127
+ cwe: r.cwe,
128
+ remediation: r.remediation,
129
+ }),
130
+ );
131
+ }
132
+
67
133
  for (const f of result.findings) {
68
134
  if (f.cwe) cweTaxa.add(f.cwe);
69
135
  if (ruleIndex.has(f.ruleId)) continue;
70
136
  ruleIndex.set(f.ruleId, rules.length);
71
- rules.push({
72
- id: f.ruleId,
73
- name: f.ruleId,
74
- shortDescription: { text: f.title },
75
- fullDescription: { text: f.message },
76
- defaultConfiguration: { level: sarifLevel(f.severity), rank: sarifRank(f.severity) },
77
- ...(f.remediation ? { help: { text: `Remediation: ${f.remediation}` } } : {}),
78
- properties: {
137
+ rules.push(
138
+ sarifRule({
139
+ id: f.ruleId,
140
+ title: f.title,
141
+ message: f.message,
142
+ severity: f.severity,
79
143
  category: f.category,
80
- ...(f.algorithm ? { algorithm: f.algorithm } : {}),
144
+ algorithm: f.algorithm,
81
145
  hndl: f.hndl,
82
- ...(f.cwe ? { cwe: f.cwe, "security-severity": securitySeverity(f.severity) } : {}),
83
- ...(f.cwe ? { tags: ["security", f.cwe] } : {}),
84
- },
85
- ...(f.cwe
86
- ? {
87
- relationships: [
88
- { target: { id: f.cwe, toolComponent: { name: "CWE" } }, kinds: ["relevant"] },
89
- ],
90
- }
91
- : {}),
92
- });
146
+ cwe: f.cwe,
147
+ remediation: f.remediation,
148
+ }),
149
+ );
93
150
  }
94
151
 
95
152
  const results = result.findings.map((f) => {
@@ -197,6 +254,8 @@ export function toJson(result: ScanResult, opts?: ReportOptions): Record<string,
197
254
  startedAt: result.startedAt,
198
255
  finishedAt: result.finishedAt,
199
256
  filesScanned: result.filesScanned,
257
+ ...(result.analyzedFiles !== undefined ? { analyzedFiles: result.analyzedFiles } : {}),
258
+ ...(result.diagnostics ? { diagnostics: result.diagnostics } : {}),
200
259
  inventory: {
201
260
  readinessScore: result.inventory.readinessScore,
202
261
  hndlCount: result.inventory.hndlCount,
@@ -283,12 +342,35 @@ export function formatSummary(result: ScanResult, options?: { color?: boolean })
283
342
  lines.push(
284
343
  `Readiness score: ${c(`${ANSI.bold}${scoreColor(inv.readinessScore)}`, `${inv.readinessScore}/100`)}`,
285
344
  );
345
+ const analyzed =
346
+ result.analyzedFiles !== undefined
347
+ ? ` Analyzed (${ANALYZABLE_LANGUAGES_LABEL}): ${result.analyzedFiles}`
348
+ : "";
286
349
  lines.push(
287
- `Files scanned: ${result.filesScanned} Findings: ${result.findings.length} HNDL-exposed: ${c(
350
+ `Files scanned: ${result.filesScanned}${analyzed} Findings: ${result.findings.length} HNDL-exposed: ${c(
288
351
  inv.hndlCount > 0 ? ANSI.red : ANSI.green,
289
352
  String(inv.hndlCount),
290
353
  )}`,
291
354
  );
355
+ // Coverage honesty: a score over zero analyzable files is not a clean bill of health.
356
+ if (result.analyzedFiles === 0 && result.filesScanned > 0) {
357
+ lines.push(
358
+ c(
359
+ ANSI.yellow,
360
+ `Note: 0 files were in a supported source language (${ANALYZABLE_LANGUAGES_LABEL}) — the readiness score does not reflect this codebase.`,
361
+ ),
362
+ );
363
+ }
364
+ // Coverage diagnostics: skipped files mean the finding count may be incomplete.
365
+ const diag = result.diagnostics;
366
+ if (diag && (diag.unreadable > 0 || diag.skippedMinified > 0)) {
367
+ lines.push(
368
+ c(
369
+ ANSI.yellow,
370
+ `Coverage: ${diag.unreadable} unreadable, ${diag.skippedMinified} skipped as minified — results may be incomplete.`,
371
+ ),
372
+ );
373
+ }
292
374
  lines.push("");
293
375
 
294
376
  // Severity breakdown.
@@ -23,6 +23,7 @@ interface WorkerToggles {
23
23
  config: boolean;
24
24
  deps: boolean;
25
25
  scanMinified: boolean;
26
+ disabledRules?: string[];
26
27
  }
27
28
 
28
29
  interface ChunkRequest {
@@ -41,6 +42,8 @@ if (parentPort) {
41
42
  try {
42
43
  const findings: Finding[] = [];
43
44
  let filesScanned = 0;
45
+ let unreadable = 0;
46
+ let skippedMinified = 0;
44
47
  const scannedNames: string[] = [];
45
48
 
46
49
  for (const rel of req.files) {
@@ -49,26 +52,34 @@ if (parentPort) {
49
52
  try {
50
53
  content = readFileSync(abs, "utf8");
51
54
  } catch {
52
- continue; // vanished / unreadable skip.
55
+ unreadable += 1;
56
+ continue;
53
57
  }
54
58
  if (!toggles.scanMinified && !isManifestFile(rel) && looksMinified(content)) {
59
+ skippedMinified += 1;
55
60
  continue;
56
61
  }
57
62
  filesScanned += 1;
58
63
  scannedNames.push(rel);
59
64
  findings.push(
60
- ...detectFile(rel, content, dets, {
61
- source: toggles.source,
62
- config: toggles.config,
63
- deps: toggles.deps,
64
- }),
65
+ ...detectFile(
66
+ rel,
67
+ content,
68
+ dets,
69
+ {
70
+ source: toggles.source,
71
+ config: toggles.config,
72
+ deps: toggles.deps,
73
+ },
74
+ toggles.disabledRules,
75
+ ),
65
76
  );
66
77
  }
67
78
 
68
79
  port.postMessage({
69
80
  index: req.index,
70
81
  files: scannedNames,
71
- result: { findings, filesScanned },
82
+ result: { findings, filesScanned, unreadable, skippedMinified },
72
83
  });
73
84
  } catch (err) {
74
85
  port.postMessage({
package/src/scan.ts CHANGED
@@ -13,22 +13,25 @@ import { readFile, stat } from "node:fs/promises";
13
13
  import * as path from "node:path";
14
14
 
15
15
  import type { Detector, Finding, ScanOptions, ScanResult } from "./types.js";
16
- import { walkFiles, toPosix, isBinaryPath, looksMinified } from "./walk.js";
17
- import { sourceDetectors } from "./detectors/source.js";
18
- import { pemDetector } from "./detectors/pem.js";
19
- import { defaultRegistry, detectorScope } from "./registry.js";
16
+ import { walkFiles, toPosix, isBinaryPath, looksMinified, matchesAny } from "./walk.js";
17
+ import { isAnalyzableSource } from "./detect-utils.js";
18
+ import { stripCommentFindings, stripIgnoredFindings } from "./comments.js";
19
+ import { hashContent, loadCache, rulesetFingerprint, saveCache } from "./cache.js";
20
+ import type { CacheEntry } from "./cache.js";
21
+ import { builtinDetectors, defaultRegistry, detectorScope } from "./registry.js";
20
22
  import { isManifestFile, scanManifest } from "./dependencies.js";
21
23
  import { buildInventory } from "./inventory.js";
22
24
  import { AbortError, BudgetExceededError } from "./errors.js";
23
25
  import { VERSION } from "./version.js";
24
26
 
25
27
  /**
26
- * The full set of built-in detectors exposed on the public API. The PEM
27
- * detector applies to every text file; the source detectors apply only to
28
- * JS/TS. The manifest scanner is handled separately (it parses JSON rather than
29
- * running a Detector).
28
+ * The full set of built-in detectors exposed on the public API. Re-exported
29
+ * from {@link builtinDetectors} in `registry.ts` the single source of truth
30
+ * the {@link defaultRegistry} is also built from so the public array and the
31
+ * registry can never drift apart. The manifest scanner is handled separately
32
+ * (it parses JSON rather than running a Detector).
30
33
  */
31
- export const detectors: Detector[] = [...sourceDetectors, pemDetector];
34
+ export const detectors: Detector[] = builtinDetectors;
32
35
 
33
36
  /** Stable comparator: by file, then line, then ruleId. Exported for reuse. */
34
37
  export function compareFindings(a: Finding, b: Finding): number {
@@ -52,8 +55,9 @@ export function detectFile(
52
55
  content: string,
53
56
  dets: readonly Detector[],
54
57
  toggles: { source: boolean; config: boolean; deps: boolean },
58
+ disabledRules?: readonly string[],
55
59
  ): Finding[] {
56
- const out: Finding[] = [];
60
+ let out: Finding[] = [];
57
61
 
58
62
  for (const det of dets) {
59
63
  if (!det.appliesTo(file)) continue;
@@ -62,10 +66,24 @@ export function detectFile(
62
66
  out.push(...det.detect({ file, content }));
63
67
  }
64
68
 
69
+ // Drop lexical false positives that land inside comments (`// migrated off
70
+ // createECDH()`), then honour inline `// qscan-ignore-line` /
71
+ // `qscan-ignore-next-line` directives. Both run before the dependency scan
72
+ // appends its findings (manifests carry neither).
73
+ out = stripCommentFindings(out, content, file);
74
+ out = stripIgnoredFindings(out, content);
75
+
65
76
  if (toggles.deps && isManifestFile(file)) {
66
77
  out.push(...scanManifest(file, content));
67
78
  }
68
79
 
80
+ // Per-rule suppression: drop findings whose ruleId was disabled. Applied here
81
+ // so both the serial scan and the worker path honour it identically.
82
+ if (disabledRules && disabledRules.length > 0) {
83
+ const disabled = new Set(disabledRules);
84
+ return out.filter((f) => !disabled.has(f.ruleId));
85
+ }
86
+
69
87
  return out;
70
88
  }
71
89
 
@@ -91,7 +109,16 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
91
109
 
92
110
  const findings: Finding[] = [];
93
111
  let filesScanned = 0;
112
+ let analyzedFiles = 0;
94
113
  let bytesScanned = 0;
114
+ let unreadable = 0;
115
+ let skippedMinified = 0;
116
+
117
+ // Optional content-hash scan cache: unchanged files reuse prior findings.
118
+ const cacheFile = options.cacheFile;
119
+ const ruleset = cacheFile ? rulesetFingerprint(dets, options.disabledRules) : "";
120
+ const cache = cacheFile ? await loadCache(cacheFile, ruleset) : null;
121
+ const nextEntries: Map<string, CacheEntry> | null = cacheFile ? new Map() : null;
95
122
 
96
123
  // Work-budget / cancellation controls (all optional, unlimited when omitted).
97
124
  const signal = options.signal;
@@ -127,12 +154,14 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
127
154
  try {
128
155
  content = await readFile(absPath, "utf8");
129
156
  } catch {
130
- continue; // unreadable / vanished fileskip.
157
+ unreadable += 1; // permissions / vanished / decode failure tracked, not silent.
158
+ continue;
131
159
  }
132
160
 
133
161
  // Skip machine-minified / generated content (unless explicitly enabled).
134
162
  // Manifests are always scanned (their findings are dependency findings).
135
163
  if (!scanMinified && !isManifestFile(reportedPath) && looksMinified(content)) {
164
+ skippedMinified += 1;
136
165
  continue;
137
166
  }
138
167
 
@@ -143,15 +172,40 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
143
172
  }
144
173
 
145
174
  filesScanned += 1;
146
- findings.push(
147
- ...detectFile(reportedPath, content, dets, {
148
- source: doSource,
149
- config: doConfig,
150
- deps: doDeps,
151
- }),
152
- );
175
+ if (isAnalyzableSource(reportedPath)) analyzedFiles += 1;
176
+
177
+ // Cache: reuse prior findings when the content hash matches; otherwise scan
178
+ // and record. `nextEntries` becomes the new cache (dropping vanished files).
179
+ let fileFindings: Finding[];
180
+ if (cache && nextEntries) {
181
+ const hash = hashContent(content);
182
+ const hit = cache.get(reportedPath);
183
+ fileFindings =
184
+ hit && hit.hash === hash
185
+ ? hit.findings
186
+ : detectFile(
187
+ reportedPath,
188
+ content,
189
+ dets,
190
+ { source: doSource, config: doConfig, deps: doDeps },
191
+ options.disabledRules,
192
+ );
193
+ nextEntries.set(reportedPath, { hash, findings: fileFindings });
194
+ } else {
195
+ fileFindings = detectFile(
196
+ reportedPath,
197
+ content,
198
+ dets,
199
+ { source: doSource, config: doConfig, deps: doDeps },
200
+ options.disabledRules,
201
+ );
202
+ }
203
+ findings.push(...fileFindings);
153
204
  }
154
205
 
206
+ // Persist the cache (best effort) before ordering/returning.
207
+ if (cacheFile && nextEntries) await saveCache(cacheFile, ruleset, nextEntries);
208
+
155
209
  // Stable ordering: by file, then line, then ruleId.
156
210
  findings.sort(compareFindings);
157
211
 
@@ -162,6 +216,8 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
162
216
  root: options.root,
163
217
  findings,
164
218
  filesScanned,
219
+ analyzedFiles,
220
+ diagnostics: { unreadable, skippedMinified },
165
221
  inventory,
166
222
  startedAt: startedAt.toISOString(),
167
223
  finishedAt: finishedAt.toISOString(),
@@ -207,14 +263,3 @@ export function filterExplicitFileList(
207
263
  async function* filterExplicitFiles(files: string[], options: ScanOptions): AsyncGenerator<string> {
208
264
  for (const rel of filterExplicitFileList(files, options)) yield rel;
209
265
  }
210
-
211
- /** Local substring/prefix matcher (mirrors the walker's pattern semantics). */
212
- function matchesAny(rel: string, patterns: readonly string[]): boolean {
213
- for (const pattern of patterns) {
214
- if (!pattern) continue;
215
- const p = toPosix(pattern).replace(/\/+$/, "");
216
- if (rel.includes(p)) return true;
217
- if (rel === p || rel.startsWith(`${p}/`)) return true;
218
- }
219
- return false;
220
- }
package/src/triage.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Triage rubric, verdict schema, and request-bundle builder — all offline and
3
+ * deterministic. Both planes share them: `@quantakrypto/agent` (networked)
4
+ * prompts a model with the rubric, while the MCP `triage_findings` tool emits a
5
+ * request bundle for the HOST agent to reason over. Keeping them here means the
6
+ * MCP server never imports the networked package and both sides use one rubric.
7
+ */
8
+ import type { Finding } from "./types.js";
9
+ import type { ContextLevel, RedactedContext } from "./agent-types.js";
10
+ import { buildContext } from "./redact.js";
11
+
12
+ /** The system rubric that defines what an exposure verdict means. */
13
+ export const TRIAGE_RUBRIC =
14
+ "You are a post-quantum cryptography triage assistant. You are given ONE finding " +
15
+ "of classical (quantum-vulnerable) cryptography detected in a codebase, with " +
16
+ "limited, possibly-redacted context. Assess its REAL-WORLD exposure and urgency. " +
17
+ "exposureScore (0-100): how exploitable/exposed this usage is — a long-lived " +
18
+ "confidentiality key over the network (harvest-now-decrypt-later) scores high; a " +
19
+ "short-lived local signature scores lower. priority: 'now' for HNDL/high-exposure, " +
20
+ "'soon' for important-but-not-urgent, 'later' for low-exposure. You NEVER decide " +
21
+ "whether the finding is valid and you NEVER suppress it — you only rank exposure. " +
22
+ "Base your rationale only on the given context; do not invent facts.";
23
+
24
+ /** JSON Schema every triage verdict must satisfy. */
25
+ export const TRIAGE_VERDICT_SCHEMA: Record<string, unknown> = {
26
+ type: "object",
27
+ required: ["exposureScore", "priority", "rationale"],
28
+ properties: {
29
+ exposureScore: { type: "number", minimum: 0, maximum: 100 },
30
+ priority: { enum: ["now", "soon", "later"] },
31
+ rationale: { type: "string" },
32
+ },
33
+ };
34
+
35
+ /** A triage request bundle: the rubric, the verdict schema, and redacted contexts. */
36
+ export interface TriageRequest {
37
+ rubric: string;
38
+ schema: Record<string, unknown>;
39
+ contexts: RedactedContext[];
40
+ }
41
+
42
+ /**
43
+ * Build a triage request bundle for a set of findings. Offline: for non-metadata
44
+ * levels, pass `readContent` to supply each finding's file text; without it,
45
+ * contexts are built at `metadata` level (no source), which is the safe default
46
+ * for the MCP plane where the host agent already has the code open.
47
+ */
48
+ export function buildTriageRequest(
49
+ findings: readonly Finding[],
50
+ level: ContextLevel = "metadata",
51
+ readContent?: (finding: Finding) => string,
52
+ ): TriageRequest {
53
+ const effectiveLevel: ContextLevel = readContent ? level : "metadata";
54
+ const contexts = findings.map((f) =>
55
+ buildContext(f, effectiveLevel, readContent ? readContent(f) : ""),
56
+ );
57
+ return { rubric: TRIAGE_RUBRIC, schema: TRIAGE_VERDICT_SCHEMA, contexts };
58
+ }
package/src/types.ts CHANGED
@@ -50,6 +50,21 @@ export interface SourceLocation {
50
50
  snippet?: string;
51
51
  }
52
52
 
53
+ /** Relative urgency an LLM triage pass assigns to a finding. */
54
+ export type TriagePriority = "now" | "soon" | "later";
55
+
56
+ /**
57
+ * Optional LLM triage annotation attached to a finding by `qscan --triage`.
58
+ * Purely additive: it re-ranks and explains, it never suppresses a finding and
59
+ * never influences the exit code (which is computed from `severity` alone).
60
+ */
61
+ export interface TriageAnnotation {
62
+ /** 0–100 real-world exposure/exploitability estimate. */
63
+ exposureScore: number;
64
+ priority: TriagePriority;
65
+ rationale: string;
66
+ }
67
+
53
68
  /** A single detected concern. */
54
69
  export interface Finding {
55
70
  /** Stable rule identifier, e.g. "rsa-keygen", "ecdh-usage", "tls-legacy-version", "dep-vulnerable". */
@@ -74,14 +89,19 @@ export interface Finding {
74
89
  * snippet for such findings, regardless of any redaction flag.
75
90
  */
76
91
  sensitive?: boolean;
92
+ /** Optional LLM triage annotation (`qscan --triage`); never affects exit code. */
93
+ triage?: TriageAnnotation;
77
94
  location: SourceLocation;
78
95
  }
79
96
 
97
+ /** Package ecosystems the dependency scanner understands. */
98
+ export type DependencyEcosystem = "npm" | "pypi" | "cargo" | "go" | "maven" | "rubygems";
99
+
80
100
  /** A known quantum-vulnerable dependency entry. */
81
101
  export interface VulnerableDependency {
82
- /** Package name. */
102
+ /** Package name (as written in the ecosystem's manifest). */
83
103
  name: string;
84
- ecosystem: "npm";
104
+ ecosystem: DependencyEcosystem;
85
105
  /** Why it's flagged (what classical crypto it provides). */
86
106
  reason: string;
87
107
  /** Algorithm families the package primarily exposes. */
@@ -99,7 +119,56 @@ export type DetectorScope = "source" | "config";
99
119
  * The programming language / surface a detector targets. `"any"` means the
100
120
  * detector is language-agnostic (e.g. PEM material, config files).
101
121
  */
102
- export type DetectorLanguage = "js" | "python" | "go" | "java" | "any";
122
+ export type DetectorLanguage =
123
+ | "js"
124
+ | "python"
125
+ | "go"
126
+ | "java"
127
+ | "csharp"
128
+ | "rust"
129
+ | "ruby"
130
+ | "c"
131
+ | "any";
132
+
133
+ /**
134
+ * Declarative metadata for a single rule a detector can emit. This is the
135
+ * catalog entry: the stable, queryable description of a rule keyed by the
136
+ * `ruleId` it stamps onto findings. It is the single source of truth for a
137
+ * rule's title / severity / category / remediation, consumed by the SARIF
138
+ * `rules[]` block, the MCP `explain_finding` resolver, and future per-rule
139
+ * enable/disable + language-pack work.
140
+ *
141
+ * For most rules the metadata is fixed and `detect()` builds findings straight
142
+ * from it (see `findingFromRule`). A few rules are inherently multi-variant
143
+ * (e.g. `node-crypto-keygen` spans RSA/EC/DSA/DH/Ed25519 at different
144
+ * severities); for those the metadata here is a REPRESENTATIVE umbrella and
145
+ * `detect()` refines the per-finding fields at match time. The catalog always
146
+ * enumerates every emittable ruleId regardless.
147
+ */
148
+ export interface RuleMeta {
149
+ /** Stable rule id — matches {@link Finding.ruleId}. Unique across the catalog. */
150
+ id: string;
151
+ /** Canonical human title. */
152
+ title: string;
153
+ category: FindingCategory;
154
+ severity: Severity;
155
+ /** Default confidence for findings of this rule. */
156
+ confidence: Confidence;
157
+ /** Harvest-now-decrypt-later exposure. */
158
+ hndl: boolean;
159
+ /** Representative classical algorithm family; refined per-finding when it varies. */
160
+ algorithm?: AlgorithmFamily;
161
+ /** Associated CWE identifier (e.g. "CWE-327"). */
162
+ cwe?: string;
163
+ /** Suggested post-quantum remediation. When omitted, derived from {@link algorithm}. */
164
+ remediation?: string;
165
+ /** Canonical one-line human explanation; may be refined per-finding. */
166
+ message: string;
167
+ /** True when this rule's matched snippet IS sensitive key material. */
168
+ sensitive?: boolean;
169
+ /** Short description of what the rule detects (for catalog / MCP surfaces). */
170
+ description?: string;
171
+ }
103
172
 
104
173
  /** A pluggable source detector. Detectors are pure and stateless. */
105
174
  export interface Detector {
@@ -118,6 +187,13 @@ export interface Detector {
118
187
  * Defaults to `"js"` when omitted.
119
188
  */
120
189
  language?: DetectorLanguage;
190
+ /**
191
+ * The rules this detector can emit, as declarative metadata. Together across
192
+ * all detectors these form the rule catalog ({@link DetectorRegistry.ruleCatalog}).
193
+ * Optional for backward compatibility with externally-defined detectors, but
194
+ * all built-in detectors declare it.
195
+ */
196
+ rules?: RuleMeta[];
121
197
  /** Whether this detector should run against the given file path. */
122
198
  appliesTo(filePath: string): boolean;
123
199
  /** Inspect a single file's contents and return zero or more findings. */
@@ -170,6 +246,21 @@ export interface ScanOptions {
170
246
  * registry's detectors are used.
171
247
  */
172
248
  detectors?: Detector[];
249
+ /**
250
+ * Rule ids to suppress. Any finding whose `ruleId` is listed here is dropped
251
+ * after detection. Serializable (a plain string array), so it is honoured on
252
+ * both the serial and the worker-thread (`scanParallel`) paths. See the rule
253
+ * catalog ({@link DetectorRegistry.ruleCatalog}) for the valid ids.
254
+ */
255
+ disabledRules?: string[];
256
+ /**
257
+ * Path to an on-disk scan cache. When set, unchanged files (same content hash)
258
+ * reuse their previous findings instead of re-running detectors, and the cache
259
+ * is rewritten after the scan. Invalidated wholesale when the tool version,
260
+ * detector set, or `disabledRules` change. Optional; omitted = no caching.
261
+ * The cache forces the in-process (serial) path.
262
+ */
263
+ cacheFile?: string;
173
264
  /** Optional progress callback. */
174
265
  onFile?: (file: string) => void;
175
266
  /**
@@ -220,12 +311,38 @@ export interface CryptoInventory {
220
311
  readinessScore: number;
221
312
  }
222
313
 
314
+ /**
315
+ * Non-fatal things that happened during a scan that reduce coverage. Surfaced so
316
+ * a silent under-scan (e.g. half the tree was unreadable) can't masquerade as a
317
+ * clean "0 findings" result — reporters warn when any count is non-zero.
318
+ */
319
+ export interface ScanDiagnostics {
320
+ /** Files that could not be read (permissions, vanished, decode failure) and were skipped. */
321
+ unreadable: number;
322
+ /** Files skipped because they look machine-minified / generated (scan with `scanMinified` to include). */
323
+ skippedMinified: number;
324
+ }
325
+
223
326
  /** The full result of a scan. */
224
327
  export interface ScanResult {
225
328
  /** The scan root (as provided). */
226
329
  root: string;
227
330
  findings: Finding[];
228
331
  filesScanned: number;
332
+ /**
333
+ * Coverage diagnostics: counts of files skipped as unreadable or minified.
334
+ * Optional for backward compatibility with hand-built results.
335
+ */
336
+ diagnostics?: ScanDiagnostics;
337
+ /**
338
+ * Of `filesScanned`, how many were in a source language the scanner can
339
+ * actually analyze for inline crypto (JS/TS, Python, Go, Java). When this is 0 but
340
+ * `filesScanned` > 0, the readiness score reflects NO analyzable code — the
341
+ * crypto likely lives in an unsupported language (Go, Java, Rust, …) — and
342
+ * reporters surface that so a bare 100/100 can't read as "safe". Optional for
343
+ * backward compatibility with hand-built results.
344
+ */
345
+ analyzedFiles?: number;
229
346
  inventory: CryptoInventory;
230
347
  /** ISO timestamps. */
231
348
  startedAt: string;