surfscan 0.0.0-stage → 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.
@@ -0,0 +1,2883 @@
1
+ // src/core/types.ts
2
+ var SEVERITIES = ["high", "medium", "low", "info"];
3
+ var SEVERITY_RANK = {
4
+ high: 3,
5
+ medium: 2,
6
+ low: 1,
7
+ info: 0
8
+ };
9
+ function isSeverity(value) {
10
+ return typeof value === "string" && SEVERITIES.includes(value);
11
+ }
12
+
13
+ // src/core/runner.ts
14
+ var DEFAULT_IGNORE_PATHS = ["**/node_modules/**", "**/.git/**"];
15
+ function compareFindings(a, b) {
16
+ return SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || a.file.localeCompare(b.file) || a.line - b.line || (a.column ?? 0) - (b.column ?? 0) || a.ruleId.localeCompare(b.ruleId);
17
+ }
18
+ function sortFindings(findings) {
19
+ return [...findings].sort(compareFindings);
20
+ }
21
+ function listTargets(provider, config, patterns, extraIgnore = []) {
22
+ return provider.list(patterns, [...DEFAULT_IGNORE_PATHS, ...extraIgnore, ...config.ignorePaths]);
23
+ }
24
+ async function runScanner(scanner, opts) {
25
+ const warnings = [];
26
+ let incomplete = false;
27
+ const paths = await scanner.findTargets(opts.provider, opts.config);
28
+ for (const link of opts.provider.takeSkippedLinks?.() ?? []) {
29
+ warnings.push(`${link}: not scanned, because surfscan does not follow links to folders or to files outside the project`);
30
+ incomplete = true;
31
+ }
32
+ const readOptions = scanner.maxFileBytes === void 0 ? void 0 : { maxBytes: scanner.maxFileBytes };
33
+ const files = [];
34
+ for (const path of paths) {
35
+ try {
36
+ const content = await opts.provider.read(path, readOptions);
37
+ if (content !== void 0) files.push({ path, content });
38
+ } catch (err) {
39
+ warnings.push(`${path}: skipped (${err.message})`);
40
+ incomplete = true;
41
+ }
42
+ }
43
+ const context = {
44
+ files,
45
+ config: opts.config,
46
+ provider: opts.provider,
47
+ options: opts.options ?? {},
48
+ warn: (message, warnOpts) => {
49
+ warnings.push(message);
50
+ if (warnOpts?.incomplete) incomplete = true;
51
+ }
52
+ };
53
+ const ignored = new Set(opts.config.ignore);
54
+ const findings = [];
55
+ for (const rule of scanner.rules) {
56
+ if (ignored.has(rule.id)) continue;
57
+ try {
58
+ findings.push(...await rule.check(context));
59
+ } catch (err) {
60
+ warnings.push(`${rule.id}: rule failed (${err.message})`);
61
+ incomplete = true;
62
+ }
63
+ }
64
+ return { findings: sortFindings(findings), warnings, incomplete };
65
+ }
66
+ async function runScanners(scanners, opts) {
67
+ const findings = [];
68
+ const warnings = [];
69
+ let incomplete = false;
70
+ for (const scanner of scanners) {
71
+ const result = await runScanner(scanner, opts);
72
+ findings.push(...result.findings);
73
+ warnings.push(...result.warnings);
74
+ incomplete ||= result.incomplete;
75
+ }
76
+ return { findings: sortFindings(findings), warnings, incomplete };
77
+ }
78
+ function summarize(findings) {
79
+ const summary = { high: 0, medium: 0, low: 0, info: 0 };
80
+ for (const f of findings) summary[f.severity]++;
81
+ return summary;
82
+ }
83
+ function hasFailures(findings, failOn) {
84
+ return findings.some((f) => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[failOn]);
85
+ }
86
+
87
+ // src/core/text.ts
88
+ var LineIndex = class {
89
+ constructor(text) {
90
+ this.text = text;
91
+ for (let i = 0; i < text.length; i++) {
92
+ if (text.charCodeAt(i) === 10) this.starts.push(i + 1);
93
+ }
94
+ }
95
+ text;
96
+ starts = [0];
97
+ positionOf(offset) {
98
+ let lo = 0;
99
+ let hi = this.starts.length - 1;
100
+ while (lo < hi) {
101
+ const mid = lo + hi + 1 >> 1;
102
+ if (this.starts[mid] <= offset) lo = mid;
103
+ else hi = mid - 1;
104
+ }
105
+ return { line: lo + 1, column: offset - this.starts[lo] + 1 };
106
+ }
107
+ get lineCount() {
108
+ return this.starts.length;
109
+ }
110
+ /** Line text without the trailing newline (or \r\n). */
111
+ lineText(line) {
112
+ const start = this.starts[line - 1];
113
+ if (start === void 0) return "";
114
+ const next = this.starts[line];
115
+ let end = next === void 0 ? this.text.length : next - 1;
116
+ if (end > start && this.text.charCodeAt(end - 1) === 13) end--;
117
+ return this.text.slice(start, end);
118
+ }
119
+ lineStart(line) {
120
+ return this.starts[line - 1] ?? this.text.length;
121
+ }
122
+ };
123
+ function stripBom(text) {
124
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
125
+ }
126
+ function excerpt(text, max = 80) {
127
+ const oneLine = text.replace(/\s+/g, " ").trim();
128
+ return oneLine.length > max ? `${oneLine.slice(0, max - 3)}...` : oneLine;
129
+ }
130
+ function codePointLabel(cp) {
131
+ return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
132
+ }
133
+
134
+ // src/core/config.ts
135
+ var CONFIG_FILE_NAME = "surfscan.json";
136
+ var DEFAULT_CONFIG = {
137
+ ignore: [],
138
+ ignorePaths: [],
139
+ deps: { minAgeDays: 30 }
140
+ };
141
+ var ConfigError = class extends Error {
142
+ constructor(message) {
143
+ super(message);
144
+ this.name = "ConfigError";
145
+ }
146
+ };
147
+ var TOP_LEVEL_KEYS = ["ignore", "ignorePaths", "deps"];
148
+ var DEPS_KEYS = ["minAgeDays"];
149
+ var RULE_ID = /^[A-Z]+-\d{3}$/;
150
+ function isPlainObject(value) {
151
+ return typeof value === "object" && value !== null && !Array.isArray(value);
152
+ }
153
+ function checkKeys(obj, allowed, where, source) {
154
+ for (const key of Object.keys(obj)) {
155
+ if (!allowed.includes(key)) {
156
+ throw new ConfigError(
157
+ `${source}: unknown key "${where}${key}". Allowed keys: ${allowed.map((k) => where + k).join(", ")}`
158
+ );
159
+ }
160
+ }
161
+ }
162
+ function stringArray(value, name, source) {
163
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
164
+ throw new ConfigError(`${source}: "${name}" must be an array of strings`);
165
+ }
166
+ return value;
167
+ }
168
+ function validateConfig(raw, source = CONFIG_FILE_NAME) {
169
+ if (!isPlainObject(raw)) {
170
+ throw new ConfigError(`${source}: config must be a JSON object`);
171
+ }
172
+ checkKeys(raw, TOP_LEVEL_KEYS, "", source);
173
+ const config = {
174
+ ignore: [...DEFAULT_CONFIG.ignore],
175
+ ignorePaths: [...DEFAULT_CONFIG.ignorePaths],
176
+ deps: { ...DEFAULT_CONFIG.deps }
177
+ };
178
+ if (raw.ignore !== void 0) {
179
+ config.ignore = stringArray(raw.ignore, "ignore", source);
180
+ for (const id of config.ignore) {
181
+ if (!RULE_ID.test(id)) {
182
+ throw new ConfigError(`${source}: "ignore" entry "${id}" is not a rule ID (expected something like RULES-001)`);
183
+ }
184
+ }
185
+ }
186
+ if (raw.ignorePaths !== void 0) {
187
+ config.ignorePaths = stringArray(raw.ignorePaths, "ignorePaths", source);
188
+ }
189
+ if (raw.deps !== void 0) {
190
+ if (!isPlainObject(raw.deps)) {
191
+ throw new ConfigError(`${source}: "deps" must be an object`);
192
+ }
193
+ checkKeys(raw.deps, DEPS_KEYS, "deps.", source);
194
+ const { minAgeDays } = raw.deps;
195
+ if (minAgeDays !== void 0) {
196
+ if (typeof minAgeDays !== "number" || !Number.isInteger(minAgeDays) || minAgeDays < 0) {
197
+ throw new ConfigError(`${source}: "deps.minAgeDays" must be a non-negative integer`);
198
+ }
199
+ config.deps.minAgeDays = minAgeDays;
200
+ }
201
+ }
202
+ return config;
203
+ }
204
+ function parseConfig(text, source = CONFIG_FILE_NAME) {
205
+ let raw;
206
+ try {
207
+ raw = JSON.parse(stripBom(text));
208
+ } catch (err) {
209
+ throw new ConfigError(`${source}: invalid JSON (${err.message})`);
210
+ }
211
+ return validateConfig(raw, source);
212
+ }
213
+ async function loadConfig(read, path, required) {
214
+ const text = await read(path);
215
+ if (text === void 0) {
216
+ if (required) throw new ConfigError(`config file not found: ${path}`);
217
+ return validateConfig({});
218
+ }
219
+ return parseConfig(text, path);
220
+ }
221
+
222
+ // src/core/node-provider.ts
223
+ import { readFile, realpath, stat } from "fs/promises";
224
+ import { isAbsolute, join, relative, sep } from "path";
225
+ import fg from "fast-glob";
226
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
227
+ var AGENT_FOLDERS = "**/.{claude,codex,cursor,github,surfscan,vscode}";
228
+ var UTF16_LE = [255, 254];
229
+ var UTF16_BE = [254, 255];
230
+ var startsWith = (bytes, mark) => mark.every((b, i) => bytes[i] === b);
231
+ function decode(bytes) {
232
+ if (startsWith(bytes, UTF16_LE)) return new TextDecoder("utf-16le").decode(bytes);
233
+ if (startsWith(bytes, UTF16_BE)) return new TextDecoder("utf-16be").decode(bytes);
234
+ return bytes.toString("utf8");
235
+ }
236
+ function createNodeProvider(root) {
237
+ const pendingLinks = /* @__PURE__ */ new Set();
238
+ const reportedLinks = /* @__PURE__ */ new Set();
239
+ async function resolveLinks(patterns, ignore, paths) {
240
+ const entries = await fg([...patterns, AGENT_FOLDERS], {
241
+ cwd: root,
242
+ dot: true,
243
+ onlyFiles: false,
244
+ objectMode: true,
245
+ followSymbolicLinks: false,
246
+ suppressErrors: true,
247
+ ignore
248
+ });
249
+ const realRoot = await realpath(root);
250
+ const extra = [];
251
+ for (const entry of entries) {
252
+ if (!entry.dirent.isSymbolicLink() || paths.includes(entry.path)) continue;
253
+ let target;
254
+ let isFile;
255
+ try {
256
+ target = await realpath(join(root, entry.path));
257
+ isFile = (await stat(target)).isFile();
258
+ } catch {
259
+ continue;
260
+ }
261
+ const inside = relative(realRoot, target);
262
+ if (isFile && !isAbsolute(inside) && !inside.startsWith(`..${sep}`)) {
263
+ if (!paths.includes(inside.split(sep).join("/"))) extra.push(entry.path);
264
+ } else if (!reportedLinks.has(entry.path)) {
265
+ reportedLinks.add(entry.path);
266
+ pendingLinks.add(entry.path);
267
+ }
268
+ }
269
+ return extra;
270
+ }
271
+ return {
272
+ async list(patterns, ignore) {
273
+ const paths = await fg(patterns, {
274
+ cwd: root,
275
+ dot: true,
276
+ onlyFiles: true,
277
+ unique: true,
278
+ followSymbolicLinks: false,
279
+ suppressErrors: true,
280
+ ignore
281
+ });
282
+ return [...paths, ...await resolveLinks(patterns, ignore, paths)].sort();
283
+ },
284
+ async read(path, opts) {
285
+ const abs = join(root, path);
286
+ const limit = opts?.maxBytes ?? MAX_FILE_BYTES;
287
+ try {
288
+ const info = await stat(abs);
289
+ if (!info.isFile()) return void 0;
290
+ if (info.size > limit) {
291
+ throw new Error(`larger than ${limit} bytes`);
292
+ }
293
+ return decode(await readFile(abs));
294
+ } catch (err) {
295
+ if (err.code === "ENOENT") return void 0;
296
+ throw err;
297
+ }
298
+ },
299
+ takeSkippedLinks() {
300
+ const links = [...pendingLinks];
301
+ pendingLinks.clear();
302
+ return links;
303
+ }
304
+ };
305
+ }
306
+
307
+ // src/core/secret-patterns.ts
308
+ var SECRET_PATTERNS = [
309
+ { name: "AWS access key ID", regex: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
310
+ { name: "Stripe live key", regex: /\b(?:sk|rk)_live_[0-9A-Za-z]{10,}\b/g },
311
+ { name: "GitHub token", regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[0-9A-Za-z]{30,}\b/g },
312
+ { name: "GitHub fine-grained token", regex: /\bgithub_pat_[0-9A-Za-z_]{30,}\b/g },
313
+ { name: "Slack token", regex: /\bxox[abposr]-[0-9A-Za-z-]{10,}\b/g },
314
+ { name: "Anthropic API key", regex: /\bsk-ant-[0-9A-Za-z_-]{20,}/g },
315
+ { name: "OpenAI API key", regex: /\bsk-(?:proj|svcacct|admin)-[0-9A-Za-z_-]{20,}|\bsk-[0-9A-Za-z]{32,}\b/g },
316
+ { name: "Google API key", regex: /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g },
317
+ { name: "npm token", regex: /\bnpm_[0-9A-Za-z]{36}\b/g },
318
+ { name: "GitLab token", regex: /\bglpat-[0-9A-Za-z_-]{20,}/g },
319
+ { name: "Hugging Face token", regex: /\bhf_[0-9A-Za-z]{30,}\b/g },
320
+ { name: "SendGrid API key", regex: /\bSG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43}(?![0-9A-Za-z_-])/g },
321
+ { name: "JSON Web Token", regex: /\beyJ[0-9A-Za-z_-]{8,}\.eyJ[0-9A-Za-z_-]{8,}\.[0-9A-Za-z_-]{8,}/g },
322
+ {
323
+ name: "PEM private key",
324
+ regex: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )*PRIVATE KEY-----|$)/g
325
+ }
326
+ ];
327
+ var HIDDEN = "[redacted]";
328
+ var NOT_A_VALUE = /^(?:\$|<|\{|%)|\.\.\.$|^\[redacted\]$/;
329
+ var SECRET_IN_CONTEXT = [
330
+ // Authorization: Bearer x. First, or the next pattern would take "Bearer" for the value.
331
+ /(\b(?:Bearer|Basic|Token)\s+)([A-Za-z0-9._~+/=-]{8,})/g,
332
+ // password=x "api_key": "x" the token is x
333
+ /(\b[\w-]*(?:password|passwd|pwd|secret|token|api[_-]?key|credential|auth)[\w-]*["']?\s*(?:=|:|\s(?:is|was)\s)\s*["']?)([^\s"',;)]{4,})/gi,
334
+ // --token x --password=x
335
+ /(--[\w-]*(?:password|passwd|token|api-?key|secret|auth)[\w-]*[= ]+["']?)([^\s"',;)]{4,})/gi,
336
+ // curl -u user:password
337
+ /((?:^|\s)(?:-u|--user)[ =]+["']?[^\s:"']+:)([^\s"']+)/g,
338
+ // https://user:password@host
339
+ /(:\/\/[^\s:/@]+:)([^\s@/]+)(?=@)/g
340
+ ];
341
+ var OPAQUE_TOKEN = /(?<![\w+/-])[A-Za-z0-9_+-]{32,}(?![\w+/-])/g;
342
+ function hideOpaqueToken(token) {
343
+ return /[A-Z]/.test(token) && /[a-z]/.test(token) && /\d/.test(token) ? maskSecret(token) : token;
344
+ }
345
+ function maskSecret(value) {
346
+ return `${value.slice(0, 4)}...`;
347
+ }
348
+ function redactSecrets(text) {
349
+ let out = text;
350
+ for (const { regex } of SECRET_PATTERNS) {
351
+ out = out.replace(new RegExp(regex.source, regex.flags), (m) => maskSecret(m));
352
+ }
353
+ for (const regex of SECRET_IN_CONTEXT) {
354
+ out = out.replace(
355
+ new RegExp(regex.source, regex.flags),
356
+ (whole, keep, value) => NOT_A_VALUE.test(value) ? whole : `${keep}${HIDDEN}`
357
+ );
358
+ }
359
+ return out.replace(OPAQUE_TOKEN, hideOpaqueToken);
360
+ }
361
+
362
+ // src/core/report/sanitize.ts
363
+ var UNSAFE = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F­؜᠎​-‏‪-‮⁠-⁤⁦-⁩]|[\u{E0000}-\u{E007F}]/gu;
364
+ function escapeUnsafe(text) {
365
+ return text.replace(UNSAFE, (ch) => {
366
+ const cp = ch.codePointAt(0);
367
+ return cp > 65535 ? `\\u{${cp.toString(16).toUpperCase()}}` : `\\u${cp.toString(16).toUpperCase().padStart(4, "0")}`;
368
+ });
369
+ }
370
+ function redactFinding(f) {
371
+ return {
372
+ ...f,
373
+ message: redactSecrets(f.message),
374
+ fix: redactSecrets(f.fix),
375
+ file: redactSecrets(f.file)
376
+ };
377
+ }
378
+
379
+ // src/core/report/text.ts
380
+ var COLORS = {
381
+ high: "\x1B[31m",
382
+ // red
383
+ medium: "\x1B[33m",
384
+ // yellow
385
+ low: "\x1B[36m",
386
+ // cyan
387
+ info: "\x1B[90m"
388
+ // grey
389
+ };
390
+ var RESET = "\x1B[0m";
391
+ var DIM = "\x1B[2m";
392
+ function location(f) {
393
+ return f.line > 0 ? `${f.file}:${f.line}` : f.file;
394
+ }
395
+ function summaryLine(findings) {
396
+ if (findings.length === 0) return "No findings";
397
+ const summary = summarize(findings);
398
+ const parts = SEVERITIES.filter((s) => summary[s] > 0).map((s) => `${summary[s]} ${s}`);
399
+ const noun = findings.length === 1 ? "finding" : "findings";
400
+ return `${findings.length} ${noun} (${parts.join(", ")})`;
401
+ }
402
+ function formatText(findings, opts) {
403
+ const safe = findings.map((f) => {
404
+ const r = redactFinding(f);
405
+ return { ...r, file: escapeUnsafe(r.file), message: escapeUnsafe(r.message), fix: escapeUnsafe(r.fix) };
406
+ });
407
+ const locWidth = Math.max(0, ...safe.map((f) => location(f).length));
408
+ const idWidth = Math.max(0, ...safe.map((f) => f.ruleId.length));
409
+ const lines = [];
410
+ for (const f of safe) {
411
+ const sev = `${f.severity.toUpperCase().padEnd(6)} `;
412
+ const sevText = opts.color ? `${COLORS[f.severity]}${sev}${RESET}` : sev;
413
+ lines.push(`${location(f).padEnd(locWidth)} ${sevText}${f.ruleId.padEnd(idWidth)} ${f.message}`);
414
+ lines.push(opts.color ? ` ${DIM}Fix: ${f.fix}${RESET}` : ` Fix: ${f.fix}`);
415
+ }
416
+ lines.push(summaryLine(findings));
417
+ if (opts.incomplete) lines.push("Incomplete: some checks could not run, so this result is not a clean bill of health (see the warnings)");
418
+ return `${lines.join("\n")}
419
+ `;
420
+ }
421
+
422
+ // src/core/report/json.ts
423
+ var JSON_REPORT_VERSION = 1;
424
+ function toJsonReport(findings, meta = {}) {
425
+ return {
426
+ version: JSON_REPORT_VERSION,
427
+ summary: summarize(findings),
428
+ complete: !meta.incomplete,
429
+ warnings: (meta.warnings ?? []).map((w) => escapeUnsafe(redactSecrets(w))),
430
+ findings: findings.map((f) => {
431
+ const r = redactFinding(f);
432
+ const out = r.column === void 0 ? { ruleId: r.ruleId, severity: r.severity, message: r.message, file: r.file, line: r.line, fix: r.fix } : { ruleId: r.ruleId, severity: r.severity, message: r.message, file: r.file, line: r.line, column: r.column, fix: r.fix };
433
+ return out;
434
+ })
435
+ };
436
+ }
437
+ function formatJson(findings, meta = {}) {
438
+ return `${JSON.stringify(toJsonReport(findings, meta), null, 2)}
439
+ `;
440
+ }
441
+
442
+ // src/scanners/shared.ts
443
+ function findingsFromMatches(rule, file, matches, message, fix, index = new LineIndex(file.content)) {
444
+ const seenLines = /* @__PURE__ */ new Set();
445
+ const out = [];
446
+ for (const m of [...matches].sort((a, b) => a.index - b.index)) {
447
+ const pos = index.positionOf(m.index);
448
+ if (seenLines.has(pos.line)) continue;
449
+ seenLines.add(pos.line);
450
+ out.push({
451
+ ruleId: rule.id,
452
+ severity: rule.severity,
453
+ message: message(m.detail),
454
+ file: file.path,
455
+ line: pos.line,
456
+ column: pos.column,
457
+ fix
458
+ });
459
+ }
460
+ return out;
461
+ }
462
+ function* matchAll(regex, text) {
463
+ const flags = regex.flags.includes("g") ? regex.flags : `${regex.flags}g`;
464
+ const re = new RegExp(regex.source, flags);
465
+ let m;
466
+ while ((m = re.exec(text)) !== null) {
467
+ yield m;
468
+ if (m[0].length === 0) re.lastIndex++;
469
+ }
470
+ }
471
+
472
+ // src/scanners/rules/patterns.ts
473
+ var CLAUSE_BOUNDARY = /[.!?;\n]/;
474
+ function clauseBefore(text, index) {
475
+ let start = index;
476
+ while (start > 0 && index - start < 60 && !CLAUSE_BOUNDARY.test(text[start - 1])) start--;
477
+ return text.slice(start, index);
478
+ }
479
+ var NEGATION = /\b(?:never|not|no|avoid|refuse|forbidden|prohibited)\b|n't\b/gi;
480
+ var PERMISSIVE = /\b(?:hesitate|forget|fail|need|afraid|worry|reason|problem|wait)\b/i;
481
+ var NEW_ACTION = /\b(?:but|then|instead|just|and)\b|:/i;
482
+ var CONDITION = /\b(?:if|when|unless|once|after|before|while)\b/i;
483
+ var MAX_WORDS_AFTER_NEGATION = 4;
484
+ var wordCount = (text) => text.split(/\s+/).filter(Boolean).length;
485
+ function isNegated(text, index) {
486
+ const before = clauseBefore(text, index);
487
+ const negation = [...before.matchAll(NEGATION)].at(-1);
488
+ if (!negation) return false;
489
+ const between = before.slice(negation.index + negation[0].length);
490
+ if (PERMISSIVE.test(between) || NEW_ACTION.test(between)) return false;
491
+ const comma = between.indexOf(",");
492
+ if (comma === -1) return wordCount(between) <= MAX_WORDS_AFTER_NEGATION;
493
+ const isList = /\b(?:or|nor)\b/i.test(text.slice(index, index + 40));
494
+ return isList && wordCount(between.slice(0, comma)) <= 2 && !CONDITION.test(before.slice(0, negation.index));
495
+ }
496
+ var INVISIBLE_NAMES = {
497
+ 8203: "ZERO WIDTH SPACE",
498
+ 8204: "ZERO WIDTH NON-JOINER",
499
+ 8205: "ZERO WIDTH JOINER",
500
+ 8206: "LEFT-TO-RIGHT MARK",
501
+ 8207: "RIGHT-TO-LEFT MARK",
502
+ 8288: "WORD JOINER",
503
+ 65279: "ZERO WIDTH NO-BREAK SPACE",
504
+ 8234: "LEFT-TO-RIGHT EMBEDDING",
505
+ 8235: "RIGHT-TO-LEFT EMBEDDING",
506
+ 8236: "POP DIRECTIONAL FORMATTING",
507
+ 8237: "LEFT-TO-RIGHT OVERRIDE",
508
+ 8238: "RIGHT-TO-LEFT OVERRIDE",
509
+ 8294: "LEFT-TO-RIGHT ISOLATE",
510
+ 8295: "RIGHT-TO-LEFT ISOLATE",
511
+ 8296: "FIRST STRONG ISOLATE",
512
+ 8297: "POP DIRECTIONAL ISOLATE"
513
+ };
514
+ var INVISIBLE = /[\u200B-\u200F\u2060\uFEFF\u202A-\u202E\u2066-\u2069]|[\u{E0000}-\u{E007F}]/gu;
515
+ var TAG_RUN = /[\u{E0000}-\u{E007F}]+/gu;
516
+ var BEFORE_EMOJI_JOINER = new RegExp("(?:\\p{Extended_Pictographic}|\\uFE0F|[\\u{1F3FB}-\\u{1F3FF}])$", "u");
517
+ var AFTER_EMOJI_JOINER = new RegExp("^\\p{Extended_Pictographic}", "u");
518
+ function isEmojiJoiner(text, index) {
519
+ return text.charCodeAt(index) === 8205 && BEFORE_EMOJI_JOINER.test(text.slice(Math.max(0, index - 4), index)) && AFTER_EMOJI_JOINER.test(text.slice(index + 1, index + 3));
520
+ }
521
+ function findInvisibleChars(text) {
522
+ const byLine = /* @__PURE__ */ new Map();
523
+ let lineNo = 0;
524
+ let lastScanned = 0;
525
+ for (const m of matchAll(INVISIBLE, text)) {
526
+ if (m.index === 0 && m[0] === "\uFEFF") continue;
527
+ if (isEmojiJoiner(text, m.index)) continue;
528
+ for (let i = lastScanned; i < m.index; i++) if (text.charCodeAt(i) === 10) lineNo++;
529
+ lastScanned = m.index;
530
+ const cp = m[0].codePointAt(0);
531
+ const existing = byLine.get(lineNo);
532
+ if (existing) {
533
+ if (!existing.codePoints.includes(cp)) existing.codePoints.push(cp);
534
+ } else {
535
+ byLine.set(lineNo, { index: m.index, detail: "", codePoints: [cp] });
536
+ }
537
+ }
538
+ const out = [...byLine.values()];
539
+ for (const match of out) {
540
+ const tags = match.codePoints.filter((cp) => cp >= 917504);
541
+ const named = match.codePoints.filter((cp) => cp < 917504).map((cp) => `${codePointLabel(cp)} ${INVISIBLE_NAMES[cp] ?? ""}`.trim());
542
+ const parts = [...named];
543
+ if (tags.length > 0) {
544
+ const lineEnd = text.indexOf("\n", match.index);
545
+ const lineText = text.slice(match.index, lineEnd === -1 ? void 0 : lineEnd);
546
+ const decoded = [...lineText.matchAll(TAG_RUN)].map((r) => [...r[0]].map((ch) => decodeTag(ch.codePointAt(0))).join("")).join(" ");
547
+ parts.push(`Unicode tag characters decoding to "${excerpt(decoded, 60)}"`);
548
+ }
549
+ match.detail = parts.join(", ");
550
+ }
551
+ return out;
552
+ }
553
+ function decodeTag(cp) {
554
+ const ascii = cp - 917504;
555
+ return ascii >= 32 && ascii < 127 ? String.fromCharCode(ascii) : "";
556
+ }
557
+ var DIRECTIVE = /(?<![-\w])(?:you (?:must|should|are|will|need to|have to)|ignore|disregard|forget|pretend|instructions?|assistant|agents?|ai|llm|claude|copilot|cursor|codex|gemini|system prompt|run|execute|install|download|upload|send|post|fetch|curl|wget|delete|exfiltrate|secrets?|passwords?|tokens?|credentials?|api[ _-]?keys?|ssh|(?:do not|don't|never) (?:tell|mention|reveal|inform))(?![-\w])|\.env\b/i;
558
+ var HTML_COMMENT = /<!--([\s\S]*?)(?:-->|$)/g;
559
+ var MD_COMMENT = /^[ \t]*\[(?:\/\/|comment|_)\]:\s*(?:#|<>)\s*(?:\((.*)\)|"(.*)"|'(.*)')[ \t]*$/gim;
560
+ var TAG_NAME = /[a-z][a-z0-9-]*\b/iy;
561
+ var HIDDEN_ATTRIBUTE = /\shidden(?=[\s/>=])/i;
562
+ var STYLE_ATTRIBUTE = /\sstyle\s*=\s*(["'])/gi;
563
+ var HIDING_STYLE = /display\s*:\s*none|visibility\s*:\s*hidden|font-size\s*:\s*0(?![.\d]*[1-9])|opacity\s*:\s*0(?![.\d]*[1-9])/i;
564
+ function tagEnd(text, from) {
565
+ let quote = "";
566
+ for (let i = from; i < text.length; i++) {
567
+ const ch = text[i];
568
+ if (quote) {
569
+ if (ch === quote) quote = "";
570
+ } else if (ch === '"' || ch === "'") {
571
+ quote = ch;
572
+ } else if (ch === ">") {
573
+ return i;
574
+ }
575
+ }
576
+ return text.indexOf(">", from);
577
+ }
578
+ function hidesContent(tag) {
579
+ if (HIDDEN_ATTRIBUTE.test(tag)) return true;
580
+ STYLE_ATTRIBUTE.lastIndex = 0;
581
+ let m;
582
+ while ((m = STYLE_ATTRIBUTE.exec(tag)) !== null) {
583
+ const close = tag.indexOf(m[1], STYLE_ATTRIBUTE.lastIndex);
584
+ const value = tag.slice(STYLE_ATTRIBUTE.lastIndex, close === -1 ? void 0 : close);
585
+ if (HIDING_STYLE.test(value)) return true;
586
+ if (close === -1) return false;
587
+ STYLE_ATTRIBUTE.lastIndex = close + 1;
588
+ }
589
+ return false;
590
+ }
591
+ function findHiddenTags(text) {
592
+ const out = [];
593
+ let at = text.indexOf("<");
594
+ while (at !== -1) {
595
+ TAG_NAME.lastIndex = at + 1;
596
+ if (!TAG_NAME.test(text)) {
597
+ at = text.indexOf("<", at + 1);
598
+ continue;
599
+ }
600
+ const end = tagEnd(text, TAG_NAME.lastIndex);
601
+ if (end === -1) break;
602
+ const tag = text.slice(at, end + 1);
603
+ if (hidesContent(tag)) out.push({ index: at, detail: `hidden markup: ${excerpt(tag, 60)}` });
604
+ at = text.indexOf("<", end + 1);
605
+ }
606
+ return out;
607
+ }
608
+ function findHiddenContent(text) {
609
+ const out = [];
610
+ for (const m of matchAll(HTML_COMMENT, text)) {
611
+ const body = m[1] ?? "";
612
+ if (DIRECTIVE.test(body)) {
613
+ out.push({ index: m.index, detail: `HTML comment: "${excerpt(body, 60)}"` });
614
+ }
615
+ }
616
+ for (const m of matchAll(MD_COMMENT, text)) {
617
+ const body = m[1] ?? m[2] ?? m[3] ?? "";
618
+ if (DIRECTIVE.test(body)) {
619
+ out.push({ index: m.index, detail: `Markdown comment: "${excerpt(body, 60)}"` });
620
+ }
621
+ }
622
+ out.push(...findHiddenTags(text));
623
+ return out;
624
+ }
625
+ var SHELLS = "(?:sudo\\s+)?(?:ba|z|k|da|fi)?sh|python[0-9.]*|node|perl|ruby|pwsh|powershell|iex|invoke-expression";
626
+ var DOWNLOADER = /\b(?:curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b/gi;
627
+ var INTO_SHELL = new RegExp(`\\|\\s*(?:${SHELLS})\\b`, "iy");
628
+ var SHELL_AROUND_DOWNLOAD = new RegExp(
629
+ [
630
+ // bash <(curl ...) sh -c "$(curl ...)"
631
+ `\\b(?:(?:ba|z|k|da)?sh|source)\\s+(?:-c\\s+)?["']?(?:<\\(|\\$\\()\\s*(?:curl|wget)\\b`,
632
+ // iex (iwr ...) Invoke-Expression (New-Object Net.WebClient).DownloadString(...)
633
+ `\\b(?:iex|invoke-expression)\\s*(?:\\(\\s*){0,2}(?:iwr|irm|invoke-webrequest|invoke-restmethod|new-object\\s+net\\.webclient)\\b`
634
+ ].join("|"),
635
+ "gi"
636
+ );
637
+ function findDownloadPipes(text) {
638
+ const out = [];
639
+ const downloader = new RegExp(DOWNLOADER.source, DOWNLOADER.flags);
640
+ let m;
641
+ while ((m = downloader.exec(text)) !== null) {
642
+ let i = m.index + m[0].length;
643
+ let found = false;
644
+ for (; i < text.length && text[i] !== "\n"; i++) {
645
+ if (text[i] !== "|") continue;
646
+ if (text[i + 1] === "|") {
647
+ i++;
648
+ continue;
649
+ }
650
+ INTO_SHELL.lastIndex = i;
651
+ const shell = INTO_SHELL.exec(text);
652
+ if (shell) {
653
+ i += shell[0].length;
654
+ out.push({ index: m.index, detail: excerpt(text.slice(m.index, i), 70) });
655
+ found = true;
656
+ break;
657
+ }
658
+ }
659
+ downloader.lastIndex = found ? i : i + 1;
660
+ }
661
+ return out;
662
+ }
663
+ function findPipeToShell(text) {
664
+ const around = [...matchAll(SHELL_AROUND_DOWNLOAD, text)].map((m) => ({ index: m.index, detail: excerpt(m[0], 70) }));
665
+ return [...findDownloadPipes(text), ...around].sort((a, b) => a.index - b.index);
666
+ }
667
+ var CRED_VERBS = "read|cat|open|print|output|echo|display|show|reveal|include|send|post|upload|exfiltrate|transmit|forward|copy|paste|share|e-?mail|leak|dump|collect|log|attach|embed|base64|curl|submit|report|grab|extract|access";
668
+ var CRED_TARGETS = [
669
+ "\\.env(?!\\.(?:example|sample|template|dist)\\b)(?:\\.[\\w-]+)*\\b",
670
+ "env(?:ironment)? files?",
671
+ "environment variables",
672
+ "process\\.env",
673
+ "~?/?\\.ssh\\b",
674
+ "id_(?:rsa|dsa|ecdsa|ed25519)",
675
+ "ssh keys?",
676
+ "private keys?",
677
+ "\\.aws/credentials",
678
+ "aws credentials",
679
+ "\\.npmrc",
680
+ "\\.netrc",
681
+ "\\.pypirc",
682
+ "\\.git-credentials",
683
+ "\\.docker/config\\.json",
684
+ "\\.kube/config",
685
+ "kubeconfig",
686
+ "credentials?",
687
+ "secrets?",
688
+ "api[ _-]?keys?",
689
+ "(?:access|auth|bearer|session|github|npm|api) ?tokens?",
690
+ "passwords?"
691
+ ].join("|");
692
+ var CRED_ACCESS = new RegExp(`\\b(?:${CRED_VERBS})\\b[^.!?;\\n]{0,60}?(?:${CRED_TARGETS})`, "gi");
693
+ var ENV_DUMP = /\b(?:printenv|env|set)\s*\|\s*(?:curl|wget|nc|netcat)\b/gi;
694
+ var UNRELATED_VERB = /^(?:log\s+(?:in|on|out)\b|open\s+(?:a|an|the)\s+(?:pr|pull request|mr|merge request|issue|ticket)\b)/i;
695
+ var ONLY_A_CONDITION = /\b(?:when|if|unless|whether)\b/i;
696
+ var CRED_STATE_AFTER = /^\s+(?:is|are)\s+(?:missing|invalid|expired|required|empty|unset|not set|wrong|incorrect)\b/i;
697
+ var FROM_ENVIRONMENT_AFTER = /^\s+from\s+(?:the\s+|your\s+)?(?:environment(?:\s+variables?)?|env\s+vars?|process\.env)\b/i;
698
+ var FROM_ENVIRONMENT_END = /\b(?:from|via|using|through)\s+(?:the\s+|your\s+)?(?:environment variables|process\.env)$/i;
699
+ var SENDS_IT_ON = /\b(?:send|post|upload|exfiltrate|transmit|forward|curl|share|e-?mail)\b|https?:/i;
700
+ function isBenignCredentialMention(text, match, index) {
701
+ if (UNRELATED_VERB.test(match) || ONLY_A_CONDITION.test(match)) return true;
702
+ const end = index + match.length;
703
+ const after = text.slice(end, end + 120).split(/[.!?;\n]/, 1)[0];
704
+ if (CRED_STATE_AFTER.test(after)) return true;
705
+ return (FROM_ENVIRONMENT_END.test(match) || FROM_ENVIRONMENT_AFTER.test(after)) && !SENDS_IT_ON.test(after);
706
+ }
707
+ function findCredentialAccess(text) {
708
+ const out = [];
709
+ for (const m of matchAll(CRED_ACCESS, text)) {
710
+ if (isNegated(text, m.index) || isBenignCredentialMention(text, m[0], m.index)) continue;
711
+ if (/\.env\.(?:example|sample|template|dist)\b/i.test(m[0])) continue;
712
+ out.push({ index: m.index, detail: excerpt(m[0], 70) });
713
+ }
714
+ for (const m of matchAll(ENV_DUMP, text)) {
715
+ out.push({ index: m.index, detail: excerpt(m[0], 70) });
716
+ }
717
+ return out;
718
+ }
719
+ var OVERRIDE = [
720
+ // ignore previous instructions / disregard the system prompt
721
+ /\b(?:ignore|disregard|forget|override|bypass)\b[^.!?;\n]{0,30}?\b(?:previous|prior|earlier|above|preceding|system|safety|original|your|initial)\b[^.!?;\n]{0,20}?\b(?:instructions?|rules|prompts?|guidelines|directives?|guardrails|polic(?:y|ies)|constraints|restrictions|messages?|context)\b/gi,
722
+ // ignore all instructions
723
+ /\b(?:ignore|disregard|forget)\s+(?:all|any)\s+(?:other\s+)?(?:instructions|prompts|directives|guidelines)\b/gi
724
+ ];
725
+ var DISABLE_SAFEGUARD = [
726
+ ...OVERRIDE,
727
+ // skip confirmation / bypass approval
728
+ /\b(?:skip|bypass|disable|turn off)\b[^.!?;\n]{0,20}?\b(?:confirmations?|approvals?|permission (?:checks?|prompts?)|safety checks?|human review|review step)\b/gi,
729
+ // flags and modes that turn off prompts
730
+ /--dangerously-skip-permissions\b|\bbypassPermissions\b|--yolo\b/gi
731
+ ];
732
+ var SKIP_CONFIRM_DIRECT = [
733
+ // do not ask for confirmation / no need to ask the user
734
+ /\b(?:do not|don't|dont|never|no need to|stop|avoid)\s+(?:ask(?:ing)?|request(?:ing)?|wait(?:ing)? for|seek(?:ing)?|prompt(?:ing)?)\b[^.!?;\n]{0,30}?\b(?:confirm(?:ation)?s?|approvals?|permission|consent|sign-?off|the user|me|anyone|humans?)\b/gi,
735
+ // do not tell the user
736
+ /\b(?:do not|don't|dont|never)\s+(?:tell|inform|notify|alert|mention (?:this |it )?to|reveal (?:this |it )?to)\b[^.!?;\n]{0,15}?\b(?:the user|users?|anyone|the human|humans|the developer)\b/gi,
737
+ /\b(?:do not|don't|dont|never)\s+(?:mention|reveal|disclose)\s+(?:this|these instructions|that you)\b/gi
738
+ ];
739
+ var WITHOUT_CONFIRM = /\bwithout\s+(?:asking|confirm(?:ation|ing)?|approval|permission|consent|human review|review|prompting|checking with)\b/gi;
740
+ function findInstructionOverride(text) {
741
+ const out = [];
742
+ for (const re of SKIP_CONFIRM_DIRECT) {
743
+ for (const m of matchAll(re, text)) out.push({ index: m.index, detail: excerpt(m[0], 70) });
744
+ }
745
+ for (const re of [...DISABLE_SAFEGUARD, WITHOUT_CONFIRM]) {
746
+ for (const m of matchAll(re, text)) {
747
+ if (!isNegated(text, m.index)) out.push({ index: m.index, detail: excerpt(m[0], 70) });
748
+ }
749
+ }
750
+ return out;
751
+ }
752
+ var MIN_BASE64_LENGTH = 80;
753
+ var MIN_HEX_LENGTH = 80;
754
+ var BASE64_RUN = new RegExp(`[A-Za-z0-9+/_-]{${MIN_BASE64_LENGTH},}={0,2}`, "g");
755
+ var HEX_RUN = new RegExp(`\\b[0-9a-fA-F]{${MIN_HEX_LENGTH},}\\b`, "g");
756
+ var HEX_ESCAPES = /(?:\\x[0-9a-fA-F]{2}){20,}/g;
757
+ function findEncodedBlocks(text) {
758
+ const out = [];
759
+ const hexSpans = [];
760
+ for (const m of matchAll(HEX_RUN, text)) {
761
+ if (!/[a-f]/i.test(m[0]) || !/\d/.test(m[0])) continue;
762
+ hexSpans.push([m.index, m.index + m[0].length]);
763
+ out.push({ index: m.index, detail: `${m[0].length}-character hex block` });
764
+ }
765
+ for (const m of matchAll(HEX_ESCAPES, text)) {
766
+ out.push({ index: m.index, detail: `${m[0].length / 4}-byte \\x escape sequence` });
767
+ }
768
+ let hexAt = 0;
769
+ let scanned = 0;
770
+ let tokenHasUrl = false;
771
+ for (const m of matchAll(BASE64_RUN, text)) {
772
+ const run = m[0];
773
+ for (let i = scanned; i < m.index; i++) {
774
+ if (/\s/.test(text[i])) tokenHasUrl = false;
775
+ else if (text.startsWith("://", i)) tokenHasUrl = true;
776
+ }
777
+ scanned = m.index;
778
+ if (!/[A-Z]/.test(run) || !/[a-z]/.test(run) || !/\d/.test(run)) continue;
779
+ while (hexAt < hexSpans.length && hexSpans[hexAt][1] <= m.index) hexAt++;
780
+ if (hexAt < hexSpans.length && m.index >= hexSpans[hexAt][0]) continue;
781
+ if (tokenHasUrl) continue;
782
+ out.push({ index: m.index, detail: `${run.length}-character base64 block` });
783
+ }
784
+ return out;
785
+ }
786
+ var INSTALL_COMMANDS = [
787
+ { re: /\b(?:npm|pnpm|bun)\s+(?:install|i|add)\b/gi, eco: "npm" },
788
+ { re: /\byarn\s+(?:global\s+)?add\b/gi, eco: "npm" },
789
+ { re: /\b(?:npx|bunx|pnpx)\b|\b(?:pnpm|yarn)\s+dlx\b|\bnpm\s+exec\b/gi, eco: "npx" },
790
+ { re: /\bdeno\s+run\b/gi, eco: "deno" },
791
+ { re: /\b(?:pip3?|python3?\s+-m\s+pip|uv\s+pip)\s+install\b|\buv\s+add\b|\bpoetry\s+add\b/gi, eco: "pip" },
792
+ { re: /\buvx\b|\buv\s+tool\s+(?:install|run)\b/gi, eco: "uvx" },
793
+ { re: /\bpipx\s+(?:install|run)\b/gi, eco: "pipx" },
794
+ { re: /\bgem\s+install\b/gi, eco: "gem" },
795
+ { re: /\bcargo\s+install\b/gi, eco: "cargo" },
796
+ { re: /\bgo\s+(?:install|get)\b/gi, eco: "go" }
797
+ ];
798
+ var VALUE_FLAGS = {
799
+ npm: ["--registry", "--prefix", "-w", "--workspace", "--tag", "--cache"],
800
+ npx: ["-c", "--call", "--registry", "--cache"],
801
+ deno: ["--config", "-c", "--import-map", "--lock"],
802
+ pip: ["-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-i", "--index-url", "--extra-index-url", "-f", "--find-links", "-t", "--target", "--prefix", "--python", "--group"],
803
+ uvx: ["--python", "--index-url", "--extra-index-url"],
804
+ pipx: ["--python", "--pip-args", "--index-url"],
805
+ gem: ["-v", "--version", "-i", "--install-dir", "--source"],
806
+ cargo: ["--version", "--vers", "--git", "--path", "--root", "--branch", "--tag", "--rev", "--registry"],
807
+ go: []
808
+ };
809
+ var EXACT_SEMVER = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
810
+ var REMOTE_SPEC = /:\/\/|^(?:git(?:\+\w+)?|github|gitlab|bitbucket):/i;
811
+ var LOCAL_SPEC = /^(?:\.|\/|~|[a-z]:[\\/])|^(?:file|link|workspace):|\.(?:whl|tar\.gz|tgz|zip)$/i;
812
+ var COMMIT_PINNED = /[#@][0-9a-f]{40}$/i;
813
+ function isLocal(spec) {
814
+ return !REMOTE_SPEC.test(spec) && LOCAL_SPEC.test(spec);
815
+ }
816
+ function isPlaceholder(spec) {
817
+ return /^<.*>$|[{}$]|^\.\.\.$/.test(spec);
818
+ }
819
+ function npmPinned(spec) {
820
+ const at = spec.lastIndexOf("@");
821
+ if (at <= 0) return false;
822
+ return EXACT_SEMVER.test(spec.slice(at + 1));
823
+ }
824
+ function pipPinned(spec) {
825
+ return /^[A-Za-z0-9._-]+(?:\[[^\]]*\])?===?[0-9][0-9A-Za-z.+!-]*$/.test(spec);
826
+ }
827
+ function stripQuotes(token) {
828
+ return token.replace(/^["']|["']$/g, "");
829
+ }
830
+ function unpinnedPackages(eco, args) {
831
+ const tokens = args.map(stripQuotes).filter((t) => t.length > 0);
832
+ const valueFlags = VALUE_FLAGS[eco];
833
+ const positionals = [];
834
+ const packageFlagValues = [];
835
+ let hasVersionFlag = false;
836
+ for (let i = 0; i < tokens.length; i++) {
837
+ const t = tokens[i];
838
+ if (t.startsWith("-")) {
839
+ const [flag, inline] = t.split("=", 2);
840
+ if (eco === "gem" && (flag === "-v" || flag === "--version") || eco === "cargo" && (flag === "--version" || flag === "--vers")) {
841
+ hasVersionFlag = true;
842
+ }
843
+ if (eco === "npx" && (flag === "-p" || flag === "--package") || eco === "uvx" && (flag === "--from" || flag === "--with")) {
844
+ const value = inline ?? tokens[++i];
845
+ if (value) packageFlagValues.push(stripQuotes(value));
846
+ continue;
847
+ }
848
+ if (valueFlags.includes(flag) && inline === void 0) i++;
849
+ continue;
850
+ }
851
+ positionals.push(t);
852
+ }
853
+ let candidates;
854
+ switch (eco) {
855
+ case "npx":
856
+ case "uvx":
857
+ case "pipx":
858
+ case "deno":
859
+ candidates = packageFlagValues.length > 0 ? packageFlagValues : positionals.slice(0, 1);
860
+ break;
861
+ default:
862
+ candidates = positionals;
863
+ }
864
+ const unpinned = [];
865
+ for (const spec of candidates) {
866
+ if (isPlaceholder(spec) || isLocal(spec)) continue;
867
+ if (REMOTE_SPEC.test(spec)) {
868
+ if (!COMMIT_PINNED.test(spec) && !(eco === "deno" && /@\d+\.\d+\.\d+/.test(spec))) unpinned.push(spec);
869
+ continue;
870
+ }
871
+ let pinned;
872
+ switch (eco) {
873
+ case "deno":
874
+ if (!/^(?:npm|jsr):/i.test(spec)) continue;
875
+ pinned = npmPinned(spec);
876
+ break;
877
+ case "npm":
878
+ case "npx":
879
+ pinned = npmPinned(spec);
880
+ break;
881
+ case "pip":
882
+ case "pipx":
883
+ pinned = pipPinned(spec);
884
+ break;
885
+ case "uvx":
886
+ pinned = pipPinned(spec) || npmPinned(spec);
887
+ break;
888
+ case "gem":
889
+ pinned = hasVersionFlag || /:\d/.test(spec);
890
+ break;
891
+ case "cargo":
892
+ pinned = hasVersionFlag || npmPinned(spec);
893
+ break;
894
+ case "go":
895
+ pinned = npmPinned(spec);
896
+ break;
897
+ }
898
+ if (!pinned) unpinned.push(spec);
899
+ }
900
+ return unpinned;
901
+ }
902
+ var ARG_TERMINATOR = /[;&|`)\n]|\s#|\s(?:and|then)\s/;
903
+ function findUnpinnedInstalls(text) {
904
+ const out = [];
905
+ for (const { re, eco } of INSTALL_COMMANDS) {
906
+ const commands = [...matchAll(re, text)];
907
+ for (const [i, m] of commands.entries()) {
908
+ const rest = text.slice(m.index + m[0].length, commands[i + 1]?.index);
909
+ const end = rest.search(ARG_TERMINATOR);
910
+ const argText = end === -1 ? rest : rest.slice(0, end);
911
+ const names = unpinnedPackages(eco, argText.split(/\s+/));
912
+ if (names.length > 0) {
913
+ out.push({ index: m.index, detail: `${m[0].replace(/\s+/g, " ")} ${names.join(" ")}` });
914
+ }
915
+ }
916
+ }
917
+ return out;
918
+ }
919
+ var URL_RE = /\bhttps?:\/\/[^\s)>\]'"`]+/i;
920
+ var FOLLOW_PHRASE = [
921
+ /\b(?:follow|obey|apply|execute|run|carry out|adhere to|comply with|do what)\b[^.!?;\n]{0,40}?\b(?:instructions?|directions|steps|rules|commands?|guidance|it says|they say)\b/i,
922
+ /\b(?:fetch|load|read|retrieve|download|pull|get|import)\b[^.!?;\n]{0,30}?\b(?:instructions?|rules|guidelines|prompts?|directives|polic(?:y|ies))\b[^.!?;\n]{0,10}?\bfrom\b/i
923
+ ];
924
+ function findRemoteInstructions(text) {
925
+ const out = [];
926
+ let offset = 0;
927
+ for (const line of text.split("\n")) {
928
+ const url = URL_RE.exec(line);
929
+ if (url && FOLLOW_PHRASE.some((re) => re.test(line))) {
930
+ out.push({ index: offset + url.index, detail: excerpt(url[0], 70) });
931
+ }
932
+ offset += line.length + 1;
933
+ }
934
+ return out;
935
+ }
936
+
937
+ // src/scanners/rules/index.ts
938
+ var RULES_TARGETS = [
939
+ "**/CLAUDE.md",
940
+ "**/CLAUDE.local.md",
941
+ "**/AGENTS.md",
942
+ "**/.cursorrules",
943
+ "**/.cursor/rules/**/*",
944
+ "**/.github/copilot-instructions.md"
945
+ ];
946
+ function findRulesTargets(provider, config) {
947
+ return listTargets(provider, config, RULES_TARGETS);
948
+ }
949
+ function textRule(spec) {
950
+ return {
951
+ id: spec.id,
952
+ severity: spec.severity,
953
+ title: spec.title,
954
+ check(context) {
955
+ const out = [];
956
+ for (const file of context.files) {
957
+ const matches = spec.detect(file.content);
958
+ if (matches.length === 0) continue;
959
+ out.push(...findingsFromMatches(spec, file, matches, spec.message, spec.fix, new LineIndex(file.content)));
960
+ }
961
+ return out;
962
+ }
963
+ };
964
+ }
965
+ var rules = [
966
+ textRule({
967
+ id: "RULES-001",
968
+ severity: "high",
969
+ title: "Invisible Unicode in instruction file",
970
+ detect: findInvisibleChars,
971
+ message: (d) => `Invisible Unicode in instruction file: ${d}`,
972
+ fix: "remove the hidden characters and review the surrounding text"
973
+ }),
974
+ textRule({
975
+ id: "RULES-002",
976
+ severity: "high",
977
+ title: "Hidden instructions in comment or markup",
978
+ detect: findHiddenContent,
979
+ message: (d) => `Hidden content in instruction file, ${d}`,
980
+ fix: "move the text into visible prose or delete it; agents read comments that rendered previews hide"
981
+ }),
982
+ textRule({
983
+ id: "RULES-003",
984
+ severity: "high",
985
+ title: "Download piped into a shell",
986
+ detect: findPipeToShell,
987
+ message: (d) => `Download piped into a shell: ${d}`,
988
+ fix: "download the script, review it, pin it by checksum, then run the local copy"
989
+ }),
990
+ textRule({
991
+ id: "RULES-004",
992
+ severity: "high",
993
+ title: "Instruction to read or send credentials",
994
+ detect: findCredentialAccess,
995
+ message: (d) => `Instruction touches credentials: ${d}`,
996
+ fix: "remove the instruction; agents should never read or transmit secrets, env files or SSH keys"
997
+ }),
998
+ textRule({
999
+ id: "RULES-005",
1000
+ severity: "high",
1001
+ title: "Instruction to override earlier instructions or skip confirmation",
1002
+ detect: findInstructionOverride,
1003
+ message: (d) => `Instruction overrides safeguards: ${d}`,
1004
+ fix: "remove the instruction; keep human confirmation for actions with side effects"
1005
+ }),
1006
+ textRule({
1007
+ id: "RULES-006",
1008
+ severity: "medium",
1009
+ title: "Long encoded block in instruction file",
1010
+ detect: findEncodedBlocks,
1011
+ message: (d) => `Encoded content in instruction file: ${d}`,
1012
+ fix: "decode and review the block; replace it with plain text or a reference to a reviewed file"
1013
+ }),
1014
+ textRule({
1015
+ id: "RULES-007",
1016
+ severity: "medium",
1017
+ title: "Instruction to install unpinned packages",
1018
+ detect: findUnpinnedInstalls,
1019
+ message: (d) => `Unpinned package install: ${d}`,
1020
+ fix: "pin exact versions (for example pkg@1.2.3 or pkg==1.2.3) or install from the lock file"
1021
+ }),
1022
+ textRule({
1023
+ id: "RULES-008",
1024
+ severity: "medium",
1025
+ title: "Instruction to fetch and follow an external URL",
1026
+ detect: findRemoteInstructions,
1027
+ message: (d) => `Instructions loaded from an external URL: ${d}`,
1028
+ fix: "copy the reviewed instructions into the repo instead of telling the agent to fetch and follow them"
1029
+ })
1030
+ ];
1031
+ var rulesScanner = {
1032
+ name: "rules",
1033
+ rules,
1034
+ findTargets: findRulesTargets
1035
+ };
1036
+
1037
+ // src/scanners/keys/gitignore.ts
1038
+ function escapeRegex(ch) {
1039
+ return /[\\^$.+()|{}]/.test(ch) ? `\\${ch}` : ch;
1040
+ }
1041
+ function globToRegex(glob) {
1042
+ let out = "";
1043
+ for (let i = 0; i < glob.length; i++) {
1044
+ const ch = glob[i];
1045
+ if (ch === "*") {
1046
+ if (glob[i + 1] === "*") {
1047
+ const atStart = i === 0 || glob[i - 1] === "/";
1048
+ const next = glob[i + 2];
1049
+ if (atStart && next === "/") {
1050
+ out += "(?:.*/)?";
1051
+ i += 2;
1052
+ } else if (atStart && next === void 0) {
1053
+ out += ".*";
1054
+ i += 1;
1055
+ } else {
1056
+ out += "[^/]*";
1057
+ i += 1;
1058
+ }
1059
+ } else {
1060
+ out += "[^/]*";
1061
+ }
1062
+ } else if (ch === "?") {
1063
+ out += "[^/]";
1064
+ } else if (ch === "[") {
1065
+ const end = glob.indexOf("]", i + 1);
1066
+ if (end === -1) {
1067
+ out += "\\[";
1068
+ } else {
1069
+ let cls = glob.slice(i + 1, end);
1070
+ if (cls.startsWith("!")) cls = `^${cls.slice(1)}`;
1071
+ out += `[${cls.replace(/\\/g, "\\\\")}]`;
1072
+ i = end;
1073
+ }
1074
+ } else if (ch === "\\" && i + 1 < glob.length) {
1075
+ const next = glob[++i];
1076
+ out += /[A-Za-z0-9]/.test(next) ? next : `\\${next}`;
1077
+ } else {
1078
+ out += escapeRegex(ch);
1079
+ }
1080
+ }
1081
+ return out;
1082
+ }
1083
+ function parseGitignore(text, base) {
1084
+ const patterns = [];
1085
+ for (let raw of text.split(/\r?\n/)) {
1086
+ raw = raw.replace(/(?<!\\)\s+$/, "");
1087
+ if (raw === "" || raw.startsWith("#")) continue;
1088
+ let negate = false;
1089
+ if (raw.startsWith("!")) {
1090
+ negate = true;
1091
+ raw = raw.slice(1);
1092
+ } else if (raw.startsWith("\\!") || raw.startsWith("\\#")) {
1093
+ raw = raw.slice(1);
1094
+ }
1095
+ let dirOnly = false;
1096
+ if (raw.endsWith("/")) {
1097
+ dirOnly = true;
1098
+ raw = raw.slice(0, -1);
1099
+ }
1100
+ const anchored = raw.includes("/");
1101
+ if (raw.startsWith("/")) raw = raw.slice(1);
1102
+ if (raw === "") continue;
1103
+ const body = globToRegex(raw);
1104
+ const prefix = base === "" ? "" : `${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`;
1105
+ const source = anchored ? `^${prefix}${body}$` : `^${prefix}(?:.*/)?${body}$`;
1106
+ patterns.push({ base, regex: new RegExp(source), negate, dirOnly });
1107
+ }
1108
+ return patterns;
1109
+ }
1110
+ function isIgnoredBy(patterns, path, isDir) {
1111
+ let result;
1112
+ for (const p of patterns) {
1113
+ if (p.dirOnly && !isDir) continue;
1114
+ if (p.regex.test(path)) result = !p.negate;
1115
+ }
1116
+ return result;
1117
+ }
1118
+ var GitignoreChecker = class {
1119
+ constructor(provider) {
1120
+ this.provider = provider;
1121
+ }
1122
+ provider;
1123
+ cache = /* @__PURE__ */ new Map();
1124
+ async patternsFor(dir) {
1125
+ const cached = this.cache.get(dir);
1126
+ if (cached) return cached;
1127
+ const file = dir === "" ? ".gitignore" : `${dir}/.gitignore`;
1128
+ let patterns = [];
1129
+ try {
1130
+ const text = await this.provider.read(file);
1131
+ if (text !== void 0) patterns = parseGitignore(text, dir);
1132
+ if (dir === "") {
1133
+ const exclude = await this.provider.read(".git/info/exclude");
1134
+ if (exclude !== void 0) patterns = [...parseGitignore(exclude, ""), ...patterns];
1135
+ }
1136
+ } catch {
1137
+ }
1138
+ this.cache.set(dir, patterns);
1139
+ return patterns;
1140
+ }
1141
+ /** True when the project's ignore files exclude `path` (relative, forward slashes). */
1142
+ async isIgnored(path) {
1143
+ const parts = path.split("/");
1144
+ const all = [];
1145
+ for (let i = 0; i < parts.length; i++) {
1146
+ const dir = parts.slice(0, i).join("/");
1147
+ all.push(...await this.patternsFor(dir));
1148
+ const current = parts.slice(0, i + 1).join("/");
1149
+ const isDir = i < parts.length - 1;
1150
+ const ignored = isIgnoredBy(all, current, isDir);
1151
+ if (isDir && ignored) return true;
1152
+ if (!isDir) return ignored ?? false;
1153
+ }
1154
+ return false;
1155
+ }
1156
+ };
1157
+
1158
+ // src/scanners/keys/index.ts
1159
+ var KEYS_TARGETS = [
1160
+ // Env files
1161
+ "**/.env",
1162
+ "**/.env.*",
1163
+ "**/*.env",
1164
+ // Agent configs
1165
+ "**/.mcp.json",
1166
+ "**/.cursor/mcp.json",
1167
+ "**/.vscode/mcp.json",
1168
+ "**/.claude/settings.json",
1169
+ "**/.claude/settings.local.json",
1170
+ "**/.codex/config.toml",
1171
+ // Credential and config files
1172
+ "**/.npmrc",
1173
+ "**/.pypirc",
1174
+ "**/.netrc",
1175
+ "**/.git-credentials",
1176
+ "**/*.{json,jsonc,yaml,yml,toml,ini,cfg,conf,properties,xml,tf,tfvars}",
1177
+ "**/*.{pem,key}",
1178
+ "**/id_{rsa,dsa,ecdsa,ed25519}"
1179
+ ];
1180
+ var KEYS_EXCLUDE = ["**/package-lock.json", "**/npm-shrinkwrap.json", "**/pnpm-lock.yaml", "**/composer.lock"];
1181
+ var MAX_KEYS_FILE_BYTES = 32 * 1024 * 1024;
1182
+ function findKeysTargets(provider, config) {
1183
+ return listTargets(provider, config, KEYS_TARGETS, KEYS_EXCLUDE);
1184
+ }
1185
+ function basename(path) {
1186
+ return path.slice(path.lastIndexOf("/") + 1);
1187
+ }
1188
+ function isEnvFile(path) {
1189
+ const name = basename(path);
1190
+ return /^\.env(?:\..+)?$/.test(name) || /\.env$/.test(name);
1191
+ }
1192
+ function isEnvTemplate(path) {
1193
+ return /(?:^|[.-])(?:example|sample|template|dist|defaults?|schema)(?:$|[.-])/i.test(basename(path));
1194
+ }
1195
+ var SECRET_NAME_PARTS = /* @__PURE__ */ new Set([
1196
+ "KEY",
1197
+ "APIKEY",
1198
+ "SECRET",
1199
+ "TOKEN",
1200
+ "PASSWORD",
1201
+ "PASSWD",
1202
+ "PASS",
1203
+ "PWD",
1204
+ "CREDENTIAL",
1205
+ "CREDENTIALS",
1206
+ "AUTH",
1207
+ "AUTHORIZATION",
1208
+ "PRIVATE",
1209
+ "DSN"
1210
+ ]);
1211
+ function nameParts(name) {
1212
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);
1213
+ }
1214
+ function isSecretName(name) {
1215
+ return nameParts(name).some(
1216
+ (p) => SECRET_NAME_PARTS.has(p) || /(?:KEY|TOKEN|SECRET|PASSWORD)$/.test(p)
1217
+ );
1218
+ }
1219
+ function isProdName(name) {
1220
+ return nameParts(name).some((p) => p === "PROD" || p === "PRODUCTION");
1221
+ }
1222
+ var PLACEHOLDER = /^(?:|x+|\*+|\.+|-+|changeme|change[-_]me|replace[-_]?me|todo|tbd|placeholder|null|none|nil|undefined|true|false|dummy|redacted|secret|password)$|^<.*>$|^\$\{.*\}$|^\$[A-Za-z_]\w*$|^\{\{.*\}\}$|^%\w+%$|^your[-_]|example/i;
1223
+ function isRealValue(value) {
1224
+ return value.length >= 8 && !PLACEHOLDER.test(value);
1225
+ }
1226
+ var URL_WITH_PASSWORD = /^[a-z][a-z0-9+.-]*:\/\/[^:/\s@]+:([^@/\s]+)@/i;
1227
+ var ASSIGNMENT = /(?:^|[\s{,])(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?[ \t]*[:=][ \t]*(?:"((?:[^"\\\n]|\\.)*)"|'([^'\n]*)'|(\$\{[^}\n]*\}|[^\s,#;}\]{[][^\s,#;}\]]*))/gm;
1228
+ function findAssignments(text) {
1229
+ const out = [];
1230
+ for (const m of matchAll(ASSIGNMENT, text)) {
1231
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
1232
+ const valueIndex = m.index + m[0].length - value.length - (m[4] === void 0 ? 1 : 0);
1233
+ out.push({ name: m[1], value, index: Math.max(m.index, valueIndex) });
1234
+ }
1235
+ return out;
1236
+ }
1237
+ function hasSecret(file) {
1238
+ for (const a of findAssignments(file.content)) {
1239
+ if (isSecretName(a.name) && isRealValue(a.value)) return a;
1240
+ if (URL_WITH_PASSWORD.test(a.value)) return a;
1241
+ }
1242
+ for (const { regex } of SECRET_PATTERNS) {
1243
+ const m = new RegExp(regex.source, regex.flags.replace("g", "")).exec(file.content);
1244
+ if (m && !/example/i.test(m[0])) return { index: m.index, detail: "" };
1245
+ }
1246
+ return void 0;
1247
+ }
1248
+ var AWS_SECRET_NAME = /^aws[_-]?secret(?:[_-]?access)?[_-]?key$/i;
1249
+ var AGENT_CONFIG = /(?:^|\/)(?:\.mcp\.json|\.cursor\/mcp\.json|\.vscode\/mcp\.json|\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml)$/;
1250
+ var REFERENCES_VARIABLE = /\$\{|\$[A-Za-z_]|%\w+%/;
1251
+ function findYamlProductionSecrets(text) {
1252
+ const out = [];
1253
+ let blockIndent = -1;
1254
+ let offset = 0;
1255
+ for (const rawLine of text.split("\n")) {
1256
+ const line = rawLine.replace(/\r$/, "");
1257
+ if (line.trim() !== "" && !line.trimStart().startsWith("#")) {
1258
+ const indent = line.length - line.trimStart().length;
1259
+ if (blockIndent !== -1 && indent <= blockIndent) blockIndent = -1;
1260
+ if (/^\s*["']?(?:prod|production)["']?:\s*$/i.test(line)) {
1261
+ if (blockIndent === -1) blockIndent = indent;
1262
+ } else if (blockIndent !== -1) {
1263
+ for (const a of findAssignments(line)) out.push({ ...a, index: offset + a.index });
1264
+ }
1265
+ }
1266
+ offset += rawLine.length + 1;
1267
+ }
1268
+ return out;
1269
+ }
1270
+ function findLiveKeys(text, path = "") {
1271
+ const out = [];
1272
+ for (const { name, regex } of SECRET_PATTERNS) {
1273
+ for (const m of matchAll(regex, text)) {
1274
+ if (/example/i.test(m[0])) continue;
1275
+ const shown = name === "PEM private key" ? "BEGIN ... PRIVATE KEY" : maskSecret(m[0]);
1276
+ out.push({ index: m.index, detail: `${name} ${shown}` });
1277
+ }
1278
+ }
1279
+ const assignments = findAssignments(text);
1280
+ const agentConfig = AGENT_CONFIG.test(path);
1281
+ for (const a of assignments) {
1282
+ if (AWS_SECRET_NAME.test(a.name) && isRealValue(a.value)) {
1283
+ out.push({ index: a.index, detail: `AWS secret access key ${maskSecret(a.value)}` });
1284
+ } else if (isProdName(a.name) && (isSecretName(a.name) && isRealValue(a.value) || URL_WITH_PASSWORD.test(a.value))) {
1285
+ out.push({ index: a.index, detail: `production credential ${a.name}=${maskSecret(a.value)}` });
1286
+ } else if (agentConfig && isSecretName(a.name) && isRealValue(a.value) && !REFERENCES_VARIABLE.test(a.value)) {
1287
+ out.push({ index: a.index, detail: `credential written into an agent config, ${a.name}=${maskSecret(a.value)}` });
1288
+ }
1289
+ }
1290
+ for (const a of findYamlProductionSecrets(text)) {
1291
+ if (isSecretName(a.name) && isRealValue(a.value) || URL_WITH_PASSWORD.test(a.value)) {
1292
+ out.push({ index: a.index, detail: `production credential ${a.name}=${maskSecret(a.value)}` });
1293
+ }
1294
+ }
1295
+ return out;
1296
+ }
1297
+ function asList(value) {
1298
+ return Array.isArray(value) ? value : value === void 0 ? [] : [value];
1299
+ }
1300
+ function findWildcardStatements(node, found = []) {
1301
+ if (Array.isArray(node)) {
1302
+ for (const item of node) findWildcardStatements(item, found);
1303
+ } else if (node && typeof node === "object") {
1304
+ const obj = node;
1305
+ const effect = obj.Effect ?? obj.effect;
1306
+ const actions = asList(obj.Action ?? obj.action ?? obj.actions);
1307
+ const resources = asList(obj.Resource ?? obj.resource ?? obj.resources);
1308
+ if ((effect === void 0 || effect === "Allow") && actions.includes("*") && resources.includes("*")) {
1309
+ found.push(obj);
1310
+ }
1311
+ for (const value of Object.values(obj)) findWildcardStatements(value, found);
1312
+ }
1313
+ return found;
1314
+ }
1315
+ var ACTION_STAR = /["']?\b(?:Action|actions)\b["']?\s*[:=]\s*(?:\[\s*)?(?:-\s*)?["']\*["']/gi;
1316
+ var RESOURCE_STAR = /["']?\b(?:Resource|resources)\b["']?\s*[:=]\s*(?:\[\s*)?(?:-\s*)?["']\*["']/i;
1317
+ var ADMIN_POLICY = /\barn:aws:iam::aws:policy\/AdministratorAccess\b|["'\s/]AdministratorAccess["'\s]|\broles\/(?:owner|editor)\b/g;
1318
+ function findAdminPolicies(path, text) {
1319
+ const out = [];
1320
+ let parsedJson = false;
1321
+ if (/\.json$/i.test(path)) {
1322
+ try {
1323
+ const data = JSON.parse(stripBom(text));
1324
+ parsedJson = true;
1325
+ if (findWildcardStatements(data).length > 0) {
1326
+ for (const m of matchAll(ACTION_STAR, text)) {
1327
+ out.push({ index: m.index, detail: 'statement allows Action "*" on Resource "*"' });
1328
+ }
1329
+ if (out.length === 0) out.push({ index: 0, detail: 'statement allows Action "*" on Resource "*"' });
1330
+ }
1331
+ } catch {
1332
+ }
1333
+ }
1334
+ if (!parsedJson) {
1335
+ for (const m of matchAll(ACTION_STAR, text)) {
1336
+ const window = text.slice(Math.max(0, m.index - 300), m.index + 300);
1337
+ if (RESOURCE_STAR.test(window)) out.push({ index: m.index, detail: 'statement allows Action "*" on Resource "*"' });
1338
+ }
1339
+ }
1340
+ for (const m of matchAll(ADMIN_POLICY, text)) {
1341
+ const what = /roles\//.test(m[0]) ? `GCP basic role ${m[0].trim()}` : "AWS AdministratorAccess policy";
1342
+ out.push({ index: m.index + (/^["'\s/]/.test(m[0]) ? 1 : 0), detail: what });
1343
+ }
1344
+ return out;
1345
+ }
1346
+ var rules2 = [
1347
+ {
1348
+ id: "KEYS-001",
1349
+ severity: "high",
1350
+ title: "Live or production key in a file the agent can read",
1351
+ check(context) {
1352
+ const out = [];
1353
+ for (const file of context.files) {
1354
+ const matches = findLiveKeys(file.content, file.path);
1355
+ out.push(
1356
+ ...findingsFromMatches(
1357
+ this,
1358
+ file,
1359
+ matches,
1360
+ (d) => `Live key the agent can read: ${d}`,
1361
+ "move the key to a secret manager or a file outside the project, rotate it, and give agents a scoped test key instead"
1362
+ )
1363
+ );
1364
+ }
1365
+ return out;
1366
+ }
1367
+ },
1368
+ {
1369
+ id: "KEYS-002",
1370
+ severity: "high",
1371
+ title: "Env file with secrets not covered by .gitignore",
1372
+ async check(context) {
1373
+ const checker = new GitignoreChecker(context.provider);
1374
+ const out = [];
1375
+ for (const file of context.files) {
1376
+ if (!isEnvFile(file.path) || isEnvTemplate(file.path)) continue;
1377
+ const secret = hasSecret(file);
1378
+ if (!secret) continue;
1379
+ if (await checker.isIgnored(file.path)) continue;
1380
+ const pos = new LineIndex(file.content).positionOf(secret.index);
1381
+ out.push({
1382
+ ruleId: this.id,
1383
+ severity: this.severity,
1384
+ message: "Env file with secrets is not covered by .gitignore",
1385
+ file: file.path,
1386
+ line: pos.line,
1387
+ fix: `add ${basename(file.path)} (or .env*) to .gitignore; if it was ever committed, rotate the secrets`
1388
+ });
1389
+ }
1390
+ return out;
1391
+ }
1392
+ },
1393
+ {
1394
+ id: "KEYS-003",
1395
+ severity: "medium",
1396
+ title: "Wildcard or admin scope in cloud policy",
1397
+ check(context) {
1398
+ const out = [];
1399
+ for (const file of context.files) {
1400
+ const matches = findAdminPolicies(file.path, file.content);
1401
+ out.push(
1402
+ ...findingsFromMatches(
1403
+ this,
1404
+ file,
1405
+ matches,
1406
+ (d) => `Over-scoped cloud permission: ${d}`,
1407
+ "grant only the actions and resources the task needs; keep admin roles away from keys agents can use"
1408
+ )
1409
+ );
1410
+ }
1411
+ return out;
1412
+ }
1413
+ }
1414
+ ];
1415
+ var keysScanner = {
1416
+ name: "keys",
1417
+ rules: rules2,
1418
+ findTargets: findKeysTargets,
1419
+ maxFileBytes: MAX_KEYS_FILE_BYTES
1420
+ };
1421
+
1422
+ // src/scanners/shared-json.ts
1423
+ function parseJsonc(raw) {
1424
+ const text = stripBom(raw);
1425
+ const out = [];
1426
+ let inString = false;
1427
+ let pendingComma = -1;
1428
+ for (let i = 0; i < text.length; i++) {
1429
+ const ch = text[i];
1430
+ if (inString) {
1431
+ out.push(ch);
1432
+ if (ch === "\\") out.push(text[++i] ?? "");
1433
+ else if (ch === '"') inString = false;
1434
+ } else if (ch === '"') {
1435
+ inString = true;
1436
+ pendingComma = -1;
1437
+ out.push(ch);
1438
+ } else if (ch === "/" && text[i + 1] === "/") {
1439
+ const end = text.indexOf("\n", i);
1440
+ i = end === -1 ? text.length : end - 1;
1441
+ } else if (ch === "/" && text[i + 1] === "*") {
1442
+ const end = text.indexOf("*/", i + 2);
1443
+ i = end === -1 ? text.length : end + 1;
1444
+ } else if (ch === "}" || ch === "]") {
1445
+ if (pendingComma !== -1) out[pendingComma] = "";
1446
+ pendingComma = -1;
1447
+ out.push(ch);
1448
+ } else {
1449
+ if (ch === ",") pendingComma = out.length;
1450
+ else if (!/\s/.test(ch)) pendingComma = -1;
1451
+ out.push(ch);
1452
+ }
1453
+ }
1454
+ return JSON.parse(out.join(""));
1455
+ }
1456
+ var JsonLocator = class {
1457
+ constructor(text) {
1458
+ this.text = text;
1459
+ this.index = new LineIndex(text);
1460
+ }
1461
+ text;
1462
+ index;
1463
+ lineAt(offset) {
1464
+ return offset < 0 ? 1 : this.index.positionOf(offset).line;
1465
+ }
1466
+ /** Line of `"key"` (first occurrence at or after `from`). */
1467
+ key(key, from = 0) {
1468
+ return this.lineAt(this.text.indexOf(JSON.stringify(key), from));
1469
+ }
1470
+ /** Offset of `"key"`, or -1. */
1471
+ keyOffset(key, from = 0) {
1472
+ return this.text.indexOf(JSON.stringify(key), from);
1473
+ }
1474
+ /** Line of a string value, searching after the given key when present. */
1475
+ stringValue(value, afterKey) {
1476
+ return this.lineAt(this.stringValueOffset(value, afterKey ? this.keyOffset(afterKey) : 0));
1477
+ }
1478
+ /** Offset of a string value at or after `from`, or anywhere if not found there; -1 if absent. */
1479
+ stringValueOffset(value, from = 0) {
1480
+ const literal = JSON.stringify(value);
1481
+ const at = this.text.indexOf(literal, Math.max(0, from));
1482
+ return at === -1 ? this.text.indexOf(literal) : at;
1483
+ }
1484
+ /** Line of a known offset. */
1485
+ lineOfOffset(offset) {
1486
+ return this.lineAt(offset);
1487
+ }
1488
+ };
1489
+
1490
+ // src/scanners/approvals/adapters/claude-code.ts
1491
+ var SHELL_TOOLS = /* @__PURE__ */ new Set(["Bash", "PowerShell"]);
1492
+ var BYPASS_MODES = /* @__PURE__ */ new Set(["bypassPermissions", "auto"]);
1493
+ var PARTIAL_MODES = /* @__PURE__ */ new Set(["acceptEdits"]);
1494
+ function isObject(value) {
1495
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1496
+ }
1497
+ function parseRule(raw, line) {
1498
+ const m = /^\s*([A-Za-z0-9_*-]+)\s*(?:\(([\s\S]*)\))?\s*$/.exec(raw);
1499
+ if (!m) return { raw, line, kind: "other" };
1500
+ const tool = m[1];
1501
+ const specifier = m[2];
1502
+ if (SHELL_TOOLS.has(tool)) {
1503
+ const command = specifier?.trim();
1504
+ return { raw, line, kind: "shell", command: command === void 0 || command === "" ? void 0 : command };
1505
+ }
1506
+ const mcp = /^mcp__(.+?)(?:__(.*))?$/.exec(tool);
1507
+ if (mcp && specifier === void 0) {
1508
+ const server = mcp[1];
1509
+ if (server.includes("*")) return { raw, line, kind: "other" };
1510
+ const toolPattern = mcp[2];
1511
+ return {
1512
+ raw,
1513
+ line,
1514
+ kind: "mcp",
1515
+ mcpServer: server,
1516
+ mcpTool: toolPattern === void 0 || toolPattern === "*" ? void 0 : toolPattern
1517
+ };
1518
+ }
1519
+ return { raw, line, kind: "other" };
1520
+ }
1521
+ var claudeCodeAdapter = {
1522
+ agent: "Claude Code",
1523
+ targets: ["**/.claude/settings.json", "**/.claude/settings.local.json"],
1524
+ matches(path) {
1525
+ return /(?:^|\/)\.claude\/settings(?:\.local)?\.json$/.test(path);
1526
+ },
1527
+ parse(file) {
1528
+ const data = JSON.parse(stripBom(file.content));
1529
+ if (!isObject(data)) return void 0;
1530
+ const loc = new JsonLocator(file.content);
1531
+ const settings = {
1532
+ agent: this.agent,
1533
+ file: file.path,
1534
+ bypass: [],
1535
+ partial: [],
1536
+ allow: [],
1537
+ allMcpServers: []
1538
+ };
1539
+ const permissions = isObject(data.permissions) ? data.permissions : {};
1540
+ const permOffset = Math.max(0, loc.keyOffset("permissions"));
1541
+ const mode = permissions.defaultMode;
1542
+ if (typeof mode === "string" && BYPASS_MODES.has(mode)) {
1543
+ settings.bypass.push({
1544
+ value: `permissions.defaultMode is "${mode}" (ignored from project files since v2.1.257, honoured by older versions)`,
1545
+ line: loc.key("defaultMode", permOffset)
1546
+ });
1547
+ } else if (typeof mode === "string" && PARTIAL_MODES.has(mode)) {
1548
+ settings.partial.push({
1549
+ value: `permissions.defaultMode is "${mode}" (file edits and filesystem commands run without asking)`,
1550
+ line: loc.key("defaultMode", permOffset)
1551
+ });
1552
+ }
1553
+ if (data.skipDangerousModePermissionPrompt === true) {
1554
+ settings.bypass.push({
1555
+ value: "skipDangerousModePermissionPrompt is true",
1556
+ line: loc.key("skipDangerousModePermissionPrompt")
1557
+ });
1558
+ }
1559
+ if (Array.isArray(permissions.allow)) {
1560
+ let cursor = Math.max(0, loc.keyOffset("allow"));
1561
+ for (const rule of permissions.allow) {
1562
+ if (typeof rule !== "string") continue;
1563
+ const offset = loc.stringValueOffset(rule, cursor);
1564
+ if (offset >= cursor) cursor = offset;
1565
+ settings.allow.push(parseRule(rule, loc.lineOfOffset(offset)));
1566
+ }
1567
+ }
1568
+ if (data.enableAllProjectMcpServers === true) {
1569
+ settings.allMcpServers.push({
1570
+ value: "enableAllProjectMcpServers is true",
1571
+ line: loc.key("enableAllProjectMcpServers")
1572
+ });
1573
+ }
1574
+ return settings;
1575
+ }
1576
+ };
1577
+
1578
+ // src/scanners/approvals/destructive.ts
1579
+ var DESTRUCTIVE_SAMPLES = [
1580
+ { command: "rm -rf /", label: "recursive delete" },
1581
+ { command: "rm -rf ~", label: "recursive delete" },
1582
+ { command: "rm -fr ./build", label: "recursive delete" },
1583
+ { command: "git push --force origin main", label: "force push" },
1584
+ { command: "git push -f origin main", label: "force push" },
1585
+ { command: "git reset --hard origin/main", label: "discard local work" },
1586
+ { command: "git clean -fdx", label: "discard local work" },
1587
+ { command: "aws s3 rb s3://bucket --force", label: "cloud resource deletion" },
1588
+ { command: "aws s3 rm s3://bucket --recursive", label: "cloud resource deletion" },
1589
+ { command: "aws ec2 terminate-instances --instance-ids i-0", label: "cloud resource deletion" },
1590
+ { command: "aws rds delete-db-instance --db-instance-identifier db", label: "cloud resource deletion" },
1591
+ { command: "aws cloudformation delete-stack --stack-name app", label: "cloud resource deletion" },
1592
+ { command: "gcloud projects delete my-project", label: "cloud resource deletion" },
1593
+ { command: "gcloud compute instances delete vm-1", label: "cloud resource deletion" },
1594
+ { command: "gcloud sql instances delete db", label: "cloud resource deletion" },
1595
+ { command: "az group delete --name rg --yes", label: "cloud resource deletion" },
1596
+ { command: "kubectl delete namespace prod", label: "cloud resource deletion" },
1597
+ { command: "terraform destroy -auto-approve", label: "cloud resource deletion" },
1598
+ { command: "pulumi destroy --yes", label: "cloud resource deletion" },
1599
+ { command: 'psql -c "DROP DATABASE app"', label: "database drop" },
1600
+ { command: 'mysql -e "DROP DATABASE app"', label: "database drop" },
1601
+ { command: "dropdb app", label: "database drop" },
1602
+ { command: 'mongosh --eval "db.dropDatabase()"', label: "database drop" },
1603
+ { command: "redis-cli FLUSHALL", label: "database drop" },
1604
+ { command: "Remove-Item -Recurse -Force C:\\", label: "recursive delete" }
1605
+ ];
1606
+ var DESTRUCTIVE_TEXT = [
1607
+ { re: /\brm\s+(?:-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*|-r\s+-f|-f\s+-r)\b|\brm\b.*--recursive/i, label: "recursive delete" },
1608
+ { re: /\bgit\s+push\b.*(?:--force\b|--force-with-lease\b|\s-f\b|\s\+\S)/, label: "force push" },
1609
+ { re: /\bgit\s+(?:reset\s+--hard|clean\s+-[a-z]*f)/, label: "discard local work" },
1610
+ { re: /\b(?:drop\s+(?:database|table|schema)|truncate\s+table|dropdb|dropdatabase|flushall|flushdb)\b/i, label: "database drop" },
1611
+ {
1612
+ re: /\b(?:aws|gcloud|az|kubectl|terraform|pulumi|doctl|heroku|flyctl|vercel)\b.*\b(?:delete|destroy|terminate|rb|rm|remove|purge)\b/i,
1613
+ label: "cloud resource deletion"
1614
+ },
1615
+ { re: /\bRemove-Item\b.*-Recurse/i, label: "recursive delete" }
1616
+ ];
1617
+ function patternToRegex(pattern) {
1618
+ let p = pattern.trim();
1619
+ if (p.endsWith(":*")) p = `${p.slice(0, -2)} *`;
1620
+ const escape = (s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1621
+ const stars = (p.match(/\*/g) ?? []).length;
1622
+ if (stars === 1 && p.endsWith(" *")) {
1623
+ return new RegExp(`^${escape(p.slice(0, -2))}(?: [\\s\\S]*)?$`);
1624
+ }
1625
+ return new RegExp(`^${p.split("*").map(escape).join("[\\s\\S]*")}$`);
1626
+ }
1627
+ var LAUNCHERS = /^(?:sudo|doas|sh|bash|zsh|fish|dash|ksh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|eval|exec|env|xargs|nohup|command|builtin|nice|timeout|watch|python[0-9.]*|py|node|deno|bun|ruby|perl|php|lua|Rscript|osascript|npx|bunx|uvx|pipx\s+run)(?:\s+(?:-c|\/c|-Command|-e|--eval|run))?\s*(?::\*|\*|\s\*)$/i;
1628
+ function isWildcardShell(command) {
1629
+ if (command === void 0) return true;
1630
+ const c = command.trim();
1631
+ return /^[\s*:]*$/.test(c) || c.startsWith("*") || LAUNCHERS.test(c);
1632
+ }
1633
+ function destructiveMatch(command) {
1634
+ for (const { re, label } of DESTRUCTIVE_TEXT) {
1635
+ if (re.test(command)) return label;
1636
+ }
1637
+ const regex = patternToRegex(command);
1638
+ for (const sample of DESTRUCTIVE_SAMPLES) {
1639
+ if (regex.test(sample.command)) return `${sample.label}, for example \`${sample.command}\``;
1640
+ }
1641
+ return void 0;
1642
+ }
1643
+
1644
+ // src/scanners/approvals/index.ts
1645
+ var adapters = [claudeCodeAdapter];
1646
+ function findApprovalsTargets(provider, config) {
1647
+ return listTargets(provider, config, adapters.flatMap((a) => a.targets));
1648
+ }
1649
+ var parsed = /* @__PURE__ */ new WeakMap();
1650
+ function settingsFor(context) {
1651
+ const cached = parsed.get(context);
1652
+ if (cached) return cached;
1653
+ const out = [];
1654
+ for (const file of context.files) {
1655
+ const adapter = adapters.find((a) => a.matches(file.path));
1656
+ if (!adapter) continue;
1657
+ try {
1658
+ const settings = adapter.parse(file);
1659
+ if (settings) out.push(settings);
1660
+ } catch (err) {
1661
+ context.warn(`${file.path}: could not parse ${adapter.agent} settings (${err.message})`, { incomplete: true });
1662
+ }
1663
+ }
1664
+ parsed.set(context, out);
1665
+ return out;
1666
+ }
1667
+ function finding(rule, file, line, message, fix) {
1668
+ return { ruleId: rule.id, severity: rule.severity, message, file, line, fix };
1669
+ }
1670
+ var rules3 = [
1671
+ {
1672
+ id: "APPR-001",
1673
+ severity: "high",
1674
+ title: "Bypass or auto-approve mode enabled",
1675
+ check(context) {
1676
+ return settingsFor(context).flatMap(
1677
+ (s) => s.bypass.map(
1678
+ (b) => finding(
1679
+ this,
1680
+ s.file,
1681
+ b.line,
1682
+ `${s.agent} skips permission prompts: ${b.value}`,
1683
+ "remove the setting; use bypass mode only for one session inside an isolated container"
1684
+ )
1685
+ )
1686
+ );
1687
+ }
1688
+ },
1689
+ {
1690
+ id: "APPR-002",
1691
+ severity: "high",
1692
+ title: "Allowlist includes destructive commands",
1693
+ check(context) {
1694
+ const out = [];
1695
+ for (const s of settingsFor(context)) {
1696
+ for (const entry of s.allow) {
1697
+ if (entry.kind !== "shell" || isWildcardShell(entry.command)) continue;
1698
+ const why = destructiveMatch(entry.command);
1699
+ if (!why) continue;
1700
+ out.push(
1701
+ finding(
1702
+ this,
1703
+ s.file,
1704
+ entry.line,
1705
+ `${s.agent} runs a destructive command without asking: ${entry.raw} (${why})`,
1706
+ "remove the rule or narrow it to a safe subcommand; keep destructive commands behind a prompt or in deny"
1707
+ )
1708
+ );
1709
+ }
1710
+ }
1711
+ return out;
1712
+ }
1713
+ },
1714
+ {
1715
+ id: "APPR-003",
1716
+ severity: "high",
1717
+ title: "Wildcard shell permission",
1718
+ check(context) {
1719
+ const out = [];
1720
+ for (const s of settingsFor(context)) {
1721
+ for (const entry of s.allow) {
1722
+ if (entry.kind !== "shell" || !isWildcardShell(entry.command)) continue;
1723
+ out.push(
1724
+ finding(
1725
+ this,
1726
+ s.file,
1727
+ entry.line,
1728
+ `${s.agent} runs any shell command without asking: ${entry.raw}`,
1729
+ "replace the wildcard with rules for the specific commands the project needs, such as Bash(npm run test *)"
1730
+ )
1731
+ );
1732
+ }
1733
+ }
1734
+ return out;
1735
+ }
1736
+ },
1737
+ {
1738
+ id: "APPR-004",
1739
+ severity: "medium",
1740
+ title: "All project MCP servers or tools auto-approved",
1741
+ check(context) {
1742
+ const out = [];
1743
+ for (const s of settingsFor(context)) {
1744
+ for (const m of s.allMcpServers) {
1745
+ out.push(
1746
+ finding(
1747
+ this,
1748
+ s.file,
1749
+ m.line,
1750
+ `${s.agent} approves every project MCP server: ${m.value}`,
1751
+ "approve servers one by one after reviewing them (enabledMcpjsonServers in Claude Code)"
1752
+ )
1753
+ );
1754
+ }
1755
+ for (const entry of s.allow) {
1756
+ if (entry.kind !== "mcp" || entry.mcpTool !== void 0) continue;
1757
+ out.push(
1758
+ finding(
1759
+ this,
1760
+ s.file,
1761
+ entry.line,
1762
+ `${s.agent} auto-approves every tool of MCP server "${entry.mcpServer}": ${entry.raw}`,
1763
+ "allow only the specific read-only tools you need, such as mcp__server__get_issue"
1764
+ )
1765
+ );
1766
+ }
1767
+ }
1768
+ return out;
1769
+ }
1770
+ },
1771
+ {
1772
+ id: "APPR-005",
1773
+ severity: "medium",
1774
+ title: "File edits auto-approved",
1775
+ check(context) {
1776
+ return settingsFor(context).flatMap(
1777
+ (s) => s.partial.map(
1778
+ (p) => finding(
1779
+ this,
1780
+ s.file,
1781
+ p.line,
1782
+ `${s.agent} approves some actions without asking: ${p.value}`,
1783
+ "use the default mode in shared project settings; let each developer opt in for their own session"
1784
+ )
1785
+ )
1786
+ );
1787
+ }
1788
+ }
1789
+ ];
1790
+ var approvalsScanner = {
1791
+ name: "approvals",
1792
+ rules: rules3,
1793
+ findTargets: findApprovalsTargets
1794
+ };
1795
+
1796
+ // src/scanners/mcp/tools.ts
1797
+ var LOCK_FILE_PATH = ".surfscan/mcp.lock.json";
1798
+ var UNNAMED_SERVER = "*";
1799
+ function isObject2(value) {
1800
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1801
+ }
1802
+ function toTools(value) {
1803
+ if (!Array.isArray(value)) return [];
1804
+ return value.flatMap((item) => isObject2(item) && typeof item.name === "string" ? [{ name: item.name, definition: item }] : []);
1805
+ }
1806
+ function toolArray(value) {
1807
+ if (Array.isArray(value)) return value;
1808
+ if (!isObject2(value)) return void 0;
1809
+ if (Array.isArray(value.tools)) return value.tools;
1810
+ return isObject2(value.result) ? value.result.tools : void 0;
1811
+ }
1812
+ function parseToolList(text) {
1813
+ const data = JSON.parse(stripBom(text));
1814
+ if (isObject2(data) && isObject2(data.servers)) {
1815
+ return Object.fromEntries(Object.entries(data.servers).map(([name, value]) => [name, toTools(toolArray(value))]));
1816
+ }
1817
+ const tools = toolArray(data);
1818
+ if (!Array.isArray(tools)) {
1819
+ throw new Error('expected a tools array or a "servers" object');
1820
+ }
1821
+ return { [UNNAMED_SERVER]: toTools(tools) };
1822
+ }
1823
+ function toolTexts(node, out = []) {
1824
+ if (Array.isArray(node)) {
1825
+ for (const item of node) toolTexts(item, out);
1826
+ } else if (isObject2(node)) {
1827
+ for (const [key, value] of Object.entries(node)) {
1828
+ if ((key === "description" || key === "title") && typeof value === "string") out.push(value);
1829
+ else toolTexts(value, out);
1830
+ }
1831
+ }
1832
+ return out;
1833
+ }
1834
+ function canonical(value) {
1835
+ if (Array.isArray(value)) return value.map(canonical);
1836
+ if (!isObject2(value)) return value;
1837
+ return Object.fromEntries(
1838
+ Object.keys(value).sort().map((key) => [key, canonical(value[key])])
1839
+ );
1840
+ }
1841
+ async function hashTool(tool) {
1842
+ const { description, inputSchema } = tool.definition;
1843
+ const bytes = new TextEncoder().encode(JSON.stringify(canonical({ description, inputSchema })));
1844
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
1845
+ const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
1846
+ return `sha256:${hex}`;
1847
+ }
1848
+ async function buildLock(tools) {
1849
+ const servers = {};
1850
+ for (const name of Object.keys(tools).sort()) {
1851
+ const hashes = {};
1852
+ for (const tool of [...tools[name]].sort((a, b) => a.name.localeCompare(b.name))) {
1853
+ hashes[tool.name] = await hashTool(tool);
1854
+ }
1855
+ servers[name] = hashes;
1856
+ }
1857
+ return { version: 1, servers };
1858
+ }
1859
+ function parseLock(text) {
1860
+ const data = JSON.parse(stripBom(text));
1861
+ if (!isObject2(data) || !isObject2(data.servers)) throw new Error('expected a "servers" object');
1862
+ return data;
1863
+ }
1864
+
1865
+ // src/scanners/mcp/config.ts
1866
+ function isObject3(value) {
1867
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1868
+ }
1869
+ function stringMap(value) {
1870
+ if (!isObject3(value)) return {};
1871
+ return Object.fromEntries(Object.entries(value).filter((e) => typeof e[1] === "string"));
1872
+ }
1873
+ function serverMap(path, data) {
1874
+ const key = /(?:^|\/)\.vscode\/mcp\.json$/.test(path) ? "servers" : "mcpServers";
1875
+ const map = data[key];
1876
+ return isObject3(map) ? map : {};
1877
+ }
1878
+ function parseMcpConfig(file) {
1879
+ const data = parseJsonc(file.content);
1880
+ if (!isObject3(data)) return [];
1881
+ const loc = new JsonLocator(file.content);
1882
+ const servers = [];
1883
+ for (const [name, value] of Object.entries(serverMap(file.path, data))) {
1884
+ if (!isObject3(value)) continue;
1885
+ const offset = Math.max(0, loc.keyOffset(name));
1886
+ const line = loc.key(name);
1887
+ servers.push({
1888
+ name,
1889
+ file: file.path,
1890
+ line,
1891
+ command: typeof value.command === "string" ? value.command : void 0,
1892
+ args: Array.isArray(value.args) ? value.args.filter((a) => typeof a === "string") : [],
1893
+ env: stringMap(value.env),
1894
+ url: typeof value.url === "string" ? value.url : void 0,
1895
+ lineOf: (text) => loc.keyOffset(text, offset) === -1 ? line : loc.key(text, offset)
1896
+ });
1897
+ }
1898
+ return servers;
1899
+ }
1900
+
1901
+ // src/scanners/mcp/known-hosts.json
1902
+ var known_hosts_default = [
1903
+ "anthropic.com",
1904
+ "asana.com",
1905
+ "atlassian.com",
1906
+ "cloudflare.com",
1907
+ "figma.com",
1908
+ "github.com",
1909
+ "githubcopilot.com",
1910
+ "gitlab.com",
1911
+ "huggingface.co",
1912
+ "intercom.com",
1913
+ "linear.app",
1914
+ "neon.tech",
1915
+ "notion.com",
1916
+ "openai.com",
1917
+ "paypal.com",
1918
+ "sentry.dev",
1919
+ "sentry.io",
1920
+ "stripe.com",
1921
+ "supabase.com",
1922
+ "vercel.com",
1923
+ "zapier.com"
1924
+ ];
1925
+
1926
+ // src/scanners/mcp/index.ts
1927
+ var MCP_TARGETS = ["**/.mcp.json", "**/.cursor/mcp.json", "**/.vscode/mcp.json"];
1928
+ function findMcpTargets(provider, config) {
1929
+ return listTargets(provider, config, MCP_TARGETS);
1930
+ }
1931
+ var parsed2 = /* @__PURE__ */ new WeakMap();
1932
+ function serversFor(context) {
1933
+ const cached = parsed2.get(context);
1934
+ if (cached) return cached;
1935
+ const out = [];
1936
+ for (const file of context.files) {
1937
+ try {
1938
+ out.push(...parseMcpConfig(file));
1939
+ } catch (err) {
1940
+ context.warn(`${file.path}: could not parse MCP config (${err.message})`, { incomplete: true });
1941
+ }
1942
+ }
1943
+ parsed2.set(context, out);
1944
+ return out;
1945
+ }
1946
+ function toolSource(context) {
1947
+ return context.options.toolSource;
1948
+ }
1949
+ function locateTools(context, source) {
1950
+ const servers = serversFor(context);
1951
+ const loc = source.content === void 0 ? void 0 : new JsonLocator(source.content);
1952
+ return Object.entries(source.tools).flatMap(([server, tools]) => {
1953
+ const configured = servers.find((s) => s.name === server);
1954
+ return tools.map((tool) => {
1955
+ const line = loc ? loc.key(tool.name) : configured?.line ?? 0;
1956
+ return {
1957
+ server,
1958
+ tool,
1959
+ file: source.file ?? configured?.file ?? server,
1960
+ line,
1961
+ lineOf: (text) => loc && source.content.includes(JSON.stringify(text)) ? loc.key(text) : line
1962
+ };
1963
+ });
1964
+ });
1965
+ }
1966
+ var POISON_TAG = /<\s*(?:important|system|secret|hidden)\s*>/gi;
1967
+ function findPoisonTag(text) {
1968
+ return [...matchAll(POISON_TAG, text)].map((m) => ({ index: m.index, detail: m[0] }));
1969
+ }
1970
+ var TEXT_DETECTORS = [findInstructionOverride, findCredentialAccess, findInvisibleChars, findHiddenContent, findPoisonTag];
1971
+ function programName(command) {
1972
+ return (command.split(/[\\/]/).pop() ?? command).toLowerCase().replace(/\.(?:exe|cmd|bat)$/, "");
1973
+ }
1974
+ var isDocker = (server) => ["docker", "podman"].includes(programName(server.command ?? ""));
1975
+ var HOME_OR_ROOT = /^(?:\/|~|\$HOME|\$\{HOME\}|\$\{env:HOME\}|\$\{env:USERPROFILE\}|\$\{userHome\}|%USERPROFILE%|[a-z]:|\/home\/[^/]+|\/Users\/[^/]+|[a-z]:[\\/]Users[\\/][^\\/]+)$/i;
1976
+ var CREDENTIAL_DIR = /(?:^|[\\/])\.(?:ssh|aws|gnupg|kube)(?:[\\/]|$)/i;
1977
+ function isBroadPath(path) {
1978
+ const trimmed = path.replace(/[\\/]+$/, "");
1979
+ return HOME_OR_ROOT.test(trimmed === "" ? "/" : trimmed) || CREDENTIAL_DIR.test(path);
1980
+ }
1981
+ function mountSource(flag, value) {
1982
+ if (flag === "--mount") return /(?:^|,)(?:source|src)=([^,]+)/.exec(value)?.[1];
1983
+ return /^((?:[a-z]:)?[^:]+):/i.exec(value)?.[1];
1984
+ }
1985
+ function broadAccess(server) {
1986
+ const out = [];
1987
+ if (isDocker(server)) {
1988
+ server.args.forEach((arg, i) => {
1989
+ const inline = /^(--volume|--mount)=(.+)$/.exec(arg);
1990
+ const flag = inline?.[1] ?? (["-v", "--volume", "--mount"].includes(arg) ? arg : void 0);
1991
+ const value = inline?.[2] ?? (flag ? server.args[i + 1] : void 0);
1992
+ if (!flag || !value) return;
1993
+ const source = mountSource(flag, value);
1994
+ if (!source) return;
1995
+ if (source.includes("docker.sock")) out.push({ what: "the Docker socket is mounted, which gives root on the host", source: value });
1996
+ else if (isBroadPath(source)) out.push({ what: `host path ${source} is mounted`, source: value });
1997
+ });
1998
+ } else if (/filesystem|\bfs\b/i.test(`${server.name} ${server.command ?? ""} ${server.args.join(" ")}`)) {
1999
+ for (const arg of server.args) {
2000
+ if (!arg.startsWith("-") && isBroadPath(arg)) out.push({ what: `it can read and write ${arg}`, source: arg });
2001
+ }
2002
+ }
2003
+ return out;
2004
+ }
2005
+ var DOCKER_VALUE_FLAGS = /* @__PURE__ */ new Set([
2006
+ "-e",
2007
+ "--env",
2008
+ "--env-file",
2009
+ "-v",
2010
+ "--volume",
2011
+ "--mount",
2012
+ "--name",
2013
+ "--network",
2014
+ "--net",
2015
+ "-p",
2016
+ "--publish",
2017
+ "-u",
2018
+ "--user",
2019
+ "-w",
2020
+ "--workdir",
2021
+ "--entrypoint",
2022
+ "--platform",
2023
+ "-l",
2024
+ "--label",
2025
+ "-m",
2026
+ "--memory",
2027
+ "--cpus",
2028
+ "--add-host",
2029
+ "-h",
2030
+ "--hostname",
2031
+ "--pull",
2032
+ "--cap-add",
2033
+ "--cap-drop",
2034
+ "--security-opt"
2035
+ ]);
2036
+ function dockerImage(args) {
2037
+ const run = args.indexOf("run");
2038
+ if (run === -1) return void 0;
2039
+ for (let i = run + 1; i < args.length; i++) {
2040
+ const arg = args[i];
2041
+ if (!arg.startsWith("-")) return arg;
2042
+ if (DOCKER_VALUE_FLAGS.has(arg)) i++;
2043
+ }
2044
+ return void 0;
2045
+ }
2046
+ function imagePinned(image) {
2047
+ if (image.includes("@sha256:")) return true;
2048
+ const tag = /:([^:/]+)$/.exec(image)?.[1];
2049
+ return tag !== void 0 && /^v?\d+\.\d+/.test(tag);
2050
+ }
2051
+ function unpinnedTarget(server) {
2052
+ if (isDocker(server)) {
2053
+ const image = dockerImage(server.args);
2054
+ return image && !imagePinned(image) ? `container image ${image}` : void 0;
2055
+ }
2056
+ const launch = [server.command ?? "", ...server.args].join(" ");
2057
+ return findUnpinnedInstalls(launch)[0]?.detail ?? findPipeToShell(launch)[0]?.detail;
2058
+ }
2059
+ var LOOPBACK = /^(?:localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0|host\.docker\.internal)$/i;
2060
+ var KNOWN_HOSTS = known_hosts_default;
2061
+ function remoteProblem(value) {
2062
+ let url;
2063
+ try {
2064
+ url = new URL(value);
2065
+ } catch {
2066
+ return void 0;
2067
+ }
2068
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
2069
+ const host = url.hostname.toLowerCase();
2070
+ if (LOOPBACK.test(host)) return void 0;
2071
+ if (url.protocol === "http:") return `connects to ${host} over plain http`;
2072
+ const known = KNOWN_HOSTS.some((k) => host === k || host.endsWith(`.${k}`));
2073
+ return known ? void 0 : `connects to ${host}, which is not on the known host list`;
2074
+ }
2075
+ function remoteUrls(server) {
2076
+ const urls = server.url ? [server.url] : [];
2077
+ return [...urls, ...server.args.filter((a) => /^https?:\/\//i.test(a))];
2078
+ }
2079
+ async function readLock(context) {
2080
+ const text = await context.provider.read(LOCK_FILE_PATH);
2081
+ if (text === void 0) return void 0;
2082
+ try {
2083
+ return parseLock(text);
2084
+ } catch (err) {
2085
+ context.warn(`${LOCK_FILE_PATH}: could not parse lock file (${err.message})`, { incomplete: true });
2086
+ return void 0;
2087
+ }
2088
+ }
2089
+ var rules4 = [
2090
+ {
2091
+ id: "MCP-001",
2092
+ severity: "high",
2093
+ title: "Injection-style text in tool description",
2094
+ check(context) {
2095
+ const source = toolSource(context);
2096
+ if (!source) return [];
2097
+ const out = [];
2098
+ for (const { server, tool, file, lineOf } of locateTools(context, source)) {
2099
+ for (const text of toolTexts(tool.definition)) {
2100
+ const details = new Set(TEXT_DETECTORS.flatMap((detect) => detect(text).map((m) => excerpt(m.detail, 90))));
2101
+ for (const detail of details) {
2102
+ out.push({
2103
+ ruleId: this.id,
2104
+ severity: this.severity,
2105
+ message: `Tool "${tool.name}" (server ${server}) has injection-style text: ${detail}`,
2106
+ file,
2107
+ line: lineOf(text),
2108
+ fix: "do not use this server until the text is reviewed; agents follow tool descriptions that users never see"
2109
+ });
2110
+ }
2111
+ }
2112
+ }
2113
+ return out;
2114
+ }
2115
+ },
2116
+ {
2117
+ id: "MCP-002",
2118
+ severity: "high",
2119
+ title: "Tool description changed since the saved lock",
2120
+ async check(context) {
2121
+ const source = toolSource(context);
2122
+ const lock = source ? await readLock(context) : void 0;
2123
+ if (!source || !lock) return [];
2124
+ const out = [];
2125
+ for (const { server, tool, file, line } of locateTools(context, source)) {
2126
+ const saved = lock.servers[server]?.[tool.name];
2127
+ if (saved === await hashTool(tool)) continue;
2128
+ out.push({
2129
+ ruleId: this.id,
2130
+ severity: this.severity,
2131
+ message: saved ? `Tool "${tool.name}" (server ${server}) changed since the saved lock` : `Tool "${tool.name}" (server ${server}) is not in the saved lock`,
2132
+ file,
2133
+ line,
2134
+ fix: "review the new description; if it is safe, save it with surfscan mcp --update-lock"
2135
+ });
2136
+ }
2137
+ return out;
2138
+ }
2139
+ },
2140
+ {
2141
+ id: "MCP-003",
2142
+ severity: "high",
2143
+ title: "Server with filesystem access to the home directory or root",
2144
+ check(context) {
2145
+ return serversFor(context).flatMap(
2146
+ (s) => broadAccess(s).map(({ what, source }) => ({
2147
+ ruleId: this.id,
2148
+ severity: this.severity,
2149
+ message: `Server "${s.name}" reaches too much of the filesystem: ${what}`,
2150
+ file: s.file,
2151
+ line: s.lineOf(source),
2152
+ fix: "limit the server to the project directory"
2153
+ }))
2154
+ );
2155
+ }
2156
+ },
2157
+ {
2158
+ id: "MCP-004",
2159
+ severity: "medium",
2160
+ title: "Server launched from an unpinned remote package",
2161
+ check(context) {
2162
+ const out = [];
2163
+ for (const s of serversFor(context)) {
2164
+ const target = unpinnedTarget(s);
2165
+ if (!target) continue;
2166
+ out.push({
2167
+ ruleId: this.id,
2168
+ severity: this.severity,
2169
+ message: `Server "${s.name}" runs whatever is newest each time it starts: ${target}`,
2170
+ file: s.file,
2171
+ line: s.line,
2172
+ fix: "pin an exact version (pkg@1.2.3, pkg==1.2.3) or a container image digest (image@sha256:...)"
2173
+ });
2174
+ }
2175
+ return out;
2176
+ }
2177
+ },
2178
+ {
2179
+ id: "MCP-005",
2180
+ severity: "medium",
2181
+ title: "Server reached over plain http or an unknown host",
2182
+ check(context) {
2183
+ const out = [];
2184
+ for (const s of serversFor(context)) {
2185
+ for (const url of remoteUrls(s)) {
2186
+ const problem = remoteProblem(url);
2187
+ if (!problem) continue;
2188
+ out.push({
2189
+ ruleId: this.id,
2190
+ severity: this.severity,
2191
+ message: `Server "${s.name}" ${problem}`,
2192
+ file: s.file,
2193
+ line: s.lineOf(url),
2194
+ fix: "use https, and confirm you trust this host; it controls the tool descriptions your agent reads"
2195
+ });
2196
+ }
2197
+ }
2198
+ return out;
2199
+ }
2200
+ },
2201
+ {
2202
+ id: "MCP-006",
2203
+ severity: "info",
2204
+ title: "No lock file yet",
2205
+ async check(context) {
2206
+ if (serversFor(context).length === 0) return [];
2207
+ if (await context.provider.read(LOCK_FILE_PATH) !== void 0) return [];
2208
+ return [
2209
+ {
2210
+ ruleId: this.id,
2211
+ severity: this.severity,
2212
+ message: "No MCP lock file yet, so tool description changes cannot be detected",
2213
+ file: LOCK_FILE_PATH,
2214
+ line: 0,
2215
+ fix: "export the tool list and run surfscan mcp --tools <file.json> --update-lock, then commit the lock file"
2216
+ }
2217
+ ];
2218
+ }
2219
+ }
2220
+ ];
2221
+ var mcpScanner = {
2222
+ name: "mcp",
2223
+ rules: rules4,
2224
+ findTargets: findMcpTargets
2225
+ };
2226
+
2227
+ // src/scanners/deps/lockfile.ts
2228
+ var LOCK_FILE_NAMES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
2229
+ function parsePackageLock(content) {
2230
+ const lock = JSON.parse(stripBom(content));
2231
+ const packages = lock?.packages;
2232
+ return (name, prefix) => {
2233
+ const entry = packages?.[`${prefix}node_modules/${name}`] ?? packages?.[`node_modules/${name}`];
2234
+ return typeof entry?.version === "string" ? entry.version : void 0;
2235
+ };
2236
+ }
2237
+ var remember = (map, name, version) => {
2238
+ if (!map.has(name)) map.set(name, version);
2239
+ };
2240
+ function parsePnpmLock(content) {
2241
+ const importers = /* @__PURE__ */ new Map();
2242
+ const packages = /* @__PURE__ */ new Map();
2243
+ const lines = content.split(/\r?\n/);
2244
+ lines.forEach((line, i) => {
2245
+ const key = /^[ \t]+['"]?((?:@[^\s'"/]+\/)?[^\s'"@/:]+)['"]?:\s*$/.exec(line);
2246
+ if (key && /^\s+specifier:/.test(lines[i + 1] ?? "")) {
2247
+ const version = /^\s+version:\s*['"]?(\d[^\s('"]*)/.exec(lines[i + 2] ?? "");
2248
+ if (version) remember(importers, key[1], version[1]);
2249
+ }
2250
+ const entry = /^[ \t]+['"]?\/?((?:@[^\s'"/]+\/)?[^\s'"@/]+)@(\d[^\s('":]*)['"]?:/.exec(line);
2251
+ if (entry) remember(packages, entry[1], entry[2]);
2252
+ });
2253
+ return (name) => importers.get(name) ?? packages.get(name);
2254
+ }
2255
+ function parseYarnLock(content) {
2256
+ const versions = /* @__PURE__ */ new Map();
2257
+ const lines = content.split(/\r?\n/);
2258
+ lines.forEach((line, i) => {
2259
+ const entry = /^"?((?:@[^/\s"]+\/)?[^@\s"]+)@.*:\s*$/.exec(line);
2260
+ const version = entry && /^\s+version:?\s+"?([^"\s]+)/.exec(lines[i + 1] ?? "");
2261
+ if (version) remember(versions, entry[1], version[1]);
2262
+ });
2263
+ return (name) => versions.get(name);
2264
+ }
2265
+ var PARSERS = {
2266
+ "package-lock.json": parsePackageLock,
2267
+ "pnpm-lock.yaml": parsePnpmLock
2268
+ };
2269
+ var parsed3 = /* @__PURE__ */ new Map();
2270
+ var MAX_PARSED = 16;
2271
+ function lockedVersion(fileName, content, name, prefix) {
2272
+ let lookup = parsed3.get(content);
2273
+ if (!lookup) {
2274
+ lookup = (PARSERS[fileName] ?? parseYarnLock)(content);
2275
+ if (parsed3.size >= MAX_PARSED) parsed3.clear();
2276
+ parsed3.set(content, lookup);
2277
+ }
2278
+ return lookup(name, prefix);
2279
+ }
2280
+
2281
+ // src/scanners/deps/registry.ts
2282
+ var DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
2283
+ var DEFAULT_DOWNLOADS_URL = "https://api.npmjs.org/downloads/point/last-week";
2284
+ var TIMEOUT_MS = 3e4;
2285
+ var CONCURRENCY = 8;
2286
+ function isObject4(value) {
2287
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2288
+ }
2289
+ function strings(value) {
2290
+ if (!isObject4(value)) return {};
2291
+ return Object.fromEntries(Object.entries(value).filter((e) => typeof e[1] === "string"));
2292
+ }
2293
+ function maintainerNames(value) {
2294
+ if (!Array.isArray(value)) return [];
2295
+ return value.flatMap((m) => {
2296
+ if (typeof m === "string") return [m.replace(/\s*<.*$/, "")];
2297
+ return isObject4(m) && typeof m.name === "string" ? [m.name] : [];
2298
+ });
2299
+ }
2300
+ function toPackument(raw) {
2301
+ const doc = isObject4(raw) ? raw : {};
2302
+ const time = isObject4(doc.time) ? doc.time : {};
2303
+ const versions = {};
2304
+ if (isObject4(doc.versions)) {
2305
+ for (const [version, data] of Object.entries(doc.versions)) {
2306
+ const v = isObject4(data) ? data : {};
2307
+ versions[version] = {
2308
+ scripts: strings(v.scripts),
2309
+ maintainers: maintainerNames(v.maintainers),
2310
+ ...typeof v.deprecated === "string" && v.deprecated ? { deprecated: v.deprecated } : {}
2311
+ };
2312
+ }
2313
+ }
2314
+ return {
2315
+ latest: strings(doc["dist-tags"]).latest,
2316
+ created: typeof time.created === "string" ? time.created : void 0,
2317
+ unpublished: time.unpublished !== void 0,
2318
+ versions,
2319
+ times: strings(time)
2320
+ };
2321
+ }
2322
+ function packagePath(name) {
2323
+ return encodeURIComponent(name).replace(/^%40/, "@").replace(/%2F/, "%2f");
2324
+ }
2325
+ async function fetchPackument(fetcher, registryUrl, name) {
2326
+ try {
2327
+ const res = await fetcher(`${registryUrl}/${packagePath(name)}`, {
2328
+ headers: { accept: "application/json" },
2329
+ signal: AbortSignal.timeout(TIMEOUT_MS)
2330
+ });
2331
+ if (res.status === 404) return { status: "missing" };
2332
+ if (!res.ok) return { status: "error", message: `HTTP ${res.status}` };
2333
+ return { status: "found", packument: toPackument(await res.json()) };
2334
+ } catch (err) {
2335
+ return { status: "error", message: err.message };
2336
+ }
2337
+ }
2338
+ async function fetchWeeklyDownloads(fetcher, downloadsUrl, name) {
2339
+ try {
2340
+ const res = await fetcher(`${downloadsUrl}/${packagePath(name)}`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
2341
+ if (!res.ok) return void 0;
2342
+ const body = await res.json();
2343
+ return isObject4(body) && typeof body.downloads === "number" ? body.downloads : void 0;
2344
+ } catch {
2345
+ return void 0;
2346
+ }
2347
+ }
2348
+ async function mapLimit(items, fn, limit = CONCURRENCY) {
2349
+ const results = new Array(items.length);
2350
+ let next = 0;
2351
+ const worker = async () => {
2352
+ while (next < items.length) {
2353
+ const i = next++;
2354
+ results[i] = await fn(items[i]);
2355
+ }
2356
+ };
2357
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
2358
+ return results;
2359
+ }
2360
+
2361
+ // src/scanners/deps/popular-packages.json
2362
+ var popular_packages_default = [
2363
+ "@angular/core",
2364
+ "@babel/core",
2365
+ "@babel/preset-env",
2366
+ "@emotion/react",
2367
+ "@modelcontextprotocol/sdk",
2368
+ "@nestjs/core",
2369
+ "@playwright/test",
2370
+ "@prisma/client",
2371
+ "@tanstack/react-query",
2372
+ "@types/node",
2373
+ "@types/react",
2374
+ "@typescript-eslint/parser",
2375
+ "async",
2376
+ "autoprefixer",
2377
+ "axios",
2378
+ "bcrypt",
2379
+ "bcryptjs",
2380
+ "body-parser",
2381
+ "bootstrap",
2382
+ "chalk",
2383
+ "cheerio",
2384
+ "chokidar",
2385
+ "classnames",
2386
+ "commander",
2387
+ "concurrently",
2388
+ "cookie-parser",
2389
+ "cors",
2390
+ "cross-env",
2391
+ "cross-spawn",
2392
+ "date-fns",
2393
+ "dayjs",
2394
+ "debug",
2395
+ "dotenv",
2396
+ "drizzle-orm",
2397
+ "electron",
2398
+ "esbuild",
2399
+ "eslint",
2400
+ "express",
2401
+ "fast-glob",
2402
+ "fastify",
2403
+ "firebase",
2404
+ "form-data",
2405
+ "fs-extra",
2406
+ "glob",
2407
+ "graphql",
2408
+ "handlebars",
2409
+ "helmet",
2410
+ "husky",
2411
+ "inquirer",
2412
+ "jest",
2413
+ "jquery",
2414
+ "js-yaml",
2415
+ "jsonwebtoken",
2416
+ "knex",
2417
+ "langchain",
2418
+ "lint-staged",
2419
+ "lodash",
2420
+ "mocha",
2421
+ "moment",
2422
+ "mongodb",
2423
+ "mongoose",
2424
+ "morgan",
2425
+ "multer",
2426
+ "mysql2",
2427
+ "nanoid",
2428
+ "next",
2429
+ "nodemailer",
2430
+ "nodemon",
2431
+ "nuxt",
2432
+ "openai",
2433
+ "passport",
2434
+ "pino",
2435
+ "playwright",
2436
+ "postcss",
2437
+ "preact",
2438
+ "prettier",
2439
+ "prisma",
2440
+ "prop-types",
2441
+ "puppeteer",
2442
+ "react",
2443
+ "react-dom",
2444
+ "react-redux",
2445
+ "react-router",
2446
+ "react-router-dom",
2447
+ "redis",
2448
+ "redux",
2449
+ "request",
2450
+ "rimraf",
2451
+ "rollup",
2452
+ "rxjs",
2453
+ "sass",
2454
+ "semver",
2455
+ "sequelize",
2456
+ "server",
2457
+ "sharp",
2458
+ "socket.io",
2459
+ "stripe",
2460
+ "styled-components",
2461
+ "supertest",
2462
+ "svelte",
2463
+ "tailwindcss",
2464
+ "ts-node",
2465
+ "tslib",
2466
+ "tslint",
2467
+ "tsup",
2468
+ "turbo",
2469
+ "typeorm",
2470
+ "typescript",
2471
+ "underscore",
2472
+ "uuid",
2473
+ "validator",
2474
+ "vite",
2475
+ "vitest",
2476
+ "webpack",
2477
+ "webpack-cli",
2478
+ "winston",
2479
+ "yargs",
2480
+ "zod",
2481
+ "zustand"
2482
+ ];
2483
+
2484
+ // src/scanners/deps/typosquat.ts
2485
+ var POPULAR_PACKAGES = popular_packages_default;
2486
+ var POPULAR = new Set(POPULAR_PACKAGES);
2487
+ var MIN_LENGTH = 5;
2488
+ function withinOneEdit(a, b) {
2489
+ if (Math.abs(a.length - b.length) > 1) return false;
2490
+ let start = 0;
2491
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
2492
+ const restA = a.slice(start);
2493
+ const restB = b.slice(start);
2494
+ if (restA === restB) return true;
2495
+ if (restA.slice(1) === restB.slice(1)) return true;
2496
+ if (restA.slice(1) === restB || restA === restB.slice(1)) return true;
2497
+ return restA.length >= 2 && restA[0] === restB[1] && restA[1] === restB[0] && restA.slice(2) === restB.slice(2);
2498
+ }
2499
+ var withoutSeparators = (name) => name.replace(/[-_.]/g, "");
2500
+ function typosquatTarget(name) {
2501
+ const lower = name.toLowerCase();
2502
+ if (POPULAR.has(lower)) return void 0;
2503
+ return POPULAR_PACKAGES.find(
2504
+ (target) => target.length >= MIN_LENGTH && (withinOneEdit(lower, target) || withoutSeparators(lower) === withoutSeparators(target))
2505
+ );
2506
+ }
2507
+
2508
+ // src/scanners/deps/index.ts
2509
+ var DEPS_TARGETS = ["**/package.json", ...LOCK_FILE_NAMES.map((name) => `**/${name}`)];
2510
+ var MAX_DEPS_FILE_BYTES = 32 * 1024 * 1024;
2511
+ function findDepsTargets(provider, config) {
2512
+ return listTargets(provider, config, DEPS_TARGETS);
2513
+ }
2514
+ var SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
2515
+ var INSTALL_HOOKS = ["preinstall", "install", "postinstall"];
2516
+ var NAME = /^(?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/i;
2517
+ var NOT_FROM_REGISTRY = /^(?:workspace|file|link|portal|git|git\+\w+|https?|github|gitlab|bitbucket):|^[^@/\s]+\/[^/\s]+$|^\.{0,2}\//i;
2518
+ var EXACT_VERSION = /^[=v]*(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?)$/;
2519
+ var UNPINNED_SPEC = /^(?:\*|x|latest|)$/i;
2520
+ var ESTABLISHED_WEEKLY_DOWNLOADS = 1e4;
2521
+ var DAY_MS = 24 * 60 * 60 * 1e3;
2522
+ function isObject5(value) {
2523
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2524
+ }
2525
+ function dirOf(path) {
2526
+ const at = path.lastIndexOf("/");
2527
+ return at === -1 ? "" : path.slice(0, at);
2528
+ }
2529
+ var joinPath = (dir, name) => dir ? `${dir}/${name}` : name;
2530
+ function lockLookup(files, dir, context) {
2531
+ return (name) => {
2532
+ let current = dir;
2533
+ for (; ; ) {
2534
+ for (const lockName of LOCK_FILE_NAMES) {
2535
+ const lock = files.get(joinPath(current, lockName));
2536
+ if (!lock) continue;
2537
+ const prefix = current === dir ? "" : `${dir.slice(current.length).replace(/^\//, "")}/`;
2538
+ try {
2539
+ const version = lockedVersion(lockName, lock.content, name, prefix);
2540
+ if (version) return version;
2541
+ } catch (err) {
2542
+ context.warn(`${lock.path}: could not parse lock file (${err.message})`, { incomplete: true });
2543
+ files.delete(lock.path);
2544
+ }
2545
+ }
2546
+ if (current === "") return void 0;
2547
+ current = dirOf(current);
2548
+ }
2549
+ };
2550
+ }
2551
+ function parseNpmrc(text) {
2552
+ const out = /* @__PURE__ */ new Map();
2553
+ for (const line of text.split(/\r?\n/)) {
2554
+ const m = /^\s*(?:(@[^:=\s]+):)?registry\s*=\s*(\S+)/.exec(line);
2555
+ if (m) out.set(m[1] ?? "", m[2]);
2556
+ }
2557
+ return out;
2558
+ }
2559
+ var PUBLIC_REGISTRY = /^https?:\/\/registry\.(?:npmjs\.org|yarnpkg\.com)\/?$/i;
2560
+ function usesPrivateRegistry(name, npmrc) {
2561
+ const scope = name.startsWith("@") ? name.slice(0, name.indexOf("/")) : "";
2562
+ const url = npmrc.get(scope) ?? npmrc.get("");
2563
+ return url !== void 0 && !PUBLIC_REGISTRY.test(url);
2564
+ }
2565
+ function resolveSpec(declared, spec) {
2566
+ const alias = /^npm:((?:@[^/]+\/)?[^@]+)(?:@(.*))?$/.exec(spec);
2567
+ if (alias) return { name: alias[1], spec: alias[2] ?? "" };
2568
+ return NOT_FROM_REGISTRY.test(spec) ? void 0 : { name: declared, spec };
2569
+ }
2570
+ async function collectRefs(context) {
2571
+ const byPath = new Map(context.files.map((f) => [f.path, f]));
2572
+ const manifests = [];
2573
+ for (const file of context.files) {
2574
+ if (!file.path.endsWith("package.json")) continue;
2575
+ try {
2576
+ const data = JSON.parse(stripBom(file.content));
2577
+ if (isObject5(data)) manifests.push({ file, data });
2578
+ } catch (err) {
2579
+ context.warn(`${file.path}: could not parse package.json (${err.message})`, { incomplete: true });
2580
+ }
2581
+ }
2582
+ const workspaceNames = new Set(manifests.map((m) => m.data.name).filter((n) => typeof n === "string"));
2583
+ const rootNpmrc = await context.provider.read(".npmrc").catch(() => void 0);
2584
+ const refs = [];
2585
+ const skippedPrivate = /* @__PURE__ */ new Set();
2586
+ for (const { file, data } of manifests) {
2587
+ const dir = dirOf(file.path);
2588
+ const localNpmrc = dir ? await context.provider.read(joinPath(dir, ".npmrc")).catch(() => void 0) : void 0;
2589
+ const npmrc = new Map([...parseNpmrc(rootNpmrc ?? ""), ...parseNpmrc(localNpmrc ?? "")]);
2590
+ const locked = lockLookup(byPath, dir, context);
2591
+ const loc = new JsonLocator(file.content);
2592
+ for (const section of SECTIONS) {
2593
+ const deps = data[section];
2594
+ if (!isObject5(deps)) continue;
2595
+ const from = Math.max(0, loc.keyOffset(section));
2596
+ let cursor = from;
2597
+ for (const [declared, rawSpec] of Object.entries(deps)) {
2598
+ const offset = loc.keyOffset(declared, cursor);
2599
+ if (offset !== -1) cursor = offset;
2600
+ if (typeof rawSpec !== "string") continue;
2601
+ const resolved = resolveSpec(declared, rawSpec.trim());
2602
+ if (!resolved || workspaceNames.has(resolved.name)) continue;
2603
+ if (!NAME.test(resolved.name)) {
2604
+ context.warn(`${file.path}: skipped invalid package name "${excerpt(resolved.name, 60)}"`);
2605
+ continue;
2606
+ }
2607
+ if (usesPrivateRegistry(resolved.name, npmrc)) {
2608
+ skippedPrivate.add(resolved.name);
2609
+ continue;
2610
+ }
2611
+ refs.push({
2612
+ name: resolved.name,
2613
+ file: file.path,
2614
+ line: loc.key(declared, offset === -1 ? from : offset),
2615
+ spec: resolved.spec,
2616
+ version: EXACT_VERSION.exec(resolved.spec)?.[1] ?? locked(declared)
2617
+ });
2618
+ }
2619
+ }
2620
+ }
2621
+ if (skippedPrivate.size > 0) {
2622
+ context.warn(`deps: skipped ${skippedPrivate.size} package(s) that use a private registry set in .npmrc`);
2623
+ }
2624
+ return refs;
2625
+ }
2626
+ var cache = /* @__PURE__ */ new WeakMap();
2627
+ function dataFor(context) {
2628
+ const cached = cache.get(context);
2629
+ if (cached) return cached;
2630
+ const pending = (async () => {
2631
+ const options = context.options;
2632
+ const refs = await collectRefs(context);
2633
+ if (options.offline) {
2634
+ context.warn("deps: offline, so only DEPS-002 and DEPS-006 ran; the other deps rules need the registry");
2635
+ return { refs, options };
2636
+ }
2637
+ const fetcher = options.fetch ?? globalThis.fetch;
2638
+ const registryUrl = options.registryUrl ?? DEFAULT_REGISTRY_URL;
2639
+ const names = [...new Set(refs.map((r) => r.name))];
2640
+ const results = await mapLimit(names, (name) => fetchPackument(fetcher, registryUrl, name));
2641
+ const lookups = new Map(names.map((name, i) => [name, results[i]]));
2642
+ const failed = names.filter((name) => lookups.get(name).status === "error");
2643
+ if (failed.length > 0) {
2644
+ const first = lookups.get(failed[0]);
2645
+ context.warn(
2646
+ `deps: results are incomplete, could not check ${failed.length} of ${names.length} package(s) on the registry (${failed[0]}: ${first.message})`,
2647
+ { incomplete: true }
2648
+ );
2649
+ }
2650
+ return { refs, lookups, options };
2651
+ })();
2652
+ cache.set(context, pending);
2653
+ return pending;
2654
+ }
2655
+ function finding2(rule, ref, message, fix) {
2656
+ return { ruleId: rule.id, severity: rule.severity, message, file: ref.file, line: ref.line, fix };
2657
+ }
2658
+ async function registryFindings(context, check) {
2659
+ const { refs, lookups } = await dataFor(context);
2660
+ const out = [];
2661
+ for (const ref of refs) {
2662
+ const lookup = lookups?.get(ref.name);
2663
+ if (lookup?.status !== "found" || lookup.packument.unpublished) continue;
2664
+ const result = check(ref, lookup.packument);
2665
+ if (result) out.push(result);
2666
+ }
2667
+ return out;
2668
+ }
2669
+ function latestTwoVersions(packument) {
2670
+ const published = Object.keys(packument.versions).filter((v) => packument.times[v] !== void 0).sort((a, b) => Date.parse(packument.times[b]) - Date.parse(packument.times[a]));
2671
+ return published.length >= 2 ? [published[0], published[1]] : void 0;
2672
+ }
2673
+ var rules5 = [
2674
+ {
2675
+ id: "DEPS-001",
2676
+ severity: "high",
2677
+ title: "Package does not exist on the registry",
2678
+ async check(context) {
2679
+ const { refs, lookups } = await dataFor(context);
2680
+ const fix = "check the name is real before installing; agents invent package names that attackers then register";
2681
+ const out = [];
2682
+ for (const ref of refs) {
2683
+ const lookup = lookups?.get(ref.name);
2684
+ if (!lookup || lookup.status === "error") continue;
2685
+ if (lookup.status === "missing") {
2686
+ out.push(finding2(this, ref, `"${ref.name}" does not exist on the npm registry`, fix));
2687
+ } else if (lookup.packument.unpublished) {
2688
+ out.push(finding2(this, ref, `"${ref.name}" was unpublished from the npm registry`, fix));
2689
+ } else if (ref.version && !(ref.version in lookup.packument.versions)) {
2690
+ out.push(finding2(this, ref, `"${ref.name}" has no published version ${ref.version}`, fix));
2691
+ }
2692
+ }
2693
+ return out;
2694
+ }
2695
+ },
2696
+ {
2697
+ id: "DEPS-002",
2698
+ severity: "high",
2699
+ title: "Name very close to a popular package",
2700
+ async check(context) {
2701
+ const { refs, options, lookups } = await dataFor(context);
2702
+ const suspects = [...new Set(refs.map((r) => r.name))].filter((name) => typosquatTarget(name));
2703
+ const isSuspect = new Set(suspects);
2704
+ const established = /* @__PURE__ */ new Set();
2705
+ if (lookups) {
2706
+ const fetcher = options.fetch ?? globalThis.fetch;
2707
+ const url = options.downloadsUrl ?? DEFAULT_DOWNLOADS_URL;
2708
+ const counts = await mapLimit(suspects, (name) => fetchWeeklyDownloads(fetcher, url, name));
2709
+ suspects.forEach((name, i) => {
2710
+ if ((counts[i] ?? 0) >= ESTABLISHED_WEEKLY_DOWNLOADS) established.add(name);
2711
+ });
2712
+ }
2713
+ return refs.filter((ref) => isSuspect.has(ref.name) && !established.has(ref.name)).map((ref) => {
2714
+ const target = typosquatTarget(ref.name);
2715
+ return finding2(this, ref, `"${ref.name}" is very close to the popular package "${target}"`, `confirm you meant this package and not "${target}"`);
2716
+ });
2717
+ }
2718
+ },
2719
+ {
2720
+ id: "DEPS-003",
2721
+ severity: "medium",
2722
+ title: "Recent maintainer or owner change",
2723
+ check(context) {
2724
+ return registryFindings(context, (ref, packument) => {
2725
+ const pair = latestTwoVersions(packument);
2726
+ if (!pair) return void 0;
2727
+ const [newest, previous] = pair;
2728
+ const before = packument.versions[previous].maintainers;
2729
+ const added = packument.versions[newest].maintainers.filter((m) => !before.includes(m));
2730
+ if (before.length === 0 || added.length === 0) return void 0;
2731
+ return finding2(
2732
+ this,
2733
+ ref,
2734
+ `"${ref.name}" gained maintainer(s) in ${newest}, compared with ${previous}: ${added.join(", ")}`,
2735
+ "review what changed in the newest release, and stay on a version you have reviewed until you are satisfied"
2736
+ );
2737
+ });
2738
+ }
2739
+ },
2740
+ {
2741
+ id: "DEPS-004",
2742
+ severity: "medium",
2743
+ title: "Newly published package",
2744
+ check(context) {
2745
+ const { minAgeDays } = context.config.deps;
2746
+ const now = Date.now();
2747
+ return registryFindings(context, (ref, packument) => {
2748
+ const created = Date.parse(packument.created ?? "");
2749
+ if (Number.isNaN(created)) return void 0;
2750
+ const ageDays = Math.floor((now - created) / DAY_MS);
2751
+ if (ageDays >= minAgeDays) return void 0;
2752
+ return finding2(
2753
+ this,
2754
+ ref,
2755
+ `"${ref.name}" was first published ${ageDays} day(s) ago (minimum ${minAgeDays})`,
2756
+ "review the package and its maintainer before using it, or wait until it has a track record"
2757
+ );
2758
+ });
2759
+ }
2760
+ },
2761
+ {
2762
+ id: "DEPS-005",
2763
+ severity: "medium",
2764
+ title: "Install scripts present",
2765
+ check(context) {
2766
+ return registryFindings(context, (ref, packument) => {
2767
+ const version = ref.version ?? packument.latest;
2768
+ const scripts = version ? packument.versions[version]?.scripts : void 0;
2769
+ const hook = INSTALL_HOOKS.find((h) => scripts?.[h]);
2770
+ if (!hook) return void 0;
2771
+ return finding2(
2772
+ this,
2773
+ ref,
2774
+ `"${ref.name}@${version}" runs a ${hook} script: ${excerpt(scripts[hook], 60)}`,
2775
+ "review the script; install with --ignore-scripts or allow only the packages that need it"
2776
+ );
2777
+ });
2778
+ }
2779
+ },
2780
+ {
2781
+ id: "DEPS-006",
2782
+ severity: "low",
2783
+ title: "Unpinned version",
2784
+ async check(context) {
2785
+ const { refs } = await dataFor(context);
2786
+ return refs.filter((ref) => UNPINNED_SPEC.test(ref.spec)).map(
2787
+ (ref) => finding2(
2788
+ this,
2789
+ ref,
2790
+ `"${ref.name}" accepts any version ("${ref.spec}")`,
2791
+ "pin a version or range, and commit a lock file so that installs are repeatable"
2792
+ )
2793
+ );
2794
+ }
2795
+ },
2796
+ {
2797
+ id: "DEPS-007",
2798
+ severity: "low",
2799
+ title: "Deprecated package",
2800
+ check(context) {
2801
+ return registryFindings(context, (ref, packument) => {
2802
+ const version = ref.version ?? packument.latest;
2803
+ const deprecated = version ? packument.versions[version]?.deprecated : void 0;
2804
+ if (!deprecated) return void 0;
2805
+ return finding2(
2806
+ this,
2807
+ ref,
2808
+ `"${ref.name}@${version}" is deprecated: ${excerpt(deprecated, 80)}`,
2809
+ "move to the suggested replacement or a maintained alternative"
2810
+ );
2811
+ });
2812
+ }
2813
+ }
2814
+ ];
2815
+ var depsScanner = {
2816
+ name: "deps",
2817
+ rules: rules5,
2818
+ findTargets: findDepsTargets,
2819
+ maxFileBytes: MAX_DEPS_FILE_BYTES
2820
+ };
2821
+
2822
+ export {
2823
+ SEVERITIES,
2824
+ SEVERITY_RANK,
2825
+ isSeverity,
2826
+ DEFAULT_IGNORE_PATHS,
2827
+ compareFindings,
2828
+ sortFindings,
2829
+ listTargets,
2830
+ runScanner,
2831
+ runScanners,
2832
+ summarize,
2833
+ hasFailures,
2834
+ CONFIG_FILE_NAME,
2835
+ DEFAULT_CONFIG,
2836
+ ConfigError,
2837
+ validateConfig,
2838
+ parseConfig,
2839
+ loadConfig,
2840
+ createNodeProvider,
2841
+ SECRET_PATTERNS,
2842
+ maskSecret,
2843
+ redactSecrets,
2844
+ escapeUnsafe,
2845
+ redactFinding,
2846
+ summaryLine,
2847
+ formatText,
2848
+ JSON_REPORT_VERSION,
2849
+ toJsonReport,
2850
+ formatJson,
2851
+ findInvisibleChars,
2852
+ findHiddenContent,
2853
+ findPipeToShell,
2854
+ findCredentialAccess,
2855
+ findInstructionOverride,
2856
+ findEncodedBlocks,
2857
+ findUnpinnedInstalls,
2858
+ findRemoteInstructions,
2859
+ RULES_TARGETS,
2860
+ rules,
2861
+ rulesScanner,
2862
+ KEYS_TARGETS,
2863
+ rules2,
2864
+ keysScanner,
2865
+ adapters,
2866
+ rules3,
2867
+ approvalsScanner,
2868
+ parseMcpConfig,
2869
+ LOCK_FILE_PATH,
2870
+ UNNAMED_SERVER,
2871
+ parseToolList,
2872
+ buildLock,
2873
+ parseLock,
2874
+ MCP_TARGETS,
2875
+ findMcpTargets,
2876
+ unpinnedTarget,
2877
+ rules4,
2878
+ mcpScanner,
2879
+ DEPS_TARGETS,
2880
+ rules5,
2881
+ depsScanner
2882
+ };
2883
+ //# sourceMappingURL=chunk-5SNUPVTB.js.map