@quantakrypto/core 0.5.0 → 0.6.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 (46) 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 +4 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +4 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/parallel.d.ts.map +1 -1
  20. package/dist/parallel.js +22 -23
  21. package/dist/parallel.js.map +1 -1
  22. package/dist/report.d.ts +8 -0
  23. package/dist/report.d.ts.map +1 -1
  24. package/dist/report.js +75 -19
  25. package/dist/report.js.map +1 -1
  26. package/dist/types.d.ts +1 -1
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/types.js.map +1 -1
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/dist/version.js.map +1 -1
  32. package/dist/walk.d.ts +31 -1
  33. package/dist/walk.d.ts.map +1 -1
  34. package/dist/walk.js +30 -9
  35. package/dist/walk.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/crypto-agility.ts +407 -0
  38. package/src/dependencies.ts +182 -16
  39. package/src/detectors/source.ts +62 -0
  40. package/src/hndl.ts +1012 -0
  41. package/src/index.ts +52 -0
  42. package/src/parallel.ts +21 -20
  43. package/src/report.ts +84 -19
  44. package/src/types.ts +9 -1
  45. package/src/version.ts +1 -1
  46. 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 {
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 {
package/src/version.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * the scan orchestrator can import it without creating a cycle through index.ts.
4
4
  * Keep in sync with packages/core/package.json.
5
5
  */
6
- export const VERSION = "0.5.0";
6
+ export const VERSION = "0.6.0";
package/src/walk.ts CHANGED
@@ -291,12 +291,34 @@ export function looksMinified(content: string): boolean {
291
291
  return avgLine > 1_000;
292
292
  }
293
293
 
294
+ /** A scannable file plus the byte size the walker already stat'd for it. */
295
+ export interface WalkedFile {
296
+ /** Relative POSIX path from the walk root. */
297
+ rel: string;
298
+ /** File size in bytes (from the walker's own stat; never re-stat downstream). */
299
+ size: number;
300
+ }
301
+
294
302
  /**
295
303
  * Recursively yield scannable file paths (relative to `root`, POSIX) under a
296
304
  * directory. If `root` points at a single file, yields just that file's
297
- * basename (subject to the size / binary filters).
305
+ * basename (subject to the size / binary filters). Thin wrapper over
306
+ * {@link walkFilesSized} that drops the size (the historical string-yielding API).
298
307
  */
299
308
  export async function* walkFiles(root: string, options: WalkOptions = {}): AsyncGenerator<string> {
309
+ for await (const f of walkFilesSized(root, options)) yield f.rel;
310
+ }
311
+
312
+ /**
313
+ * Like {@link walkFiles} but yields each file WITH the byte size the walker
314
+ * already obtained while applying the size limit. Callers that need sizes (the
315
+ * parallel scanner's byte-balanced chunking) consume this directly so they do
316
+ * not stat every file a second time (P2 double-stat elimination).
317
+ */
318
+ export async function* walkFilesSized(
319
+ root: string,
320
+ options: WalkOptions = {},
321
+ ): AsyncGenerator<WalkedFile> {
300
322
  const include = options.include ?? [];
301
323
  const exclude = options.exclude ?? [];
302
324
  const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
@@ -312,7 +334,7 @@ export async function* walkFiles(root: string, options: WalkOptions = {}): Async
312
334
  isIncluded(name, include) &&
313
335
  passesSizeLimit(name, rootStat.size, maxFileSize)
314
336
  ) {
315
- yield name;
337
+ yield { rel: name, size: rootStat.size };
316
338
  }
317
339
  return;
318
340
  }
@@ -327,20 +349,33 @@ interface WalkContext {
327
349
  ignores: readonly string[];
328
350
  }
329
351
 
352
+ /**
353
+ * Manifests bypass the normal file-size cap (they carry the whole dependency
354
+ * tree and must not be dropped), but still get a generous hard ceiling so a
355
+ * pathological or hostile lockfile can't be read unbounded and OOM the scan.
356
+ * 16 MiB comfortably covers real monorepo lockfiles (npm's own is well under 5).
357
+ */
358
+ export const MANIFEST_MAX_BYTES = 16 * 1024 * 1024;
359
+
330
360
  /**
331
361
  * True if a file passes the size limit. Dependency manifests (package.json /
332
- * package-lock.json) are exempt from the cap so large lockfiles still get
333
- * scanned for vulnerable dependencies instead of being silently dropped.
362
+ * package-lock.json / yarn.lock / …) get the larger {@link MANIFEST_MAX_BYTES}
363
+ * ceiling instead of the ordinary cap, so large lockfiles still get scanned for
364
+ * vulnerable dependencies without letting an enormous one exhaust memory.
365
+ * Exported for unit testing.
334
366
  */
335
- function passesSizeLimit(rel: string, size: number, maxFileSize: number): boolean {
336
- // Dependency manifests are always read (they can exceed the size cap but carry
337
- // the whole dependency tree). Uses the single {@link isManifestFile} definition.
338
- if (isManifestFile(rel)) return true;
367
+ export function passesSizeLimit(rel: string, size: number, maxFileSize: number): boolean {
368
+ // Uses the single {@link isManifestFile} definition.
369
+ if (isManifestFile(rel)) return size <= MANIFEST_MAX_BYTES;
339
370
  return size <= maxFileSize;
340
371
  }
341
372
 
342
373
  /** Internal recursive directory walker. `relDir` is POSIX-relative to the root. */
343
- async function* walkDir(absDir: string, relDir: string, ctx: WalkContext): AsyncGenerator<string> {
374
+ async function* walkDir(
375
+ absDir: string,
376
+ relDir: string,
377
+ ctx: WalkContext,
378
+ ): AsyncGenerator<WalkedFile> {
344
379
  let entries: Dirent[];
345
380
  try {
346
381
  entries = await readdir(absDir, { withFileTypes: true });
@@ -377,13 +412,15 @@ async function* walkDir(absDir: string, relDir: string, ctx: WalkContext): Async
377
412
  if (!manifest && isBinaryPath(rel)) continue;
378
413
  if (!manifest && isGeneratedPath(rel)) continue;
379
414
 
415
+ let size: number;
380
416
  try {
381
417
  const s = await stat(abs);
382
418
  if (!passesSizeLimit(rel, s.size, ctx.maxFileSize)) continue;
419
+ size = s.size;
383
420
  } catch {
384
421
  continue;
385
422
  }
386
423
 
387
- yield rel;
424
+ yield { rel, size };
388
425
  }
389
426
  }