@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/dist/hndl.js ADDED
@@ -0,0 +1,752 @@
1
+ /**
2
+ * HNDL ("harvest now, decrypt later") data-risk quantifier - roadmap initiative 2.
3
+ *
4
+ * A crypto finding on its own answers "what is broken"; it does not answer "how
5
+ * much does it matter". This module joins the crypto finding to the DATA it
6
+ * protects and makes Mosca's inequality concrete, so a migration backlog can be
7
+ * ranked by real exposure instead of by finding counts.
8
+ *
9
+ * Exposure per finding = crypto-vulnerability (V) x data-sensitivity (S) x
10
+ * Mosca-factor (M), each normalised to 0..1, reported as an integer 0..100:
11
+ *
12
+ * - V: how breakable + how confidently detected the crypto is, discounted hard
13
+ * when the finding is not HNDL-exposed (a signature cannot be broken
14
+ * retroactively to reveal harvested ciphertext).
15
+ * - S: the declared sensitivity class of the data the finding sits next to.
16
+ * - M: the fraction of the data's protection horizon (retention / secrecy
17
+ * lifetime + migration time) that extends BEYOND the quantum-threat
18
+ * horizon. Mosca: concern exists when X + Y > Z.
19
+ *
20
+ * The data map is a declared `hndl.yml` (assets, classification, retention +
21
+ * secrecy lifetime, path/scope bindings). Parsing is hand-rolled and
22
+ * zero-dependency, consistent with the rest of core (ADR-0001): no YAML library
23
+ * is pulled in. The full model is documented in docs/HNDL.md so the score is
24
+ * contestable rather than magic.
25
+ */
26
+ import { readFile } from "node:fs/promises";
27
+ import * as path from "node:path";
28
+ import { fingerprintFinding } from "./baseline.js";
29
+ import { defaultRegistry, detectorScope } from "./registry.js";
30
+ /* -------------------------------------------------------------------------- */
31
+ /* Model constants (exported so the score is contestable, not magic) */
32
+ /* -------------------------------------------------------------------------- */
33
+ /** Version of the exposure model. Bump on any weight / formula change. */
34
+ export const HNDL_MODEL_VERSION = "1";
35
+ /** Canonical `hndl.yml` file name discovered at a scan root. */
36
+ export const HNDL_FILENAME = "hndl.yml";
37
+ const CLASSIFICATIONS = [
38
+ "public",
39
+ "internal",
40
+ "confidential",
41
+ "regulated",
42
+ ];
43
+ const HNDL_SCOPES = ["source", "config", "dependency"];
44
+ /** Crypto-vulnerability weight (V) contributed by a finding's severity. */
45
+ export const SEVERITY_VULNERABILITY = {
46
+ critical: 1.0,
47
+ high: 0.8,
48
+ medium: 0.5,
49
+ low: 0.25,
50
+ info: 0.1,
51
+ };
52
+ /** Crypto-vulnerability weight (V) scaled by detector confidence. */
53
+ export const CONFIDENCE_WEIGHT = {
54
+ high: 1.0,
55
+ medium: 0.85,
56
+ low: 0.6,
57
+ };
58
+ /** Data-sensitivity weight (S) per classification. */
59
+ export const CLASSIFICATION_SENSITIVITY = {
60
+ public: 0.1,
61
+ internal: 0.4,
62
+ confidential: 0.7,
63
+ regulated: 1.0,
64
+ };
65
+ /**
66
+ * Discount applied to V when a finding is NOT harvest-now-decrypt-later exposed
67
+ * (e.g. a pure signature). A forged signature is a future-integrity problem, not
68
+ * a retroactive-confidentiality one, so it barely moves the HNDL needle.
69
+ */
70
+ export const NON_HNDL_DISCOUNT = 0.15;
71
+ /**
72
+ * Default years until a cryptographically-relevant quantum computer is assumed
73
+ * to exist (Z in Mosca's inequality). 15 years is a defensible mid-point of the
74
+ * commonly-cited Mosca / NIST window; override per-org in `hndl.yml`.
75
+ */
76
+ export const DEFAULT_QUANTUM_THREAT_YEARS = 15;
77
+ /**
78
+ * Default years to complete the org's PQC migration (Y in Mosca's inequality).
79
+ * Override per-org in `hndl.yml`.
80
+ */
81
+ export const DEFAULT_MIGRATION_HORIZON_YEARS = 5;
82
+ /** Classification assumed for findings that bind to no declared asset. */
83
+ export const DEFAULT_UNBOUND_CLASSIFICATION = "internal";
84
+ /** Thrown when `hndl.yml` is malformed (structure or a bad known value). */
85
+ export class HndlError extends Error {
86
+ name = "HndlError";
87
+ /** The `hndl.yml` path the error relates to, when known. */
88
+ path;
89
+ constructor(message, hndlPath) {
90
+ super(message);
91
+ this.path = hndlPath;
92
+ }
93
+ }
94
+ /** How many findings the summary's `topExposures` lists. */
95
+ const TOP_EXPOSURES = 5;
96
+ /* -------------------------------------------------------------------------- */
97
+ /* Fingerprint + scope resolution */
98
+ /* -------------------------------------------------------------------------- */
99
+ /**
100
+ * Identity key for a finding's exposure. Prefers a `finding.fingerprint` field
101
+ * when a build supplies one (another workstream is adding it to core's Finding
102
+ * type + reporters); falls back to the canonical `fingerprintFinding()` hash
103
+ * (`sha256(ruleId|file|normalizedSnippet)`) otherwise. INTEGRATION POINT: once
104
+ * `Finding.fingerprint` lands, this transparently switches to it - no caller
105
+ * change needed.
106
+ */
107
+ export function findingFingerprint(f) {
108
+ const declared = f.fingerprint;
109
+ if (typeof declared === "string" && declared.length > 0)
110
+ return declared;
111
+ return fingerprintFinding(f);
112
+ }
113
+ /** ruleId → detector scope, built once from the default registry. */
114
+ let scopeIndex;
115
+ function ruleScopeIndex() {
116
+ if (scopeIndex)
117
+ return scopeIndex;
118
+ const index = new Map();
119
+ for (const det of defaultRegistry.all()) {
120
+ const scope = detectorScope(det);
121
+ for (const rule of det.rules ?? [])
122
+ index.set(rule.id, scope);
123
+ }
124
+ scopeIndex = index;
125
+ return index;
126
+ }
127
+ /**
128
+ * The scope a finding belongs to. Dependency findings (from the manifest
129
+ * scanner, category "dependency") are "dependency"; everything else is looked up
130
+ * by ruleId in the detector registry, defaulting to "source".
131
+ */
132
+ export function findingScope(f) {
133
+ if (f.category === "dependency")
134
+ return "dependency";
135
+ return ruleScopeIndex().get(f.ruleId) ?? "source";
136
+ }
137
+ /* -------------------------------------------------------------------------- */
138
+ /* Glob matching (hand-rolled, zero-dependency) */
139
+ /* -------------------------------------------------------------------------- */
140
+ /**
141
+ * Match a POSIX path against a glob supporting `**` (any path segments incl.
142
+ * `/`), `*` (any run of non-`/` chars), and `?` (a single non-`/` char). A
143
+ * trailing `/**` also matches the directory itself. Everything else is literal.
144
+ */
145
+ export function globMatch(glob, filePath) {
146
+ return globToRegExp(glob).test(filePath);
147
+ }
148
+ const globCache = new Map();
149
+ function globToRegExp(glob) {
150
+ const cached = globCache.get(glob);
151
+ if (cached)
152
+ return cached;
153
+ let re = "^";
154
+ for (let i = 0; i < glob.length; i++) {
155
+ const ch = glob[i];
156
+ if (ch === "*") {
157
+ if (glob[i + 1] === "*") {
158
+ // `**` - any number of characters including path separators. Consume a
159
+ // following `/` so `a/**/b` matches `a/b` (zero segments) too.
160
+ i++;
161
+ if (glob[i + 1] === "/")
162
+ i++;
163
+ re += ".*";
164
+ }
165
+ else {
166
+ re += "[^/]*";
167
+ }
168
+ }
169
+ else if (ch === "?") {
170
+ re += "[^/]";
171
+ }
172
+ else if ("\\^$.|+()[]{}".includes(ch)) {
173
+ re += `\\${ch}`;
174
+ }
175
+ else {
176
+ re += ch;
177
+ }
178
+ }
179
+ re += "$";
180
+ const compiled = new RegExp(re);
181
+ globCache.set(glob, compiled);
182
+ return compiled;
183
+ }
184
+ /* -------------------------------------------------------------------------- */
185
+ /* Exposure math */
186
+ /* -------------------------------------------------------------------------- */
187
+ function clamp01(x) {
188
+ return x < 0 ? 0 : x > 1 ? 1 : x;
189
+ }
190
+ /** V - crypto-vulnerability factor for a finding (0..1). */
191
+ export function vulnerabilityFactor(f) {
192
+ const base = SEVERITY_VULNERABILITY[f.severity] * CONFIDENCE_WEIGHT[f.confidence];
193
+ return clamp01(base * (f.hndl ? 1 : NON_HNDL_DISCOUNT));
194
+ }
195
+ /**
196
+ * M - the Mosca factor (0..1). `X` is the data's protection horizon
197
+ * (max of retention + secrecy lifetime), `Y` the migration horizon, `Z` the
198
+ * quantum-threat horizon. M is the fraction of `X + Y` that falls after `Z`:
199
+ * 0 when the threat is beyond the whole horizon, 1 when the threat is already
200
+ * here. Mosca's inequality (X + Y > Z) is exactly `M > 0`.
201
+ */
202
+ export function moscaFactor(secrecyHorizonYears, horizon) {
203
+ const span = secrecyHorizonYears + horizon.migrationHorizonYears;
204
+ if (span <= 0)
205
+ return 0;
206
+ const margin = span - horizon.quantumThreatYears;
207
+ return clamp01(margin / span);
208
+ }
209
+ function scoreFinding(f, input, horizon) {
210
+ const sensitivity = CLASSIFICATION_SENSITIVITY[input.classification];
211
+ const secrecyHorizonYears = Math.max(input.retentionYears, input.secrecyLifetimeYears);
212
+ const mosca = moscaFactor(secrecyHorizonYears, horizon);
213
+ const exposureScore = Math.round(100 * clamp01(input.vulnerability * sensitivity * mosca));
214
+ const moscaMarginYears = secrecyHorizonYears + horizon.migrationHorizonYears - horizon.quantumThreatYears;
215
+ const rationale = {
216
+ vulnerability: round3(input.vulnerability),
217
+ sensitivity: round3(sensitivity),
218
+ mosca: round3(mosca),
219
+ classification: input.classification,
220
+ retentionYears: input.retentionYears,
221
+ secrecyLifetimeYears: input.secrecyLifetimeYears,
222
+ secrecyHorizonYears,
223
+ quantumThreatYears: horizon.quantumThreatYears,
224
+ migrationHorizonYears: horizon.migrationHorizonYears,
225
+ moscaMarginYears,
226
+ moscaBreach: moscaMarginYears > 0,
227
+ hndl: f.hndl,
228
+ severity: f.severity,
229
+ confidence: f.confidence,
230
+ scope: findingScope(f),
231
+ bound: input.bound,
232
+ };
233
+ return {
234
+ fingerprint: findingFingerprint(f),
235
+ ruleId: f.ruleId,
236
+ file: f.location.file,
237
+ dataAsset: null,
238
+ exposureScore,
239
+ rationale,
240
+ };
241
+ }
242
+ function round3(x) {
243
+ return Math.round(x * 1000) / 1000;
244
+ }
245
+ /* -------------------------------------------------------------------------- */
246
+ /* Binding + top-level compute */
247
+ /* -------------------------------------------------------------------------- */
248
+ /** Assets whose path globs + optional scope filter match a finding. */
249
+ function matchingAssets(f, map, scope) {
250
+ const out = [];
251
+ for (const asset of map.assets) {
252
+ if (asset.scopes && !asset.scopes.includes(scope))
253
+ continue;
254
+ if (asset.paths.some((g) => globMatch(g, f.location.file)))
255
+ out.push(asset);
256
+ }
257
+ return out;
258
+ }
259
+ /**
260
+ * Compute HNDL exposure for a set of findings against a declared data map.
261
+ *
262
+ * A finding binds to every declared asset whose path glob (and optional scope
263
+ * filter) matches its file; when several match, the WORST-CASE (highest)
264
+ * exposure is chosen and its asset recorded, since risk ranking should not be
265
+ * diluted by an incidental low-sensitivity overlap. A finding that binds to no
266
+ * asset is still scored, using the map's `defaults.classification` and the
267
+ * global horizons, and flagged `bound: false` so it is visibly a fallback. Its
268
+ * secrecy lifetime is assumed to be the quantum-threat horizon Z (the
269
+ * minimum-concern horizon; see {@link scoreFinding} call below and docs/HNDL.md
270
+ * §4), so the fallback classification produces a real, rankable exposure rather
271
+ * than a dead 0.
272
+ *
273
+ * Purely additive: it never mutates findings and never affects a scan's exit
274
+ * code.
275
+ */
276
+ export function computeHndl(findings, map) {
277
+ const exposures = [];
278
+ const byFingerprint = new Map();
279
+ const assetFindings = new Map();
280
+ const assetMax = new Map();
281
+ for (const f of findings) {
282
+ const vulnerability = vulnerabilityFactor(f);
283
+ const scope = findingScope(f);
284
+ const candidates = matchingAssets(f, map, scope);
285
+ let best;
286
+ if (candidates.length === 0) {
287
+ best = scoreFinding(f, {
288
+ vulnerability,
289
+ classification: map.defaults.classification,
290
+ // Unbound: the data's lifetime is unknown, so we cannot read X off a
291
+ // declared asset. Assuming X = 0 would drive M (hence the score) to a
292
+ // dead 0 for EVERY unbound finding under any sane horizon, making the
293
+ // documented `defaults.classification` fallback unrankable. Instead we
294
+ // assume the MINIMUM-CONCERN horizon: data captured today must stay
295
+ // confidential at least until the quantum threat arrives (X = Z). That
296
+ // yields M = Y / (Y + Z) - a small but non-zero, rankable exposure that
297
+ // never exceeds a declared long-lived asset's and self-adjusts to any
298
+ // per-org horizon override. Retention stays 0 (genuinely unknown); the
299
+ // secrecy lifetime carries the assumption. See docs/HNDL.md §4.
300
+ retentionYears: 0,
301
+ secrecyLifetimeYears: map.horizon.quantumThreatYears,
302
+ bound: false,
303
+ }, map.horizon);
304
+ }
305
+ else {
306
+ let bestAsset;
307
+ best = undefined;
308
+ for (const asset of candidates) {
309
+ const scored = scoreFinding(f, {
310
+ vulnerability,
311
+ classification: asset.classification,
312
+ retentionYears: asset.retentionYears,
313
+ secrecyLifetimeYears: asset.secrecyLifetimeYears,
314
+ bound: true,
315
+ }, map.horizon);
316
+ if (!bestAsset || scored.exposureScore > best.exposureScore) {
317
+ best = scored;
318
+ bestAsset = asset;
319
+ }
320
+ }
321
+ best.dataAsset = bestAsset.key;
322
+ assetFindings.set(bestAsset.key, (assetFindings.get(bestAsset.key) ?? 0) + 1);
323
+ assetMax.set(bestAsset.key, Math.max(assetMax.get(bestAsset.key) ?? 0, best.exposureScore));
324
+ }
325
+ exposures.push(best);
326
+ byFingerprint.set(best.fingerprint, best);
327
+ }
328
+ const assets = map.assets.map((a) => ({
329
+ key: a.key,
330
+ name: a.name,
331
+ classification: a.classification,
332
+ retentionYears: a.retentionYears,
333
+ secrecyLifetimeYears: a.secrecyLifetimeYears,
334
+ findings: assetFindings.get(a.key) ?? 0,
335
+ maxExposure: assetMax.get(a.key) ?? 0,
336
+ outlivesThreat: Math.max(a.retentionYears, a.secrecyLifetimeYears) > map.horizon.quantumThreatYears,
337
+ }));
338
+ const scores = exposures.map((e) => e.exposureScore);
339
+ const maxExposure = scores.reduce((m, s) => Math.max(m, s), 0);
340
+ const meanExposure = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
341
+ const topExposures = [...exposures]
342
+ .sort((a, b) => b.exposureScore - a.exposureScore || a.fingerprint.localeCompare(b.fingerprint))
343
+ .slice(0, TOP_EXPOSURES);
344
+ const summary = {
345
+ findingsScored: exposures.length,
346
+ assetsDeclared: map.assets.length,
347
+ assetsWithFindings: assets.filter((a) => a.findings > 0).length,
348
+ assetsOutlivingHorizon: assets.filter((a) => a.outlivesThreat).length,
349
+ moscaBreaches: exposures.filter((e) => e.rationale.moscaBreach).length,
350
+ maxExposure,
351
+ meanExposure,
352
+ topExposures,
353
+ };
354
+ return {
355
+ modelVersion: HNDL_MODEL_VERSION,
356
+ horizon: map.horizon,
357
+ exposures,
358
+ byFingerprint,
359
+ assets,
360
+ summary,
361
+ };
362
+ }
363
+ /** Strip an inline `# comment` that begins after whitespace, outside quotes. */
364
+ function stripInlineComment(text) {
365
+ let quote;
366
+ for (let i = 0; i < text.length; i++) {
367
+ const ch = text[i];
368
+ if (quote) {
369
+ if (ch === quote)
370
+ quote = undefined;
371
+ continue;
372
+ }
373
+ if (ch === '"' || ch === "'") {
374
+ quote = ch;
375
+ continue;
376
+ }
377
+ if (ch === "#" && (i === 0 || /\s/.test(text[i - 1]))) {
378
+ return text.slice(0, i);
379
+ }
380
+ }
381
+ return text;
382
+ }
383
+ /** Tokenise text into non-blank, non-comment lines with their indent depth. */
384
+ function tokenizeYaml(text, file) {
385
+ const lines = [];
386
+ const raw = text.split(/\r?\n/);
387
+ for (let i = 0; i < raw.length; i++) {
388
+ const line = raw[i];
389
+ if (line.includes("\t")) {
390
+ throw new HndlError(`tabs are not allowed for indentation (line ${i + 1})`, file);
391
+ }
392
+ const stripped = stripInlineComment(line).replace(/\s+$/, "");
393
+ if (stripped.trim() === "")
394
+ continue;
395
+ const indent = stripped.length - stripped.trimStart().length;
396
+ lines.push({ indent, content: stripped.trimStart(), n: i + 1 });
397
+ }
398
+ return lines;
399
+ }
400
+ /** True when `content` opens a `key:` mapping entry (colon outside quotes). */
401
+ function isKeyLine(content) {
402
+ return /^[A-Za-z0-9_.-]+\s*:(\s|$)/.test(content);
403
+ }
404
+ function splitKey(content, file, n) {
405
+ const idx = content.indexOf(":");
406
+ if (idx < 0)
407
+ throw new HndlError(`expected "key: value" (line ${n})`, file);
408
+ return { key: content.slice(0, idx).trim(), value: content.slice(idx + 1).trim() };
409
+ }
410
+ /** Coerce a scalar token: quoted string, boolean, number, or bare string. */
411
+ function parseScalar(token) {
412
+ if (token.length >= 2) {
413
+ const q = token[0];
414
+ if ((q === '"' || q === "'") && token[token.length - 1] === q) {
415
+ return token.slice(1, -1);
416
+ }
417
+ }
418
+ if (token === "true")
419
+ return true;
420
+ if (token === "false")
421
+ return false;
422
+ if (/^-?\d+(\.\d+)?$/.test(token))
423
+ return Number(token);
424
+ return token;
425
+ }
426
+ /**
427
+ * Parse a mapping/sequence VALUE token: either an inline-flow list
428
+ * (`[a, "b", c]`, `[]`) or a plain scalar. Only flat flow lists of scalars are
429
+ * supported - enough for `paths: [...]` / `scopes: [...]` without a full YAML
430
+ * flow grammar.
431
+ */
432
+ function parseValueToken(token, file, n) {
433
+ if (token.startsWith("[")) {
434
+ if (!token.endsWith("]")) {
435
+ throw new HndlError(`unterminated inline list (line ${n})`, file);
436
+ }
437
+ return parseFlowList(token.slice(1, -1), file, n);
438
+ }
439
+ return parseScalar(token);
440
+ }
441
+ /** Split a flow-list body on top-level commas (quote-aware) → scalar elements. */
442
+ function parseFlowList(body, file, n) {
443
+ const out = [];
444
+ let cur = "";
445
+ let quote;
446
+ for (let i = 0; i < body.length; i++) {
447
+ const ch = body[i];
448
+ if (quote) {
449
+ cur += ch;
450
+ if (ch === quote)
451
+ quote = undefined;
452
+ continue;
453
+ }
454
+ if (ch === '"' || ch === "'") {
455
+ quote = ch;
456
+ cur += ch;
457
+ continue;
458
+ }
459
+ if (ch === ",") {
460
+ out.push(cur.trim());
461
+ cur = "";
462
+ continue;
463
+ }
464
+ cur += ch;
465
+ }
466
+ if (quote)
467
+ throw new HndlError(`unterminated string in inline list (line ${n})`, file);
468
+ const last = cur.trim();
469
+ // A trailing empty segment only when the list itself is empty (`[]`).
470
+ if (last !== "" || out.length > 0)
471
+ out.push(last);
472
+ return out.filter((t) => t !== "").map((t) => parseScalar(t));
473
+ }
474
+ /** Recursive-descent parse of the tokenised lines into a generic value. */
475
+ function parseYaml(text, file) {
476
+ const lines = tokenizeYaml(text, file);
477
+ if (lines.length === 0)
478
+ return {};
479
+ function parseNode(i, indent) {
480
+ const first = lines[i];
481
+ if (first.content === "-" || first.content.startsWith("- ")) {
482
+ return parseSeq(i, indent);
483
+ }
484
+ return parseMap(i, indent);
485
+ }
486
+ function parseSeq(start, indent) {
487
+ const arr = [];
488
+ let i = start;
489
+ while (i < lines.length &&
490
+ lines[i].indent === indent &&
491
+ (lines[i].content === "-" || lines[i].content.startsWith("- "))) {
492
+ const line = lines[i];
493
+ const rest = line.content === "-" ? "" : line.content.slice(2).trim();
494
+ const itemIndent = indent + 2;
495
+ if (rest === "") {
496
+ if (i + 1 < lines.length && lines[i + 1].indent > indent) {
497
+ const [val, next] = parseNode(i + 1, lines[i + 1].indent);
498
+ arr.push(val);
499
+ i = next;
500
+ }
501
+ else {
502
+ arr.push(null);
503
+ i++;
504
+ }
505
+ }
506
+ else if (isKeyLine(rest)) {
507
+ // Map item introduced by the dash: rewrite this line as its first key at
508
+ // the deeper indent, then let parseMap consume it + aligned siblings.
509
+ lines[i] = { indent: itemIndent, content: rest, n: line.n };
510
+ const [val, next] = parseMap(i, itemIndent);
511
+ arr.push(val);
512
+ i = next;
513
+ }
514
+ else {
515
+ arr.push(parseValueToken(rest, file, line.n));
516
+ i++;
517
+ }
518
+ }
519
+ return [arr, i];
520
+ }
521
+ function parseMap(start, indent) {
522
+ const obj = {};
523
+ let i = start;
524
+ while (i < lines.length &&
525
+ lines[i].indent === indent &&
526
+ !lines[i].content.startsWith("- ") &&
527
+ lines[i].content !== "-") {
528
+ const line = lines[i];
529
+ const { key, value } = splitKey(line.content, file, line.n);
530
+ if (value !== "") {
531
+ obj[key] = parseValueToken(value, file, line.n);
532
+ i++;
533
+ }
534
+ else if (i + 1 < lines.length && lines[i + 1].indent > indent) {
535
+ const [val, next] = parseNode(i + 1, lines[i + 1].indent);
536
+ obj[key] = val;
537
+ i = next;
538
+ }
539
+ else {
540
+ obj[key] = null;
541
+ i++;
542
+ }
543
+ }
544
+ return [obj, i];
545
+ }
546
+ const [value] = parseNode(0, lines[0].indent);
547
+ return value;
548
+ }
549
+ /* -------------------------------------------------------------------------- */
550
+ /* hndl.yml validation → HndlMap */
551
+ /* -------------------------------------------------------------------------- */
552
+ function isObject(v) {
553
+ return typeof v === "object" && v !== null && !Array.isArray(v);
554
+ }
555
+ function asString(v, what, file) {
556
+ if (typeof v !== "string" || v.length === 0) {
557
+ throw new HndlError(`${what} must be a non-empty string`, file);
558
+ }
559
+ return v;
560
+ }
561
+ function asNonNegNumber(v, what, file) {
562
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
563
+ throw new HndlError(`${what} must be a non-negative number`, file);
564
+ }
565
+ return v;
566
+ }
567
+ function asClassification(v, what, file) {
568
+ if (typeof v !== "string" || !CLASSIFICATIONS.includes(v)) {
569
+ throw new HndlError(`${what} must be one of: ${CLASSIFICATIONS.join(", ")}`, file);
570
+ }
571
+ return v;
572
+ }
573
+ function asStringList(v, what, file) {
574
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string" || x.length === 0)) {
575
+ throw new HndlError(`${what} must be a list of non-empty strings`, file);
576
+ }
577
+ return v;
578
+ }
579
+ /**
580
+ * Validate a parsed `hndl.yml` object into a typed {@link HndlMap}. Applies
581
+ * defaults for `version`, `horizon`, and `defaults`; requires each asset to have
582
+ * a unique `key`, a `name`, a valid `classification`, non-negative retention +
583
+ * secrecy lifetime, and at least one path glob.
584
+ */
585
+ export function parseHndlMap(text, file = HNDL_FILENAME) {
586
+ const root = parseYaml(text, file);
587
+ if (!isObject(root)) {
588
+ throw new HndlError(`hndl.yml must be a mapping at the top level`, file);
589
+ }
590
+ let version = 1;
591
+ if ("version" in root) {
592
+ const v = root["version"];
593
+ if (typeof v !== "number" || !Number.isInteger(v)) {
594
+ throw new HndlError(`"version" must be an integer`, file);
595
+ }
596
+ version = v;
597
+ }
598
+ const horizon = {
599
+ quantumThreatYears: DEFAULT_QUANTUM_THREAT_YEARS,
600
+ migrationHorizonYears: DEFAULT_MIGRATION_HORIZON_YEARS,
601
+ };
602
+ if ("horizon" in root && root["horizon"] !== null) {
603
+ const h = root["horizon"];
604
+ if (!isObject(h))
605
+ throw new HndlError(`"horizon" must be a mapping`, file);
606
+ if ("quantum_threat_years" in h) {
607
+ horizon.quantumThreatYears = asNonNegNumber(h["quantum_threat_years"], `"horizon.quantum_threat_years"`, file);
608
+ }
609
+ if ("migration_horizon_years" in h) {
610
+ horizon.migrationHorizonYears = asNonNegNumber(h["migration_horizon_years"], `"horizon.migration_horizon_years"`, file);
611
+ }
612
+ }
613
+ const defaults = { classification: DEFAULT_UNBOUND_CLASSIFICATION };
614
+ if ("defaults" in root && root["defaults"] !== null) {
615
+ const d = root["defaults"];
616
+ if (!isObject(d))
617
+ throw new HndlError(`"defaults" must be a mapping`, file);
618
+ if ("classification" in d) {
619
+ defaults.classification = asClassification(d["classification"], `"defaults.classification"`, file);
620
+ }
621
+ }
622
+ const rawAssets = "assets" in root ? root["assets"] : [];
623
+ if (!Array.isArray(rawAssets)) {
624
+ throw new HndlError(`"assets" must be a list`, file);
625
+ }
626
+ const assets = [];
627
+ const seenKeys = new Set();
628
+ for (let idx = 0; idx < rawAssets.length; idx++) {
629
+ const a = rawAssets[idx];
630
+ if (!isObject(a))
631
+ throw new HndlError(`assets[${idx}] must be a mapping`, file);
632
+ const key = asString(a["key"], `assets[${idx}].key`, file);
633
+ if (seenKeys.has(key))
634
+ throw new HndlError(`duplicate asset key "${key}"`, file);
635
+ seenKeys.add(key);
636
+ const asset = {
637
+ key,
638
+ name: asString(a["name"], `assets[${idx}].name`, file),
639
+ classification: asClassification(a["classification"], `assets[${idx}].classification`, file),
640
+ retentionYears: asNonNegNumber(a["retention_years"], `assets[${idx}].retention_years`, file),
641
+ secrecyLifetimeYears: asNonNegNumber(a["secrecy_lifetime_years"], `assets[${idx}].secrecy_lifetime_years`, file),
642
+ paths: asStringList(a["paths"], `assets[${idx}].paths`, file),
643
+ };
644
+ if (asset.paths.length === 0) {
645
+ throw new HndlError(`assets[${idx}].paths must list at least one glob`, file);
646
+ }
647
+ if ("scopes" in a && a["scopes"] !== null) {
648
+ const scopes = asStringList(a["scopes"], `assets[${idx}].scopes`, file);
649
+ for (const s of scopes) {
650
+ if (!HNDL_SCOPES.includes(s)) {
651
+ throw new HndlError(`assets[${idx}].scopes: "${s}" must be one of: ${HNDL_SCOPES.join(", ")}`, file);
652
+ }
653
+ }
654
+ asset.scopes = scopes;
655
+ }
656
+ assets.push(asset);
657
+ }
658
+ return { version, horizon, defaults, assets };
659
+ }
660
+ /**
661
+ * Load and validate the `hndl.yml` for a scan. By default reads
662
+ * `<root>/hndl.yml`; pass an explicit `*.yml` path to read a named file. A
663
+ * missing file is always an error here (the caller opted into `--hndl`).
664
+ *
665
+ * @throws {HndlError} On a missing file, unreadable file, or malformed content.
666
+ */
667
+ export async function loadHndlMap(root) {
668
+ const base = path.basename(root);
669
+ const file = base === HNDL_FILENAME || base.endsWith(".yml") || base.endsWith(".yaml")
670
+ ? path.resolve(root)
671
+ : path.resolve(root, HNDL_FILENAME);
672
+ let text;
673
+ try {
674
+ text = await readFile(file, "utf8");
675
+ }
676
+ catch {
677
+ throw new HndlError(`hndl.yml not found: ${file} (run "qscan hndl init" to scaffold one)`, file);
678
+ }
679
+ return { map: parseHndlMap(text, file), path: file };
680
+ }
681
+ /* -------------------------------------------------------------------------- */
682
+ /* hndl.yml scaffolding (`qscan hndl init`) */
683
+ /* -------------------------------------------------------------------------- */
684
+ /** First path segment of a POSIX file path, or "." for a root-level file. */
685
+ function topDir(file) {
686
+ const slash = file.indexOf("/");
687
+ return slash < 0 ? "." : file.slice(0, slash);
688
+ }
689
+ /** A URL/key-safe slug for a directory name. */
690
+ function slugify(dir) {
691
+ const slug = dir
692
+ .toLowerCase()
693
+ .replace(/[^a-z0-9]+/g, "-")
694
+ .replace(/^-+|-+$/g, "");
695
+ return slug.length > 0 ? slug : "root";
696
+ }
697
+ /**
698
+ * Scaffold an `hndl.yml` document seeded from a scan's findings. Config-scope,
699
+ * HNDL-exposed findings are the crypto-to-data join points (KMS, secrets-at-rest,
700
+ * database TDE, brokers, JOSE/JWE), so each directory that contains one becomes a
701
+ * proposed asset stub the user fills in. The returned string is valid `hndl.yml`
702
+ * (it round-trips through {@link parseHndlMap}); the classification / retention /
703
+ * secrecy values are conservative placeholders the user is expected to correct.
704
+ */
705
+ export function scaffoldHndlYaml(findings) {
706
+ // Group data-adjacent findings by their top-level directory.
707
+ const byDir = new Map();
708
+ for (const f of findings) {
709
+ if (findingScope(f) !== "config" || !f.hndl)
710
+ continue;
711
+ const dir = topDir(f.location.file);
712
+ let rules = byDir.get(dir);
713
+ if (!rules) {
714
+ rules = new Set();
715
+ byDir.set(dir, rules);
716
+ }
717
+ rules.add(f.ruleId);
718
+ }
719
+ const lines = [
720
+ "# hndl.yml - HNDL (harvest-now-decrypt-later) data map for this repo.",
721
+ "#",
722
+ "# qScan joins each crypto finding to the data it protects and scores exposure as",
723
+ "# crypto-vulnerability x data-sensitivity x Mosca-factor (retention + secrecy",
724
+ "# lifetime vs the quantum-threat horizon). See docs/HNDL.md for the model.",
725
+ "#",
726
+ "# Fill in the assets below: correct the classification, the retention_years and",
727
+ "# secrecy_lifetime_years, and the path globs. Delete stubs that do not apply.",
728
+ "version: 1",
729
+ "",
730
+ "horizon:",
731
+ ` quantum_threat_years: ${DEFAULT_QUANTUM_THREAT_YEARS} # Z: years until a CRQC is assumed`,
732
+ ` migration_horizon_years: ${DEFAULT_MIGRATION_HORIZON_YEARS} # Y: years to finish your PQC migration`,
733
+ "",
734
+ "defaults:",
735
+ ` classification: ${DEFAULT_UNBOUND_CLASSIFICATION} # applied to findings that match no asset below`,
736
+ "",
737
+ "assets:",
738
+ ];
739
+ if (byDir.size === 0) {
740
+ lines.push(" # No data-adjacent (config-scope, HNDL) findings were detected to seed from.", " # Declare your data assets manually, for example:", " - key: customer-pii", " name: Customer PII", " classification: regulated", " retention_years: 7", " secrecy_lifetime_years: 25", " paths:", ' - "src/**"');
741
+ }
742
+ else {
743
+ const dirs = [...byDir.keys()].sort();
744
+ for (const dir of dirs) {
745
+ const rules = [...byDir.get(dir)].sort();
746
+ const glob = dir === "." ? "*" : `${dir}/**`;
747
+ lines.push(` # detected data-adjacent crypto here: ${rules.join(", ")}`, ` - key: ${slugify(dir)}`, ` name: ${dir === "." ? "Repository root" : dir}`, " classification: confidential # TODO: public | internal | confidential | regulated", " retention_years: 7 # TODO: how long the data is kept", " secrecy_lifetime_years: 10 # TODO: how long it must stay confidential", " paths:", ` - "${glob}"`);
748
+ }
749
+ }
750
+ return `${lines.join("\n")}\n`;
751
+ }
752
+ //# sourceMappingURL=hndl.js.map