ecdsa-scan 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.
package/src/scan.js ADDED
@@ -0,0 +1,308 @@
1
+ // Scan engine: walk a directory, build a FileContext per source file, run every
2
+ // rule against it and collect findings plus the crypto inventory.
3
+ //
4
+ // Everything here is read-only. The scanner opens files, never writes them.
5
+
6
+ import { readFile, readdir, stat } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { maskComments, maskLiterals } from "./lib/mask.js";
9
+ import { compileIgnore } from "./lib/glob.js";
10
+ import { balancedSpan, findCalls, matchAll, snippetAt } from "./lib/text.js";
11
+ import { rules as allRules } from "./rules/index.js";
12
+
13
+ export const CONFIDENCE_ORDER = { advisory: 0, suspected: 1, confirmed: 2 };
14
+ export const CONFIDENCE_LEVELS = Object.keys(CONFIDENCE_ORDER);
15
+
16
+ /** Directories that never contain first-party source worth scanning. */
17
+ export const DEFAULT_SKIP_DIRS = new Set([
18
+ "node_modules",
19
+ ".git",
20
+ ".hg",
21
+ ".svn",
22
+ "dist",
23
+ "build",
24
+ "out",
25
+ "vendor",
26
+ ".next",
27
+ ".nuxt",
28
+ ".turbo",
29
+ ".cache",
30
+ "coverage",
31
+ "__pycache__",
32
+ ".venv",
33
+ "venv",
34
+ ".tox",
35
+ ".mypy_cache",
36
+ ".pytest_cache",
37
+ "target",
38
+ ".gradle",
39
+ ".idea",
40
+ ".vscode",
41
+ ]);
42
+
43
+ const LANG_BY_EXT = {
44
+ ".js": "js",
45
+ ".mjs": "js",
46
+ ".cjs": "js",
47
+ ".jsx": "js",
48
+ ".ts": "ts",
49
+ ".tsx": "ts",
50
+ ".mts": "ts",
51
+ ".cts": "ts",
52
+ ".py": "python",
53
+ ".go": "go",
54
+ ".pem": "keyfile",
55
+ ".key": "keyfile",
56
+ ".p8": "keyfile",
57
+ ".pk8": "keyfile",
58
+ ".p12": "keyfile",
59
+ ".pfx": "keyfile",
60
+ ".jks": "keyfile",
61
+ ".asc": "keyfile",
62
+ };
63
+
64
+ const KEY_FILENAMES = new Set(["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "server.key", "privkey.pem"]);
65
+
66
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
67
+
68
+ export function languageOf(filePath) {
69
+ const base = path.basename(filePath);
70
+ if (KEY_FILENAMES.has(base)) return "keyfile";
71
+ return LANG_BY_EXT[path.extname(filePath).toLowerCase()] ?? null;
72
+ }
73
+
74
+ const TEST_DIR_RE = /(^|\/)(tests?|__tests__|spec|specs|fixtures?|testdata|examples?|samples?|mocks?|__mocks__|e2e|demo|demos|docs)(\/|$)/i;
75
+ const TEST_FILE_RE = /(\.(test|spec)\.[jt]sx?$|_test\.go$|(^|\/)test_[^/]+\.py$|[^/]+_test\.py$|\.fixture\.)/i;
76
+
77
+ export function looksLikeTestPath(relPath) {
78
+ return TEST_DIR_RE.test(relPath) || TEST_FILE_RE.test(relPath);
79
+ }
80
+
81
+ /**
82
+ * Everything a rule gets to look at. `code` is the comment-masked copy — use it
83
+ * for almost everything; `text` is the raw file, for the few rules where a hit
84
+ * inside a comment still matters (a pasted private key, for example).
85
+ */
86
+ export function createFileContext({ filePath, relPath, text, lang }) {
87
+ const code = maskComments(text, lang);
88
+ const structure = maskLiterals(code, lang);
89
+ const lines = text.split("\n");
90
+ const lineStarts = [0];
91
+ for (let i = 0; i < text.length; i++) {
92
+ if (text[i] === "\n") lineStarts.push(i + 1);
93
+ }
94
+
95
+ function positionAt(index) {
96
+ let low = 0;
97
+ let high = lineStarts.length - 1;
98
+ while (low < high) {
99
+ const mid = (low + high + 1) >> 1;
100
+ if (lineStarts[mid] <= index) low = mid;
101
+ else high = mid - 1;
102
+ }
103
+ return { line: low + 1, column: index - lineStarts[low] + 1 };
104
+ }
105
+
106
+ return {
107
+ path: filePath,
108
+ relPath,
109
+ lang,
110
+ family: lang === "js" || lang === "ts" ? "jsts" : lang,
111
+ text,
112
+ code,
113
+ // Same as `code`, but with the contents of strings, template literals and
114
+ // regular expressions blanked out. Rules looking for real code structure
115
+ // use this so documentation and code samples do not become findings.
116
+ structure,
117
+ lines,
118
+ isTestPath: looksLikeTestPath(relPath),
119
+ positionAt,
120
+ lineAt: (index) => positionAt(index).line,
121
+ snippet: (line) => snippetAt(lines, line),
122
+ /** Text of the lines around `index`, for cheap context checks. */
123
+ window(index, before = 4, after = 4) {
124
+ const { line } = positionAt(index);
125
+ return lines.slice(Math.max(0, line - 1 - before), line + after).join("\n");
126
+ },
127
+ /** Text of the statement `index` sits in (bounded by line breaks/semicolons). */
128
+ lineOf(index) {
129
+ const { line } = positionAt(index);
130
+ return lines[line - 1] ?? "";
131
+ },
132
+ /**
133
+ * True when the character at `index` sits inside a string, template or
134
+ * regex literal — i.e. the match is documentation or a code sample rather
135
+ * than executable code. Rules use this to stay quiet on their own examples.
136
+ */
137
+ isMasked: (index) => code[index] !== " " && structure[index] === " ",
138
+ has: (re, source = code) => (typeof re === "string" ? source.includes(re) : re.test(source)),
139
+ matchAll: (re, source = code) => matchAll(source, re),
140
+ findCalls: (re, source = code) => findCalls(source, re),
141
+ balancedSpan: (index, source = code) => balancedSpan(source, index),
142
+ };
143
+ }
144
+
145
+ function isBinary(buffer) {
146
+ const limit = Math.min(buffer.length, 4096);
147
+ for (let i = 0; i < limit; i++) {
148
+ if (buffer[i] === 0) return true;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /** Recursively collect scannable files under `root`. */
154
+ export async function collectFiles(root, { ignore, skipDirs = DEFAULT_SKIP_DIRS, followSymlinks = false } = {}) {
155
+ const isIgnored = ignore ?? (() => false);
156
+ const files = [];
157
+ const errors = [];
158
+
159
+ async function walk(dir) {
160
+ let entries;
161
+ try {
162
+ entries = await readdir(dir, { withFileTypes: true });
163
+ } catch (err) {
164
+ errors.push({ path: dir, message: err.message });
165
+ return;
166
+ }
167
+ for (const entry of entries) {
168
+ const full = path.join(dir, entry.name);
169
+ const rel = path.relative(root, full).split(path.sep).join("/");
170
+ if (isIgnored(rel)) continue;
171
+ if (entry.isSymbolicLink() && !followSymlinks) continue;
172
+ if (entry.isDirectory()) {
173
+ if (skipDirs.has(entry.name)) continue;
174
+ await walk(full);
175
+ continue;
176
+ }
177
+ if (!entry.isFile()) continue;
178
+ const lang = languageOf(full);
179
+ if (!lang) continue;
180
+ files.push({ path: full, relPath: rel, lang });
181
+ }
182
+ }
183
+
184
+ const info = await stat(root);
185
+ if (info.isFile()) {
186
+ const rel = path.basename(root);
187
+ const lang = languageOf(root);
188
+ if (lang && !isIgnored(rel)) files.push({ path: root, relPath: rel, lang });
189
+ } else {
190
+ await walk(root);
191
+ }
192
+ files.sort((a, b) => a.relPath.localeCompare(b.relPath));
193
+ return { files, errors };
194
+ }
195
+
196
+ function ruleAppliesTo(rule, lang) {
197
+ if (!rule.languages || rule.languages === "any") return true;
198
+ return rule.languages.includes(lang);
199
+ }
200
+
201
+ /**
202
+ * Run the rule set over a directory (or a single file).
203
+ * Returns findings sorted by file/line plus the crypto inventory.
204
+ */
205
+ export async function scan(root, options = {}) {
206
+ const rules = options.rules ?? allRules;
207
+ const isIgnored = compileIgnore(options.ignore);
208
+ const { files, errors } = await collectFiles(root, { ignore: isIgnored });
209
+
210
+ const findings = [];
211
+ const inventory = new Map();
212
+ let scannedFiles = 0;
213
+ let skippedFiles = 0;
214
+
215
+ for (const file of files) {
216
+ let buffer;
217
+ try {
218
+ buffer = await readFile(file.path);
219
+ } catch (err) {
220
+ errors.push({ path: file.path, message: err.message });
221
+ continue;
222
+ }
223
+ // Key stores (.p12/.pfx) are binary but still worth reporting on, so the
224
+ // binary guard applies to source files only.
225
+ if (buffer.length > MAX_FILE_BYTES || (isBinary(buffer) && file.lang !== "keyfile")) {
226
+ skippedFiles++;
227
+ continue;
228
+ }
229
+ const ctx = createFileContext({
230
+ filePath: file.path,
231
+ relPath: file.relPath,
232
+ text: isBinary(buffer) ? "" : buffer.toString("utf8"),
233
+ lang: file.lang,
234
+ });
235
+ scannedFiles++;
236
+
237
+ for (const rule of rules) {
238
+ if (!ruleAppliesTo(rule, ctx.lang)) continue;
239
+ let produced;
240
+ try {
241
+ produced = rule.kind === "inventory" ? rule.collect(ctx) : rule.match(ctx);
242
+ } catch (err) {
243
+ errors.push({ path: file.path, message: `rule ${rule.id} failed: ${err.message}` });
244
+ continue;
245
+ }
246
+ if (!produced) continue;
247
+ if (rule.kind === "inventory") {
248
+ for (const item of produced) {
249
+ const key = `${item.kind}:${item.name}`;
250
+ const existing = inventory.get(key) ?? { ...item, files: new Set() };
251
+ existing.files.add(ctx.relPath);
252
+ if (item.detail && !existing.detail) existing.detail = item.detail;
253
+ inventory.set(key, existing);
254
+ }
255
+ continue;
256
+ }
257
+ for (const raw of produced) {
258
+ const position = raw.index !== undefined ? ctx.positionAt(raw.index) : { line: raw.line, column: raw.column ?? 1 };
259
+ findings.push({
260
+ ruleId: rule.id,
261
+ title: rule.title,
262
+ severity: raw.severity ?? rule.severity,
263
+ confidence: raw.confidence ?? rule.confidence,
264
+ message: raw.message,
265
+ note: raw.note ?? rule.note,
266
+ why: rule.why,
267
+ fix: rule.fix,
268
+ docs: rule.docs,
269
+ file: ctx.path,
270
+ relPath: ctx.relPath,
271
+ line: position.line,
272
+ column: position.column,
273
+ snippet: raw.snippet ?? ctx.snippet(position.line),
274
+ });
275
+ }
276
+ }
277
+ }
278
+
279
+ // One finding per rule/file/line/column — rules that scan both raw and masked
280
+ // text can otherwise report the same spot twice.
281
+ const seen = new Set();
282
+ const deduped = findings.filter((f) => {
283
+ const key = `${f.ruleId}|${f.relPath}|${f.line}|${f.column}`;
284
+ if (seen.has(key)) return false;
285
+ seen.add(key);
286
+ return true;
287
+ });
288
+
289
+ deduped.sort(
290
+ (a, b) => a.relPath.localeCompare(b.relPath) || a.line - b.line || a.column - b.column || a.ruleId.localeCompare(b.ruleId)
291
+ );
292
+
293
+ return {
294
+ root,
295
+ findings: deduped,
296
+ inventory: [...inventory.values()]
297
+ .map((item) => ({ kind: item.kind, name: item.name, detail: item.detail, files: [...item.files].sort() }))
298
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)),
299
+ stats: { scannedFiles, skippedFiles, totalCandidates: files.length },
300
+ errors,
301
+ };
302
+ }
303
+
304
+ /** Keep only findings at or above `min` confidence. */
305
+ export function filterByConfidence(findings, min) {
306
+ const floor = CONFIDENCE_ORDER[min] ?? 0;
307
+ return findings.filter((f) => (CONFIDENCE_ORDER[f.confidence] ?? 0) >= floor);
308
+ }