xploitscan 1.0.3 → 1.0.7
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 +243 -15
- package/dist/index.js.map +1 -1
- package/dist/templates/cursor-security.mdc +112 -0
- package/dist/templates/cursorrules-legacy.txt +51 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1925,6 +1925,85 @@ function buildASTContext(content, filePath) {
|
|
|
1925
1925
|
};
|
|
1926
1926
|
}
|
|
1927
1927
|
|
|
1928
|
+
// src/scanners/osv.ts
|
|
1929
|
+
var OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
1930
|
+
var OSV_VULN_URL = "https://api.osv.dev/v1/vulns";
|
|
1931
|
+
var NETWORK_TIMEOUT_MS = 5e3;
|
|
1932
|
+
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
1933
|
+
const controller = new AbortController();
|
|
1934
|
+
const id = setTimeout(() => controller.abort(), timeoutMs);
|
|
1935
|
+
try {
|
|
1936
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
1937
|
+
} finally {
|
|
1938
|
+
clearTimeout(id);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
async function queryOsvBatch(queries) {
|
|
1942
|
+
if (queries.length === 0) return [];
|
|
1943
|
+
try {
|
|
1944
|
+
const batchRes = await fetchWithTimeout(
|
|
1945
|
+
OSV_BATCH_URL,
|
|
1946
|
+
{
|
|
1947
|
+
method: "POST",
|
|
1948
|
+
headers: { "Content-Type": "application/json" },
|
|
1949
|
+
body: JSON.stringify({ queries })
|
|
1950
|
+
},
|
|
1951
|
+
NETWORK_TIMEOUT_MS
|
|
1952
|
+
);
|
|
1953
|
+
if (!batchRes.ok) return [];
|
|
1954
|
+
const batch = await batchRes.json();
|
|
1955
|
+
if (!Array.isArray(batch.results)) return [];
|
|
1956
|
+
const uniqueVulnIds = /* @__PURE__ */ new Set();
|
|
1957
|
+
for (const result of batch.results) {
|
|
1958
|
+
for (const v of result.vulns ?? []) uniqueVulnIds.add(v.id);
|
|
1959
|
+
}
|
|
1960
|
+
const vulnDetails = /* @__PURE__ */ new Map();
|
|
1961
|
+
for (const vulnId of uniqueVulnIds) {
|
|
1962
|
+
try {
|
|
1963
|
+
const detailRes = await fetchWithTimeout(
|
|
1964
|
+
`${OSV_VULN_URL}/${encodeURIComponent(vulnId)}`,
|
|
1965
|
+
{ method: "GET" },
|
|
1966
|
+
NETWORK_TIMEOUT_MS
|
|
1967
|
+
);
|
|
1968
|
+
if (!detailRes.ok) continue;
|
|
1969
|
+
const detail = await detailRes.json();
|
|
1970
|
+
vulnDetails.set(vulnId, detail);
|
|
1971
|
+
} catch {
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
const matches = [];
|
|
1975
|
+
batch.results.forEach((result, i) => {
|
|
1976
|
+
const ids = (result.vulns ?? []).map((v) => v.id);
|
|
1977
|
+
if (ids.length === 0) return;
|
|
1978
|
+
const q = queries[i];
|
|
1979
|
+
matches.push({
|
|
1980
|
+
ecosystem: q.package.ecosystem,
|
|
1981
|
+
name: q.package.name,
|
|
1982
|
+
version: q.version,
|
|
1983
|
+
vulns: ids.map((id) => vulnDetails.get(id) ?? { id })
|
|
1984
|
+
});
|
|
1985
|
+
});
|
|
1986
|
+
return matches;
|
|
1987
|
+
} catch {
|
|
1988
|
+
return [];
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
function osvSeverity(vuln) {
|
|
1992
|
+
const vectors = vuln.severity ?? [];
|
|
1993
|
+
for (const v of vectors) {
|
|
1994
|
+
const match = v.score.match(/\b(\d+(?:\.\d+)?)\b/);
|
|
1995
|
+
if (!match) continue;
|
|
1996
|
+
const score = parseFloat(match[1]);
|
|
1997
|
+
if (Number.isFinite(score)) {
|
|
1998
|
+
if (score >= 9) return "critical";
|
|
1999
|
+
if (score >= 7) return "high";
|
|
2000
|
+
if (score >= 4) return "medium";
|
|
2001
|
+
return "low";
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return "medium";
|
|
2005
|
+
}
|
|
2006
|
+
|
|
1928
2007
|
// src/scanners/dependency-scanner.ts
|
|
1929
2008
|
var KNOWN_VULN_NPM = {
|
|
1930
2009
|
"lodash": [{
|
|
@@ -2225,6 +2304,94 @@ function scanDependencies(files) {
|
|
|
2225
2304
|
}
|
|
2226
2305
|
return findings;
|
|
2227
2306
|
}
|
|
2307
|
+
async function scanDependenciesOsv(files, alreadyFoundByRule) {
|
|
2308
|
+
const lookups = [];
|
|
2309
|
+
for (const { path: filePath, content } of files) {
|
|
2310
|
+
if (filePath.endsWith("package.json") && !filePath.includes("node_modules")) {
|
|
2311
|
+
try {
|
|
2312
|
+
const pkg = JSON.parse(content);
|
|
2313
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2314
|
+
for (const [name, rawVersion] of Object.entries(allDeps)) {
|
|
2315
|
+
const version = String(rawVersion).replace(/^[\^~>=<]*/g, "").trim();
|
|
2316
|
+
if (!version || version === "*" || version === "latest") continue;
|
|
2317
|
+
const line = content.split("\n").findIndex((l) => l.includes(`"${name}"`)) + 1;
|
|
2318
|
+
lookups.push({ ecosystem: "npm", name, version, file: filePath, line: line || 1, content });
|
|
2319
|
+
}
|
|
2320
|
+
} catch {
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
if (filePath.match(/requirements.*\.txt$/i)) {
|
|
2324
|
+
const lines = content.split("\n");
|
|
2325
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2326
|
+
const m = lines[i].trim().match(/^([a-zA-Z0-9_-]+)==([\d.]+)/);
|
|
2327
|
+
if (!m) continue;
|
|
2328
|
+
lookups.push({
|
|
2329
|
+
ecosystem: "PyPI",
|
|
2330
|
+
name: m[1].toLowerCase(),
|
|
2331
|
+
version: m[2],
|
|
2332
|
+
file: filePath,
|
|
2333
|
+
line: i + 1,
|
|
2334
|
+
content
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
if (filePath.endsWith("Gemfile.lock")) {
|
|
2339
|
+
const lines = content.split("\n");
|
|
2340
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2341
|
+
const m = lines[i].match(/^\s{4}([a-z0-9_-]+)\s+\(([\d.]+)\)/);
|
|
2342
|
+
if (!m) continue;
|
|
2343
|
+
lookups.push({
|
|
2344
|
+
ecosystem: "RubyGems",
|
|
2345
|
+
name: m[1],
|
|
2346
|
+
version: m[2],
|
|
2347
|
+
file: filePath,
|
|
2348
|
+
line: i + 1,
|
|
2349
|
+
content
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
if (lookups.length === 0) return [];
|
|
2355
|
+
const CHUNK = 500;
|
|
2356
|
+
const findings = [];
|
|
2357
|
+
for (let start = 0; start < lookups.length; start += CHUNK) {
|
|
2358
|
+
const chunk = lookups.slice(start, start + CHUNK);
|
|
2359
|
+
const matches = await queryOsvBatch(
|
|
2360
|
+
chunk.map((l) => ({ package: { name: l.name, ecosystem: l.ecosystem }, version: l.version }))
|
|
2361
|
+
);
|
|
2362
|
+
const matchByKey = /* @__PURE__ */ new Map();
|
|
2363
|
+
for (const m of matches) matchByKey.set(`${m.ecosystem}:${m.name}:${m.version}`, m);
|
|
2364
|
+
for (const lookup of chunk) {
|
|
2365
|
+
const key = `${lookup.ecosystem}:${lookup.name}:${lookup.version}`;
|
|
2366
|
+
const match = matchByKey.get(key);
|
|
2367
|
+
if (!match) continue;
|
|
2368
|
+
for (const vuln of match.vulns) {
|
|
2369
|
+
const dedupeKey = `${lookup.name}:${vuln.id}`;
|
|
2370
|
+
if (alreadyFoundByRule.has(dedupeKey)) continue;
|
|
2371
|
+
alreadyFoundByRule.add(dedupeKey);
|
|
2372
|
+
const severity = osvSeverity(vuln);
|
|
2373
|
+
const title = vuln.summary || `${lookup.name} vulnerability ${vuln.id}`;
|
|
2374
|
+
const description = vuln.details || vuln.summary || `${lookup.name}@${lookup.version} is affected by ${vuln.id}. See https://osv.dev/vulnerability/${vuln.id} for details.`;
|
|
2375
|
+
findings.push({
|
|
2376
|
+
id: `${vuln.id}-${lookup.file}:${lookup.line}`,
|
|
2377
|
+
rule: vuln.id,
|
|
2378
|
+
severity,
|
|
2379
|
+
title,
|
|
2380
|
+
description: description.slice(0, 500),
|
|
2381
|
+
file: lookup.file,
|
|
2382
|
+
line: lookup.line,
|
|
2383
|
+
snippet: getSnippet2(lookup.content, lookup.line),
|
|
2384
|
+
fix: `Upgrade ${lookup.name} to a version that resolves ${vuln.id}. See https://osv.dev/vulnerability/${vuln.id}`,
|
|
2385
|
+
category: "Dependencies",
|
|
2386
|
+
source: "custom",
|
|
2387
|
+
owasp: "A06:2021",
|
|
2388
|
+
cwe: "CWE-1395"
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return findings;
|
|
2394
|
+
}
|
|
2228
2395
|
|
|
2229
2396
|
// src/scanners/entropy-scanner.ts
|
|
2230
2397
|
function shannonEntropy(str) {
|
|
@@ -3003,13 +3170,13 @@ function renderSarifReport(result) {
|
|
|
3003
3170
|
}
|
|
3004
3171
|
},
|
|
3005
3172
|
results: result.findings.map((f) => {
|
|
3006
|
-
const
|
|
3173
|
+
const messageText = f.fix ? `${f.title}: ${f.description}
|
|
3174
|
+
Suggested fix: ${f.fix}` : `${f.title}: ${f.description}`;
|
|
3175
|
+
return {
|
|
3007
3176
|
ruleId: f.rule,
|
|
3008
3177
|
ruleIndex: ruleIndex.get(f.rule) ?? 0,
|
|
3009
3178
|
level: SEVERITY_TO_SARIF[f.severity],
|
|
3010
|
-
message: {
|
|
3011
|
-
text: `${f.title}: ${f.description}`
|
|
3012
|
-
},
|
|
3179
|
+
message: { text: messageText },
|
|
3013
3180
|
locations: [
|
|
3014
3181
|
{
|
|
3015
3182
|
physicalLocation: {
|
|
@@ -3022,10 +3189,6 @@ function renderSarifReport(result) {
|
|
|
3022
3189
|
}
|
|
3023
3190
|
]
|
|
3024
3191
|
};
|
|
3025
|
-
if (f.fix) {
|
|
3026
|
-
entry.fixes = [{ description: { text: f.fix } }];
|
|
3027
|
-
}
|
|
3028
|
-
return entry;
|
|
3029
3192
|
})
|
|
3030
3193
|
}
|
|
3031
3194
|
]
|
|
@@ -3140,6 +3303,24 @@ async function scanCommand(directory, options) {
|
|
|
3140
3303
|
f.confidence = "high";
|
|
3141
3304
|
}
|
|
3142
3305
|
allFindings.push(...depFindings);
|
|
3306
|
+
const dedupeKeys = /* @__PURE__ */ new Set();
|
|
3307
|
+
for (const f of depFindings) {
|
|
3308
|
+
const match = f.title.match(/^(\S+)/);
|
|
3309
|
+
if (match) dedupeKeys.add(`${match[1].toLowerCase()}:${f.rule}`);
|
|
3310
|
+
}
|
|
3311
|
+
spinner.text = "Checking dependencies against OSV.dev...";
|
|
3312
|
+
try {
|
|
3313
|
+
const osvFindings = await scanDependenciesOsv(fileContentsForAnalysis, dedupeKeys);
|
|
3314
|
+
for (const f of osvFindings) {
|
|
3315
|
+
f.confidence = "high";
|
|
3316
|
+
}
|
|
3317
|
+
allFindings.push(...osvFindings);
|
|
3318
|
+
if (verbose && osvFindings.length > 0) {
|
|
3319
|
+
spinner.info(`OSV.dev found ${osvFindings.length} additional vulnerable dependencies`);
|
|
3320
|
+
}
|
|
3321
|
+
} catch (err) {
|
|
3322
|
+
if (verbose) spinner.info(`OSV.dev lookup skipped: ${err instanceof Error ? err.message : "unknown error"}`);
|
|
3323
|
+
}
|
|
3143
3324
|
if (verbose && depFindings.length > 0) {
|
|
3144
3325
|
spinner.info(`Dependency scanner found ${depFindings.length} issues`);
|
|
3145
3326
|
}
|
|
@@ -3592,11 +3773,54 @@ async function uninstallHookCommand() {
|
|
|
3592
3773
|
}
|
|
3593
3774
|
}
|
|
3594
3775
|
|
|
3776
|
+
// src/commands/cursor.ts
|
|
3777
|
+
import chalk5 from "chalk";
|
|
3778
|
+
import { mkdirSync, existsSync as existsSync5, writeFileSync as writeFileSync2, readFileSync as readFileSync4 } from "fs";
|
|
3779
|
+
import { join as join7, dirname } from "path";
|
|
3780
|
+
import { fileURLToPath } from "url";
|
|
3781
|
+
async function cursorInstallCommand(opts = {}) {
|
|
3782
|
+
const cwd = process.cwd();
|
|
3783
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
3784
|
+
const candidates = [
|
|
3785
|
+
join7(here, "templates"),
|
|
3786
|
+
join7(here, "..", "templates"),
|
|
3787
|
+
join7(here, "..", "src", "templates")
|
|
3788
|
+
];
|
|
3789
|
+
const templatesDir = candidates.find((p) => existsSync5(join7(p, "cursor-security.mdc")));
|
|
3790
|
+
if (!templatesDir) {
|
|
3791
|
+
console.error(chalk5.red("Could not locate XploitScan rule templates. Try reinstalling: npm i -g xploitscan@latest"));
|
|
3792
|
+
process.exit(1);
|
|
3793
|
+
}
|
|
3794
|
+
const mdcSrc = readFileSync4(join7(templatesDir, "cursor-security.mdc"), "utf-8");
|
|
3795
|
+
const legacySrc = readFileSync4(join7(templatesDir, "cursorrules-legacy.txt"), "utf-8");
|
|
3796
|
+
if (!opts.legacyOnly) {
|
|
3797
|
+
const mdcDir = join7(cwd, ".cursor", "rules");
|
|
3798
|
+
const mdcPath = join7(mdcDir, "xploitscan-security.mdc");
|
|
3799
|
+
if (existsSync5(mdcPath) && !opts.force) {
|
|
3800
|
+
console.log(chalk5.yellow(`Skipping ${mdcPath} (already exists, use --force to overwrite)`));
|
|
3801
|
+
} else {
|
|
3802
|
+
mkdirSync(mdcDir, { recursive: true });
|
|
3803
|
+
writeFileSync2(mdcPath, mdcSrc);
|
|
3804
|
+
console.log(chalk5.green(`Installed ${mdcPath}`));
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
const legacyPath = join7(cwd, ".cursorrules");
|
|
3808
|
+
if (existsSync5(legacyPath) && !opts.force) {
|
|
3809
|
+
console.log(chalk5.yellow(`Skipping ${legacyPath} (already exists, use --force to overwrite)`));
|
|
3810
|
+
} else {
|
|
3811
|
+
writeFileSync2(legacyPath, legacySrc);
|
|
3812
|
+
console.log(chalk5.green(`Installed ${legacyPath}`));
|
|
3813
|
+
}
|
|
3814
|
+
console.log("");
|
|
3815
|
+
console.log(chalk5.cyan("Cursor will pick up these rules automatically the next time you open the project."));
|
|
3816
|
+
console.log(chalk5.gray("Run a scan any time to catch what slipped through: npx xploitscan scan ."));
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3595
3819
|
// src/index.ts
|
|
3596
3820
|
var program = new Command();
|
|
3597
3821
|
program.name("xploitscan").description(
|
|
3598
3822
|
"AI security scanner for vibe-coded apps. Find vulnerabilities before attackers do."
|
|
3599
|
-
).version("1.0.
|
|
3823
|
+
).version("1.0.7");
|
|
3600
3824
|
program.command("scan").description("Scan a directory for security vulnerabilities").argument("[directory]", "Directory to scan", ".").option("--no-ai", "Skip AI-powered analysis").option("-f, --format <format>", "Output format: terminal, json, sarif", "terminal").option("-v, --verbose", "Show detailed output", false).option("--diff [base]", "Scan only files changed vs base branch (default: main)").option("-w, --watch", "Watch for file changes and re-scan automatically", false).action(async (directory, opts) => {
|
|
3601
3825
|
await scanCommand(directory, {
|
|
3602
3826
|
directory,
|
|
@@ -3616,26 +3840,30 @@ hook.command("install").description("Install a git pre-commit hook that runs Xpl
|
|
|
3616
3840
|
await installHookCommand({ force: opts.force });
|
|
3617
3841
|
});
|
|
3618
3842
|
hook.command("uninstall").description("Remove the XploitScan pre-commit hook").action(uninstallHookCommand);
|
|
3843
|
+
var cursor = program.command("cursor").description("Manage XploitScan integration with Cursor IDE");
|
|
3844
|
+
cursor.command("install").description("Drop XploitScan security rules into .cursor/rules and .cursorrules so Cursor enforces them at write-time").option("-f, --force", "Overwrite existing rule files", false).option("--legacy-only", "Only install the legacy .cursorrules file (skip .cursor/rules/*.mdc)", false).action(async (opts) => {
|
|
3845
|
+
await cursorInstallCommand({ force: opts.force, legacyOnly: opts.legacyOnly });
|
|
3846
|
+
});
|
|
3619
3847
|
program.command("upgrade").description("Upgrade to XploitScan Pro for unlimited scans").action(async () => {
|
|
3620
3848
|
const { getStoredToken: getStoredToken2, getCheckoutUrl } = await import("./api-HTHCG6QE.js");
|
|
3621
|
-
const
|
|
3849
|
+
const chalk6 = (await import("chalk")).default;
|
|
3622
3850
|
const token = getStoredToken2();
|
|
3623
3851
|
if (!token) {
|
|
3624
|
-
console.log(
|
|
3852
|
+
console.log(chalk6.yellow("Please log in first: xploitscan auth login"));
|
|
3625
3853
|
return;
|
|
3626
3854
|
}
|
|
3627
|
-
console.log(
|
|
3855
|
+
console.log(chalk6.cyan("Creating checkout session..."));
|
|
3628
3856
|
const url = await getCheckoutUrl();
|
|
3629
3857
|
if (url) {
|
|
3630
|
-
console.log(
|
|
3858
|
+
console.log(chalk6.green(`
|
|
3631
3859
|
Open this URL to upgrade:`));
|
|
3632
|
-
console.log(
|
|
3860
|
+
console.log(chalk6.bold.underline(url));
|
|
3633
3861
|
const { execFile: execFile4 } = await import("child_process");
|
|
3634
3862
|
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
3635
3863
|
execFile4(openCmd, [url], () => {
|
|
3636
3864
|
});
|
|
3637
3865
|
} else {
|
|
3638
|
-
console.log(
|
|
3866
|
+
console.log(chalk6.red("Failed to create checkout session. Please try again."));
|
|
3639
3867
|
}
|
|
3640
3868
|
});
|
|
3641
3869
|
program.parse();
|