@quantakrypto/core 0.1.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 (102) hide show
  1. package/README.md +337 -0
  2. package/dist/baseline.d.ts +37 -0
  3. package/dist/baseline.d.ts.map +1 -0
  4. package/dist/baseline.js +99 -0
  5. package/dist/baseline.js.map +1 -0
  6. package/dist/cbom.d.ts +25 -0
  7. package/dist/cbom.d.ts.map +1 -0
  8. package/dist/cbom.js +131 -0
  9. package/dist/cbom.js.map +1 -0
  10. package/dist/changed.d.ts +13 -0
  11. package/dist/changed.d.ts.map +1 -0
  12. package/dist/changed.js +67 -0
  13. package/dist/changed.js.map +1 -0
  14. package/dist/config.d.ts +55 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +232 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/cwe.d.ts +16 -0
  19. package/dist/cwe.d.ts.map +1 -0
  20. package/dist/cwe.js +16 -0
  21. package/dist/cwe.js.map +1 -0
  22. package/dist/dependencies.d.ts +25 -0
  23. package/dist/dependencies.d.ts.map +1 -0
  24. package/dist/dependencies.js +276 -0
  25. package/dist/dependencies.js.map +1 -0
  26. package/dist/detect-utils.d.ts +63 -0
  27. package/dist/detect-utils.d.ts.map +1 -0
  28. package/dist/detect-utils.js +113 -0
  29. package/dist/detect-utils.js.map +1 -0
  30. package/dist/detectors/pem.d.ts +9 -0
  31. package/dist/detectors/pem.d.ts.map +1 -0
  32. package/dist/detectors/pem.js +137 -0
  33. package/dist/detectors/pem.js.map +1 -0
  34. package/dist/detectors/source.d.ts +25 -0
  35. package/dist/detectors/source.d.ts.map +1 -0
  36. package/dist/detectors/source.js +575 -0
  37. package/dist/detectors/source.js.map +1 -0
  38. package/dist/index.d.ts +31 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +39 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/inventory.d.ts +15 -0
  43. package/dist/inventory.d.ts.map +1 -0
  44. package/dist/inventory.js +74 -0
  45. package/dist/inventory.js.map +1 -0
  46. package/dist/parallel.d.ts +34 -0
  47. package/dist/parallel.d.ts.map +1 -0
  48. package/dist/parallel.js +237 -0
  49. package/dist/parallel.js.map +1 -0
  50. package/dist/registry.d.ts +42 -0
  51. package/dist/registry.d.ts.map +1 -0
  52. package/dist/registry.js +51 -0
  53. package/dist/registry.js.map +1 -0
  54. package/dist/remediation.d.ts +42 -0
  55. package/dist/remediation.d.ts.map +1 -0
  56. package/dist/remediation.js +131 -0
  57. package/dist/remediation.js.map +1 -0
  58. package/dist/report.d.ts +24 -0
  59. package/dist/report.d.ts.map +1 -0
  60. package/dist/report.js +286 -0
  61. package/dist/report.js.map +1 -0
  62. package/dist/scan-worker.d.ts +2 -0
  63. package/dist/scan-worker.d.ts.map +1 -0
  64. package/dist/scan-worker.js +63 -0
  65. package/dist/scan-worker.js.map +1 -0
  66. package/dist/scan.d.ts +27 -0
  67. package/dist/scan.d.ts.map +1 -0
  68. package/dist/scan.js +168 -0
  69. package/dist/scan.js.map +1 -0
  70. package/dist/types.d.ts +190 -0
  71. package/dist/types.d.ts.map +1 -0
  72. package/dist/types.js +9 -0
  73. package/dist/types.js.map +1 -0
  74. package/dist/version.d.ts +7 -0
  75. package/dist/version.d.ts.map +1 -0
  76. package/dist/version.js +7 -0
  77. package/dist/version.js.map +1 -0
  78. package/dist/walk.d.ts +37 -0
  79. package/dist/walk.d.ts.map +1 -0
  80. package/dist/walk.js +260 -0
  81. package/dist/walk.js.map +1 -0
  82. package/package.json +40 -0
  83. package/src/baseline.ts +111 -0
  84. package/src/cbom.ts +166 -0
  85. package/src/changed.ts +76 -0
  86. package/src/config.ts +295 -0
  87. package/src/cwe.ts +20 -0
  88. package/src/dependencies.ts +299 -0
  89. package/src/detect-utils.ts +157 -0
  90. package/src/detectors/pem.ts +162 -0
  91. package/src/detectors/source.ts +733 -0
  92. package/src/index.ts +79 -0
  93. package/src/inventory.ts +94 -0
  94. package/src/parallel.ts +288 -0
  95. package/src/registry.ts +71 -0
  96. package/src/remediation.ts +173 -0
  97. package/src/report.ts +340 -0
  98. package/src/scan-worker.ts +80 -0
  99. package/src/scan.ts +187 -0
  100. package/src/types.ts +224 -0
  101. package/src/version.ts +6 -0
  102. package/src/walk.ts +289 -0
package/src/scan.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Scan orchestrator. Walks the target (or an explicit file list), runs the
3
+ * applicable detectors over each file, parses dependency manifests, aggregates
4
+ * everything into an inventory, and returns a {@link ScanResult} with timing and
5
+ * the tool version.
6
+ *
7
+ * Detector scope (source vs config) is driven by each {@link Detector}'s
8
+ * declared `scope` (defaulting to "source"), not by ruleId prefixes. The
9
+ * detector set comes from {@link defaultRegistry} unless overridden via
10
+ * `options.detectors`.
11
+ */
12
+ import { readFile, stat } from "node:fs/promises";
13
+ import * as path from "node:path";
14
+
15
+ import type { Detector, Finding, ScanOptions, ScanResult } from "./types.js";
16
+ import { walkFiles, toPosix, isBinaryPath, looksMinified } from "./walk.js";
17
+ import { sourceDetectors } from "./detectors/source.js";
18
+ import { pemDetector } from "./detectors/pem.js";
19
+ import { defaultRegistry, detectorScope } from "./registry.js";
20
+ import { isManifestFile, scanManifest } from "./dependencies.js";
21
+ import { buildInventory } from "./inventory.js";
22
+ import { VERSION } from "./version.js";
23
+
24
+ /**
25
+ * The full set of built-in detectors exposed on the public API. The PEM
26
+ * detector applies to every text file; the source detectors apply only to
27
+ * JS/TS. The manifest scanner is handled separately (it parses JSON rather than
28
+ * running a Detector).
29
+ */
30
+ export const detectors: Detector[] = [...sourceDetectors, pemDetector];
31
+
32
+ /** Stable comparator: by file, then line, then ruleId. Exported for reuse. */
33
+ export function compareFindings(a: Finding, b: Finding): number {
34
+ if (a.location.file !== b.location.file) return a.location.file < b.location.file ? -1 : 1;
35
+ if (a.location.line !== b.location.line) return a.location.line - b.location.line;
36
+ return a.ruleId < b.ruleId ? -1 : a.ruleId > b.ruleId ? 1 : 0;
37
+ }
38
+
39
+ /** Resolve the active detector list for a scan (override or the default registry). */
40
+ function resolveDetectors(options: ScanOptions): Detector[] {
41
+ return options.detectors ?? defaultRegistry.all();
42
+ }
43
+
44
+ /**
45
+ * Run all applicable detectors + the manifest scanner over a single file's
46
+ * contents, honouring the source / config / dependencies toggles. Pure: no I/O.
47
+ * Exported so the parallel worker and unit tests can drive it directly.
48
+ */
49
+ export function detectFile(
50
+ file: string,
51
+ content: string,
52
+ dets: readonly Detector[],
53
+ toggles: { source: boolean; config: boolean; deps: boolean },
54
+ ): Finding[] {
55
+ const out: Finding[] = [];
56
+
57
+ for (const det of dets) {
58
+ if (!det.appliesTo(file)) continue;
59
+ const isConfig = detectorScope(det) === "config";
60
+ if (isConfig ? !toggles.config : !toggles.source) continue;
61
+ out.push(...det.detect({ file, content }));
62
+ }
63
+
64
+ if (toggles.deps && isManifestFile(file)) {
65
+ out.push(...scanManifest(file, content));
66
+ }
67
+
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Recursively scan a directory (or single file, or explicit file list) for
73
+ * classical asymmetric crypto. Honours the source / dependencies / config
74
+ * toggles (all default true) and reports progress through `options.onFile`.
75
+ */
76
+ export async function scan(options: ScanOptions): Promise<ScanResult> {
77
+ const startedAt = new Date();
78
+
79
+ const doSource = options.source !== false;
80
+ const doDeps = options.dependencies !== false;
81
+ const doConfig = options.config !== false;
82
+ const scanMinified = options.scanMinified === true;
83
+ const dets = resolveDetectors(options);
84
+
85
+ // Resolve whether the root is a file so we can rebuild absolute paths to read.
86
+ const rootStat = await stat(options.root);
87
+ const rootIsFile = rootStat.isFile();
88
+ const baseDir = rootIsFile ? path.dirname(options.root) : options.root;
89
+ const singleFileName = rootIsFile ? path.basename(options.root) : null;
90
+
91
+ const findings: Finding[] = [];
92
+ let filesScanned = 0;
93
+
94
+ // Source of relative paths: an explicit file list (incremental) or the walker.
95
+ const relPaths: AsyncIterable<string> = options.files
96
+ ? filterExplicitFiles(options.files, options)
97
+ : walkFiles(options.root, {
98
+ include: options.include,
99
+ exclude: options.exclude,
100
+ noDefaultIgnores: options.noDefaultIgnores,
101
+ maxFileSize: options.maxFileSize,
102
+ });
103
+
104
+ for await (const rel of relPaths) {
105
+ // In single-file mode, walkFiles yields the basename; map back to the file.
106
+ const absPath = singleFileName ? options.root : path.join(baseDir, ...rel.split("/"));
107
+ const reportedPath = singleFileName ? toPosix(rel) : rel;
108
+
109
+ options.onFile?.(reportedPath);
110
+
111
+ let content: string;
112
+ try {
113
+ content = await readFile(absPath, "utf8");
114
+ } catch {
115
+ continue; // unreadable / vanished file — skip.
116
+ }
117
+
118
+ // Skip machine-minified / generated content (unless explicitly enabled).
119
+ // Manifests are always scanned (their findings are dependency findings).
120
+ if (!scanMinified && !isManifestFile(reportedPath) && looksMinified(content)) {
121
+ continue;
122
+ }
123
+
124
+ filesScanned += 1;
125
+ findings.push(
126
+ ...detectFile(reportedPath, content, dets, {
127
+ source: doSource,
128
+ config: doConfig,
129
+ deps: doDeps,
130
+ }),
131
+ );
132
+ }
133
+
134
+ // Stable ordering: by file, then line, then ruleId.
135
+ findings.sort(compareFindings);
136
+
137
+ const inventory = buildInventory(findings);
138
+ const finishedAt = new Date();
139
+
140
+ return {
141
+ root: options.root,
142
+ findings,
143
+ filesScanned,
144
+ inventory,
145
+ startedAt: startedAt.toISOString(),
146
+ finishedAt: finishedAt.toISOString(),
147
+ toolVersion: VERSION,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * 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
154
+ * deterministic output. Size limits are enforced later (we still read manifests
155
+ * over the cap). Non-existent files are simply skipped at read time.
156
+ */
157
+ async function* filterExplicitFiles(files: string[], options: ScanOptions): AsyncGenerator<string> {
158
+ const include = options.include ?? [];
159
+ const exclude = options.exclude ?? [];
160
+ const seen = new Set<string>();
161
+ const list = [...files]
162
+ .map((f) => toPosix(f))
163
+ .filter((f) => {
164
+ if (seen.has(f)) return false;
165
+ seen.add(f);
166
+ return true;
167
+ })
168
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
169
+
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
+ }
176
+ }
177
+
178
+ /** Local substring/prefix matcher (mirrors the walker's pattern semantics). */
179
+ function matchesAny(rel: string, patterns: readonly string[]): boolean {
180
+ for (const pattern of patterns) {
181
+ if (!pattern) continue;
182
+ const p = toPosix(pattern).replace(/\/+$/, "");
183
+ if (rel.includes(p)) return true;
184
+ if (rel === p || rel.startsWith(`${p}/`)) return true;
185
+ }
186
+ return false;
187
+ }
package/src/types.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @quantakrypto/core — shared types (the locked public contract).
3
+ *
4
+ * These types are the stable interface between every tool in the monorepo
5
+ * (qScan, the MCP server, the GitHub Action). Treat additions as backwards
6
+ * compatible; treat renames/removals as breaking.
7
+ */
8
+
9
+ /** How serious a finding is, ordered most → least severe. */
10
+ export type Severity = "critical" | "high" | "medium" | "low" | "info";
11
+
12
+ /** How sure the detector is that the finding is a real use of the algorithm. */
13
+ export type Confidence = "high" | "medium" | "low";
14
+
15
+ /** What kind of cryptographic concern a finding represents. */
16
+ export type FindingCategory =
17
+ | "kem" // key encapsulation / public-key encryption (e.g. RSA-OAEP)
18
+ | "key-exchange" // (EC)DH
19
+ | "signature" // RSA/ECDSA/EdDSA signing
20
+ | "tls" // transport configuration
21
+ | "certificate" // X.509 / PKI material
22
+ | "dependency" // a quantum-vulnerable library in the dependency tree
23
+ | "hash" // weak / pre-quantum hash usage
24
+ | "rng"; // randomness concerns
25
+
26
+ /** Classical asymmetric algorithm families that are not quantum-safe. */
27
+ export type AlgorithmFamily =
28
+ | "RSA"
29
+ | "ECDH"
30
+ | "ECDSA"
31
+ | "EdDSA"
32
+ | "DH"
33
+ | "DSA"
34
+ | "X25519"
35
+ | "X448"
36
+ | "ECIES"
37
+ | "unknown";
38
+
39
+ /** A precise location inside a scanned file. */
40
+ export interface SourceLocation {
41
+ /** Path relative to the scan root, using POSIX separators. */
42
+ file: string;
43
+ /** 1-based line number. */
44
+ line: number;
45
+ /** 1-based column number, if known. */
46
+ column?: number;
47
+ /** 1-based end line, for multi-line matches. */
48
+ endLine?: number;
49
+ /** The matched source text (trimmed, single line). */
50
+ snippet?: string;
51
+ }
52
+
53
+ /** A single detected concern. */
54
+ export interface Finding {
55
+ /** Stable rule identifier, e.g. "rsa-keygen", "ecdh-usage", "tls-legacy-version", "dep-vulnerable". */
56
+ ruleId: string;
57
+ title: string;
58
+ category: FindingCategory;
59
+ severity: Severity;
60
+ confidence: Confidence;
61
+ /** The classical algorithm involved, when applicable. */
62
+ algorithm?: AlgorithmFamily;
63
+ /** True when this is exposed to "harvest now, decrypt later". */
64
+ hndl: boolean;
65
+ /** One-line human explanation of the concern. */
66
+ message: string;
67
+ /** Suggested post-quantum remediation (e.g. ML-KEM, hybrid X25519MLKEM768). */
68
+ remediation?: string;
69
+ /** Associated CWE identifier, e.g. "CWE-327" (broken crypto), "CWE-326" (weak strength). */
70
+ cwe?: string;
71
+ location: SourceLocation;
72
+ }
73
+
74
+ /** A known quantum-vulnerable dependency entry. */
75
+ export interface VulnerableDependency {
76
+ /** Package name. */
77
+ name: string;
78
+ ecosystem: "npm";
79
+ /** Why it's flagged (what classical crypto it provides). */
80
+ reason: string;
81
+ /** Algorithm families the package primarily exposes. */
82
+ algorithms: AlgorithmFamily[];
83
+ severity: Severity;
84
+ }
85
+
86
+ /**
87
+ * Which logical scope a detector belongs to. Drives the source/config scope
88
+ * toggles in {@link ScanOptions} (replacing the old ruleId-prefix inference).
89
+ */
90
+ export type DetectorScope = "source" | "config";
91
+
92
+ /**
93
+ * The programming language / surface a detector targets. `"any"` means the
94
+ * detector is language-agnostic (e.g. PEM material, config files).
95
+ */
96
+ export type DetectorLanguage = "js" | "python" | "go" | "java" | "any";
97
+
98
+ /** A pluggable source detector. Detectors are pure and stateless. */
99
+ export interface Detector {
100
+ /** Unique id, used as the Finding.ruleId prefix space. */
101
+ id: string;
102
+ /** Human description of what the detector looks for. */
103
+ description: string;
104
+ /**
105
+ * Logical scope of this detector's findings. Used by `scan()` to honour the
106
+ * `config` / `source` toggles. Defaults to `"source"` when omitted (for
107
+ * backward compatibility with externally-defined detectors).
108
+ */
109
+ scope?: DetectorScope;
110
+ /**
111
+ * Language this detector targets, for documentation / registry filtering.
112
+ * Defaults to `"js"` when omitted.
113
+ */
114
+ language?: DetectorLanguage;
115
+ /** Whether this detector should run against the given file path. */
116
+ appliesTo(filePath: string): boolean;
117
+ /** Inspect a single file's contents and return zero or more findings. */
118
+ detect(input: DetectorInput): Finding[];
119
+ }
120
+
121
+ export interface DetectorInput {
122
+ /** Path relative to the scan root (POSIX). */
123
+ file: string;
124
+ /** Full file contents. */
125
+ content: string;
126
+ }
127
+
128
+ /** Options controlling a scan. */
129
+ export interface ScanOptions {
130
+ /** Absolute or relative directory (or single file) to scan. */
131
+ root: string;
132
+ /**
133
+ * Restrict the walk to paths matching one of these include patterns
134
+ * (substring or relative-path-prefix match). When omitted, all non-excluded
135
+ * files are scanned. Wired into the walker.
136
+ */
137
+ include?: string[];
138
+ /** Extra exclude patterns (in addition to the built-in defaults). */
139
+ exclude?: string[];
140
+ /** Disable the built-in ignore list (node_modules, .git, dist, …). */
141
+ noDefaultIgnores?: boolean;
142
+ /** Scan source files for inline crypto usage. Default: true. */
143
+ source?: boolean;
144
+ /** Scan dependency manifests/lockfiles for vulnerable libraries. Default: true. */
145
+ dependencies?: boolean;
146
+ /** Scan config files (TLS, certificates). Default: true. */
147
+ config?: boolean;
148
+ /** Max file size to read, in bytes. Default: 2 MiB. */
149
+ maxFileSize?: number;
150
+ /**
151
+ * Scan minified / generated / bundled files (large single-line content)
152
+ * instead of skipping them. Default: false (skip them for speed).
153
+ */
154
+ scanMinified?: boolean;
155
+ /**
156
+ * Explicit relative file list (POSIX, relative to `root`) to scan instead of
157
+ * walking the tree. Used for incremental / changed-files scans. Each path is
158
+ * still subject to the binary / size filters. When set, the directory walk is
159
+ * bypassed entirely.
160
+ */
161
+ files?: string[];
162
+ /**
163
+ * Override / extend the built-in detector set. When omitted, the default
164
+ * registry's detectors are used.
165
+ */
166
+ detectors?: Detector[];
167
+ /** Optional progress callback. */
168
+ onFile?: (file: string) => void;
169
+ }
170
+
171
+ /** Extra options for {@link scanParallel}, layered onto {@link ScanOptions}. */
172
+ export interface ParallelScanOptions extends ScanOptions {
173
+ /**
174
+ * Number of worker threads. Default: `os.availableParallelism()`. A value of
175
+ * 0 or 1 forces the in-process serial path.
176
+ */
177
+ concurrency?: number;
178
+ /**
179
+ * Combined-size floor (bytes) below which the scan always runs in-process.
180
+ * Default: 2 MiB. Also stays serial below `parallelFileThreshold` files.
181
+ */
182
+ parallelThresholdBytes?: number;
183
+ /** File-count floor below which the scan always runs in-process. Default: 200. */
184
+ parallelFileThreshold?: number;
185
+ /** Target bytes per worker chunk. Default: 4 MiB. */
186
+ chunkBytes?: number;
187
+ }
188
+
189
+ /** Aggregated counts produced from a scan's findings. */
190
+ export interface CryptoInventory {
191
+ byAlgorithm: Partial<Record<AlgorithmFamily, number>>;
192
+ byCategory: Partial<Record<FindingCategory, number>>;
193
+ bySeverity: Record<Severity, number>;
194
+ /** Number of findings exposed to harvest-now-decrypt-later. */
195
+ hndlCount: number;
196
+ /** 0–100 readiness score (100 = no classical asymmetric crypto found). */
197
+ readinessScore: number;
198
+ }
199
+
200
+ /** The full result of a scan. */
201
+ export interface ScanResult {
202
+ /** The scan root (as provided). */
203
+ root: string;
204
+ findings: Finding[];
205
+ filesScanned: number;
206
+ inventory: CryptoInventory;
207
+ /** ISO timestamps. */
208
+ startedAt: string;
209
+ finishedAt: string;
210
+ /** Tool version that produced the result. */
211
+ toolVersion: string;
212
+ }
213
+
214
+ /** Output formats qScan / reporters can emit. */
215
+ export type ReportFormat = "human" | "json" | "sarif";
216
+
217
+ /** A remediation recommendation for a classical algorithm. */
218
+ export interface Remediation {
219
+ algorithm: AlgorithmFamily;
220
+ /** Short recommended replacement, e.g. "ML-KEM-768 (hybrid X25519MLKEM768)". */
221
+ recommendation: string;
222
+ /** Longer rationale. */
223
+ detail: string;
224
+ }
package/src/version.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The tool version surfaced in reports. Kept in its own module so reporters and
3
+ * the scan orchestrator can import it without creating a cycle through index.ts.
4
+ * Keep in sync with packages/core/package.json.
5
+ */
6
+ export const VERSION = "0.1.0";
package/src/walk.ts ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Filesystem walker. A zero-dependency recursive async generator that yields
3
+ * scannable text files as relative POSIX paths. It honours a default ignore
4
+ * list, user-supplied exclude patterns, a max file size, and a binary-extension
5
+ * filter. The root may be a directory or a single file.
6
+ */
7
+ import { readdir, stat } from "node:fs/promises";
8
+ import type { Dirent } from "node:fs";
9
+ import * as path from "node:path";
10
+
11
+ /** Directories ignored by default (can be disabled with noDefaultIgnores). */
12
+ export const DEFAULT_IGNORES: readonly string[] = [
13
+ "node_modules",
14
+ ".git",
15
+ "dist",
16
+ "build",
17
+ ".next",
18
+ "out",
19
+ "coverage",
20
+ "vendor",
21
+ ".turbo",
22
+ ".cache",
23
+ ];
24
+
25
+ /** Default maximum file size to read: 2 MiB. */
26
+ export const DEFAULT_MAX_FILE_SIZE = 2 * 1024 * 1024;
27
+
28
+ /**
29
+ * File extensions we treat as binary / non-text and therefore skip. Keeping this
30
+ * as an extension allow-list-by-exclusion is cheap and avoids reading bytes.
31
+ */
32
+ const BINARY_EXTENSIONS = new Set<string>([
33
+ // images
34
+ ".png",
35
+ ".jpg",
36
+ ".jpeg",
37
+ ".gif",
38
+ ".webp",
39
+ ".bmp",
40
+ ".ico",
41
+ ".tiff",
42
+ ".avif",
43
+ // fonts
44
+ ".woff",
45
+ ".woff2",
46
+ ".ttf",
47
+ ".otf",
48
+ ".eot",
49
+ // archives / compressed
50
+ ".zip",
51
+ ".gz",
52
+ ".tgz",
53
+ ".bz2",
54
+ ".xz",
55
+ ".7z",
56
+ ".rar",
57
+ ".tar",
58
+ // media
59
+ ".mp3",
60
+ ".mp4",
61
+ ".mov",
62
+ ".avi",
63
+ ".mkv",
64
+ ".wav",
65
+ ".flac",
66
+ ".ogg",
67
+ ".webm",
68
+ // documents / binaries
69
+ ".pdf",
70
+ ".doc",
71
+ ".docx",
72
+ ".xls",
73
+ ".xlsx",
74
+ ".ppt",
75
+ ".pptx",
76
+ ".exe",
77
+ ".dll",
78
+ ".so",
79
+ ".dylib",
80
+ ".bin",
81
+ ".o",
82
+ ".a",
83
+ ".class",
84
+ ".wasm",
85
+ // data blobs / db
86
+ ".db",
87
+ ".sqlite",
88
+ ".sqlite3",
89
+ ".dat",
90
+ ".pack",
91
+ ".idx",
92
+ // misc
93
+ ".lock",
94
+ ".map",
95
+ ".min.js",
96
+ ".node",
97
+ ]);
98
+
99
+ /** Options accepted by {@link walkFiles}. */
100
+ export interface WalkOptions {
101
+ /**
102
+ * Restrict to paths matching one of these include patterns (substring or
103
+ * relative-path-prefix match). When omitted/empty, all files pass.
104
+ */
105
+ include?: string[];
106
+ /** Extra exclude patterns (substring or relative-path-prefix match). */
107
+ exclude?: string[];
108
+ /** Disable the built-in directory ignore list. */
109
+ noDefaultIgnores?: boolean;
110
+ /** Max file size in bytes; larger files are skipped. */
111
+ maxFileSize?: number;
112
+ }
113
+
114
+ /** Normalise a path to forward-slash POSIX separators. */
115
+ export function toPosix(p: string): string {
116
+ return p.split(path.sep).join("/");
117
+ }
118
+
119
+ /** True if `rel` (a POSIX relative path) matches any pattern (substring/prefix). */
120
+ function matchesAny(rel: string, patterns: readonly string[]): boolean {
121
+ for (const pattern of patterns) {
122
+ if (!pattern) continue;
123
+ const p = toPosix(pattern).replace(/\/+$/, "");
124
+ // Substring match (handles "src/legacy" or "secrets")...
125
+ if (rel.includes(p)) return true;
126
+ // ...and explicit path-prefix match ("foo" should match "foo/bar.ts").
127
+ if (rel === p || rel.startsWith(`${p}/`)) return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /** True if `rel` (a POSIX relative path) matches any exclude pattern. */
133
+ function isExcluded(rel: string, exclude: readonly string[]): boolean {
134
+ return matchesAny(rel, exclude);
135
+ }
136
+
137
+ /**
138
+ * True if `rel` passes the include filter. An empty include list means "include
139
+ * everything"; otherwise the file must match at least one include pattern.
140
+ */
141
+ function isIncluded(rel: string, include: readonly string[]): boolean {
142
+ if (include.length === 0) return true;
143
+ return matchesAny(rel, include);
144
+ }
145
+
146
+ /** True if the file's extension marks it as binary / non-text. */
147
+ export function isBinaryPath(rel: string): boolean {
148
+ const lower = rel.toLowerCase();
149
+ // Handle compound extensions like ".min.js" first.
150
+ if (lower.endsWith(".min.js")) return true;
151
+ const ext = path.posix.extname(lower);
152
+ return BINARY_EXTENSIONS.has(ext);
153
+ }
154
+
155
+ /**
156
+ * Compound / pattern extensions that mark generated or bundled output we skip
157
+ * by default (beyond `.min.js` / `.map`, which {@link isBinaryPath} handles).
158
+ */
159
+ const GENERATED_PATH_RE =
160
+ /(?:\.min\.[mc]?js|[.-]min\.[mc]?js|\.bundle\.[mc]?js|\.chunk\.[mc]?js|\.generated\.[jt]sx?|_pb\.js|\.pb\.go)$/i;
161
+
162
+ /** True if the path looks like generated / bundled output (by name). */
163
+ export function isGeneratedPath(rel: string): boolean {
164
+ return GENERATED_PATH_RE.test(rel.toLowerCase());
165
+ }
166
+
167
+ /**
168
+ * Heuristic content check for machine-minified / generated files with no
169
+ * telltale extension: a very long average line length, or any single line over
170
+ * ~50 KB, in the first ~64 KB sampled. Used at read time, not in the walker.
171
+ */
172
+ export function looksMinified(content: string): boolean {
173
+ const sample = content.length > 65_536 ? content.slice(0, 65_536) : content;
174
+ if (sample.length === 0) return false;
175
+ let maxLine = 0;
176
+ let cur = 0;
177
+ let lines = 1;
178
+ for (let i = 0; i < sample.length; i++) {
179
+ if (sample.charCodeAt(i) === 10 /* \n */) {
180
+ if (cur > maxLine) maxLine = cur;
181
+ cur = 0;
182
+ lines++;
183
+ } else {
184
+ cur++;
185
+ }
186
+ }
187
+ if (cur > maxLine) maxLine = cur;
188
+ if (maxLine > 50_000) return true;
189
+ const avgLine = sample.length / lines;
190
+ return avgLine > 1_000;
191
+ }
192
+
193
+ /**
194
+ * Recursively yield scannable file paths (relative to `root`, POSIX) under a
195
+ * directory. If `root` points at a single file, yields just that file's
196
+ * basename (subject to the size / binary filters).
197
+ */
198
+ export async function* walkFiles(root: string, options: WalkOptions = {}): AsyncGenerator<string> {
199
+ const include = options.include ?? [];
200
+ const exclude = options.exclude ?? [];
201
+ const maxFileSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
202
+ const ignores = options.noDefaultIgnores ? [] : DEFAULT_IGNORES;
203
+
204
+ const rootStat = await stat(root);
205
+
206
+ // Single-file mode: yield the file itself (by basename) if it passes filters.
207
+ if (rootStat.isFile()) {
208
+ const name = toPosix(path.basename(root));
209
+ if (
210
+ !isBinaryPath(name) &&
211
+ isIncluded(name, include) &&
212
+ passesSizeLimit(name, rootStat.size, maxFileSize)
213
+ ) {
214
+ yield name;
215
+ }
216
+ return;
217
+ }
218
+
219
+ yield* walkDir(root, "", { include, exclude, maxFileSize, ignores });
220
+ }
221
+
222
+ interface WalkContext {
223
+ include: readonly string[];
224
+ exclude: readonly string[];
225
+ maxFileSize: number;
226
+ ignores: readonly string[];
227
+ }
228
+
229
+ /**
230
+ * True if a file passes the size limit. Dependency manifests (package.json /
231
+ * package-lock.json) are exempt from the cap so large lockfiles still get
232
+ * scanned for vulnerable dependencies instead of being silently dropped.
233
+ */
234
+ function passesSizeLimit(rel: string, size: number, maxFileSize: number): boolean {
235
+ if (isManifestPath(rel)) return true;
236
+ return size <= maxFileSize;
237
+ }
238
+
239
+ /** True if the path's basename is a dependency manifest we always read. */
240
+ function isManifestPath(rel: string): boolean {
241
+ const base = rel.split("/").pop() ?? rel;
242
+ return base === "package.json" || base === "package-lock.json";
243
+ }
244
+
245
+ /** Internal recursive directory walker. `relDir` is POSIX-relative to the root. */
246
+ async function* walkDir(absDir: string, relDir: string, ctx: WalkContext): AsyncGenerator<string> {
247
+ let entries: Dirent[];
248
+ try {
249
+ entries = await readdir(absDir, { withFileTypes: true });
250
+ } catch {
251
+ // Unreadable directory (permissions, transient races) — skip silently.
252
+ return;
253
+ }
254
+
255
+ // Stable, deterministic ordering for reproducible scans.
256
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
257
+
258
+ for (const entry of entries) {
259
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
260
+ const abs = path.join(absDir, entry.name);
261
+
262
+ if (entry.isSymbolicLink()) {
263
+ // Don't follow symlinks: avoids cycles and escaping the root.
264
+ continue;
265
+ }
266
+
267
+ if (entry.isDirectory()) {
268
+ if (ctx.ignores.includes(entry.name)) continue;
269
+ if (isExcluded(rel, ctx.exclude)) continue;
270
+ yield* walkDir(abs, rel, ctx);
271
+ continue;
272
+ }
273
+
274
+ if (!entry.isFile()) continue;
275
+ if (isExcluded(rel, ctx.exclude)) continue;
276
+ if (!isIncluded(rel, ctx.include)) continue;
277
+ if (isBinaryPath(rel)) continue;
278
+ if (isGeneratedPath(rel)) continue;
279
+
280
+ try {
281
+ const s = await stat(abs);
282
+ if (!passesSizeLimit(rel, s.size, ctx.maxFileSize)) continue;
283
+ } catch {
284
+ continue;
285
+ }
286
+
287
+ yield rel;
288
+ }
289
+ }