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 +8 -0
- package/dist/index.js +84 -34
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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 =
|
|
251
|
-
if (!fullPath.startsWith(
|
|
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((
|
|
421
|
+
return new Promise((resolve4) => {
|
|
372
422
|
execFile("semgrep", ["--version"], (error) => {
|
|
373
|
-
|
|
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((
|
|
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
|
-
|
|
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((
|
|
549
|
+
return new Promise((resolve4) => {
|
|
500
550
|
execFile2("gitleaks", ["version"], (error) => {
|
|
501
|
-
|
|
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((
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
2275
|
-
const fallbackRulesDir =
|
|
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((
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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",
|