@quantakrypto/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/crypto-agility.d.ts +158 -0
  2. package/dist/crypto-agility.d.ts.map +1 -0
  3. package/dist/crypto-agility.js +285 -0
  4. package/dist/crypto-agility.js.map +1 -0
  5. package/dist/dependencies.d.ts.map +1 -1
  6. package/dist/dependencies.js +180 -18
  7. package/dist/dependencies.js.map +1 -1
  8. package/dist/detectors/source.d.ts.map +1 -1
  9. package/dist/detectors/source.js +52 -0
  10. package/dist/detectors/source.js.map +1 -1
  11. package/dist/hndl.d.ts +241 -0
  12. package/dist/hndl.d.ts.map +1 -0
  13. package/dist/hndl.js +752 -0
  14. package/dist/hndl.js.map +1 -0
  15. package/dist/index.d.ts +6 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +6 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/mandates.d.ts +138 -0
  20. package/dist/mandates.d.ts.map +1 -0
  21. package/dist/mandates.js +228 -0
  22. package/dist/mandates.js.map +1 -0
  23. package/dist/parallel.d.ts.map +1 -1
  24. package/dist/parallel.js +22 -23
  25. package/dist/parallel.js.map +1 -1
  26. package/dist/report.d.ts +8 -0
  27. package/dist/report.d.ts.map +1 -1
  28. package/dist/report.js +75 -19
  29. package/dist/report.js.map +1 -1
  30. package/dist/types.d.ts +1 -1
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js.map +1 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/version.js.map +1 -1
  36. package/dist/walk.d.ts +31 -1
  37. package/dist/walk.d.ts.map +1 -1
  38. package/dist/walk.js +30 -9
  39. package/dist/walk.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/crypto-agility.ts +407 -0
  42. package/src/dependencies.ts +182 -16
  43. package/src/detectors/source.ts +62 -0
  44. package/src/hndl.ts +1012 -0
  45. package/src/index.ts +71 -0
  46. package/src/mandates.ts +365 -0
  47. package/src/parallel.ts +21 -20
  48. package/src/report.ts +84 -19
  49. package/src/types.ts +9 -1
  50. package/src/version.ts +1 -1
  51. package/src/walk.ts +47 -10
package/src/index.ts CHANGED
@@ -83,6 +83,42 @@ export { changedFiles } from "./changed.js";
83
83
  export { loadConfig, ConfigError, CONFIG_FILENAME } from "./config.js";
84
84
  export type { QuantakryptoFileConfig, LoadConfigResult } from "./config.js";
85
85
 
86
+ // HNDL (harvest-now-decrypt-later) data-risk quantifier (see docs/HNDL.md).
87
+ export {
88
+ HNDL_MODEL_VERSION,
89
+ HNDL_FILENAME,
90
+ SEVERITY_VULNERABILITY,
91
+ CONFIDENCE_WEIGHT,
92
+ CLASSIFICATION_SENSITIVITY,
93
+ NON_HNDL_DISCOUNT,
94
+ DEFAULT_QUANTUM_THREAT_YEARS,
95
+ DEFAULT_MIGRATION_HORIZON_YEARS,
96
+ DEFAULT_UNBOUND_CLASSIFICATION,
97
+ HndlError,
98
+ computeHndl,
99
+ vulnerabilityFactor,
100
+ moscaFactor,
101
+ globMatch,
102
+ findingFingerprint,
103
+ findingScope,
104
+ parseHndlMap,
105
+ loadHndlMap,
106
+ scaffoldHndlYaml,
107
+ } from "./hndl.js";
108
+ export type {
109
+ DataClassification,
110
+ HndlScope,
111
+ HndlDataAsset,
112
+ HndlHorizon,
113
+ HndlDefaults,
114
+ HndlMap,
115
+ ExposureRationale,
116
+ FindingExposure,
117
+ AssetExposure,
118
+ HndlSummary,
119
+ HndlReport,
120
+ } from "./hndl.js";
121
+
86
122
  // Filesystem walker (relative POSIX paths, default ignores, size/binary filters).
87
123
  export { walkFiles, isBinaryPath, looksMinified } from "./walk.js";
88
124
 
@@ -122,6 +158,22 @@ export { mergeCboms } from "./cbom-merge.js";
122
158
  // OpenVEX 0.2.0 export — quantum-readiness posture as VEX statements.
123
159
  export { toOpenVex } from "./vex.js";
124
160
  export type { OpenVexDocument, OpenVexStatement, OpenVexOptions } from "./vex.js";
161
+ // Crypto-agility manifest: agent-consumable crypto-posture document.
162
+ export {
163
+ buildCryptoAgilityManifest,
164
+ validateCryptoAgilityManifest,
165
+ CRYPTO_AGILITY_MANIFEST_VERSION,
166
+ CRYPTO_AGILITY_WELL_KNOWN_PATH,
167
+ } from "./crypto-agility.js";
168
+ export type {
169
+ CryptoAgilityManifest,
170
+ CryptoAgilityManifestOptions,
171
+ CryptoAgilityPosture,
172
+ CryptoAgilityCbomSummary,
173
+ CryptoAgilityFamily,
174
+ CryptoAgilityPolicy,
175
+ ManifestValidation,
176
+ } from "./crypto-agility.js";
125
177
  // ISO/IEC 27001 A.8.24 evidence-chain readiness report.
126
178
  export { buildReadinessReport, signReadinessReport, verifyReadinessReport } from "./evidence.js";
127
179
  export type {
@@ -137,6 +189,25 @@ export type {
137
189
  export { buildPolicyMapping, parseCryptoPolicy } from "./policy.js";
138
190
  export type { CryptoPolicy, PolicyVerdict, PolicyMapping, PolicyFindingVerdict } from "./policy.js";
139
191
 
192
+ // Compliance mandates → dated, clause-named verdicts + gate (policy-as-code).
193
+ export {
194
+ MANDATES,
195
+ mandateIds,
196
+ getMandate,
197
+ assertKnownMandates,
198
+ evaluateMandates,
199
+ mandateGateFails,
200
+ } from "./mandates.js";
201
+ export type {
202
+ Mandate,
203
+ MandateRule,
204
+ MandateRuleTier,
205
+ MandateStatus,
206
+ MandateFindingVerdict,
207
+ MandateEvaluation,
208
+ MandateGateOptions,
209
+ } from "./mandates.js";
210
+
140
211
  // Remediation lookup (family + tier-aware + profile-aware) and stateful-HBS guidance.
141
212
  export {
142
213
  remediationFor,
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Policy-as-code compliance mandates → dated, clause-named verdicts for findings.
3
+ *
4
+ * `CryptoPolicy` (policy.ts) classifies findings by algorithm family but is
5
+ * date-blind. A mandate adds the missing dimension: named clauses with an effective
6
+ * DATE ("CNSA 2.0 disallows classical public-key crypto after 2035"). The evaluator
7
+ * compares each finding's algorithm against the selected mandates and today's date,
8
+ * so a finding on a prohibited family reads as `due` (every deadline still ahead),
9
+ * `deprecated` (the DEPRECATE deadline has passed — a warning), or `violation` (the
10
+ * DISALLOW deadline has passed — a failure), always naming the governing clause,
11
+ * deadline, and citation. This is what turns the inventory into an enforceable,
12
+ * mandate-mapped gate rather than a neutral list.
13
+ *
14
+ * Pure and deterministic (the caller supplies `now`), so it is trivially testable.
15
+ * qScan consumes it today for the `--mandate` gate; because it operates on the
16
+ * shared `Finding[]`, qProbe or the GitHub Action can reuse it unchanged.
17
+ *
18
+ * Catalog scope: the two regimes that carry hard algorithm deadlines — CNSA 2.0 and
19
+ * NIST IR 8547. DORA / NIS2 / PCI DSS require approved cryptography but set no
20
+ * independent algorithm date; they inherit these timelines and are cited in docs.
21
+ */
22
+ import type { AlgorithmFamily, Finding } from "./types.js";
23
+ import { PQC_STANDARDS } from "./standards.js";
24
+
25
+ /**
26
+ * All Shor-broken classical asymmetric families — the mandate's SCOPE. A finding
27
+ * on one of these is adjudicated against the selected mandates; findings on
28
+ * anything else (hashes, RNG, dependency, or TLS-configuration findings) are out
29
+ * of scope for a PQC-asymmetric mandate and are tallied as `notInScope` instead
30
+ * of inflating the conformant count.
31
+ */
32
+ const CLASSICAL_PUBLIC_KEY: readonly AlgorithmFamily[] = [
33
+ "RSA",
34
+ "ECDH",
35
+ "ECDSA",
36
+ "EdDSA",
37
+ "DH",
38
+ "DSA",
39
+ "X25519",
40
+ "X448",
41
+ "ECIES",
42
+ ];
43
+
44
+ /**
45
+ * The PROHIBITED subset the dated clauses apply to. X25519 and X448 are
46
+ * deliberately excluded: they are the classical half of the recommended hybrid
47
+ * key exchange (X25519MLKEM768 — permitted and recommended under the NIST
48
+ * profile), and a static scan cannot distinguish a standalone exchange from the
49
+ * hybrid's classical leg. Prohibiting them would false-positive exactly the orgs
50
+ * that hybridized correctly, so they stay in scope but read `conformant`.
51
+ */
52
+ const PROHIBITED_FAMILIES: readonly AlgorithmFamily[] = CLASSICAL_PUBLIC_KEY.filter(
53
+ (family) => family !== "X25519" && family !== "X448",
54
+ );
55
+
56
+ /**
57
+ * Effective dates derived from the standards source of truth
58
+ * (`PQC_STANDARDS.transitionTimeline`), so a quarterly standards update moves the
59
+ * mandate deadlines automatically (test/standards.test.ts asserts they agree).
60
+ *
61
+ * Boundary choice: "deprecate AFTER 2030" leaves the whole stated year permitted,
62
+ * so each clause takes effect on the LAST day of its year (`YYYY-12-31`) —
63
+ * conservative by a single day, unlike `YYYY-01-01`, which would bite roughly a
64
+ * year early.
65
+ */
66
+ const { deprecateAfter, disallowAfter } = PQC_STANDARDS.transitionTimeline;
67
+ const DEPRECATE_EFFECTIVE = `${deprecateAfter}-12-31`;
68
+ const DISALLOW_EFFECTIVE = `${disallowAfter}-12-31`;
69
+
70
+ /** Which enforcement tier a clause encodes: warn (`deprecate`) or fail (`disallow`). */
71
+ export type MandateRuleTier = "deprecate" | "disallow";
72
+
73
+ export interface MandateRule {
74
+ /** The named clause this rule encodes (verbatim in the gate's failure message). */
75
+ clause: string;
76
+ /** Enforcement tier: a passed `deprecate` date warns; a passed `disallow` date fails. */
77
+ tier: MandateRuleTier;
78
+ /** Algorithm families prohibited from the effective date onward. */
79
+ prohibits: AlgorithmFamily[];
80
+ /** ISO date (YYYY-MM-DD) the prohibition takes effect. */
81
+ effective: string;
82
+ /** One-line human description of the clause. */
83
+ note: string;
84
+ }
85
+
86
+ export interface Mandate {
87
+ id: string;
88
+ name: string;
89
+ authority: string;
90
+ citation: string;
91
+ /** When this catalog entry was last reviewed against the source. */
92
+ asOf: string;
93
+ rules: MandateRule[];
94
+ }
95
+
96
+ /** The bundled mandate catalog. Keyed by `--mandate <id>`. */
97
+ export const MANDATES: Record<string, Mandate> = {
98
+ "cnsa-2.0": {
99
+ id: "cnsa-2.0",
100
+ name: "CNSA 2.0",
101
+ authority: "NSA",
102
+ citation: "NSA Commercial National Security Algorithm Suite 2.0 (CNSA 2.0)",
103
+ asOf: "2026-07",
104
+ rules: [
105
+ {
106
+ clause: `CNSA 2.0 — deprecate classical PKC after ${deprecateAfter}`,
107
+ tier: "deprecate",
108
+ prohibits: [...PROHIBITED_FAMILIES],
109
+ effective: DEPRECATE_EFFECTIVE,
110
+ note: "Classical public-key cryptography deprecated; systems should use CNSA 2.0 PQC exclusively.",
111
+ },
112
+ {
113
+ clause: `CNSA 2.0 — disallow classical PKC after ${disallowAfter}`,
114
+ tier: "disallow",
115
+ prohibits: [...PROHIBITED_FAMILIES],
116
+ effective: DISALLOW_EFFECTIVE,
117
+ note: "Classical public-key cryptography disallowed; the migration must be complete.",
118
+ },
119
+ ],
120
+ },
121
+ "nist-ir-8547": {
122
+ id: "nist-ir-8547",
123
+ name: "NIST IR 8547",
124
+ authority: "NIST",
125
+ citation: "NIST IR 8547 (Transition to Post-Quantum Cryptography Standards)",
126
+ asOf: "2026-07",
127
+ rules: [
128
+ {
129
+ clause: `NIST IR 8547 — deprecate classical PKC after ${deprecateAfter}`,
130
+ tier: "deprecate",
131
+ prohibits: [...PROHIBITED_FAMILIES],
132
+ effective: DEPRECATE_EFFECTIVE,
133
+ note: `112-bit-security classical public-key algorithms deprecated after ${deprecateAfter}.`,
134
+ },
135
+ {
136
+ clause: `NIST IR 8547 — disallow classical PKC after ${disallowAfter}`,
137
+ tier: "disallow",
138
+ prohibits: [...PROHIBITED_FAMILIES],
139
+ effective: DISALLOW_EFFECTIVE,
140
+ note: `Classical public-key algorithms disallowed after ${disallowAfter}.`,
141
+ },
142
+ ],
143
+ },
144
+ };
145
+
146
+ export const mandateIds = (): string[] => Object.keys(MANDATES);
147
+ export const getMandate = (id: string): Mandate | undefined => MANDATES[id];
148
+
149
+ /**
150
+ * Validate mandate ids loudly, matching `parseCryptoPolicy`'s fail-loud
151
+ * convention: a mistyped id must never silently evaluate to an empty gate.
152
+ * Throws an `Error` naming every unknown id and the known catalog.
153
+ * `evaluateMandates` itself stays lenient (unknown ids are skipped) so callers
154
+ * decide where to fail.
155
+ */
156
+ export function assertKnownMandates(ids: readonly string[]): void {
157
+ const unknown = ids.filter((id) => !MANDATES[id]);
158
+ if (unknown.length > 0) {
159
+ throw new Error(
160
+ `unknown mandate id(s): ${unknown.join(", ")}; known mandates: ${mandateIds().join(", ")}`,
161
+ );
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A finding's status against a mandate, worst last:
167
+ * - `conformant` — in scope (classical asymmetric) but prohibited by no selected
168
+ * clause (e.g. X25519 as the presumed hybrid leg).
169
+ * - `due` — prohibited, with every deadline still ahead.
170
+ * - `deprecated` — the DEPRECATE deadline has passed; a warning, not a failure.
171
+ * - `violation` — the DISALLOW deadline has passed; fails the default gate.
172
+ */
173
+ export type MandateStatus = "conformant" | "due" | "deprecated" | "violation";
174
+
175
+ export interface MandateFindingVerdict {
176
+ ruleId: string;
177
+ algorithm: AlgorithmFamily | "unknown";
178
+ file: string;
179
+ line: number;
180
+ /** Mandate id (e.g. "cnsa-2.0"). */
181
+ mandate: string;
182
+ /**
183
+ * The governing clause: the next upcoming clause when `due`, the passed
184
+ * DEPRECATE clause when `deprecated`, the passed DISALLOW clause on `violation`.
185
+ */
186
+ clause: string;
187
+ /** ISO effective date of the governing clause. */
188
+ effective: string;
189
+ status: MandateStatus;
190
+ /** Whole months from `now` to the governing deadline; negative once it has passed. */
191
+ monthsUntil: number;
192
+ /**
193
+ * ISO effective date of this mandate's DISALLOW clause for the family, or null
194
+ * when the mandate carries none. The gate's `leadMonths` measures against this.
195
+ */
196
+ disallowEffective: string | null;
197
+ /** Whole months from `now` to `disallowEffective`; null when there is none. */
198
+ monthsUntilDisallow: number | null;
199
+ citation: string;
200
+ }
201
+
202
+ export interface MandateEvaluation {
203
+ /** The `now` the evaluation was computed against (ISO). */
204
+ now: string;
205
+ /** Mandate ids evaluated. */
206
+ mandates: string[];
207
+ /**
208
+ * Counts of IN-SCOPE findings (classical asymmetric families) by their worst
209
+ * status across the selected mandates. Out-of-scope findings are excluded so
210
+ * the conformant count is an honest statement about asymmetric crypto only.
211
+ */
212
+ summary: Record<MandateStatus, number>;
213
+ /**
214
+ * Findings outside the mandate's scope (hashes, RNG, dependency, TLS-config…),
215
+ * which a PQC-asymmetric mandate does not adjudicate.
216
+ */
217
+ notInScope: number;
218
+ /** One row per (prohibited finding × applicable mandate). */
219
+ findings: MandateFindingVerdict[];
220
+ /** Earliest still-future deadline across non-violation rows, or null. */
221
+ nextDeadline: string | null;
222
+ /** True when at least one DISALLOW deadline has passed (a `violation` exists). */
223
+ hasViolation: boolean;
224
+ }
225
+
226
+ const MONTH_MS = 2_629_800_000; // average month
227
+
228
+ function monthsBetween(fromMs: number, toMs: number): number {
229
+ return Math.round((toMs - fromMs) / MONTH_MS);
230
+ }
231
+
232
+ const STATUS_RANK: Record<MandateStatus, number> = {
233
+ conformant: 0,
234
+ due: 1,
235
+ deprecated: 2,
236
+ violation: 3,
237
+ };
238
+
239
+ /**
240
+ * Evaluate findings against the selected mandates as of `now`. Unknown mandate
241
+ * ids are ignored — callers validate up front with {@link assertKnownMandates}.
242
+ * A finding outside the classical-asymmetric scope is counted in `notInScope`;
243
+ * an in-scope finding no selected mandate prohibits is `conformant`. Neither
244
+ * contributes a verdict row.
245
+ */
246
+ export function evaluateMandates(
247
+ findings: readonly Finding[],
248
+ mandateIdList: readonly string[],
249
+ now: Date,
250
+ ): MandateEvaluation {
251
+ const nowMs = now.getTime();
252
+ const selected = mandateIdList.map(getMandate).filter((m): m is Mandate => Boolean(m));
253
+
254
+ const rows: MandateFindingVerdict[] = [];
255
+ const perFindingWorst: MandateStatus[] = [];
256
+ let notInScope = 0;
257
+ let nextDeadlineMs: number | null = null;
258
+
259
+ for (const f of findings) {
260
+ const algo = f.algorithm ?? "unknown";
261
+ if (!CLASSICAL_PUBLIC_KEY.includes(algo as AlgorithmFamily)) {
262
+ notInScope++;
263
+ continue;
264
+ }
265
+ let worst: MandateStatus = "conformant";
266
+ for (const mandate of selected) {
267
+ // The applicable clauses for this family, earliest deadline first.
268
+ const applicable = mandate.rules
269
+ .filter((r) => r.prohibits.includes(algo as AlgorithmFamily))
270
+ .sort((a, b) => a.effective.localeCompare(b.effective));
271
+ if (applicable.length === 0) continue;
272
+
273
+ // Tier the clauses so both stay live: a passed DISALLOW clause is a
274
+ // violation; a passed DEPRECATE clause (disallow still ahead) is the
275
+ // deprecated warning tier; otherwise the finding is due against the next
276
+ // upcoming clause.
277
+ const passed = applicable.filter((r) => nowMs >= new Date(r.effective).getTime());
278
+ const passedDisallow = passed.filter((r) => r.tier === "disallow");
279
+ let status: MandateStatus;
280
+ let governing: MandateRule;
281
+ if (passedDisallow.length > 0) {
282
+ status = "violation";
283
+ governing = passedDisallow[0];
284
+ } else if (passed.length > 0) {
285
+ status = "deprecated";
286
+ governing = passed[passed.length - 1];
287
+ } else {
288
+ status = "due";
289
+ governing = applicable[0];
290
+ }
291
+
292
+ // The disallow clause (earliest, if several) anchors `leadMonths`.
293
+ const disallowRule = applicable.find((r) => r.tier === "disallow") ?? null;
294
+ const disallowMs = disallowRule ? new Date(disallowRule.effective).getTime() : null;
295
+
296
+ if (status !== "violation") {
297
+ for (const r of applicable) {
298
+ const effMs = new Date(r.effective).getTime();
299
+ if (effMs > nowMs && (nextDeadlineMs === null || effMs < nextDeadlineMs))
300
+ nextDeadlineMs = effMs;
301
+ }
302
+ }
303
+ if (STATUS_RANK[status] > STATUS_RANK[worst]) worst = status;
304
+ rows.push({
305
+ ruleId: f.ruleId,
306
+ algorithm: algo,
307
+ file: f.location.file,
308
+ line: f.location.line,
309
+ mandate: mandate.id,
310
+ clause: governing.clause,
311
+ effective: governing.effective,
312
+ status,
313
+ monthsUntil: monthsBetween(nowMs, new Date(governing.effective).getTime()),
314
+ disallowEffective: disallowRule ? disallowRule.effective : null,
315
+ monthsUntilDisallow: disallowMs !== null ? monthsBetween(nowMs, disallowMs) : null,
316
+ citation: mandate.citation,
317
+ });
318
+ }
319
+ perFindingWorst.push(worst);
320
+ }
321
+
322
+ const summary: Record<MandateStatus, number> = {
323
+ conformant: 0,
324
+ due: 0,
325
+ deprecated: 0,
326
+ violation: 0,
327
+ };
328
+ for (const s of perFindingWorst) summary[s]++;
329
+
330
+ return {
331
+ now: now.toISOString(),
332
+ mandates: selected.map((m) => m.id),
333
+ summary,
334
+ notInScope,
335
+ findings: rows,
336
+ nextDeadline:
337
+ nextDeadlineMs !== null ? new Date(nextDeadlineMs).toISOString().slice(0, 10) : null,
338
+ hasViolation: summary.violation > 0,
339
+ };
340
+ }
341
+
342
+ export interface MandateGateOptions {
343
+ /** Fail when a DISALLOW deadline is within this many months (early enforcement). */
344
+ leadMonths?: number;
345
+ /** Fail on any mandate-prohibited finding regardless of the deadlines. */
346
+ failNow?: boolean;
347
+ }
348
+
349
+ /**
350
+ * The gate decision under the "deadline-aware" default: fail only once a DISALLOW
351
+ * deadline has passed (`violation`). A passed DEPRECATE date (`deprecated`) is a
352
+ * warning and does not fail the build. `leadMonths` fails early when a disallow
353
+ * deadline is within the window; `failNow` fails on any prohibited finding
354
+ * immediately.
355
+ */
356
+ export function mandateGateFails(ev: MandateEvaluation, opts: MandateGateOptions = {}): boolean {
357
+ if (ev.hasViolation) return true;
358
+ if (opts.failNow) return ev.findings.length > 0;
359
+ if (opts.leadMonths !== undefined) {
360
+ return ev.findings.some(
361
+ (v) => v.monthsUntilDisallow !== null && v.monthsUntilDisallow <= opts.leadMonths!,
362
+ );
363
+ }
364
+ return false;
365
+ }
package/src/parallel.ts CHANGED
@@ -17,7 +17,7 @@ import { fileURLToPath } from "node:url";
17
17
  import type { Worker as NodeWorker } from "node:worker_threads";
18
18
 
19
19
  import type { Finding, ParallelScanOptions, ScanResult } from "./types.js";
20
- import { walkFiles } from "./walk.js";
20
+ import { walkFilesSized } from "./walk.js";
21
21
  import { isAnalyzableSource } from "./detect-utils.js";
22
22
  import { buildInventory } from "./inventory.js";
23
23
  import { compareFindings, filterExplicitFileList, scan } from "./scan.js";
@@ -115,30 +115,31 @@ function shouldParallelize(options: ParallelScanOptions, files: SizedFile[]): bo
115
115
  * explicit `files` list or the walker. Sizes power byte-balanced chunking.
116
116
  */
117
117
  async function enumerateFiles(options: ParallelScanOptions, baseDir: string): Promise<SizedFile[]> {
118
- const rels: string[] = [];
118
+ const sized: SizedFile[] = [];
119
119
  if (options.files) {
120
+ // Explicit file list: the walker isn't involved, so stat each once here.
120
121
  // Apply the SAME include/exclude/binary filtering the serial path uses via
121
- // `filterExplicitFiles`, so `--parallel` is byte-for-byte identical to serial.
122
- for (const rel of filterExplicitFileList(options.files, options)) rels.push(rel);
123
- } else {
124
- for await (const rel of walkFiles(options.root, {
125
- include: options.include,
126
- exclude: options.exclude,
127
- noDefaultIgnores: options.noDefaultIgnores,
128
- maxFileSize: options.maxFileSize,
129
- })) {
130
- rels.push(rel);
122
+ // `filterExplicitFileList`, so `--parallel` is byte-for-byte identical to serial.
123
+ for (const rel of filterExplicitFileList(options.files, options)) {
124
+ let size = 0;
125
+ try {
126
+ size = (await stat(path.join(baseDir, ...rel.split("/")))).size;
127
+ } catch {
128
+ // Unreadable now; keep with size 0 (the worker read will skip if it's gone).
129
+ }
130
+ sized.push({ rel, size });
131
131
  }
132
+ return sized;
132
133
  }
133
134
 
134
- const sized: SizedFile[] = [];
135
- for (const rel of rels) {
136
- let size = 0;
137
- try {
138
- size = (await stat(path.join(baseDir, ...rel.split("/")))).size;
139
- } catch {
140
- // Unreadable now; keep with size 0 — worker read will skip if it's gone.
141
- }
135
+ // Directory walk: reuse the size the walker already stat'd while enforcing the
136
+ // size limit, so files are not stat'd a second time here (double-stat fix).
137
+ for await (const { rel, size } of walkFilesSized(options.root, {
138
+ include: options.include,
139
+ exclude: options.exclude,
140
+ noDefaultIgnores: options.noDefaultIgnores,
141
+ maxFileSize: options.maxFileSize,
142
+ })) {
142
143
  sized.push({ rel, size });
143
144
  }
144
145
  return sized;
package/src/report.ts CHANGED
@@ -11,6 +11,8 @@ import { remediationFor, remediationForTier, remediationForProfile } from "./rem
11
11
  import type { SecurityTier } from "./remediation.js";
12
12
  import type { StandardsProfile } from "./standards-profiles.js";
13
13
  import { fingerprintFinding } from "./baseline.js";
14
+ import { findingFingerprint } from "./hndl.js";
15
+ import type { FindingExposure, HndlReport } from "./hndl.js";
14
16
 
15
17
  /** Minimal SARIF 2.1.0 log shape (kept permissive on purpose). */
16
18
  export interface SarifLog {
@@ -36,6 +38,29 @@ export interface ReportOptions {
36
38
  * {@link toJson}.
37
39
  */
38
40
  catalog?: RuleMeta[];
41
+ /**
42
+ * Optional HNDL exposure analysis ({@link computeHndl}). When supplied, each
43
+ * finding gains its `exposure` fields (score, bound data asset, rationale)
44
+ * keyed by fingerprint, and the report carries the repo-level HNDL summary.
45
+ * Purely additive: it never changes finding identity, ordering, or exit codes.
46
+ */
47
+ hndl?: HndlReport;
48
+ }
49
+
50
+ /** The per-finding exposure block emitted in JSON / SARIF, or undefined. */
51
+ function exposureFor(f: Finding, hndl: HndlReport | undefined): FindingExposure | undefined {
52
+ if (!hndl) return undefined;
53
+ return hndl.byFingerprint.get(findingFingerprint(f));
54
+ }
55
+
56
+ /** The repo HNDL summary block shared by JSON output and the SARIF run. */
57
+ function hndlSummaryBlock(hndl: HndlReport): Record<string, unknown> {
58
+ return {
59
+ modelVersion: hndl.modelVersion,
60
+ horizon: hndl.horizon,
61
+ summary: hndl.summary,
62
+ assets: hndl.assets,
63
+ };
39
64
  }
40
65
 
41
66
  const SARIF_SCHEMA =
@@ -105,6 +130,16 @@ function sarifRule(spec: {
105
130
  };
106
131
  }
107
132
 
133
+ /** SARIF result.properties fragment for a finding's HNDL exposure, or empty. */
134
+ function exposureProperties(exposure: FindingExposure | undefined): Record<string, unknown> {
135
+ if (!exposure) return {};
136
+ return {
137
+ exposureScore: exposure.exposureScore,
138
+ dataAsset: exposure.dataAsset,
139
+ exposureRationale: exposure.rationale,
140
+ };
141
+ }
142
+
108
143
  /** Serialize a scan result as SARIF 2.1.0. */
109
144
  export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
110
145
  const redactSnippets = opts?.redactSnippets ?? false;
@@ -171,6 +206,10 @@ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
171
206
  // edit above it. `quantakrypto/v1` names our scheme.
172
207
  partialFingerprints: { "quantakrypto/v1": fingerprintFinding(f) },
173
208
  properties: {
209
+ // Same stable identity mirrored into properties so non-GitHub SARIF
210
+ // consumers (our platform ingest) can read one uniform `fingerprint`
211
+ // field across JSON and SARIF without reaching into partialFingerprints.
212
+ fingerprint: fingerprintFinding(f),
174
213
  category: f.category,
175
214
  severity: f.severity,
176
215
  confidence: f.confidence,
@@ -178,6 +217,7 @@ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
178
217
  ...(f.algorithm ? { algorithm: f.algorithm } : {}),
179
218
  ...(f.remediation ? { remediation: f.remediation } : {}),
180
219
  ...(f.cwe ? { cwe: f.cwe } : {}),
220
+ ...exposureProperties(exposureFor(f, opts?.hndl)),
181
221
  },
182
222
  ...(f.cwe
183
223
  ? {
@@ -233,6 +273,7 @@ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
233
273
  },
234
274
  },
235
275
  ...(taxonomies.length > 0 ? { taxonomies } : {}),
276
+ ...(opts?.hndl ? { properties: { hndl: hndlSummaryBlock(opts.hndl) } } : {}),
236
277
  results,
237
278
  },
238
279
  ],
@@ -258,6 +299,7 @@ function securitySeverity(severity: Severity): string {
258
299
  /** Serialize a scan result as a plain JSON-friendly object. */
259
300
  export function toJson(result: ScanResult, opts?: ReportOptions): Record<string, unknown> {
260
301
  const redactSnippets = opts?.redactSnippets ?? false;
302
+ const hndl = opts?.hndl;
261
303
  return {
262
304
  toolVersion: result.toolVersion,
263
305
  root: result.root,
@@ -273,25 +315,48 @@ export function toJson(result: ScanResult, opts?: ReportOptions): Record<string,
273
315
  byCategory: result.inventory.byCategory,
274
316
  byAlgorithm: result.inventory.byAlgorithm,
275
317
  },
276
- findings: result.findings.map((f) => ({
277
- ruleId: f.ruleId,
278
- title: f.title,
279
- category: f.category,
280
- severity: f.severity,
281
- confidence: f.confidence,
282
- algorithm: f.algorithm,
283
- hndl: f.hndl,
284
- message: f.message,
285
- remediation: f.remediation,
286
- cwe: f.cwe,
287
- location: {
288
- file: f.location.file,
289
- line: f.location.line,
290
- column: f.location.column,
291
- endLine: f.location.endLine,
292
- snippet: emittedSnippet(f, redactSnippets),
293
- },
294
- })),
318
+ ...(hndl ? { hndl: hndlSummaryBlock(hndl) } : {}),
319
+ findings: result.findings.map((f) => {
320
+ const exposure = exposureFor(f, hndl);
321
+ return {
322
+ // Stable, line-INSENSITIVE identity of the finding: sha256 of
323
+ // ruleId | normalized-POSIX-repo-relative-path | normalized-snippet
324
+ // (the SARIF partialFingerprints trick, line number deliberately
325
+ // excluded). Reused verbatim from the baseline module so JSON identity,
326
+ // SARIF partialFingerprints, and the baseline suppression set are one and
327
+ // the same value. A line move does NOT change it; when no snippet context
328
+ // exists it falls back to ruleId|path. This is the cross-run identity the
329
+ // platform keys posture drift on.
330
+ fingerprint: fingerprintFinding(f),
331
+ ruleId: f.ruleId,
332
+ title: f.title,
333
+ category: f.category,
334
+ severity: f.severity,
335
+ confidence: f.confidence,
336
+ algorithm: f.algorithm,
337
+ hndl: f.hndl,
338
+ message: f.message,
339
+ remediation: f.remediation,
340
+ cwe: f.cwe,
341
+ location: {
342
+ file: f.location.file,
343
+ line: f.location.line,
344
+ column: f.location.column,
345
+ endLine: f.location.endLine,
346
+ snippet: emittedSnippet(f, redactSnippets),
347
+ },
348
+ ...(exposure
349
+ ? {
350
+ exposure: {
351
+ fingerprint: exposure.fingerprint,
352
+ exposureScore: exposure.exposureScore,
353
+ dataAsset: exposure.dataAsset,
354
+ rationale: exposure.rationale,
355
+ },
356
+ }
357
+ : {}),
358
+ };
359
+ }),
295
360
  };
296
361
  }
297
362
 
package/src/types.ts CHANGED
@@ -95,7 +95,15 @@ 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" | "nuget";
98
+ export type DependencyEcosystem =
99
+ | "npm"
100
+ | "pypi"
101
+ | "cargo"
102
+ | "go"
103
+ | "maven"
104
+ | "rubygems"
105
+ | "nuget"
106
+ | "composer";
99
107
 
100
108
  /** A known quantum-vulnerable dependency entry. */
101
109
  export interface VulnerableDependency {