xploitscan 1.3.0 → 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/dist/index.js CHANGED
@@ -21,13 +21,13 @@ import {
21
21
  syncUser,
22
22
  uploadScanResults,
23
23
  vendoredReason
24
- } from "./chunk-LWX7UPO5.js";
24
+ } from "./chunk-2VDAYFPC.js";
25
25
 
26
26
  // src/index.ts
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
  );
@@ -627,6 +677,14 @@ ${existingFindings.map((f) => `- ${f.file}:${f.line} \u2014 ${f.title}`).join("\
627
677
  const response = await client.messages.create({
628
678
  model: "claude-sonnet-4-5-20250514",
629
679
  max_tokens: 4096,
680
+ // Pinned for the same reason as the FP filter, and the stakes are
681
+ // higher here: findings returned by this analyzer are pushed onto
682
+ // allFindings in commands/scan.ts, which is what calculateGrade()
683
+ // scores. Left at the API default of 1.0, a user could scan the same
684
+ // unchanged commit twice and get a different letter grade — with
685
+ // nothing in the output to explain why. Discovery arguably benefits
686
+ // from sampling variety; a grade the user is asked to trust does not.
687
+ temperature: 0,
630
688
  messages: [
631
689
  {
632
690
  role: "user",
@@ -2091,7 +2149,7 @@ function renderDatadogReport(result) {
2091
2149
 
2092
2150
  // src/commands/scan.ts
2093
2151
  async function scanCommand(directory, options) {
2094
- const dir = resolve2(directory || ".");
2152
+ const dir = resolve3(directory || ".");
2095
2153
  const format = options.format ?? "terminal";
2096
2154
  const verbose = options.verbose ?? false;
2097
2155
  const startTime = Date.now();
@@ -2263,8 +2321,8 @@ ${isMonthlyCap ? "Monthly" : "Daily"} scan limit reached.`));
2263
2321
  }
2264
2322
  spinner.text = "Running external scanners...";
2265
2323
  spinner.color = "yellow";
2266
- const rulesDir = resolve2(join6(import.meta.dirname, "../../rules"));
2267
- const fallbackRulesDir = resolve2(join6(dir, "../rules"));
2324
+ const rulesDir = resolve3(join6(import.meta.dirname, "../../rules"));
2325
+ const fallbackRulesDir = resolve3(join6(dir, "../rules"));
2268
2326
  const [semgrepResult, gitleaksResult] = await Promise.allSettled([
2269
2327
  runSemgrep(dir, rulesDir).catch(() => runSemgrep(dir, fallbackRulesDir)).catch(() => ({ findings: [], available: false })),
2270
2328
  runGitleaks(dir).catch(() => ({ findings: [], available: false }))
@@ -2554,7 +2612,7 @@ async function whoamiCommand() {
2554
2612
  }
2555
2613
  }
2556
2614
  async function waitForBrowserLogin() {
2557
- return new Promise((resolve3, reject) => {
2615
+ return new Promise((resolve4, reject) => {
2558
2616
  const expectedState = randomUUID();
2559
2617
  const server = createServer((req, res) => {
2560
2618
  if (!req.url) {
@@ -2587,7 +2645,7 @@ async function waitForBrowserLogin() {
2587
2645
  </html>
2588
2646
  `);
2589
2647
  server.close();
2590
- resolve3({ token, email, userId });
2648
+ resolve4({ token, email, userId });
2591
2649
  } else {
2592
2650
  res.writeHead(400);
2593
2651
  res.end("Missing parameters");
@@ -2782,11 +2840,11 @@ async function uninstallHookCommand() {
2782
2840
  // src/commands/cursor.ts
2783
2841
  import chalk5 from "chalk";
2784
2842
  import { mkdirSync, existsSync as existsSync7, writeFileSync as writeFileSync2, readFileSync as readFileSync5 } from "fs";
2785
- import { join as join8, dirname } from "path";
2843
+ import { join as join8, dirname as dirname2 } from "path";
2786
2844
  import { fileURLToPath } from "url";
2787
2845
  async function cursorInstallCommand(opts = {}) {
2788
2846
  const cwd = process.cwd();
2789
- const here = dirname(fileURLToPath(import.meta.url));
2847
+ const here = dirname2(fileURLToPath(import.meta.url));
2790
2848
  const candidates = [
2791
2849
  join8(here, "templates"),
2792
2850
  join8(here, "..", "templates"),
@@ -2826,7 +2884,7 @@ async function cursorInstallCommand(opts = {}) {
2826
2884
  var program = new Command();
2827
2885
  program.name("xploitscan").description(
2828
2886
  "AI security scanner for vibe-coded apps. Find vulnerabilities before attackers do."
2829
- ).version("1.3.0");
2887
+ ).version("1.3.2");
2830
2888
  program.command("scan").description("Scan a directory for security vulnerabilities").argument("[directory]", "Directory to scan", ".").option("--no-ai", "Skip AI-powered analysis").option(
2831
2889
  "-f, --format <format>",
2832
2890
  "Output format: terminal, json, sarif, splunk-hec, elastic-ecs, datadog-logs",
@@ -2859,7 +2917,7 @@ cursor.command("install").description("Drop XploitScan security rules into .curs
2859
2917
  await cursorInstallCommand({ force: opts.force, legacyOnly: opts.legacyOnly });
2860
2918
  });
2861
2919
  program.command("upgrade").description("Upgrade to XploitScan Pro for unlimited scans").action(async () => {
2862
- const { getStoredToken: getStoredToken2, getCheckoutUrl } = await import("./api-AX6R4QAD.js");
2920
+ const { getStoredToken: getStoredToken2, getCheckoutUrl } = await import("./api-6FD27EJY.js");
2863
2921
  const chalk6 = (await import("chalk")).default;
2864
2922
  const token = getStoredToken2();
2865
2923
  if (!token) {