xploitscan 1.0.9 → 1.0.11
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 +3 -3
- package/dist/index.js +184 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
**Security scanner for AI-generated code.** Find vulnerabilities before attackers do.
|
|
7
7
|
|
|
8
|
-
Built for developers shipping code via Cursor, Lovable, Bolt, Replit, and Claude Code.
|
|
8
|
+
Built for developers shipping code via Cursor, Lovable, Bolt, Replit, and Claude Code. 158 security rules. Plain-English results. Copy-paste fixes.
|
|
9
9
|
|
|
10
10
|
## Quick Start
|
|
11
11
|
|
|
@@ -17,7 +17,7 @@ No install, no config, no account required. Your code stays 100% local.
|
|
|
17
17
|
|
|
18
18
|
## What It Catches
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
158 rules across 15+ categories:
|
|
21
21
|
|
|
22
22
|
| Category | Examples | Rules |
|
|
23
23
|
|----------|---------|-------|
|
|
@@ -130,7 +130,7 @@ Scan via the web at [xploitscan.com](https://xploitscan.com):
|
|
|
130
130
|
- SOC2/ISO27001 compliance mapping
|
|
131
131
|
- Slack and Discord webhook notifications
|
|
132
132
|
|
|
133
|
-
**Free**: 5 scans/day, 30 core rules. **Pro** ($29/mo): unlimited scans, all
|
|
133
|
+
**Free**: 5 scans/day, 30 core rules. **Pro** ($29/mo): unlimited scans, all 158 rules, and all dashboard features. **Team** ($99/mo): everything in Pro plus 5 seats, shared scan history, RBAC, and portfolio reports. Annual plans save 20%.
|
|
134
134
|
|
|
135
135
|
## Supported Languages
|
|
136
136
|
|
package/dist/index.js
CHANGED
|
@@ -577,15 +577,19 @@ var xssVulnerability = {
|
|
|
577
577
|
];
|
|
578
578
|
const matches = [];
|
|
579
579
|
for (const pattern of patterns) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
() => "Sanitize user input before rendering as HTML. Use a library like DOMPurify: DOMPurify.sanitize(userInput)"
|
|
587
|
-
)
|
|
580
|
+
const raw = findMatches(
|
|
581
|
+
content,
|
|
582
|
+
pattern,
|
|
583
|
+
xssVulnerability,
|
|
584
|
+
filePath,
|
|
585
|
+
() => "Sanitize user input before rendering as HTML. Use a library like DOMPurify: DOMPurify.sanitize(userInput)"
|
|
588
586
|
);
|
|
587
|
+
for (const m of raw) {
|
|
588
|
+
const lineText = content.split("\n")[m.line - 1] || "";
|
|
589
|
+
if (/\.innerHTML\s*=\s*['"]/.test(lineText) && !/\$\{/.test(lineText)) continue;
|
|
590
|
+
if (/\.innerHTML\s*=\s*['"][^'"]*['"]\s*$/.test(lineText)) continue;
|
|
591
|
+
matches.push(m);
|
|
592
|
+
}
|
|
589
593
|
}
|
|
590
594
|
return matches;
|
|
591
595
|
}
|
|
@@ -688,13 +692,21 @@ var nextPublicSecret = {
|
|
|
688
692
|
];
|
|
689
693
|
const matches = [];
|
|
690
694
|
for (const p of patterns) {
|
|
691
|
-
|
|
695
|
+
const raw = findMatches(
|
|
692
696
|
content,
|
|
693
697
|
p,
|
|
694
698
|
nextPublicSecret,
|
|
695
699
|
filePath,
|
|
696
700
|
() => "Remove the NEXT_PUBLIC_ prefix. Only use NEXT_PUBLIC_ for values safe to expose in the browser."
|
|
697
|
-
)
|
|
701
|
+
);
|
|
702
|
+
for (const m of raw) {
|
|
703
|
+
const lineText = content.split("\n")[m.line - 1] || "";
|
|
704
|
+
if (/PUBLISHABLE|ANON_KEY|PUBLIC_KEY/i.test(lineText)) continue;
|
|
705
|
+
if (/CLERK_PUBLISHABLE/i.test(lineText)) continue;
|
|
706
|
+
if (/STRIPE_PUBLISHABLE/i.test(lineText)) continue;
|
|
707
|
+
if (/=\s*["']?\s*$|=\s*["']?pk_(?:test|live)_["']?\s*$/.test(lineText)) continue;
|
|
708
|
+
matches.push(m);
|
|
709
|
+
}
|
|
698
710
|
}
|
|
699
711
|
return matches;
|
|
700
712
|
}
|
|
@@ -766,6 +778,8 @@ var unvalidatedRedirect = {
|
|
|
766
778
|
category: "Injection",
|
|
767
779
|
description: "Redirecting users to URLs from untrusted input enables phishing attacks.",
|
|
768
780
|
check(content, filePath) {
|
|
781
|
+
if (isTestFile(filePath)) return [];
|
|
782
|
+
if (/isAllowedRedirect|validateRedirect|isSafeRedirect|allowedDomains|trustedDomains|whitelist.*url|allowlist.*url/i.test(content)) return [];
|
|
769
783
|
const patterns = [
|
|
770
784
|
/window\.location\s*=\s*(?!["'`]https?:\/\/)/g,
|
|
771
785
|
/window\.location\.href\s*=\s*(?!["'`]https?:\/\/)/g,
|
|
@@ -1156,6 +1170,7 @@ var dangerousInnerHTML = {
|
|
|
1156
1170
|
const value = m[1].trim();
|
|
1157
1171
|
if (/^[A-Z][A-Z0-9_]+$/.test(value)) continue;
|
|
1158
1172
|
if (/^["'`][^$]*["'`]$/.test(value)) continue;
|
|
1173
|
+
if (/JSON\.stringify/i.test(value)) continue;
|
|
1159
1174
|
const lineNum = content.substring(0, m.index).split("\n").length;
|
|
1160
1175
|
findings.push({
|
|
1161
1176
|
rule: "VC063",
|
|
@@ -1389,26 +1404,37 @@ var complianceMap = {
|
|
|
1389
1404
|
VC148: { owasp: "A09:2021", cwe: "CWE-209" },
|
|
1390
1405
|
VC149: { owasp: "A07:2021", cwe: "CWE-798" },
|
|
1391
1406
|
VC150: { owasp: "A07:2021", cwe: "CWE-615" },
|
|
1392
|
-
VC151: { owasp: "A07:2021", cwe: "CWE-214" }
|
|
1407
|
+
VC151: { owasp: "A07:2021", cwe: "CWE-214" },
|
|
1408
|
+
// VC152–VC158: New rules
|
|
1409
|
+
VC152: { owasp: "A08:2021", cwe: "CWE-345" },
|
|
1410
|
+
VC153: { owasp: "A05:2021", cwe: "CWE-942" },
|
|
1411
|
+
VC154: { owasp: "A03:2021", cwe: "CWE-20" },
|
|
1412
|
+
VC155: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
1413
|
+
VC156: { owasp: "A04:2021", cwe: "CWE-770" },
|
|
1414
|
+
VC157: { owasp: "A05:2021", cwe: "CWE-16" },
|
|
1415
|
+
VC158: { owasp: "A01:2021", cwe: "CWE-639" }
|
|
1393
1416
|
};
|
|
1394
1417
|
var consoleLogProduction = {
|
|
1395
1418
|
id: "VC097",
|
|
1396
1419
|
title: "Console.log Left in Production Code",
|
|
1397
|
-
severity: "
|
|
1420
|
+
severity: "low",
|
|
1398
1421
|
category: "Performance",
|
|
1399
1422
|
description: "console.log statements left in production code can leak sensitive data, slow down rendering, and clutter browser consoles.",
|
|
1400
1423
|
check(content, filePath) {
|
|
1401
|
-
if (filePath
|
|
1424
|
+
if (isTestFile(filePath)) return [];
|
|
1425
|
+
if (/(?:migrate|seed|script|cli|setup|dev)\./i.test(filePath)) return [];
|
|
1402
1426
|
if (!/console\.log\s*\(/g.test(content)) return [];
|
|
1403
1427
|
const lines = content.split("\n");
|
|
1428
|
+
const logCount = lines.filter((l) => /console\.log\s*\(/.test(l.trim()) && !l.trim().startsWith("//")).length;
|
|
1429
|
+
if (logCount > 5) return [];
|
|
1404
1430
|
const matches = [];
|
|
1405
1431
|
for (let i = 0; i < lines.length; i++) {
|
|
1406
1432
|
const line = lines[i].trim();
|
|
1407
1433
|
if (/console\.log\s*\(/.test(line) && !line.startsWith("//") && !line.startsWith("*") && !/if\s*\(\s*(?:debug|process\.env)/i.test(lines[Math.max(0, i - 1)] + line)) {
|
|
1408
|
-
matches.push({ rule: "VC097", title: consoleLogProduction.title, severity: "
|
|
1434
|
+
matches.push({ rule: "VC097", title: consoleLogProduction.title, severity: "low", category: "Performance", file: filePath, line: i + 1, snippet: getSnippet(content, i + 1), fix: "Remove console.log or use a structured logger that can be disabled in production." });
|
|
1409
1435
|
}
|
|
1410
1436
|
}
|
|
1411
|
-
return matches.slice(0,
|
|
1437
|
+
return matches.slice(0, 1);
|
|
1412
1438
|
}
|
|
1413
1439
|
};
|
|
1414
1440
|
var todoLeftInCode = {
|
|
@@ -1512,6 +1538,8 @@ function runCustomRules(content, filePath, disabledRules = [], tier = "free", ex
|
|
|
1512
1538
|
if (/function runScan\(files\)|export function runCustomRules/.test(content) && /const (?:rules|allRules)\s*[:=]/.test(content) && /findMatches/.test(content)) {
|
|
1513
1539
|
return findings;
|
|
1514
1540
|
}
|
|
1541
|
+
if (/pro-rules-bundle|\.bundle\./i.test(filePath)) return findings;
|
|
1542
|
+
if (/onboarding|demo-data|example-vulnerable|code-sample/i.test(filePath)) return findings;
|
|
1515
1543
|
const ruleset = tier === "pro" && extraRules.length > 0 ? [...freeRules, ...extraRules] : freeRules;
|
|
1516
1544
|
for (const rule of ruleset) {
|
|
1517
1545
|
if (disabledRules.includes(rule.id)) continue;
|
|
@@ -1888,6 +1916,120 @@ function chunkFiles(files, maxChars) {
|
|
|
1888
1916
|
return chunks;
|
|
1889
1917
|
}
|
|
1890
1918
|
|
|
1919
|
+
// src/scanners/ai-fp-filter.ts
|
|
1920
|
+
import Anthropic2 from "@anthropic-ai/sdk";
|
|
1921
|
+
var REVIEW_SYSTEM_PROMPT = `You are reviewing security scan findings for false positives. Your job is to look at each finding and the surrounding code to determine if it's a REAL security vulnerability or a FALSE POSITIVE.
|
|
1922
|
+
|
|
1923
|
+
Common false positive patterns you should catch:
|
|
1924
|
+
- Auth check exists inside the function body (requireUser, requireUserForApi, getSession, etc.) but the scanner only checked the function signature
|
|
1925
|
+
- The flagged pattern is in example/documentation/tutorial code, not production code
|
|
1926
|
+
- The variable is developer-controlled (constants, config values, static strings), not user input
|
|
1927
|
+
- The flagged function is a database method (conn.exec, db.exec, prisma.$executeRaw) not a shell command (child_process.exec)
|
|
1928
|
+
- The comparison is a type check (typeof x === "string") not a secret comparison
|
|
1929
|
+
- The file is a test, mock, or fixture file
|
|
1930
|
+
- The "secret" is a publishable/public key (pk_test_, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) designed to be client-side
|
|
1931
|
+
- The innerHTML/dangerouslySetInnerHTML uses a static constant or JSON.stringify, not user input
|
|
1932
|
+
- The redirect URL has already been validated (isAllowedRedirect, validateRedirect)
|
|
1933
|
+
- The webhook endpoint is for a non-Stripe service but flagged as "Stripe webhook"
|
|
1934
|
+
- The "sensitive data in URL" is in a comment or documentation, not actual code
|
|
1935
|
+
- Package-lock.json URLs flagged as secrets (they're npm registry URLs, not secrets)
|
|
1936
|
+
|
|
1937
|
+
For each finding, respond ONLY with a JSON array. No other text.
|
|
1938
|
+
Each element: {"index": <number>, "verdict": "real" or "fp", "reason": "<1 sentence>"}`;
|
|
1939
|
+
var MAX_FINDINGS_PER_BATCH = 15;
|
|
1940
|
+
var MAX_CONTEXT_LINES = 10;
|
|
1941
|
+
var MAX_TOTAL_FINDINGS = 50;
|
|
1942
|
+
function getExpandedContext(content, line, contextLines = MAX_CONTEXT_LINES) {
|
|
1943
|
+
const lines = content.split("\n");
|
|
1944
|
+
const start = Math.max(0, line - 1 - contextLines);
|
|
1945
|
+
const end = Math.min(lines.length, line + contextLines);
|
|
1946
|
+
return lines.slice(start, end).map((l, i) => {
|
|
1947
|
+
const lineNum = start + i + 1;
|
|
1948
|
+
const marker = lineNum === line ? ">>>" : " ";
|
|
1949
|
+
return `${marker} ${lineNum} | ${l}`;
|
|
1950
|
+
}).join("\n");
|
|
1951
|
+
}
|
|
1952
|
+
function buildReviewPrompt(findings, fileContent) {
|
|
1953
|
+
const parts = [];
|
|
1954
|
+
for (let i = 0; i < findings.length; i++) {
|
|
1955
|
+
const f = findings[i];
|
|
1956
|
+
const context = getExpandedContext(fileContent, f.line);
|
|
1957
|
+
parts.push(`--- Finding ${i} ---
|
|
1958
|
+
Rule: ${f.rule} (${f.title})
|
|
1959
|
+
Severity: ${f.severity}
|
|
1960
|
+
File: ${f.file}
|
|
1961
|
+
Line: ${f.line}
|
|
1962
|
+
Description: ${f.description}
|
|
1963
|
+
Suggested fix: ${f.fix || "N/A"}
|
|
1964
|
+
|
|
1965
|
+
Code context:
|
|
1966
|
+
${context}
|
|
1967
|
+
`);
|
|
1968
|
+
}
|
|
1969
|
+
return `Review these ${findings.length} security scan findings. For each one, determine if it's a real vulnerability or a false positive based on the code context.
|
|
1970
|
+
|
|
1971
|
+
${parts.join("\n")}`;
|
|
1972
|
+
}
|
|
1973
|
+
function parseReviewResponse(text) {
|
|
1974
|
+
try {
|
|
1975
|
+
const cleaned = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
|
|
1976
|
+
const parsed = JSON.parse(cleaned);
|
|
1977
|
+
if (!Array.isArray(parsed)) return [];
|
|
1978
|
+
return parsed.filter(
|
|
1979
|
+
(r) => typeof r === "object" && r !== null && "index" in r && "verdict" in r && r.verdict === "real" || r.verdict === "fp"
|
|
1980
|
+
);
|
|
1981
|
+
} catch {
|
|
1982
|
+
return [];
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
async function filterFalsePositives(findings, fileContents) {
|
|
1986
|
+
if (!process.env.ANTHROPIC_API_KEY) return findings;
|
|
1987
|
+
if (findings.length === 0) return findings;
|
|
1988
|
+
const toReview = findings.slice(0, MAX_TOTAL_FINDINGS);
|
|
1989
|
+
const overflow = findings.slice(MAX_TOTAL_FINDINGS);
|
|
1990
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1991
|
+
for (const f of toReview) {
|
|
1992
|
+
const group = byFile.get(f.file) || [];
|
|
1993
|
+
group.push(f);
|
|
1994
|
+
byFile.set(f.file, group);
|
|
1995
|
+
}
|
|
1996
|
+
let client;
|
|
1997
|
+
try {
|
|
1998
|
+
client = new Anthropic2();
|
|
1999
|
+
} catch {
|
|
2000
|
+
return findings;
|
|
2001
|
+
}
|
|
2002
|
+
const fpIndices = /* @__PURE__ */ new Set();
|
|
2003
|
+
for (const [file, fileFindings] of byFile) {
|
|
2004
|
+
const content = fileContents.get(file);
|
|
2005
|
+
if (!content) continue;
|
|
2006
|
+
for (let i = 0; i < fileFindings.length; i += MAX_FINDINGS_PER_BATCH) {
|
|
2007
|
+
const batch = fileFindings.slice(i, i + MAX_FINDINGS_PER_BATCH);
|
|
2008
|
+
const prompt = buildReviewPrompt(batch, content);
|
|
2009
|
+
try {
|
|
2010
|
+
const response = await client.messages.create({
|
|
2011
|
+
model: "claude-haiku-4-5-20251001",
|
|
2012
|
+
max_tokens: 1024,
|
|
2013
|
+
system: REVIEW_SYSTEM_PROMPT,
|
|
2014
|
+
messages: [{ role: "user", content: prompt }]
|
|
2015
|
+
});
|
|
2016
|
+
const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
2017
|
+
const results = parseReviewResponse(text);
|
|
2018
|
+
for (const r of results) {
|
|
2019
|
+
if (r.verdict === "fp" && r.index >= 0 && r.index < batch.length) {
|
|
2020
|
+
const globalIndex = toReview.indexOf(batch[r.index]);
|
|
2021
|
+
if (globalIndex !== -1) fpIndices.add(globalIndex);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
} catch {
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const filtered = toReview.filter((_, i) => !fpIndices.has(i));
|
|
2030
|
+
return [...filtered, ...overflow];
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1891
2033
|
// src/scanners/ast-analyzer.ts
|
|
1892
2034
|
function stripComments(content) {
|
|
1893
2035
|
let result = "";
|
|
@@ -2540,6 +2682,7 @@ function scanEntropy(files) {
|
|
|
2540
2682
|
if (SKIP_FILENAMES.test(basename)) continue;
|
|
2541
2683
|
if (filePath.includes("node_modules")) continue;
|
|
2542
2684
|
if (filePath.includes(".min.")) continue;
|
|
2685
|
+
if (/pro-rules-bundle|\.bundle\.|\.chunk\./i.test(filePath)) continue;
|
|
2543
2686
|
if (/(?:\.test\.|\.spec\.|__tests__|__mocks__|fixtures?\/)/i.test(filePath)) continue;
|
|
2544
2687
|
const lines = content.split("\n");
|
|
2545
2688
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -3483,6 +3626,30 @@ async function scanCommand(directory, options) {
|
|
|
3483
3626
|
chalk2.gray("Tip: Set ANTHROPIC_API_KEY for AI-powered contextual analysis")
|
|
3484
3627
|
);
|
|
3485
3628
|
}
|
|
3629
|
+
if (process.env.ANTHROPIC_API_KEY && allFindings.length > 0) {
|
|
3630
|
+
spinner.text = "AI reviewing findings for false positives...";
|
|
3631
|
+
spinner.color = "cyan";
|
|
3632
|
+
try {
|
|
3633
|
+
const fileContentsMap = /* @__PURE__ */ new Map();
|
|
3634
|
+
for (const f of allFindings) {
|
|
3635
|
+
if (!fileContentsMap.has(f.file)) {
|
|
3636
|
+
const content = readFileContents(dir, f.file);
|
|
3637
|
+
if (content) fileContentsMap.set(f.file, content);
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
const beforeCount = allFindings.length;
|
|
3641
|
+
const filtered = await filterFalsePositives(allFindings, fileContentsMap);
|
|
3642
|
+
const removed = beforeCount - filtered.length;
|
|
3643
|
+
if (removed > 0) {
|
|
3644
|
+
allFindings.length = 0;
|
|
3645
|
+
allFindings.push(...filtered);
|
|
3646
|
+
}
|
|
3647
|
+
if (removed > 0 && verbose) {
|
|
3648
|
+
spinner.info(`AI review removed ${removed} false positive${removed !== 1 ? "s" : ""}`);
|
|
3649
|
+
}
|
|
3650
|
+
} catch {
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3486
3653
|
spinner.stop();
|
|
3487
3654
|
const seen = /* @__PURE__ */ new Set();
|
|
3488
3655
|
const dedupedFindings = allFindings.filter((f) => {
|
|
@@ -3512,9 +3679,9 @@ async function scanCommand(directory, options) {
|
|
|
3512
3679
|
if (tier === "free" && !isSilent) {
|
|
3513
3680
|
console.log("");
|
|
3514
3681
|
if (userPlan === "anonymous") {
|
|
3515
|
-
console.log(chalk2.gray(" Scanned with 30 free rules.") + chalk2.cyan(" Log in to unlock all
|
|
3682
|
+
console.log(chalk2.gray(" Scanned with 30 free rules.") + chalk2.cyan(" Log in to unlock all 158 rules \u2192") + chalk2.bold(" xploitscan auth login"));
|
|
3516
3683
|
} else {
|
|
3517
|
-
console.log(chalk2.gray(" Scanned with 30 rules.") + chalk2.cyan(" Upgrade to Pro for all
|
|
3684
|
+
console.log(chalk2.gray(" Scanned with 30 rules.") + chalk2.cyan(" Upgrade to Pro for all 158 rules \u2192") + chalk2.bold(" xploitscan upgrade"));
|
|
3518
3685
|
}
|
|
3519
3686
|
console.log("");
|
|
3520
3687
|
}
|