xploitscan 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,6 +155,14 @@ legacy/** scanner
155
155
  For a single reviewed-and-accepted finding, prefer an inline
156
156
  `// VC<id>-OK: <reason>` comment instead of a file- or path-wide rule.
157
157
 
158
+ **Where it is looked up.** Every `.xploitscanignore` between your project root
159
+ and the directory being scanned applies, so `xploitscan scan packages/web/src`
160
+ still honours the file at the top of the repo. Patterns are interpreted
161
+ relative to the file that declares them — a root file naming
162
+ `packages/web/src/lib/demo.ts` matches that file whether you scan the repo root
163
+ or `packages/web/src` — and a nested file takes precedence over a broader rule
164
+ above it, the same way `.gitignore` does.
165
+
158
166
  ### Vendored and generated code
159
167
 
160
168
  Some of what a scanner finds isn't code you wrote. AI app builders copy
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  import { Command } from "commander";
28
28
 
29
29
  // src/commands/scan.ts
30
- import { resolve as resolve2, join as join6 } from "path";
30
+ import { resolve as resolve3, join as join6 } from "path";
31
31
  import { watch as fsWatch, existsSync as existsSync5 } from "fs";
32
32
  import ora from "ora";
33
33
  import chalk2 from "chalk";
@@ -36,12 +36,54 @@ import chalk2 from "chalk";
36
36
  import fg from "fast-glob";
37
37
  import ignore2 from "ignore";
38
38
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
39
- import { join as join2, resolve } from "path";
39
+ import { join as join2, resolve as resolve2 } from "path";
40
40
 
41
41
  // src/utils/ignore-rules.ts
42
42
  import ignore from "ignore";
43
- import { readFileSync, existsSync } from "fs";
44
- import { join } from "path";
43
+ import { readFileSync } from "fs";
44
+
45
+ // src/utils/ignore-discovery.ts
46
+ import { existsSync } from "fs";
47
+ import { dirname, join, relative, resolve, sep } from "path";
48
+ function discoverIgnoreFiles(directory) {
49
+ const found = [];
50
+ let dir = resolve(directory);
51
+ for (; ; ) {
52
+ const file = join(dir, ".xploitscanignore");
53
+ if (existsSync(file)) found.push({ file, dir });
54
+ if (existsSync(join(dir, ".git"))) break;
55
+ const parent = dirname(dir);
56
+ if (parent === dir) break;
57
+ dir = parent;
58
+ }
59
+ return found.reverse();
60
+ }
61
+ function rebasePattern(pattern, fromDir, toDir) {
62
+ const trimmed = pattern.trim();
63
+ if (trimmed === "" || trimmed.startsWith("#")) return pattern;
64
+ const from = resolve(fromDir);
65
+ const to = resolve(toDir);
66
+ if (from === to) return pattern;
67
+ const rel = relative(from, to).split(sep).join("/");
68
+ if (rel === "" || rel.startsWith("..")) return pattern;
69
+ const negated = trimmed.startsWith("!");
70
+ const body = negated ? trimmed.slice(1) : trimmed;
71
+ const anchored = body.startsWith("/");
72
+ const bare = anchored ? body.slice(1) : body;
73
+ const withoutTrailing = bare.endsWith("/") ? bare.slice(0, -1) : bare;
74
+ if (!anchored && !withoutTrailing.includes("/")) return pattern;
75
+ const keep = (p) => negated ? `!${p}` : p;
76
+ if (withoutTrailing === rel || rel.startsWith(`${withoutTrailing}/`)) {
77
+ return keep("**");
78
+ }
79
+ if (bare.startsWith(`${rel}/`)) return keep(bare.slice(rel.length + 1));
80
+ return null;
81
+ }
82
+ function rebaseContent(content, fromDir, toDir) {
83
+ return content.split("\n").map((line) => rebasePattern(line, fromDir, toDir)).filter((line) => line !== null).join("\n");
84
+ }
85
+
86
+ // src/utils/ignore-rules.ts
45
87
  var RULE_TOKEN = "(?:VC\\d+|scanner)";
46
88
  var RULE_SCOPED_RE = new RegExp(
47
89
  `^\\s*([^#].*?\\S)\\s+(${RULE_TOKEN}(?:\\s*,\\s*${RULE_TOKEN})*)\\s*$`,
@@ -50,12 +92,17 @@ var RULE_SCOPED_RE = new RegExp(
50
92
  function isRuleScopedLine(line) {
51
93
  return RULE_SCOPED_RE.test(line);
52
94
  }
53
- function parseRuleIgnores(content) {
95
+ function parseRuleIgnores(content, fromDir, toDir) {
54
96
  const entries = [];
55
97
  for (const line of content.split("\n")) {
56
98
  const m = line.match(RULE_SCOPED_RE);
57
99
  if (!m) continue;
58
- const glob = m[1].trim();
100
+ let glob = m[1].trim();
101
+ if (fromDir && toDir) {
102
+ const rebased = rebasePattern(glob, fromDir, toDir);
103
+ if (rebased === null) continue;
104
+ glob = rebased;
105
+ }
59
106
  const rules = new Set(
60
107
  m[2].split(",").map((r) => r.trim().toUpperCase())
61
108
  );
@@ -64,13 +111,14 @@ function parseRuleIgnores(content) {
64
111
  return entries;
65
112
  }
66
113
  function loadRuleIgnores(dir) {
67
- const p = join(dir, ".xploitscanignore");
68
- if (!existsSync(p)) return [];
69
- try {
70
- return parseRuleIgnores(readFileSync(p, "utf-8"));
71
- } catch {
72
- return [];
114
+ const entries = [];
115
+ for (const { file, dir: ignoreDir } of discoverIgnoreFiles(dir)) {
116
+ try {
117
+ entries.push(...parseRuleIgnores(readFileSync(file, "utf-8"), ignoreDir, dir));
118
+ } catch {
119
+ }
73
120
  }
121
+ return entries;
74
122
  }
75
123
  function applyRuleIgnores(findings, entries) {
76
124
  if (entries.length === 0) return { kept: findings, suppressed: [] };
@@ -225,10 +273,12 @@ async function collectFiles(directory) {
225
273
  const gitignoreContent = readFileSync2(gitignorePath, "utf-8");
226
274
  ig.add(gitignoreContent);
227
275
  }
228
- const xploitscanIgnorePath = join2(directory, ".xploitscanignore");
229
- if (existsSync2(xploitscanIgnorePath)) {
230
- const xploitscanIgnoreContent = readFileSync2(xploitscanIgnorePath, "utf-8").split("\n").filter((line) => !isRuleScopedLine(line)).join("\n");
231
- ig.add(xploitscanIgnoreContent);
276
+ for (const { file, dir } of discoverIgnoreFiles(directory)) {
277
+ try {
278
+ const content = readFileSync2(file, "utf-8").split("\n").filter((line) => !isRuleScopedLine(line)).join("\n");
279
+ ig.add(rebaseContent(content, dir, directory));
280
+ } catch {
281
+ }
232
282
  }
233
283
  ig.add(ALWAYS_IGNORE);
234
284
  const patterns = SOURCE_EXTENSIONS.map((ext) => `**/*.${ext}`);
@@ -247,8 +297,8 @@ async function collectFiles(directory) {
247
297
  }
248
298
  function readFileContents(directory, filePath) {
249
299
  try {
250
- const fullPath = resolve(join2(directory, filePath));
251
- if (!fullPath.startsWith(resolve(directory))) {
300
+ const fullPath = resolve2(join2(directory, filePath));
301
+ if (!fullPath.startsWith(resolve2(directory))) {
252
302
  throw new Error("Path traversal detected");
253
303
  }
254
304
  return readFileSync2(fullPath, "utf-8");
@@ -368,9 +418,9 @@ function semgrepSeverityToXploitscan(level) {
368
418
  return SEVERITY_MAP[level] ?? "medium";
369
419
  }
370
420
  async function isSemgrepInstalled() {
371
- return new Promise((resolve3) => {
421
+ return new Promise((resolve4) => {
372
422
  execFile("semgrep", ["--version"], (error) => {
373
- resolve3(!error);
423
+ resolve4(!error);
374
424
  });
375
425
  });
376
426
  }
@@ -401,7 +451,7 @@ async function runSemgrep(directory, customRulesDir) {
401
451
  args.push("--config", customRulesDir);
402
452
  }
403
453
  args.push(directory);
404
- await new Promise((resolve3, reject) => {
454
+ await new Promise((resolve4, reject) => {
405
455
  const proc = execFile(
406
456
  "semgrep",
407
457
  args,
@@ -410,7 +460,7 @@ async function runSemgrep(directory, customRulesDir) {
410
460
  if (error && error.code !== 1) {
411
461
  reject(new Error(`Semgrep failed: ${stderr || error.message}`));
412
462
  } else {
413
- resolve3();
463
+ resolve4();
414
464
  }
415
465
  }
416
466
  );
@@ -496,9 +546,9 @@ var RULE_SEVERITY = {
496
546
  "firebase-api-key": "high"
497
547
  };
498
548
  async function isGitleaksInstalled() {
499
- return new Promise((resolve3) => {
549
+ return new Promise((resolve4) => {
500
550
  execFile2("gitleaks", ["version"], (error) => {
501
- resolve3(!error);
551
+ resolve4(!error);
502
552
  });
503
553
  });
504
554
  }
@@ -524,7 +574,7 @@ async function runGitleaks(directory) {
524
574
  "0"
525
575
  // Don't fail on findings
526
576
  ];
527
- await new Promise((resolve3, reject) => {
577
+ await new Promise((resolve4, reject) => {
528
578
  execFile2(
529
579
  "gitleaks",
530
580
  args,
@@ -534,7 +584,7 @@ async function runGitleaks(directory) {
534
584
  const sanitizedError = (stderr || error.message).slice(0, 200);
535
585
  reject(new Error(`Gitleaks failed: ${sanitizedError}`));
536
586
  } else {
537
- resolve3();
587
+ resolve4();
538
588
  }
539
589
  }
540
590
  );
@@ -2099,7 +2149,7 @@ function renderDatadogReport(result) {
2099
2149
 
2100
2150
  // src/commands/scan.ts
2101
2151
  async function scanCommand(directory, options) {
2102
- const dir = resolve2(directory || ".");
2152
+ const dir = resolve3(directory || ".");
2103
2153
  const format = options.format ?? "terminal";
2104
2154
  const verbose = options.verbose ?? false;
2105
2155
  const startTime = Date.now();
@@ -2271,8 +2321,8 @@ ${isMonthlyCap ? "Monthly" : "Daily"} scan limit reached.`));
2271
2321
  }
2272
2322
  spinner.text = "Running external scanners...";
2273
2323
  spinner.color = "yellow";
2274
- const rulesDir = resolve2(join6(import.meta.dirname, "../../rules"));
2275
- const fallbackRulesDir = resolve2(join6(dir, "../rules"));
2324
+ const rulesDir = resolve3(join6(import.meta.dirname, "../../rules"));
2325
+ const fallbackRulesDir = resolve3(join6(dir, "../rules"));
2276
2326
  const [semgrepResult, gitleaksResult] = await Promise.allSettled([
2277
2327
  runSemgrep(dir, rulesDir).catch(() => runSemgrep(dir, fallbackRulesDir)).catch(() => ({ findings: [], available: false })),
2278
2328
  runGitleaks(dir).catch(() => ({ findings: [], available: false }))
@@ -2562,7 +2612,7 @@ async function whoamiCommand() {
2562
2612
  }
2563
2613
  }
2564
2614
  async function waitForBrowserLogin() {
2565
- return new Promise((resolve3, reject) => {
2615
+ return new Promise((resolve4, reject) => {
2566
2616
  const expectedState = randomUUID();
2567
2617
  const server = createServer((req, res) => {
2568
2618
  if (!req.url) {
@@ -2595,7 +2645,7 @@ async function waitForBrowserLogin() {
2595
2645
  </html>
2596
2646
  `);
2597
2647
  server.close();
2598
- resolve3({ token, email, userId });
2648
+ resolve4({ token, email, userId });
2599
2649
  } else {
2600
2650
  res.writeHead(400);
2601
2651
  res.end("Missing parameters");
@@ -2790,11 +2840,11 @@ async function uninstallHookCommand() {
2790
2840
  // src/commands/cursor.ts
2791
2841
  import chalk5 from "chalk";
2792
2842
  import { mkdirSync, existsSync as existsSync7, writeFileSync as writeFileSync2, readFileSync as readFileSync5 } from "fs";
2793
- import { join as join8, dirname } from "path";
2843
+ import { join as join8, dirname as dirname2 } from "path";
2794
2844
  import { fileURLToPath } from "url";
2795
2845
  async function cursorInstallCommand(opts = {}) {
2796
2846
  const cwd = process.cwd();
2797
- const here = dirname(fileURLToPath(import.meta.url));
2847
+ const here = dirname2(fileURLToPath(import.meta.url));
2798
2848
  const candidates = [
2799
2849
  join8(here, "templates"),
2800
2850
  join8(here, "..", "templates"),
@@ -2834,7 +2884,7 @@ async function cursorInstallCommand(opts = {}) {
2834
2884
  var program = new Command();
2835
2885
  program.name("xploitscan").description(
2836
2886
  "AI security scanner for vibe-coded apps. Find vulnerabilities before attackers do."
2837
- ).version("1.3.1");
2887
+ ).version("1.3.2");
2838
2888
  program.command("scan").description("Scan a directory for security vulnerabilities").argument("[directory]", "Directory to scan", ".").option("--no-ai", "Skip AI-powered analysis").option(
2839
2889
  "-f, --format <format>",
2840
2890
  "Output format: terminal, json, sarif, splunk-hec, elastic-ecs, datadog-logs",