kramscan 0.1.1 → 0.2.0
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 +392 -236
- package/dist/agent/confirmation.d.ts +5 -1
- package/dist/agent/confirmation.js +29 -9
- package/dist/agent/context.js +2 -3
- package/dist/agent/orchestrator.d.ts +2 -0
- package/dist/agent/orchestrator.js +50 -8
- package/dist/agent/prompts/system.d.ts +1 -1
- package/dist/agent/prompts/system.js +5 -7
- package/dist/agent/skills/health-check.js +22 -2
- package/dist/agent/skills/index.d.ts +1 -0
- package/dist/agent/skills/index.js +3 -1
- package/dist/agent/skills/verify-finding.d.ts +17 -0
- package/dist/agent/skills/verify-finding.js +91 -0
- package/dist/agent/skills/web-scan.js +46 -0
- package/dist/cli.js +150 -149
- package/dist/commands/agent.js +38 -38
- package/dist/commands/ai.d.ts +2 -0
- package/dist/commands/ai.js +112 -0
- package/dist/commands/analyze.js +103 -54
- package/dist/commands/config.js +55 -29
- package/dist/commands/doctor.js +20 -15
- package/dist/commands/onboard.js +188 -141
- package/dist/commands/report.js +68 -76
- package/dist/commands/scan.js +261 -81
- package/dist/commands/scans.d.ts +2 -0
- package/dist/commands/scans.js +51 -0
- package/dist/core/ai-client.d.ts +6 -1
- package/dist/core/ai-client.js +80 -12
- package/dist/core/ai-payloads.d.ts +17 -0
- package/dist/core/ai-payloads.js +54 -0
- package/dist/core/config-schema.d.ts +197 -0
- package/dist/core/config-schema.js +68 -0
- package/dist/core/config-schema.test.d.ts +1 -0
- package/dist/core/config-schema.test.js +151 -0
- package/dist/core/config.d.ts +8 -31
- package/dist/core/config.js +68 -11
- package/dist/core/errors.d.ts +71 -0
- package/dist/core/errors.js +162 -0
- package/dist/core/scan-index.d.ts +19 -0
- package/dist/core/scan-index.js +52 -0
- package/dist/core/scan-storage.d.ts +11 -0
- package/dist/core/scan-storage.js +69 -0
- package/dist/core/scanner.d.ts +95 -13
- package/dist/core/scanner.js +336 -248
- package/dist/core/vulnerability-detector.d.ts +3 -0
- package/dist/core/vulnerability-detector.js +25 -15
- package/dist/core/vulnerability-detector.test.d.ts +1 -0
- package/dist/core/vulnerability-detector.test.js +210 -0
- package/dist/index.js +3 -0
- package/dist/plugins/PluginManager.d.ts +27 -0
- package/dist/plugins/PluginManager.js +166 -0
- package/dist/plugins/index.d.ts +7 -0
- package/dist/plugins/index.js +19 -0
- package/dist/plugins/types.d.ts +55 -0
- package/dist/plugins/types.js +25 -0
- package/dist/plugins/vulnerabilities/CSRFPlugin.d.ts +8 -0
- package/dist/plugins/vulnerabilities/CSRFPlugin.js +34 -0
- package/dist/plugins/vulnerabilities/SQLInjectionPlugin.d.ts +11 -0
- package/dist/plugins/vulnerabilities/SQLInjectionPlugin.js +109 -0
- package/dist/plugins/vulnerabilities/SecurityHeadersPlugin.d.ts +11 -0
- package/dist/plugins/vulnerabilities/SecurityHeadersPlugin.js +63 -0
- package/dist/plugins/vulnerabilities/SensitiveDataPlugin.d.ts +9 -0
- package/dist/plugins/vulnerabilities/SensitiveDataPlugin.js +32 -0
- package/dist/plugins/vulnerabilities/XSSPlugin.d.ts +15 -0
- package/dist/plugins/vulnerabilities/XSSPlugin.js +81 -0
- package/dist/reports/PdfGenerator.d.ts +36 -0
- package/dist/reports/PdfGenerator.js +379 -0
- package/dist/utils/logger.d.ts +33 -1
- package/dist/utils/logger.js +127 -8
- package/dist/utils/theme.d.ts +55 -0
- package/dist/utils/theme.js +195 -0
- package/package.json +1 -1
package/dist/core/scanner.js
CHANGED
|
@@ -5,10 +5,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.Scanner = void 0;
|
|
7
7
|
const puppeteer_1 = __importDefault(require("puppeteer"));
|
|
8
|
+
const events_1 = require("events");
|
|
8
9
|
const vulnerability_detector_1 = require("./vulnerability-detector");
|
|
9
10
|
const config_1 = require("./config");
|
|
11
|
+
const ai_client_1 = require("./ai-client");
|
|
12
|
+
const ai_payloads_1 = require("./ai-payloads");
|
|
10
13
|
const logger_1 = require("../utils/logger");
|
|
11
|
-
|
|
14
|
+
const plugins_1 = require("../plugins");
|
|
15
|
+
const plugins_2 = require("../plugins");
|
|
16
|
+
class Scanner extends events_1.EventEmitter {
|
|
12
17
|
browser = null;
|
|
13
18
|
detector;
|
|
14
19
|
visitedUrls = new Set();
|
|
@@ -18,21 +23,123 @@ class Scanner {
|
|
|
18
23
|
headersChecked = new Set();
|
|
19
24
|
rateLimiter;
|
|
20
25
|
retryConfig;
|
|
21
|
-
|
|
26
|
+
maxConcurrency = 5;
|
|
27
|
+
strictScope = true;
|
|
28
|
+
baseOrigin = null;
|
|
29
|
+
maxPages = 30;
|
|
30
|
+
maxLinksPerPage = 50;
|
|
31
|
+
includePatterns = [];
|
|
32
|
+
excludePatterns = [];
|
|
33
|
+
userAgent = "KramScan/0.1.0";
|
|
34
|
+
scanErrors = [];
|
|
35
|
+
pluginErrors = new Map();
|
|
36
|
+
usePlugins = true;
|
|
37
|
+
useAiPayloads = false;
|
|
38
|
+
payloadGenerator = null;
|
|
39
|
+
constructor(usePlugins = true) {
|
|
40
|
+
super();
|
|
41
|
+
this.usePlugins = usePlugins;
|
|
22
42
|
this.detector = new vulnerability_detector_1.VulnerabilityDetector();
|
|
43
|
+
this.detector.setOnVulnerabilityFound((vuln) => {
|
|
44
|
+
this.emit("vuln:found", { vulnerability: vuln });
|
|
45
|
+
});
|
|
23
46
|
this.rateLimiter = {
|
|
24
47
|
lastRequestTime: 0,
|
|
25
|
-
minInterval: 200,
|
|
48
|
+
minInterval: 200,
|
|
26
49
|
};
|
|
27
50
|
this.retryConfig = {
|
|
28
51
|
maxRetries: 3,
|
|
29
52
|
baseDelay: 1000,
|
|
30
53
|
maxDelay: 10000,
|
|
31
54
|
};
|
|
55
|
+
// Register default plugins
|
|
56
|
+
if (usePlugins) {
|
|
57
|
+
this.registerDefaultPlugins();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
registerDefaultPlugins() {
|
|
61
|
+
plugins_1.pluginManager.register(new plugins_2.XSSPlugin());
|
|
62
|
+
plugins_1.pluginManager.register(new plugins_2.SQLInjectionPlugin());
|
|
63
|
+
plugins_1.pluginManager.register(new plugins_2.SecurityHeadersPlugin());
|
|
64
|
+
plugins_1.pluginManager.register(new plugins_2.SensitiveDataPlugin());
|
|
65
|
+
plugins_1.pluginManager.register(new plugins_2.CSRFPlugin());
|
|
66
|
+
}
|
|
67
|
+
// Type-safe event emitter methods
|
|
68
|
+
emit(event, data) {
|
|
69
|
+
return super.emit(event, data);
|
|
70
|
+
}
|
|
71
|
+
on(event, listener) {
|
|
72
|
+
return super.on(event, listener);
|
|
32
73
|
}
|
|
33
|
-
|
|
74
|
+
once(event, listener) {
|
|
75
|
+
return super.once(event, listener);
|
|
76
|
+
}
|
|
77
|
+
getScanErrors() {
|
|
78
|
+
return [...this.scanErrors];
|
|
79
|
+
}
|
|
80
|
+
getPluginErrors() {
|
|
81
|
+
return new Map(this.pluginErrors);
|
|
82
|
+
}
|
|
83
|
+
async initializeScanSettings(targetUrl, options) {
|
|
34
84
|
const config = await (0, config_1.getConfig)();
|
|
35
85
|
this.rateLimiter.minInterval = 1000 / (config.scan.rateLimitPerSecond || 5);
|
|
86
|
+
this.maxConcurrency = Math.max(1, config.scan.maxThreads || 5);
|
|
87
|
+
this.strictScope = options.strictScope ?? (config.scan.strictScope ?? true);
|
|
88
|
+
this.baseOrigin = new URL(targetUrl).origin;
|
|
89
|
+
this.userAgent = config.scan.userAgent || this.userAgent;
|
|
90
|
+
// Load scan profile
|
|
91
|
+
const profileName = options.profile || config.scan.defaultProfile || "balanced";
|
|
92
|
+
const profile = await (0, config_1.getScanProfile)(profileName);
|
|
93
|
+
this.maxPages = Math.max(1, options.maxPages ?? profile?.maxPages ?? 30);
|
|
94
|
+
this.maxLinksPerPage = Math.max(1, options.maxLinksPerPage ?? profile?.maxLinksPerPage ?? 50);
|
|
95
|
+
// Initialize AI payload generator if requested and AI is enabled
|
|
96
|
+
this.useAiPayloads = options.useAiPayloads ?? false;
|
|
97
|
+
if (this.useAiPayloads && config.ai.enabled) {
|
|
98
|
+
try {
|
|
99
|
+
const aiClient = await (0, ai_client_1.createAIClient)();
|
|
100
|
+
this.payloadGenerator = new ai_payloads_1.PayloadGenerator(aiClient);
|
|
101
|
+
logger_1.logger.info("AI contextual payload generation enabled");
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
logger_1.logger.warn(`Failed to initialize AI payload generator: ${err.message}`);
|
|
105
|
+
this.useAiPayloads = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const compileList = (values) => {
|
|
109
|
+
if (!values || values.length === 0)
|
|
110
|
+
return [];
|
|
111
|
+
const patterns = [];
|
|
112
|
+
for (const raw of values) {
|
|
113
|
+
try {
|
|
114
|
+
patterns.push(new RegExp(raw));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
logger_1.logger.warn(`Invalid regex pattern ignored: ${raw}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return patterns;
|
|
121
|
+
};
|
|
122
|
+
this.includePatterns = compileList(options.include);
|
|
123
|
+
this.excludePatterns = compileList(options.exclude);
|
|
124
|
+
}
|
|
125
|
+
resetScanState() {
|
|
126
|
+
this.detector.clear();
|
|
127
|
+
this.visitedUrls.clear();
|
|
128
|
+
this.crawledUrls = 0;
|
|
129
|
+
this.testedForms = 0;
|
|
130
|
+
this.requestsMade = 0;
|
|
131
|
+
this.headersChecked.clear();
|
|
132
|
+
this.scanErrors = [];
|
|
133
|
+
this.pluginErrors.clear();
|
|
134
|
+
this.rateLimiter.lastRequestTime = 0;
|
|
135
|
+
this.removeAllListeners();
|
|
136
|
+
// Reset plugin manager state
|
|
137
|
+
if (this.usePlugins) {
|
|
138
|
+
const securityHeadersPlugin = plugins_1.pluginManager.getPlugin("Security Headers Analyzer");
|
|
139
|
+
if (securityHeadersPlugin && typeof securityHeadersPlugin.reset === "function") {
|
|
140
|
+
securityHeadersPlugin.reset();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
36
143
|
}
|
|
37
144
|
async initialize(options = {}) {
|
|
38
145
|
const headless = options.headless ?? true;
|
|
@@ -51,16 +158,20 @@ class Scanner {
|
|
|
51
158
|
const startTime = Date.now();
|
|
52
159
|
const depth = options.depth ?? 2;
|
|
53
160
|
const timeout = options.timeout ?? 30000;
|
|
54
|
-
|
|
161
|
+
this.resetScanState();
|
|
162
|
+
await this.initializeScanSettings(targetUrl, options);
|
|
55
163
|
if (!this.browser) {
|
|
56
164
|
await this.initialize(options);
|
|
57
165
|
}
|
|
166
|
+
this.emit("scan:start", { target: targetUrl, options });
|
|
58
167
|
logger_1.logger.info(`Starting scan of ${targetUrl} (depth: ${depth}, timeout: ${timeout}ms)`);
|
|
59
168
|
try {
|
|
60
169
|
await this.crawl(targetUrl, depth, timeout);
|
|
61
170
|
}
|
|
62
171
|
catch (error) {
|
|
63
|
-
|
|
172
|
+
const err = error;
|
|
173
|
+
this.emit("scan:error", { error: err });
|
|
174
|
+
logger_1.logger.error(`Scan failed: ${err.message}`);
|
|
64
175
|
throw error;
|
|
65
176
|
}
|
|
66
177
|
finally {
|
|
@@ -79,6 +190,7 @@ class Scanner {
|
|
|
79
190
|
requestsMade: this.requestsMade,
|
|
80
191
|
},
|
|
81
192
|
};
|
|
193
|
+
this.emit("scan:complete", { result });
|
|
82
194
|
return result;
|
|
83
195
|
}
|
|
84
196
|
async applyRateLimit() {
|
|
@@ -108,62 +220,198 @@ class Scanner {
|
|
|
108
220
|
}
|
|
109
221
|
throw new Error(`Failed after ${this.retryConfig.maxRetries + 1} attempts: ${lastError?.message}`);
|
|
110
222
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
223
|
+
async createInstrumentedPage() {
|
|
224
|
+
const page = await this.browser.newPage();
|
|
225
|
+
await page.setUserAgent(this.userAgent);
|
|
226
|
+
await page.setRequestInterception(true);
|
|
227
|
+
page.on("request", (request) => {
|
|
228
|
+
this.requestsMade++;
|
|
229
|
+
const resourceType = request.resourceType();
|
|
230
|
+
if (resourceType === "image" || resourceType === "font" || resourceType === "media") {
|
|
231
|
+
request.abort();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
request.continue();
|
|
235
|
+
});
|
|
236
|
+
return page;
|
|
237
|
+
}
|
|
238
|
+
async runInIsolatedPage(operation) {
|
|
239
|
+
const page = await this.createInstrumentedPage();
|
|
240
|
+
try {
|
|
241
|
+
return await operation(page);
|
|
115
242
|
}
|
|
116
|
-
|
|
117
|
-
|
|
243
|
+
finally {
|
|
244
|
+
await page.close().catch((err) => logger_1.logger.debug(`Error closing page: ${err.message}`));
|
|
118
245
|
}
|
|
119
|
-
return payload;
|
|
120
246
|
}
|
|
121
247
|
async crawl(url, depth, timeout) {
|
|
248
|
+
if (this.crawledUrls >= this.maxPages) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
122
251
|
if (depth === 0 || this.visitedUrls.has(url)) {
|
|
123
252
|
return;
|
|
124
253
|
}
|
|
125
254
|
this.visitedUrls.add(url);
|
|
126
255
|
this.crawledUrls++;
|
|
127
|
-
|
|
256
|
+
this.emit("crawl:start", { url, depth });
|
|
257
|
+
this.emit("crawl:page", { url, crawledCount: this.crawledUrls, maxPages: this.maxPages });
|
|
258
|
+
this.emit("progress", {
|
|
259
|
+
stage: "crawling",
|
|
260
|
+
current: this.crawledUrls,
|
|
261
|
+
total: this.maxPages,
|
|
262
|
+
message: `Crawling: ${url}`
|
|
263
|
+
});
|
|
264
|
+
const page = await this.createInstrumentedPage();
|
|
128
265
|
try {
|
|
129
|
-
await page.setRequestInterception(true);
|
|
130
|
-
page.on("request", (request) => {
|
|
131
|
-
this.requestsMade++;
|
|
132
|
-
request.continue();
|
|
133
|
-
});
|
|
134
266
|
const response = await this.withRetry(() => page.goto(url, { waitUntil: "networkidle2", timeout }), `crawl ${url}`);
|
|
135
267
|
if (!response) {
|
|
136
268
|
logger_1.logger.warn(`No response from ${url}`);
|
|
269
|
+
this.scanErrors.push({ url, error: "No response" });
|
|
137
270
|
return;
|
|
138
271
|
}
|
|
139
|
-
const host = new URL(url).host;
|
|
140
|
-
if (!this.headersChecked.has(host)) {
|
|
141
|
-
this.detector.analyzeSecurityHeaders(url, response.headers());
|
|
142
|
-
this.headersChecked.add(host);
|
|
143
|
-
}
|
|
144
272
|
const content = await page.content();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
273
|
+
const headers = response.headers();
|
|
274
|
+
// Analyze with plugins
|
|
275
|
+
if (this.usePlugins) {
|
|
276
|
+
await this.runPlugins(page, url, content, headers, timeout);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
// Fallback to legacy detector
|
|
280
|
+
await this.runLegacyDetection(page, url, content, headers, timeout);
|
|
281
|
+
}
|
|
149
282
|
if (depth > 1) {
|
|
150
283
|
const links = await this.extractLinks(page, url);
|
|
151
|
-
for (const link of links
|
|
284
|
+
for (const link of links) {
|
|
152
285
|
await this.crawl(link, depth - 1, timeout);
|
|
153
286
|
}
|
|
154
287
|
}
|
|
288
|
+
this.emit("crawl:complete", { url });
|
|
155
289
|
}
|
|
156
290
|
catch (error) {
|
|
157
|
-
|
|
291
|
+
const err = error;
|
|
292
|
+
this.scanErrors.push({ url, error: err.message });
|
|
293
|
+
this.emit("crawl:error", { url, error: err });
|
|
294
|
+
logger_1.logger.error(`Error crawling ${url}: ${err.message}`);
|
|
158
295
|
}
|
|
159
296
|
finally {
|
|
160
297
|
await page.close().catch(err => logger_1.logger.debug(`Error closing page: ${err.message}`));
|
|
161
298
|
}
|
|
162
299
|
}
|
|
163
|
-
async
|
|
300
|
+
async runPlugins(page, url, content, headers, timeout) {
|
|
301
|
+
const context = {
|
|
302
|
+
page,
|
|
303
|
+
url,
|
|
304
|
+
baseUrl: this.baseOrigin || url,
|
|
305
|
+
timeout,
|
|
306
|
+
userAgent: this.userAgent,
|
|
307
|
+
payloadGenerator: this.payloadGenerator,
|
|
308
|
+
};
|
|
309
|
+
// Analyze headers
|
|
310
|
+
const host = new URL(url).host;
|
|
311
|
+
if (!this.headersChecked.has(host)) {
|
|
312
|
+
const headerResults = await plugins_1.pluginManager.analyzeHeaders(context, headers);
|
|
313
|
+
this.processPluginResults(headerResults);
|
|
314
|
+
this.headersChecked.add(host);
|
|
315
|
+
}
|
|
316
|
+
// Analyze content
|
|
317
|
+
const contentResults = await plugins_1.pluginManager.analyzeContent(context, content);
|
|
318
|
+
this.processPluginResults(contentResults);
|
|
319
|
+
// Test URL parameters
|
|
320
|
+
await this.testUrlParametersWithPlugins(page, url, timeout);
|
|
321
|
+
// Test forms
|
|
322
|
+
await this.testFormsWithPlugins(page, url, timeout);
|
|
323
|
+
}
|
|
324
|
+
processPluginResults(results) {
|
|
325
|
+
for (const result of results) {
|
|
326
|
+
// Track plugin errors
|
|
327
|
+
if (result.errors.length > 0) {
|
|
328
|
+
const existing = this.pluginErrors.get(result.plugin) || [];
|
|
329
|
+
this.pluginErrors.set(result.plugin, [...existing, ...result.errors]);
|
|
330
|
+
}
|
|
331
|
+
// Add vulnerabilities to detector
|
|
332
|
+
for (const vuln of result.vulnerabilities) {
|
|
333
|
+
this.detector.addVulnerability(vuln);
|
|
334
|
+
}
|
|
335
|
+
// Emit event for monitoring
|
|
336
|
+
this.emit("plugin:execute", {
|
|
337
|
+
plugin: result.plugin,
|
|
338
|
+
url: result.errors[0]?.url || "",
|
|
339
|
+
duration: result.duration,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async testUrlParametersWithPlugins(page, url, timeout) {
|
|
344
|
+
try {
|
|
345
|
+
const urlObj = new URL(url);
|
|
346
|
+
const params = Array.from(urlObj.searchParams.keys());
|
|
347
|
+
for (const param of params) {
|
|
348
|
+
const value = urlObj.searchParams.get(param) || "";
|
|
349
|
+
const context = {
|
|
350
|
+
page,
|
|
351
|
+
url,
|
|
352
|
+
baseUrl: this.baseOrigin || url,
|
|
353
|
+
timeout,
|
|
354
|
+
userAgent: this.userAgent,
|
|
355
|
+
payloadGenerator: this.payloadGenerator,
|
|
356
|
+
};
|
|
357
|
+
const results = await plugins_1.pluginManager.testParameter(context, param, value);
|
|
358
|
+
this.processPluginResults(results);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
logger_1.logger.debug(`Error testing URL parameters with plugins: ${error.message}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async testFormsWithPlugins(page, url, timeout) {
|
|
164
366
|
const forms = await page.$$("form");
|
|
165
|
-
|
|
166
|
-
|
|
367
|
+
if (forms.length > 0) {
|
|
368
|
+
this.emit("form:test", { url, formCount: forms.length });
|
|
369
|
+
}
|
|
370
|
+
for (const form of forms) {
|
|
371
|
+
this.testedForms++;
|
|
372
|
+
try {
|
|
373
|
+
const inputs = await form.$$eval("input, textarea, select", (elements) => elements.map((el) => ({
|
|
374
|
+
name: el.getAttribute("name") || "",
|
|
375
|
+
type: el.getAttribute("type") || "text",
|
|
376
|
+
value: el.value || "",
|
|
377
|
+
})).filter((input) => input.name && input.type !== "hidden" && input.type !== "submit"));
|
|
378
|
+
const formData = {
|
|
379
|
+
action: await form.evaluate((el) => el.action || ""),
|
|
380
|
+
method: await form.evaluate((el) => el.method || "GET"),
|
|
381
|
+
inputs,
|
|
382
|
+
};
|
|
383
|
+
const context = {
|
|
384
|
+
page,
|
|
385
|
+
url,
|
|
386
|
+
baseUrl: this.baseOrigin || url,
|
|
387
|
+
timeout,
|
|
388
|
+
userAgent: this.userAgent,
|
|
389
|
+
payloadGenerator: this.payloadGenerator,
|
|
390
|
+
};
|
|
391
|
+
const results = await plugins_1.pluginManager.testFormInput(context, formData);
|
|
392
|
+
this.processPluginResults(results);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
logger_1.logger.debug(`Error testing form with plugins: ${error.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async runLegacyDetection(page, url, content, headers, timeout) {
|
|
400
|
+
const host = new URL(url).host;
|
|
401
|
+
if (!this.headersChecked.has(host)) {
|
|
402
|
+
this.detector.analyzeSecurityHeaders(url, headers);
|
|
403
|
+
this.headersChecked.add(host);
|
|
404
|
+
}
|
|
405
|
+
this.detector.detectSensitiveData(url, content);
|
|
406
|
+
this.detector.detectInfoDisclosure(url, content);
|
|
407
|
+
await this.testFormsLegacy(page, url, timeout);
|
|
408
|
+
await this.testUrlParametersLegacy(page, url, timeout);
|
|
409
|
+
}
|
|
410
|
+
async testFormsLegacy(page, url, timeout) {
|
|
411
|
+
const forms = await page.$$("form");
|
|
412
|
+
if (forms.length > 0) {
|
|
413
|
+
this.emit("form:test", { url, formCount: forms.length });
|
|
414
|
+
}
|
|
167
415
|
for (const form of forms) {
|
|
168
416
|
this.testedForms++;
|
|
169
417
|
try {
|
|
@@ -177,66 +425,60 @@ class Scanner {
|
|
|
177
425
|
if (!name || type === "hidden" || type === "submit") {
|
|
178
426
|
continue;
|
|
179
427
|
}
|
|
180
|
-
inputTests.push(() => this.testXSS(
|
|
181
|
-
inputTests.push(() => this.testSQLi(
|
|
182
|
-
inputTests.push(() => this.testLFI(page, url, name, timeout));
|
|
183
|
-
inputTests.push(() => this.testCMDI(page, url, name, timeout));
|
|
428
|
+
inputTests.push(() => this.runInIsolatedPage((testPage) => this.testXSS(testPage, url, name, timeout)));
|
|
429
|
+
inputTests.push(() => this.runInIsolatedPage((testPage) => this.testSQLi(testPage, url, name, timeout)));
|
|
184
430
|
}
|
|
185
|
-
|
|
186
|
-
await this.runWithConcurrency(inputTests, maxConcurrency);
|
|
431
|
+
await this.runWithConcurrency(inputTests, this.maxConcurrency);
|
|
187
432
|
}
|
|
188
433
|
catch (error) {
|
|
189
434
|
logger_1.logger.debug(`Error testing form: ${error.message}`);
|
|
190
435
|
}
|
|
191
436
|
}
|
|
192
437
|
}
|
|
193
|
-
async
|
|
194
|
-
const executing = [];
|
|
195
|
-
for (const task of tasks) {
|
|
196
|
-
const promise = task().catch((error) => {
|
|
197
|
-
logger_1.logger.debug(`Task failed: ${error.message}`);
|
|
198
|
-
return undefined;
|
|
199
|
-
});
|
|
200
|
-
executing.push(promise);
|
|
201
|
-
if (executing.length >= maxConcurrency) {
|
|
202
|
-
await Promise.race(executing);
|
|
203
|
-
executing.splice(executing.findIndex(p => p === promise), 1);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
await Promise.all(executing);
|
|
207
|
-
}
|
|
208
|
-
async testUrlParameters(page, baseUrl, timeout) {
|
|
438
|
+
async testUrlParametersLegacy(page, baseUrl, timeout) {
|
|
209
439
|
try {
|
|
210
440
|
const url = new URL(baseUrl);
|
|
211
441
|
const params = Array.from(url.searchParams.keys());
|
|
212
442
|
for (const param of params) {
|
|
213
|
-
await this.
|
|
214
|
-
await this.
|
|
215
|
-
await this.testPathTraversal(page, baseUrl, param, timeout);
|
|
216
|
-
await this.testCMDI(page, baseUrl, param, timeout);
|
|
217
|
-
await this.testSSRF(page, baseUrl, param, timeout);
|
|
218
|
-
await this.testOpenRedirect(page, baseUrl, param, timeout);
|
|
443
|
+
await this.testXSS(page, baseUrl, param, timeout);
|
|
444
|
+
await this.testSQLi(page, baseUrl, param, timeout);
|
|
219
445
|
}
|
|
220
446
|
}
|
|
221
447
|
catch (error) {
|
|
222
448
|
logger_1.logger.debug(`Error testing URL parameters: ${error.message}`);
|
|
223
449
|
}
|
|
224
450
|
}
|
|
451
|
+
async runWithConcurrency(tasks, maxConcurrency) {
|
|
452
|
+
if (tasks.length === 0) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const workerCount = Math.max(1, Math.min(maxConcurrency, tasks.length));
|
|
456
|
+
let nextIndex = 0;
|
|
457
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
458
|
+
while (nextIndex < tasks.length) {
|
|
459
|
+
const currentTask = tasks[nextIndex++];
|
|
460
|
+
try {
|
|
461
|
+
await currentTask();
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
logger_1.logger.debug(`Task failed: ${error.message}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
await Promise.all(workers);
|
|
469
|
+
}
|
|
225
470
|
async testXSS(page, url, param, timeout) {
|
|
226
471
|
const payloads = [
|
|
227
472
|
"<script>alert('XSS')</script>",
|
|
228
473
|
'"><script>alert(1)</script>',
|
|
229
474
|
"<img src=x onerror=alert(1)>",
|
|
230
|
-
"'-alert(1)-'",
|
|
231
|
-
"<svg/onload=alert(1)>",
|
|
232
475
|
];
|
|
233
476
|
for (const payload of payloads) {
|
|
234
477
|
try {
|
|
235
|
-
const
|
|
236
|
-
const testUrl = this.buildTestUrl(url, param, sanitizedPayload);
|
|
478
|
+
const testUrl = this.buildTestUrl(url, param, payload);
|
|
237
479
|
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `XSS test for ${param}`);
|
|
238
480
|
const content = await page.content();
|
|
239
|
-
this.detector.detectXSS(url, param,
|
|
481
|
+
this.detector.detectXSS(url, param, payload, content);
|
|
240
482
|
}
|
|
241
483
|
catch (error) {
|
|
242
484
|
logger_1.logger.debug(`XSS test failed for ${param}: ${error.message}`);
|
|
@@ -244,31 +486,11 @@ class Scanner {
|
|
|
244
486
|
}
|
|
245
487
|
}
|
|
246
488
|
async testSQLi(page, url, param, timeout) {
|
|
247
|
-
const errorBasedPayloads = [
|
|
248
|
-
"'",
|
|
249
|
-
"1' OR '1'='1",
|
|
250
|
-
"1; DROP TABLE users--",
|
|
251
|
-
"' OR 1=1--",
|
|
252
|
-
"' UNION SELECT 1--",
|
|
253
|
-
"1' AND '1'='1",
|
|
254
|
-
];
|
|
255
|
-
const timeBasedPayloads = [
|
|
256
|
-
"' AND SLEEP(5)--",
|
|
257
|
-
"1' AND SLEEP(5)--",
|
|
258
|
-
"'; WAITFOR DELAY '00:00:05'--",
|
|
259
|
-
];
|
|
260
|
-
try {
|
|
261
|
-
await page.content();
|
|
262
|
-
}
|
|
263
|
-
catch (error) {
|
|
264
|
-
logger_1.logger.debug(`Could not get original response for SQLi test: ${error.message}`);
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
489
|
+
const errorBasedPayloads = ["'", "1' OR '1'='1", "' OR 1=1--"];
|
|
267
490
|
for (const payload of errorBasedPayloads) {
|
|
268
491
|
try {
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `SQLi error test for ${param}`);
|
|
492
|
+
const testUrl = this.buildTestUrl(url, param, payload);
|
|
493
|
+
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `SQLi test for ${param}`);
|
|
272
494
|
const content = await page.content();
|
|
273
495
|
this.detector.detectSQLi(url, param, content);
|
|
274
496
|
}
|
|
@@ -276,162 +498,6 @@ class Scanner {
|
|
|
276
498
|
logger_1.logger.debug(`SQLi test failed for ${param}: ${error.message}`);
|
|
277
499
|
}
|
|
278
500
|
}
|
|
279
|
-
const startTime = Date.now();
|
|
280
|
-
for (const payload of timeBasedPayloads) {
|
|
281
|
-
try {
|
|
282
|
-
const sanitizedPayload = this.sanitizePayload(payload);
|
|
283
|
-
const testUrl = this.buildTestUrl(url, param, sanitizedPayload);
|
|
284
|
-
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `Blind SQLi test for ${param}`);
|
|
285
|
-
const testTime = Date.now() - startTime;
|
|
286
|
-
this.detector.detectBlindSQLi(url, param, 0, testTime);
|
|
287
|
-
}
|
|
288
|
-
catch (error) {
|
|
289
|
-
logger_1.logger.debug(`Blind SQLi test failed for ${param}: ${error.message}`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
async testLFI(page, url, param, timeout) {
|
|
294
|
-
const lfiPayloads = [
|
|
295
|
-
"../../../../etc/passwd",
|
|
296
|
-
"../../../../../../etc/passwd",
|
|
297
|
-
"..\\..\\..\\..\\..\\windows\\system32\\drivers\\etc\\hosts",
|
|
298
|
-
"..%2f..%2f..%2f..%2fetc%2fpasswd",
|
|
299
|
-
"/etc/passwd",
|
|
300
|
-
"/etc/shadow",
|
|
301
|
-
];
|
|
302
|
-
for (const payload of lfiPayloads) {
|
|
303
|
-
try {
|
|
304
|
-
const sanitizedPayload = this.sanitizePayload(payload);
|
|
305
|
-
const testUrl = this.buildTestUrl(url, param, sanitizedPayload);
|
|
306
|
-
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `LFI test for ${param}`);
|
|
307
|
-
const content = await page.content();
|
|
308
|
-
this.detector.detectLFI(url, param, sanitizedPayload, content);
|
|
309
|
-
}
|
|
310
|
-
catch (error) {
|
|
311
|
-
logger_1.logger.debug(`LFI test failed for ${param}: ${error.message}`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
async testPathTraversal(page, url, param, timeout) {
|
|
316
|
-
const traversalPayloads = [
|
|
317
|
-
"../../../../etc/passwd",
|
|
318
|
-
"..%2f..%2f..%2f..%2fetc%2fpasswd",
|
|
319
|
-
"....//....//....//etc/passwd",
|
|
320
|
-
"..\\..\\..\\..\\windows\\system32\\config\\sam",
|
|
321
|
-
];
|
|
322
|
-
for (const payload of traversalPayloads) {
|
|
323
|
-
try {
|
|
324
|
-
const sanitizedPayload = this.sanitizePayload(payload);
|
|
325
|
-
const testUrl = this.buildTestUrl(url, param, sanitizedPayload);
|
|
326
|
-
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `Path traversal test for ${param}`);
|
|
327
|
-
const content = await page.content();
|
|
328
|
-
this.detector.detectPathTraversal(url, param, sanitizedPayload, content);
|
|
329
|
-
}
|
|
330
|
-
catch (error) {
|
|
331
|
-
logger_1.logger.debug(`Path traversal test failed for ${param}: ${error.message}`);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
async testCMDI(page, url, param, timeout) {
|
|
336
|
-
const cmdiPayloads = [
|
|
337
|
-
"; whoami",
|
|
338
|
-
"| whoami",
|
|
339
|
-
"`whoami`",
|
|
340
|
-
"$(whoami)",
|
|
341
|
-
"; id",
|
|
342
|
-
"| id",
|
|
343
|
-
"; cat /etc/passwd",
|
|
344
|
-
"| ls -la",
|
|
345
|
-
"& ping -c 3 127.0.0.1",
|
|
346
|
-
"&& whoami",
|
|
347
|
-
];
|
|
348
|
-
for (const payload of cmdiPayloads) {
|
|
349
|
-
try {
|
|
350
|
-
const sanitizedPayload = this.sanitizePayload(payload);
|
|
351
|
-
const testUrl = this.buildTestUrl(url, param, sanitizedPayload);
|
|
352
|
-
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `CMDI test for ${param}`);
|
|
353
|
-
const content = await page.content();
|
|
354
|
-
this.detector.detectCMDI(url, param, sanitizedPayload, content);
|
|
355
|
-
}
|
|
356
|
-
catch (error) {
|
|
357
|
-
logger_1.logger.debug(`CMDI test failed for ${param}: ${error.message}`);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
async testSSRF(page, url, param, _timeout) {
|
|
362
|
-
const ssrfPayloads = [
|
|
363
|
-
"http://localhost",
|
|
364
|
-
"http://127.0.0.1",
|
|
365
|
-
"http://169.254.169.254/latest/meta-data/",
|
|
366
|
-
"https://metadata.google.internal/",
|
|
367
|
-
"http://0.0.0.0",
|
|
368
|
-
"http://[::1]",
|
|
369
|
-
];
|
|
370
|
-
for (const payload of ssrfPayloads) {
|
|
371
|
-
try {
|
|
372
|
-
const sanitizedPayload = this.sanitizePayload(payload);
|
|
373
|
-
const testUrl = this.buildTestUrl(url, param, sanitizedPayload);
|
|
374
|
-
try {
|
|
375
|
-
await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout: 5000 }), `SSRF test for ${param}`);
|
|
376
|
-
}
|
|
377
|
-
catch {
|
|
378
|
-
// Expected to fail for internal URLs
|
|
379
|
-
}
|
|
380
|
-
const content = await page.content();
|
|
381
|
-
this.detector.detectSSRF(url, param, sanitizedPayload, content);
|
|
382
|
-
}
|
|
383
|
-
catch (error) {
|
|
384
|
-
logger_1.logger.debug(`SSRF test failed for ${param}: ${error.message}`);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
async testOpenRedirect(page, url, param, _timeout) {
|
|
389
|
-
const redirectPayloads = [
|
|
390
|
-
"https://evil.com",
|
|
391
|
-
"https://google.com",
|
|
392
|
-
"//evil.com",
|
|
393
|
-
"///evil.com",
|
|
394
|
-
"https://google.com%23",
|
|
395
|
-
"javascript:alert(1)",
|
|
396
|
-
];
|
|
397
|
-
for (const payload of redirectPayloads) {
|
|
398
|
-
try {
|
|
399
|
-
const sanitizedPayload = this.sanitizePayload(payload);
|
|
400
|
-
const testUrl = this.buildTestUrl(url, param, sanitizedPayload);
|
|
401
|
-
const response = await this.withRetry(() => page.goto(testUrl, { waitUntil: "domcontentloaded", timeout: 5000 }), `Open redirect test for ${param}`);
|
|
402
|
-
if (response) {
|
|
403
|
-
const finalUrl = response.url();
|
|
404
|
-
this.detector.detectOpenRedirect(url, param, sanitizedPayload, finalUrl);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
catch (error) {
|
|
408
|
-
logger_1.logger.debug(`Open redirect test failed for ${param}: ${error.message}`);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
async testIDOR(baseUrl, param, timeout) {
|
|
413
|
-
const page = await this.browser.newPage();
|
|
414
|
-
try {
|
|
415
|
-
const url = new URL(baseUrl);
|
|
416
|
-
const originalValue = url.searchParams.get(param);
|
|
417
|
-
if (!originalValue || isNaN(Number(originalValue))) {
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
const originalResponse = await this.withRetry(() => page.goto(baseUrl, { waitUntil: "networkidle2", timeout }), `IDOR original request for ${param}`);
|
|
421
|
-
const originalContent = originalResponse ? await originalResponse.text() : "";
|
|
422
|
-
const modifiedValue = String(Number(originalValue) + 1);
|
|
423
|
-
url.searchParams.set(param, modifiedValue);
|
|
424
|
-
const testUrl = url.toString();
|
|
425
|
-
const testResponse = await this.withRetry(() => page.goto(testUrl, { waitUntil: "networkidle2", timeout }), `IDOR test request for ${param}`);
|
|
426
|
-
const testContent = testResponse ? await testResponse.text() : "";
|
|
427
|
-
this.detector.detectIDOR(baseUrl, param, originalValue, testContent, originalContent);
|
|
428
|
-
}
|
|
429
|
-
catch (error) {
|
|
430
|
-
logger_1.logger.debug(`IDOR test failed for ${param}: ${error.message}`);
|
|
431
|
-
}
|
|
432
|
-
finally {
|
|
433
|
-
await page.close().catch(err => logger_1.logger.debug(`Error closing page: ${err.message}`));
|
|
434
|
-
}
|
|
435
501
|
}
|
|
436
502
|
buildTestUrl(baseUrl, param, value) {
|
|
437
503
|
try {
|
|
@@ -446,20 +512,42 @@ class Scanner {
|
|
|
446
512
|
async extractLinks(page, baseUrl) {
|
|
447
513
|
const links = await page.$$eval("a[href]", (anchors) => anchors.map((a) => a.getAttribute("href")).filter(Boolean));
|
|
448
514
|
const absoluteLinks = [];
|
|
515
|
+
const baseOrigin = this.baseOrigin || new URL(baseUrl).origin;
|
|
516
|
+
const isAllowedByPatterns = (candidate) => {
|
|
517
|
+
const asString = candidate.toString();
|
|
518
|
+
for (const pattern of this.excludePatterns) {
|
|
519
|
+
if (pattern.test(asString)) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (this.includePatterns.length > 0) {
|
|
524
|
+
return this.includePatterns.some((pattern) => pattern.test(asString));
|
|
525
|
+
}
|
|
526
|
+
return true;
|
|
527
|
+
};
|
|
449
528
|
for (const link of links) {
|
|
450
529
|
if (!link)
|
|
451
530
|
continue;
|
|
452
531
|
try {
|
|
453
532
|
const absolute = new URL(link, baseUrl);
|
|
454
|
-
if (
|
|
455
|
-
|
|
533
|
+
if (!["http:", "https:"].includes(absolute.protocol)) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (this.strictScope && absolute.origin !== baseOrigin) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!isAllowedByPatterns(absolute)) {
|
|
540
|
+
continue;
|
|
456
541
|
}
|
|
542
|
+
absolute.hash = "";
|
|
543
|
+
absoluteLinks.push(absolute.toString());
|
|
457
544
|
}
|
|
458
545
|
catch {
|
|
459
546
|
// Skip invalid URLs
|
|
460
547
|
}
|
|
461
548
|
}
|
|
462
|
-
|
|
549
|
+
const deduped = [...new Set(absoluteLinks)];
|
|
550
|
+
return deduped.slice(0, this.maxLinksPerPage);
|
|
463
551
|
}
|
|
464
552
|
async close() {
|
|
465
553
|
if (this.browser) {
|