@quantakrypto/core 0.1.0 → 0.2.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 (57) hide show
  1. package/README.md +6 -0
  2. package/dist/cbom.js +1 -1
  3. package/dist/cbom.js.map +1 -1
  4. package/dist/changed.d.ts.map +1 -1
  5. package/dist/changed.js +7 -4
  6. package/dist/changed.js.map +1 -1
  7. package/dist/detect-utils.d.ts +2 -0
  8. package/dist/detect-utils.d.ts.map +1 -1
  9. package/dist/detect-utils.js +2 -0
  10. package/dist/detect-utils.js.map +1 -1
  11. package/dist/detectors/pem.d.ts.map +1 -1
  12. package/dist/detectors/pem.js +7 -0
  13. package/dist/detectors/pem.js.map +1 -1
  14. package/dist/detectors/source.d.ts.map +1 -1
  15. package/dist/detectors/source.js +5 -2
  16. package/dist/detectors/source.js.map +1 -1
  17. package/dist/errors.d.ts +20 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +24 -0
  20. package/dist/errors.js.map +1 -0
  21. package/dist/index.d.ts +3 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/parallel.d.ts.map +1 -1
  26. package/dist/parallel.js +30 -12
  27. package/dist/parallel.js.map +1 -1
  28. package/dist/report.d.ts +12 -2
  29. package/dist/report.d.ts.map +1 -1
  30. package/dist/report.js +18 -18
  31. package/dist/report.js.map +1 -1
  32. package/dist/scan.d.ts +11 -0
  33. package/dist/scan.d.ts.map +1 -1
  34. package/dist/scan.js +34 -7
  35. package/dist/scan.js.map +1 -1
  36. package/dist/severity.d.ts +22 -0
  37. package/dist/severity.d.ts.map +1 -0
  38. package/dist/severity.js +30 -0
  39. package/dist/severity.js.map +1 -0
  40. package/dist/types.d.ts +23 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +4 -4
  45. package/src/cbom.ts +1 -1
  46. package/src/changed.ts +11 -4
  47. package/src/detect-utils.ts +3 -0
  48. package/src/detectors/pem.ts +9 -0
  49. package/src/detectors/source.ts +5 -2
  50. package/src/errors.ts +25 -0
  51. package/src/index.ts +7 -0
  52. package/src/parallel.ts +27 -9
  53. package/src/report.ts +29 -20
  54. package/src/scan.ts +41 -8
  55. package/src/severity.ts +40 -0
  56. package/src/types.ts +23 -0
  57. package/src/version.ts +1 -1
package/src/parallel.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * (small file counts / small total bytes) and whenever workers are unavailable.
10
10
  */
11
11
  import { stat } from "node:fs/promises";
12
+ import { existsSync } from "node:fs";
12
13
  import * as os from "node:os";
13
14
  import * as path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
@@ -16,9 +17,9 @@ import { fileURLToPath } from "node:url";
16
17
  import type { Worker as NodeWorker } from "node:worker_threads";
17
18
 
18
19
  import type { Finding, ParallelScanOptions, ScanResult } from "./types.js";
19
- import { walkFiles, toPosix } from "./walk.js";
20
+ import { walkFiles } from "./walk.js";
20
21
  import { buildInventory } from "./inventory.js";
21
- import { compareFindings, scan } from "./scan.js";
22
+ import { compareFindings, filterExplicitFileList, scan } from "./scan.js";
22
23
  import { VERSION } from "./version.js";
23
24
 
24
25
  /** One unit of work dispatched to a worker. */
@@ -106,7 +107,9 @@ function shouldParallelize(options: ParallelScanOptions, files: SizedFile[]): bo
106
107
  async function enumerateFiles(options: ParallelScanOptions, baseDir: string): Promise<SizedFile[]> {
107
108
  const rels: string[] = [];
108
109
  if (options.files) {
109
- for (const f of options.files) rels.push(toPosix(f));
110
+ // Apply the SAME include/exclude/binary filtering the serial path uses via
111
+ // `filterExplicitFiles`, so `--parallel` is byte-for-byte identical to serial.
112
+ for (const rel of filterExplicitFileList(options.files, options)) rels.push(rel);
110
113
  } else {
111
114
  for await (const rel of walkFiles(options.root, {
112
115
  include: options.include,
@@ -131,11 +134,21 @@ async function enumerateFiles(options: ParallelScanOptions, baseDir: string): Pr
131
134
  return sized;
132
135
  }
133
136
 
134
- /** Resolve the built worker entry (dist/scan-worker.js) next to this module. */
135
- function workerEntryPath(): string {
136
- // After build this file is dist/parallel.js and the worker is dist/scan-worker.js.
137
+ /**
138
+ * Resolve the worker entry next to this module. In a normal build it's
139
+ * `dist/scan-worker.js`. When running from source under a TypeScript loader
140
+ * (tsx) the built JS doesn't exist, so fall back to `scan-worker.ts` and tell
141
+ * the worker to load tsx as well — so the parallel scanner works in dev/source
142
+ * mode (and is exercisable by tests), not only after a build.
143
+ */
144
+ function workerEntry(): { entry: string; execArgv?: string[] } {
137
145
  const here = fileURLToPath(import.meta.url);
138
- return path.join(path.dirname(here), "scan-worker.js");
146
+ const dir = path.dirname(here);
147
+ const js = path.join(dir, "scan-worker.js");
148
+ if (existsSync(js)) return { entry: js };
149
+ const ts = path.join(dir, "scan-worker.ts");
150
+ if (existsSync(ts)) return { entry: ts, execArgv: ["--import", "tsx"] };
151
+ return { entry: js }; // neither present: let Worker surface a clear ENOENT
139
152
  }
140
153
 
141
154
  /**
@@ -171,7 +184,7 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
171
184
 
172
185
  const chunks = chunkByBytes(files, options.chunkBytes ?? DEFAULT_CHUNK_BYTES);
173
186
  const concurrency = Math.min(resolveConcurrency(options), chunks.length);
174
- const entry = workerEntryPath();
187
+ const { entry, execArgv } = workerEntry();
175
188
 
176
189
  const toggles = {
177
190
  source: options.source !== false,
@@ -185,6 +198,7 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
185
198
  results = await runPool(
186
199
  WorkerCtor,
187
200
  entry,
201
+ execArgv,
188
202
  baseDir,
189
203
  toggles,
190
204
  chunks,
@@ -215,6 +229,7 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
215
229
  function runPool(
216
230
  WorkerCtor: typeof import("node:worker_threads").Worker,
217
231
  entry: string,
232
+ execArgv: string[] | undefined,
218
233
  baseDir: string,
219
234
  toggles: { source: boolean; config: boolean; deps: boolean; scanMinified: boolean },
220
235
  chunks: ScanChunk[],
@@ -243,7 +258,10 @@ function runPool(
243
258
  };
244
259
 
245
260
  const spawn = (): NodeWorker => {
246
- const w = new WorkerCtor(entry, { workerData: { baseDir, toggles } });
261
+ const w = new WorkerCtor(entry, {
262
+ workerData: { baseDir, toggles },
263
+ ...(execArgv ? { execArgv } : {}),
264
+ });
247
265
  w.on(
248
266
  "message",
249
267
  (msg: { index: number; result?: ChunkResult; files?: string[]; error?: string }) => {
package/src/report.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  * or a human-readable text summary. No third-party dependencies — ANSI colour
4
4
  * is emitted with raw escape codes and is off by default.
5
5
  */
6
- import type { ScanResult, Severity } from "./types.js";
6
+ import type { Finding, ScanResult, Severity } from "./types.js";
7
7
  import { VERSION } from "./version.js";
8
+ import { SEVERITY_ORDER, sarifLevel } from "./severity.js";
8
9
 
9
10
  /** Minimal SARIF 2.1.0 log shape (kept permissive on purpose). */
10
11
  export interface SarifLog {
@@ -13,22 +14,30 @@ export interface SarifLog {
13
14
  runs: unknown[];
14
15
  }
15
16
 
17
+ /** Options shared by the structured reporters ({@link toSarif} / {@link toJson}). */
18
+ export interface ReportOptions {
19
+ /**
20
+ * Omit `location.snippet` from every finding in the output. Defaults to false
21
+ * (snippets are included). Snippets of `sensitive` findings (e.g. PEM key
22
+ * blocks, SSH public keys) are ALWAYS omitted regardless of this flag — the
23
+ * snippet there IS the sensitive value.
24
+ */
25
+ redactSnippets?: boolean;
26
+ }
27
+
16
28
  const SARIF_SCHEMA =
17
29
  "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
18
30
 
19
- const INFORMATION_URI = "https://github.com/qproof-tools/qproof";
31
+ const INFORMATION_URI = "https://github.com/quantakrypto/tools";
20
32
 
21
- /** Map our severity to a SARIF result level. */
22
- function sarifLevel(severity: Severity): "error" | "warning" | "note" {
23
- switch (severity) {
24
- case "critical":
25
- case "high":
26
- return "error";
27
- case "medium":
28
- return "warning";
29
- default:
30
- return "note";
31
- }
33
+ /**
34
+ * Resolve the snippet to emit for a finding, honouring redaction. Sensitive
35
+ * findings (key material) never expose their snippet; otherwise the snippet is
36
+ * dropped only when `redactSnippets` is set.
37
+ */
38
+ function emittedSnippet(f: Finding, redactSnippets: boolean): string | undefined {
39
+ if (redactSnippets || f.sensitive) return undefined;
40
+ return f.location.snippet;
32
41
  }
33
42
 
34
43
  /** Map our severity to a SARIF rule-level default (used in rules[].defaultConfiguration). */
@@ -48,7 +57,8 @@ function sarifRank(severity: Severity): number {
48
57
  }
49
58
 
50
59
  /** Serialize a scan result as SARIF 2.1.0. */
51
- export function toSarif(result: ScanResult): SarifLog {
60
+ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
61
+ const redactSnippets = opts?.redactSnippets ?? false;
52
62
  // Build the unique rule set (one rule per ruleId encountered) and collect the
53
63
  // set of CWE taxa referenced by any rule.
54
64
  const ruleIndex = new Map<string, number>();
@@ -86,6 +96,7 @@ export function toSarif(result: ScanResult): SarifLog {
86
96
  const region: Record<string, number> = { startLine: f.location.line };
87
97
  if (typeof f.location.column === "number") region.startColumn = f.location.column;
88
98
  if (typeof f.location.endLine === "number") region.endLine = f.location.endLine;
99
+ const snippet = emittedSnippet(f, redactSnippets);
89
100
 
90
101
  return {
91
102
  ruleId: f.ruleId,
@@ -116,7 +127,7 @@ export function toSarif(result: ScanResult): SarifLog {
116
127
  artifactLocation: { uri: f.location.file },
117
128
  region: {
118
129
  ...region,
119
- ...(f.location.snippet ? { snippet: { text: f.location.snippet } } : {}),
130
+ ...(snippet ? { snippet: { text: snippet } } : {}),
120
131
  },
121
132
  },
122
133
  },
@@ -178,7 +189,8 @@ function securitySeverity(severity: Severity): string {
178
189
  }
179
190
 
180
191
  /** Serialize a scan result as a plain JSON-friendly object. */
181
- export function toJson(result: ScanResult): Record<string, unknown> {
192
+ export function toJson(result: ScanResult, opts?: ReportOptions): Record<string, unknown> {
193
+ const redactSnippets = opts?.redactSnippets ?? false;
182
194
  return {
183
195
  toolVersion: result.toolVersion,
184
196
  root: result.root,
@@ -208,7 +220,7 @@ export function toJson(result: ScanResult): Record<string, unknown> {
208
220
  line: f.location.line,
209
221
  column: f.location.column,
210
222
  endLine: f.location.endLine,
211
- snippet: f.location.snippet,
223
+ snippet: emittedSnippet(f, redactSnippets),
212
224
  },
213
225
  })),
214
226
  };
@@ -231,9 +243,6 @@ const ANSI = {
231
243
  cyan: "\x1b[36m",
232
244
  } as const;
233
245
 
234
- /** Severity ordering for grouping / sorting, most → least severe. */
235
- const SEVERITY_ORDER: Severity[] = ["critical", "high", "medium", "low", "info"];
236
-
237
246
  function severityColor(sev: Severity): string {
238
247
  switch (sev) {
239
248
  case "critical":
package/src/scan.ts CHANGED
@@ -19,6 +19,7 @@ import { pemDetector } from "./detectors/pem.js";
19
19
  import { defaultRegistry, detectorScope } from "./registry.js";
20
20
  import { isManifestFile, scanManifest } from "./dependencies.js";
21
21
  import { buildInventory } from "./inventory.js";
22
+ import { AbortError, BudgetExceededError } from "./errors.js";
22
23
  import { VERSION } from "./version.js";
23
24
 
24
25
  /**
@@ -90,6 +91,12 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
90
91
 
91
92
  const findings: Finding[] = [];
92
93
  let filesScanned = 0;
94
+ let bytesScanned = 0;
95
+
96
+ // Work-budget / cancellation controls (all optional, unlimited when omitted).
97
+ const signal = options.signal;
98
+ const maxFiles = options.maxFiles;
99
+ const maxBytes = options.maxBytes;
93
100
 
94
101
  // Source of relative paths: an explicit file list (incremental) or the walker.
95
102
  const relPaths: AsyncIterable<string> = options.files
@@ -102,6 +109,14 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
102
109
  });
103
110
 
104
111
  for await (const rel of relPaths) {
112
+ // Cooperative cancellation: check the signal at the top of every iteration.
113
+ if (signal?.aborted) throw new AbortError();
114
+
115
+ // File-count budget: enforced before reading the next file.
116
+ if (typeof maxFiles === "number" && filesScanned >= maxFiles) {
117
+ throw new BudgetExceededError(`maxFiles budget exceeded (limit: ${maxFiles}).`);
118
+ }
119
+
105
120
  // In single-file mode, walkFiles yields the basename; map back to the file.
106
121
  const absPath = singleFileName ? options.root : path.join(baseDir, ...rel.split("/"));
107
122
  const reportedPath = singleFileName ? toPosix(rel) : rel;
@@ -121,6 +136,12 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
121
136
  continue;
122
137
  }
123
138
 
139
+ // Cumulative-byte budget: enforced once this file's bytes are accounted for.
140
+ bytesScanned += Buffer.byteLength(content, "utf8");
141
+ if (typeof maxBytes === "number" && bytesScanned > maxBytes) {
142
+ throw new BudgetExceededError(`maxBytes budget exceeded (limit: ${maxBytes}).`);
143
+ }
144
+
124
145
  filesScanned += 1;
125
146
  findings.push(
126
147
  ...detectFile(reportedPath, content, dets, {
@@ -150,11 +171,18 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
150
171
 
151
172
  /**
152
173
  * Filter an explicit relative file list down to the paths that pass the binary
153
- * filter and the include/exclude patterns, yielding them in sorted order for
174
+ * filter and the include/exclude patterns, returning them deduped and sorted for
154
175
  * deterministic output. Size limits are enforced later (we still read manifests
155
176
  * over the cap). Non-existent files are simply skipped at read time.
177
+ *
178
+ * Pure + synchronous, and exported so the parallel enumerator applies the EXACT
179
+ * same filtering for explicit file lists as the serial path (byte-for-byte
180
+ * identical results under `--parallel`).
156
181
  */
157
- async function* filterExplicitFiles(files: string[], options: ScanOptions): AsyncGenerator<string> {
182
+ export function filterExplicitFileList(
183
+ files: readonly string[],
184
+ options: Pick<ScanOptions, "include" | "exclude">,
185
+ ): string[] {
158
186
  const include = options.include ?? [];
159
187
  const exclude = options.exclude ?? [];
160
188
  const seen = new Set<string>();
@@ -167,12 +195,17 @@ async function* filterExplicitFiles(files: string[], options: ScanOptions): Asyn
167
195
  })
168
196
  .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
169
197
 
170
- for (const rel of list) {
171
- if (isBinaryPath(rel)) continue;
172
- if (include.length > 0 && !matchesAny(rel, include)) continue;
173
- if (matchesAny(rel, exclude)) continue;
174
- yield rel;
175
- }
198
+ return list.filter((rel) => {
199
+ if (isBinaryPath(rel)) return false;
200
+ if (include.length > 0 && !matchesAny(rel, include)) return false;
201
+ if (matchesAny(rel, exclude)) return false;
202
+ return true;
203
+ });
204
+ }
205
+
206
+ /** Async-generator wrapper around {@link filterExplicitFileList} for `scan()`. */
207
+ async function* filterExplicitFiles(files: string[], options: ScanOptions): AsyncGenerator<string> {
208
+ for (const rel of filterExplicitFileList(files, options)) yield rel;
176
209
  }
177
210
 
178
211
  /** Local substring/prefix matcher (mirrors the walker's pattern semantics). */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Severity utilities shared across the monorepo (qScan, the MCP server, the
3
+ * GitHub Action). Lifts the previously-duplicated ordering / threshold / SARIF
4
+ * mapping logic into one place so every consumer agrees on what "at or above a
5
+ * threshold" means and how a severity maps to a SARIF level.
6
+ */
7
+ import type { Severity } from "./types.js";
8
+
9
+ /** Severity ordering, most → least severe. Index 0 is the most severe. */
10
+ export const SEVERITY_ORDER: readonly Severity[] = ["critical", "high", "medium", "low", "info"];
11
+
12
+ /**
13
+ * Rank of a severity within {@link SEVERITY_ORDER} (0 = most severe). Lower
14
+ * ranks are more severe; unknown values fall to the end.
15
+ */
16
+ export function severityRank(s: Severity): number {
17
+ const i = SEVERITY_ORDER.indexOf(s);
18
+ return i < 0 ? SEVERITY_ORDER.length : i;
19
+ }
20
+
21
+ /**
22
+ * True when `severity` is at or above `threshold` (i.e. at least as severe).
23
+ * Because lower ranks are more severe, "at or above" is `rank <= threshold`.
24
+ */
25
+ export function meetsThreshold(severity: Severity, threshold: Severity): boolean {
26
+ return severityRank(severity) <= severityRank(threshold);
27
+ }
28
+
29
+ /** Map our severity to a SARIF 2.1.0 result level. */
30
+ export function sarifLevel(severity: Severity): "error" | "warning" | "note" {
31
+ switch (severity) {
32
+ case "critical":
33
+ case "high":
34
+ return "error";
35
+ case "medium":
36
+ return "warning";
37
+ default:
38
+ return "note";
39
+ }
40
+ }
package/src/types.ts CHANGED
@@ -68,6 +68,12 @@ export interface Finding {
68
68
  remediation?: string;
69
69
  /** Associated CWE identifier, e.g. "CWE-327" (broken crypto), "CWE-326" (weak strength). */
70
70
  cwe?: string;
71
+ /**
72
+ * True when the matched snippet IS the sensitive value (e.g. a PEM private/
73
+ * public key block, an `ssh-rsa AAAA…` public key). Reporters ALWAYS drop the
74
+ * snippet for such findings, regardless of any redaction flag.
75
+ */
76
+ sensitive?: boolean;
71
77
  location: SourceLocation;
72
78
  }
73
79
 
@@ -166,6 +172,23 @@ export interface ScanOptions {
166
172
  detectors?: Detector[];
167
173
  /** Optional progress callback. */
168
174
  onFile?: (file: string) => void;
175
+ /**
176
+ * Optional abort signal. When it fires mid-walk the scan stops cooperatively
177
+ * and rejects with an `AbortError` (a `DOMException`-like error with
178
+ * `name === "AbortError"`).
179
+ */
180
+ signal?: AbortSignal;
181
+ /**
182
+ * Work budget: maximum number of files to scan. When exceeded mid-walk the
183
+ * scan stops and throws a `BudgetExceededError`. Unlimited when omitted.
184
+ */
185
+ maxFiles?: number;
186
+ /**
187
+ * Work budget: maximum cumulative bytes of scanned file content. When
188
+ * exceeded mid-walk the scan stops and throws a `BudgetExceededError`.
189
+ * Unlimited when omitted.
190
+ */
191
+ maxBytes?: number;
169
192
  }
170
193
 
171
194
  /** Extra options for {@link scanParallel}, layered onto {@link ScanOptions}. */
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.1.0";
6
+ export const VERSION = "0.2.0";