xploitscan 1.1.11 → 1.2.1

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
@@ -7,6 +7,7 @@ import {
7
7
  clearProRulesCache,
8
8
  clearToken,
9
9
  detectFramework,
10
+ detectPlatform,
10
11
  downloadProRulesBundle,
11
12
  filterFalsePositives,
12
13
  getStoredToken,
@@ -18,22 +19,73 @@ import {
18
19
  storeToken,
19
20
  syncUser,
20
21
  uploadScanResults
21
- } from "./chunk-HYXMH2H6.js";
22
+ } from "./chunk-4OFR3SIS.js";
22
23
 
23
24
  // src/index.ts
24
25
  import { Command } from "commander";
25
26
 
26
27
  // src/commands/scan.ts
27
- import { resolve as resolve2, join as join5 } from "path";
28
- import { watch as fsWatch } from "fs";
28
+ import { resolve as resolve2, join as join6 } from "path";
29
+ import { watch as fsWatch, existsSync as existsSync5 } from "fs";
29
30
  import ora from "ora";
30
31
  import chalk2 from "chalk";
31
32
 
32
33
  // src/utils/files.ts
33
34
  import fg from "fast-glob";
35
+ import ignore2 from "ignore";
36
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
37
+ import { join as join2, resolve } from "path";
38
+
39
+ // src/utils/ignore-rules.ts
34
40
  import ignore from "ignore";
35
41
  import { readFileSync, existsSync } from "fs";
36
- import { join, resolve } from "path";
42
+ import { join } from "path";
43
+ var RULE_TOKEN = "(?:VC\\d+|scanner)";
44
+ var RULE_SCOPED_RE = new RegExp(
45
+ `^\\s*([^#].*?\\S)\\s+(${RULE_TOKEN}(?:\\s*,\\s*${RULE_TOKEN})*)\\s*$`,
46
+ "i"
47
+ );
48
+ function isRuleScopedLine(line) {
49
+ return RULE_SCOPED_RE.test(line);
50
+ }
51
+ function parseRuleIgnores(content) {
52
+ const entries = [];
53
+ for (const line of content.split("\n")) {
54
+ const m = line.match(RULE_SCOPED_RE);
55
+ if (!m) continue;
56
+ const glob = m[1].trim();
57
+ const rules = new Set(
58
+ m[2].split(",").map((r) => r.trim().toUpperCase())
59
+ );
60
+ entries.push({ matcher: ignore.default().add(glob), rules, raw: line.trim() });
61
+ }
62
+ return entries;
63
+ }
64
+ function loadRuleIgnores(dir) {
65
+ const p = join(dir, ".xploitscanignore");
66
+ if (!existsSync(p)) return [];
67
+ try {
68
+ return parseRuleIgnores(readFileSync(p, "utf-8"));
69
+ } catch {
70
+ return [];
71
+ }
72
+ }
73
+ function applyRuleIgnores(findings, entries) {
74
+ if (entries.length === 0) return { kept: findings, suppressed: [] };
75
+ const kept = [];
76
+ const suppressed = [];
77
+ for (const f of findings) {
78
+ const rule = (f.rule ?? "").toUpperCase();
79
+ const hit = entries.some(
80
+ (e) => e.matcher.ignores(f.file) && (e.rules.has("SCANNER") || e.rules.has(rule))
81
+ );
82
+ if (hit) suppressed.push(f);
83
+ else kept.push(f);
84
+ }
85
+ return { kept, suppressed };
86
+ }
87
+
88
+ // src/utils/files.ts
37
89
  var SOURCE_EXTENSIONS = [
38
90
  "js",
39
91
  "jsx",
@@ -165,15 +217,15 @@ var ALWAYS_IGNORE = [
165
217
  "vulnerable-samples/**"
166
218
  ];
167
219
  async function collectFiles(directory) {
168
- const ig = ignore.default();
169
- const gitignorePath = join(directory, ".gitignore");
170
- if (existsSync(gitignorePath)) {
171
- const gitignoreContent = readFileSync(gitignorePath, "utf-8");
220
+ const ig = ignore2.default();
221
+ const gitignorePath = join2(directory, ".gitignore");
222
+ if (existsSync2(gitignorePath)) {
223
+ const gitignoreContent = readFileSync2(gitignorePath, "utf-8");
172
224
  ig.add(gitignoreContent);
173
225
  }
174
- const xploitscanIgnorePath = join(directory, ".xploitscanignore");
175
- if (existsSync(xploitscanIgnorePath)) {
176
- const xploitscanIgnoreContent = readFileSync(xploitscanIgnorePath, "utf-8");
226
+ const xploitscanIgnorePath = join2(directory, ".xploitscanignore");
227
+ if (existsSync2(xploitscanIgnorePath)) {
228
+ const xploitscanIgnoreContent = readFileSync2(xploitscanIgnorePath, "utf-8").split("\n").filter((line) => !isRuleScopedLine(line)).join("\n");
177
229
  ig.add(xploitscanIgnoreContent);
178
230
  }
179
231
  ig.add(ALWAYS_IGNORE);
@@ -193,11 +245,11 @@ async function collectFiles(directory) {
193
245
  }
194
246
  function readFileContents(directory, filePath) {
195
247
  try {
196
- const fullPath = resolve(join(directory, filePath));
248
+ const fullPath = resolve(join2(directory, filePath));
197
249
  if (!fullPath.startsWith(resolve(directory))) {
198
250
  throw new Error("Path traversal detected");
199
251
  }
200
- return readFileSync(fullPath, "utf-8");
252
+ return readFileSync2(fullPath, "utf-8");
201
253
  } catch {
202
254
  return null;
203
255
  }
@@ -215,8 +267,8 @@ function getSnippet(content, line, contextLines = 2) {
215
267
 
216
268
  // src/utils/config.ts
217
269
  import { cosmiconfig } from "cosmiconfig";
218
- import { readFileSync as readFileSync2 } from "fs";
219
- import { join as join2 } from "path";
270
+ import { readFileSync as readFileSync3 } from "fs";
271
+ import { join as join3 } from "path";
220
272
  var defaults = {
221
273
  exclude: [],
222
274
  ai: true,
@@ -225,8 +277,8 @@ var defaults = {
225
277
  };
226
278
  function loadXploitscanRc(directory) {
227
279
  try {
228
- const rcPath = join2(directory, ".xploitscanrc");
229
- const content = readFileSync2(rcPath, "utf-8");
280
+ const rcPath = join3(directory, ".xploitscanrc");
281
+ const content = readFileSync3(rcPath, "utf-8");
230
282
  return JSON.parse(content);
231
283
  } catch {
232
284
  return null;
@@ -297,9 +349,9 @@ async function loadConfig(directory) {
297
349
 
298
350
  // src/scanners/semgrep.ts
299
351
  import { execFile } from "child_process";
300
- import { existsSync as existsSync2 } from "fs";
352
+ import { existsSync as existsSync3 } from "fs";
301
353
  import { readFile, mkdtemp, rm } from "fs/promises";
302
- import { join as join3 } from "path";
354
+ import { join as join4 } from "path";
303
355
  import { tmpdir } from "os";
304
356
  var SEVERITY_MAP = {
305
357
  error: "high",
@@ -323,8 +375,8 @@ async function runSemgrep(directory, customRulesDir) {
323
375
  return { findings: [], available: false };
324
376
  }
325
377
  const findings = [];
326
- const tmpDir = await mkdtemp(join3(tmpdir(), "xploitscan-semgrep-"));
327
- const sarifPath = join3(tmpDir, "results.sarif");
378
+ const tmpDir = await mkdtemp(join4(tmpdir(), "xploitscan-semgrep-"));
379
+ const sarifPath = join4(tmpDir, "results.sarif");
328
380
  try {
329
381
  const args = [
330
382
  "scan",
@@ -340,7 +392,7 @@ async function runSemgrep(directory, customRulesDir) {
340
392
  "1000000"
341
393
  ];
342
394
  args.push("--config", "auto");
343
- if (customRulesDir && existsSync2(customRulesDir)) {
395
+ if (customRulesDir && existsSync3(customRulesDir)) {
344
396
  args.push("--config", customRulesDir);
345
397
  }
346
398
  args.push(directory);
@@ -358,7 +410,7 @@ async function runSemgrep(directory, customRulesDir) {
358
410
  }
359
411
  );
360
412
  });
361
- if (!existsSync2(sarifPath)) return { findings, available: true };
413
+ if (!existsSync3(sarifPath)) return { findings, available: true };
362
414
  const sarifContent = await readFile(sarifPath, "utf-8");
363
415
  const sarif = JSON.parse(sarifContent);
364
416
  for (const run of sarif.runs ?? []) {
@@ -416,8 +468,8 @@ function truncate(str, max) {
416
468
  // src/scanners/gitleaks.ts
417
469
  import { execFile as execFile2 } from "child_process";
418
470
  import { readFile as readFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
419
- import { existsSync as existsSync3 } from "fs";
420
- import { join as join4 } from "path";
471
+ import { existsSync as existsSync4 } from "fs";
472
+ import { join as join5 } from "path";
421
473
  import { tmpdir as tmpdir2 } from "os";
422
474
  var RULE_SEVERITY = {
423
475
  "aws-access-token": "critical",
@@ -451,8 +503,8 @@ async function runGitleaks(directory) {
451
503
  return { findings: [], available: false };
452
504
  }
453
505
  const findings = [];
454
- const tmpDir = await mkdtemp2(join4(tmpdir2(), "xploitscan-gitleaks-"));
455
- const reportPath = join4(tmpDir, "results.json");
506
+ const tmpDir = await mkdtemp2(join5(tmpdir2(), "xploitscan-gitleaks-"));
507
+ const reportPath = join5(tmpDir, "results.json");
456
508
  try {
457
509
  const args = [
458
510
  "detect",
@@ -482,7 +534,7 @@ async function runGitleaks(directory) {
482
534
  }
483
535
  );
484
536
  });
485
- if (!existsSync3(reportPath)) return { findings, available: true };
537
+ if (!existsSync4(reportPath)) return { findings, available: true };
486
538
  const reportContent = await readFile2(reportPath, "utf-8");
487
539
  if (!reportContent.trim()) return { findings, available: true };
488
540
  const results = JSON.parse(reportContent);
@@ -1718,6 +1770,21 @@ function renderTerminalReport(result, files) {
1718
1770
  if (frameworks.length > 0 && frameworks[0] !== "unknown") {
1719
1771
  console.log(chalk.gray(" Frameworks: ") + chalk.white(frameworks.join(", ")));
1720
1772
  }
1773
+ const platforms = detectPlatform(files);
1774
+ if (platforms.length > 0) {
1775
+ const PLATFORM_LABELS = {
1776
+ lovable: "Lovable",
1777
+ bolt: "Bolt",
1778
+ replit: "Replit",
1779
+ cursor: "Cursor",
1780
+ v0: "v0",
1781
+ "claude-code": "Claude Code"
1782
+ };
1783
+ const labels = platforms.map((p) => PLATFORM_LABELS[p] ?? p);
1784
+ console.log(
1785
+ chalk.gray(" Detected: ") + chalk.cyan(labels.join(" + ") + " project") + chalk.gray(" \u2014 rules tuned for what these tools ship by default")
1786
+ );
1787
+ }
1721
1788
  }
1722
1789
  const GRADE_PERCENTILES = { "A+": 98, "A": 90, "B": 70, "C": 45, "D": 20, "F": 5 };
1723
1790
  const { grade, score, summary } = calculateGrade(findings, filesScanned);
@@ -2163,8 +2230,8 @@ ${isMonthlyCap ? "Monthly" : "Daily"} scan limit reached.`));
2163
2230
  }
2164
2231
  spinner.text = "Running external scanners...";
2165
2232
  spinner.color = "yellow";
2166
- const rulesDir = resolve2(join5(import.meta.dirname, "../../rules"));
2167
- const fallbackRulesDir = resolve2(join5(dir, "../rules"));
2233
+ const rulesDir = resolve2(join6(import.meta.dirname, "../../rules"));
2234
+ const fallbackRulesDir = resolve2(join6(dir, "../rules"));
2168
2235
  const [semgrepResult, gitleaksResult] = await Promise.allSettled([
2169
2236
  runSemgrep(dir, rulesDir).catch(() => runSemgrep(dir, fallbackRulesDir)).catch(() => ({ findings: [], available: false })),
2170
2237
  runGitleaks(dir).catch(() => ({ findings: [], available: false }))
@@ -2252,12 +2319,23 @@ ${isMonthlyCap ? "Monthly" : "Daily"} scan limit reached.`));
2252
2319
  }
2253
2320
  spinner.stop();
2254
2321
  const seen = /* @__PURE__ */ new Set();
2255
- const dedupedFindings = allFindings.filter((f) => {
2322
+ const afterDedup = allFindings.filter((f) => {
2256
2323
  const ruleKey = f.source === "gitleaks" ? `secret:${f.file}:${f.line}` : `${f.rule}:${f.file}:${f.line}`;
2257
2324
  if (seen.has(ruleKey)) return false;
2258
2325
  seen.add(ruleKey);
2259
2326
  return true;
2260
2327
  });
2328
+ const { kept: dedupedFindings, suppressed: ruleSuppressed } = applyRuleIgnores(
2329
+ afterDedup,
2330
+ loadRuleIgnores(dir)
2331
+ );
2332
+ if (verbose && ruleSuppressed.length > 0 && !isSilent) {
2333
+ console.log(
2334
+ chalk2.gray(
2335
+ ` .xploitscanignore suppressed ${ruleSuppressed.length} finding${ruleSuppressed.length === 1 ? "" : "s"} by rule scope`
2336
+ )
2337
+ );
2338
+ }
2261
2339
  const result = {
2262
2340
  findings: dedupedFindings,
2263
2341
  filesScanned: files.length,
@@ -2265,6 +2343,17 @@ ${isMonthlyCap ? "Monthly" : "Daily"} scan limit reached.`));
2265
2343
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2266
2344
  directory: dir
2267
2345
  };
2346
+ const PLATFORM_MARKERS = [
2347
+ ".cursorrules",
2348
+ ".cursor/",
2349
+ ".replit",
2350
+ "replit.nix",
2351
+ ".bolt/",
2352
+ ".stackblitzrc",
2353
+ "CLAUDE.md",
2354
+ ".claude/"
2355
+ ];
2356
+ const platformMarkerFiles = PLATFORM_MARKERS.filter((m) => existsSync5(join6(dir, m.replace(/\/$/, "")))).map((m) => ({ path: m, content: "" }));
2268
2357
  switch (format) {
2269
2358
  case "json":
2270
2359
  renderJsonReport(result);
@@ -2282,7 +2371,7 @@ ${isMonthlyCap ? "Monthly" : "Daily"} scan limit reached.`));
2282
2371
  renderDatadogReport(result);
2283
2372
  break;
2284
2373
  default:
2285
- renderTerminalReport(result, fileContentsForAnalysis);
2374
+ renderTerminalReport(result, [...fileContentsForAnalysis, ...platformMarkerFiles]);
2286
2375
  break;
2287
2376
  }
2288
2377
  if (aiReviewed && !isSilent) {
@@ -2511,13 +2600,13 @@ Open this URL in your browser to log in:`));
2511
2600
  // src/commands/hook.ts
2512
2601
  import { execSync } from "child_process";
2513
2602
  import {
2514
- existsSync as existsSync4,
2515
- readFileSync as readFileSync3,
2603
+ existsSync as existsSync6,
2604
+ readFileSync as readFileSync4,
2516
2605
  writeFileSync,
2517
2606
  chmodSync,
2518
2607
  unlinkSync
2519
2608
  } from "fs";
2520
- import { join as join6 } from "path";
2609
+ import { join as join7 } from "path";
2521
2610
  import chalk4 from "chalk";
2522
2611
  var HOOK_START_MARKER = "# xploitscan-hook-start";
2523
2612
  var HOOK_END_MARKER = "# xploitscan-hook-end";
@@ -2570,12 +2659,12 @@ async function installHookCommand(options) {
2570
2659
  console.log(chalk4.gray(" Run this command from inside a git repo."));
2571
2660
  process.exit(1);
2572
2661
  }
2573
- const hooksDir = join6(gitRoot, ".git", "hooks");
2574
- const hookPath = join6(hooksDir, "pre-commit");
2662
+ const hooksDir = join7(gitRoot, ".git", "hooks");
2663
+ const hookPath = join7(hooksDir, "pre-commit");
2575
2664
  let existingContent = "";
2576
2665
  let hadExisting = false;
2577
- if (existsSync4(hookPath)) {
2578
- existingContent = readFileSync3(hookPath, "utf-8");
2666
+ if (existsSync6(hookPath)) {
2667
+ existingContent = readFileSync4(hookPath, "utf-8");
2579
2668
  hadExisting = true;
2580
2669
  const alreadyInstalled = existingContent.includes(HOOK_START_MARKER) && existingContent.includes(HOOK_END_MARKER);
2581
2670
  if (alreadyInstalled && !options.force) {
@@ -2620,12 +2709,12 @@ async function uninstallHookCommand() {
2620
2709
  console.log(chalk4.gray(" Run this command from inside a git repo."));
2621
2710
  process.exit(1);
2622
2711
  }
2623
- const hookPath = join6(gitRoot, ".git", "hooks", "pre-commit");
2624
- if (!existsSync4(hookPath)) {
2712
+ const hookPath = join7(gitRoot, ".git", "hooks", "pre-commit");
2713
+ if (!existsSync6(hookPath)) {
2625
2714
  console.log(chalk4.yellow("\u26A0 No pre-commit hook found. Nothing to uninstall."));
2626
2715
  return;
2627
2716
  }
2628
- const content = readFileSync3(hookPath, "utf-8");
2717
+ const content = readFileSync4(hookPath, "utf-8");
2629
2718
  if (!content.includes(HOOK_START_MARKER)) {
2630
2719
  console.log(chalk4.yellow("\u26A0 XploitScan hook not found in pre-commit file."));
2631
2720
  console.log(chalk4.gray(" Nothing to remove."));
@@ -2651,28 +2740,28 @@ async function uninstallHookCommand() {
2651
2740
 
2652
2741
  // src/commands/cursor.ts
2653
2742
  import chalk5 from "chalk";
2654
- import { mkdirSync, existsSync as existsSync5, writeFileSync as writeFileSync2, readFileSync as readFileSync4 } from "fs";
2655
- import { join as join7, dirname } from "path";
2743
+ import { mkdirSync, existsSync as existsSync7, writeFileSync as writeFileSync2, readFileSync as readFileSync5 } from "fs";
2744
+ import { join as join8, dirname } from "path";
2656
2745
  import { fileURLToPath } from "url";
2657
2746
  async function cursorInstallCommand(opts = {}) {
2658
2747
  const cwd = process.cwd();
2659
2748
  const here = dirname(fileURLToPath(import.meta.url));
2660
2749
  const candidates = [
2661
- join7(here, "templates"),
2662
- join7(here, "..", "templates"),
2663
- join7(here, "..", "src", "templates")
2750
+ join8(here, "templates"),
2751
+ join8(here, "..", "templates"),
2752
+ join8(here, "..", "src", "templates")
2664
2753
  ];
2665
- const templatesDir = candidates.find((p) => existsSync5(join7(p, "cursor-security.mdc")));
2754
+ const templatesDir = candidates.find((p) => existsSync7(join8(p, "cursor-security.mdc")));
2666
2755
  if (!templatesDir) {
2667
2756
  console.error(chalk5.red("Could not locate XploitScan rule templates. Try reinstalling: npm i -g xploitscan@latest"));
2668
2757
  process.exit(1);
2669
2758
  }
2670
- const mdcSrc = readFileSync4(join7(templatesDir, "cursor-security.mdc"), "utf-8");
2671
- const legacySrc = readFileSync4(join7(templatesDir, "cursorrules-legacy.txt"), "utf-8");
2759
+ const mdcSrc = readFileSync5(join8(templatesDir, "cursor-security.mdc"), "utf-8");
2760
+ const legacySrc = readFileSync5(join8(templatesDir, "cursorrules-legacy.txt"), "utf-8");
2672
2761
  if (!opts.legacyOnly) {
2673
- const mdcDir = join7(cwd, ".cursor", "rules");
2674
- const mdcPath = join7(mdcDir, "xploitscan-security.mdc");
2675
- if (existsSync5(mdcPath) && !opts.force) {
2762
+ const mdcDir = join8(cwd, ".cursor", "rules");
2763
+ const mdcPath = join8(mdcDir, "xploitscan-security.mdc");
2764
+ if (existsSync7(mdcPath) && !opts.force) {
2676
2765
  console.log(chalk5.yellow(`Skipping ${mdcPath} (already exists, use --force to overwrite)`));
2677
2766
  } else {
2678
2767
  mkdirSync(mdcDir, { recursive: true });
@@ -2680,8 +2769,8 @@ async function cursorInstallCommand(opts = {}) {
2680
2769
  console.log(chalk5.green(`Installed ${mdcPath}`));
2681
2770
  }
2682
2771
  }
2683
- const legacyPath = join7(cwd, ".cursorrules");
2684
- if (existsSync5(legacyPath) && !opts.force) {
2772
+ const legacyPath = join8(cwd, ".cursorrules");
2773
+ if (existsSync7(legacyPath) && !opts.force) {
2685
2774
  console.log(chalk5.yellow(`Skipping ${legacyPath} (already exists, use --force to overwrite)`));
2686
2775
  } else {
2687
2776
  writeFileSync2(legacyPath, legacySrc);
@@ -2696,7 +2785,7 @@ async function cursorInstallCommand(opts = {}) {
2696
2785
  var program = new Command();
2697
2786
  program.name("xploitscan").description(
2698
2787
  "AI security scanner for vibe-coded apps. Find vulnerabilities before attackers do."
2699
- ).version("1.1.11");
2788
+ ).version("1.2.1");
2700
2789
  program.command("scan").description("Scan a directory for security vulnerabilities").argument("[directory]", "Directory to scan", ".").option("--no-ai", "Skip AI-powered analysis").option(
2701
2790
  "-f, --format <format>",
2702
2791
  "Output format: terminal, json, sarif, splunk-hec, elastic-ecs, datadog-logs",
@@ -2725,7 +2814,7 @@ cursor.command("install").description("Drop XploitScan security rules into .curs
2725
2814
  await cursorInstallCommand({ force: opts.force, legacyOnly: opts.legacyOnly });
2726
2815
  });
2727
2816
  program.command("upgrade").description("Upgrade to XploitScan Pro for unlimited scans").action(async () => {
2728
- const { getStoredToken: getStoredToken2, getCheckoutUrl } = await import("./api-GHG3FCFB.js");
2817
+ const { getStoredToken: getStoredToken2, getCheckoutUrl } = await import("./api-MFCSQQ2Y.js");
2729
2818
  const chalk6 = (await import("chalk")).default;
2730
2819
  const token = getStoredToken2();
2731
2820
  if (!token) {