rav-xss 1.0.28

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.
@@ -0,0 +1,778 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const axios = require("axios");
6
+ const inquirer = require("inquirer");
7
+ const ora = require("ora");
8
+ const chalk = require("chalk");
9
+
10
+ const { sleep, ensureDir } = require("../utils/helpers");
11
+ const { colors } = require("../config/colors");
12
+ const { Logger } = require("../utils/logger");
13
+ const boxManager = require("../utils/box");
14
+ const { Reporter } = require("../utils/reporter");
15
+ const { getDefaultConfig } = require("../config/manager");
16
+
17
+ const CATEGORY_MAP = {
18
+ "basic": ["🔰 Basic Payloads", "Standard HTML tags & events"],
19
+ "filterevasion": ["🛡️ Filter Evasion", "Encoding, null bytes, obfuscation"],
20
+ "polyglots": ["🎭 Polyglots", "Multi-context payloads"],
21
+ "wafbypass": ["🔥 WAF Bypass", "Cloudflare, ModSecurity evasion"],
22
+ "purereflex": ["💎 Pure Reflex", "Reflected-only payloads, no template injection"]
23
+ };
24
+
25
+ class XSSScanner {
26
+ constructor(config, args) {
27
+ this.config = config;
28
+ this.args = args;
29
+ this.payloads = [];
30
+ this.category = args.category || null;
31
+ this.mode = args.mode || config.mode || null;
32
+
33
+ const defaultConfig = getDefaultConfig();
34
+ const defaultUrl = defaultConfig.targets[0].url;
35
+ const defaultName = defaultConfig.targets[0].name;
36
+
37
+ this.targetUrl = args.url || (config.targets?.length > 0 ? config.targets[0].url : defaultUrl);
38
+ this.targetName = config.targets?.length > 0 ? config.targets[0].name : defaultName;
39
+
40
+ this.results = {
41
+ scan_start: null,
42
+ scan_end: null,
43
+ total_tests: 0,
44
+ vulns_found: 0,
45
+ findings: []
46
+ };
47
+ this.reporter = null;
48
+
49
+ this.payloadsDir = path.join(__dirname, "..", "..", "payloads");
50
+ }
51
+
52
+ /**
53
+ * 🚀 Inicializa o scanner com menu interativo e configuração
54
+ * @returns {Promise<void>}
55
+ */
56
+ async initialize() {
57
+ while (!this.category || !this.mode) {
58
+ if (!this.category) {
59
+ await this.showMainMenu();
60
+ }
61
+
62
+ if (this.category && !this.mode) {
63
+ await this.showModeMenu();
64
+ }
65
+ }
66
+
67
+ if (!this.targetUrl || !this.targetUrl.includes("[XSS]")) {
68
+ await this.promptForUrl();
69
+ }
70
+
71
+ if (!this.targetUrl || !this.targetUrl.includes("[XSS]")) {
72
+ const defaultConfig = getDefaultConfig();
73
+ this.targetUrl = defaultConfig.targets[0].url;
74
+ }
75
+
76
+ await this.loadPayloads();
77
+ ensureDir(this.config.scanner.report_dir);
78
+ this.reporter = new Reporter(this.config.scanner.report_dir);
79
+
80
+ if (this.mode === "playwright") {
81
+ const { BrowserManager } = require("./browser");
82
+ this.browserManager = new BrowserManager(this.config, { ...this.args, mode: "playwright" });
83
+ await this.browserManager.launch();
84
+ }
85
+ }
86
+
87
+ /**
88
+ * 🔄 Exibe menu de seleção de modo de execução
89
+ * Pula automaticamente para axios se estiver no Termux
90
+ * Verifica e instala dependências do Playwright quando necessário
91
+ * @returns {Promise<void>}
92
+ */
93
+ async showModeMenu() {
94
+ console.clear();
95
+
96
+ const { isTermux } = require("./browser");
97
+
98
+ if (isTermux()) {
99
+ this.mode = "axios";
100
+ console.log(colors.warning("\n 📱 Termux detected - Using Axios mode automatically\n"));
101
+ await sleep(2000);
102
+ return;
103
+ }
104
+
105
+ const defaultConfig = getDefaultConfig();
106
+ const displayUrl = this.targetUrl || defaultConfig.targets[0].url;
107
+
108
+ Logger.showBanner(this.config, 0, this.category, displayUrl);
109
+
110
+ const modeChoices = [
111
+ {
112
+ name: ` ${colors.action("⚡")} ${colors.action.bold("Axios Mode")} ${colors.dim("—")} ${colors.menuDescription("Fast HTTP requests (recommended)")}`,
113
+ value: "axios",
114
+ short: "Axios Mode"
115
+ },
116
+ {
117
+ name: ` ${colors.highlight("🌐")} ${colors.highlight.bold("Playwright Mode")} ${colors.dim("—")} ${colors.menuDescription("Real browser automation (slower)")}`,
118
+ value: "playwright",
119
+ short: "Playwright Mode"
120
+ },
121
+ new inquirer.Separator(colors.dim("─".repeat(55))),
122
+ {
123
+ name: ` ${colors.down("⮘")} ${colors.down.bold("Back to Categories")}`,
124
+ value: "back",
125
+ short: "Back"
126
+ }
127
+ ];
128
+
129
+ const { mode } = await inquirer.prompt([
130
+ {
131
+ type: "list",
132
+ name: "mode",
133
+ prefix: colors.highlight2.bold("✸"),
134
+ message: colors.highlight2.bold("SELECT MODE"),
135
+ choices: modeChoices,
136
+ pageSize: 5,
137
+ loop: false
138
+ }
139
+ ]);
140
+
141
+ if (mode === "back") {
142
+ this.category = null;
143
+ this.mode = null;
144
+ return;
145
+ }
146
+
147
+ if (mode === "playwright") {
148
+ try {
149
+ const { execSync } = require("child_process");
150
+ const os = require("os");
151
+ const fs = require("fs");
152
+ const path = require("path");
153
+
154
+ let browserPath;
155
+ const home = os.homedir();
156
+
157
+ if (process.platform === "win32") {
158
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
159
+ browserPath = path.join(localAppData, "ms-playwright");
160
+ } else if (process.platform === "darwin") {
161
+ browserPath = path.join(home, "Library", "Caches", "ms-playwright");
162
+ } else {
163
+ const cacheDir = process.env.XDG_CACHE_HOME || path.join(home, ".cache");
164
+ browserPath = path.join(cacheDir, "ms-playwright");
165
+ }
166
+
167
+ const browsersInstalled = fs.existsSync(browserPath);
168
+
169
+ if (!browsersInstalled) {
170
+ console.log(colors.warning("\n ⚠️ Playwright browsers not found!"));
171
+ console.log(colors.muted(" Installing Chromium automatically...\n"));
172
+ execSync("npx playwright install chromium", {
173
+ stdio: "inherit",
174
+ timeout: 120000
175
+ });
176
+ console.log(colors.success("\n ✅ Chromium installed successfully!\n"));
177
+ }
178
+
179
+ console.log(colors.success(" ✅ Playwright mode activated with Chromium\n"));
180
+ await sleep(1500);
181
+ } catch (installError) {
182
+ console.log(colors.error(`\n ❌ Failed to install Chromium: ${installError.message}\n`));
183
+ console.log(colors.muted(" Switching to Axios mode...\n"));
184
+ await sleep(2000);
185
+ this.mode = "axios";
186
+ console.log(colors.success(" ✅ Axios mode selected - fast and lightweight\n"));
187
+ await sleep(1500);
188
+ return;
189
+ }
190
+ } else {
191
+ console.log(colors.success("\n ✅ Axios mode selected - fast and lightweight\n"));
192
+ await sleep(1500);
193
+ }
194
+
195
+ this.mode = mode;
196
+ }
197
+
198
+ /**
199
+ * 🌐 Solicita URL alvo com placeholder [XSS]
200
+ * @returns {Promise<void>}
201
+ */
202
+ async promptForUrl() {
203
+ console.clear();
204
+
205
+ const defaultConfig = getDefaultConfig();
206
+ const demoUrl = defaultConfig.targets[0].url;
207
+ const demoName = defaultConfig.targets[0].name;
208
+
209
+ Logger.showBanner(this.config, 0, this.category, this.targetUrl || demoUrl);
210
+
211
+ console.log(colors.link2.bold("\n ⚙ CONFIGURE TARGET URL\n"));
212
+ console.log(colors.muted(" Enter the URL with [XSS] as placeholder for the payload\n"));
213
+ console.log(colors.text(" 💡 Demo target: ") + colors.link(demoUrl));
214
+ console.log(colors.muted(` (${demoName} - public XSS testing page)\n`));
215
+ console.log(colors.highlight(` ✸ Mode: ${this.mode === "playwright" ? "🌐 Playwright" : "⚡ Axios"}\n`));
216
+
217
+ const { url } = await inquirer.prompt([
218
+ {
219
+ type: "input",
220
+ name: "url",
221
+ prefix: colors.action(">"),
222
+ message: colors.text("Target URL:"),
223
+ default: this.targetUrl || demoUrl,
224
+ validate: (input) => {
225
+ if (!input.includes("[XSS]")) {
226
+ return colors.error("URL must contain [XSS] placeholder!");
227
+ }
228
+ return true;
229
+ }
230
+ }
231
+ ]);
232
+
233
+ this.targetUrl = url;
234
+ console.log(colors.success(`\n✅ Target configured: ${this.truncateUrl(url)}`));
235
+
236
+ if (url === demoUrl) {
237
+ console.log(colors.highlight2("\n ℹ️ Using demo target - perfect for testing!"));
238
+ }
239
+
240
+ await sleep(1500);
241
+ }
242
+
243
+ /**
244
+ * 🧹 Limpa arquivos de configuração e pasta de relatórios
245
+ * @returns {Promise<void>}
246
+ */
247
+ async cleanExit() {
248
+ console.clear();
249
+
250
+ const { confirm } = await inquirer.prompt([
251
+ {
252
+ type: "confirm",
253
+ name: "confirm",
254
+ message: chalk.hex("#F72585").bold("⚠️ This will DELETE all config files and reports. Continue?"),
255
+ default: false,
256
+ }
257
+ ]);
258
+
259
+ if (!confirm) {
260
+ console.log(colors.success("\n✅ Operation cancelled. Returning to menu..."));
261
+ await sleep(1500);
262
+ return;
263
+ }
264
+
265
+ console.log(colors.warning("\n🧹 Cleaning up...\n"));
266
+
267
+ const configFiles = [
268
+ path.join(process.cwd(), "config.json"),
269
+ path.join(process.cwd(), "config.txt"),
270
+ path.join(__dirname, "..", "..", "config.json"),
271
+ path.join(__dirname, "..", "..", "config.txt")
272
+ ];
273
+
274
+ let cleaned = 0;
275
+ for (const file of configFiles) {
276
+ try {
277
+ if (fs.existsSync(file)) {
278
+ fs.unlinkSync(file);
279
+ console.log(colors.success(` ✓ Deleted: ${path.basename(file)}`));
280
+ cleaned++;
281
+ }
282
+ } catch (e) {
283
+ console.log(colors.error(` ✗ Error deleting ${path.basename(file)}: ${e.message}`));
284
+ }
285
+ }
286
+
287
+ const reportsDir = path.join(__dirname, "..", "..", "reports");
288
+ try {
289
+ if (fs.existsSync(reportsDir)) {
290
+ fs.rmSync(reportsDir, { recursive: true, force: true });
291
+ console.log(colors.success(" ✓ Reports folder deleted"));
292
+ cleaned++;
293
+ } else {
294
+ console.log(colors.muted(" ℹ Reports folder not found"));
295
+ }
296
+ } catch (e) {
297
+ console.log(colors.error(` ✗ Error deleting reports: ${e.message}`));
298
+ }
299
+
300
+ if (cleaned > 0) {
301
+ console.log(colors.success(`\n✅ Cleanup completed! ${cleaned} item(s) removed.`));
302
+ } else {
303
+ console.log(colors.muted("\nℹ Nothing to clean."));
304
+ }
305
+
306
+ Logger.showExit();
307
+ await sleep(2000);
308
+ process.exit(0);
309
+ }
310
+
311
+ /**
312
+ * 📋 Exibe o menu principal interativo
313
+ * @returns {Promise<void>}
314
+ */
315
+ async showMainMenu() {
316
+ while (true) {
317
+ try {
318
+ console.clear();
319
+
320
+ const defaultConfig = getDefaultConfig();
321
+ const displayUrl = this.targetUrl || defaultConfig.targets[0].url;
322
+
323
+ Logger.showBanner(this.config, 0, "Select category...", displayUrl);
324
+
325
+ if (!fs.existsSync(this.payloadsDir)) {
326
+ console.log(colors.error(`\n Payloads directory not found!`));
327
+ console.log(colors.muted(` Expected: ${this.payloadsDir}\n`));
328
+ console.log(colors.text(` Make sure the package was installed correctly.`));
329
+ console.log(colors.text(` Try: npm uninstall -g rav-xss && npm install -g rav-xss\n`));
330
+ process.exit(1);
331
+ }
332
+
333
+ const folders = fs.readdirSync(this.payloadsDir, { withFileTypes: true })
334
+ .filter(dirent => dirent.isDirectory())
335
+ .map(dirent => dirent.name);
336
+
337
+ if (folders.length === 0) {
338
+ console.log(colors.error(`\n No payload categories found in:`));
339
+ console.log(colors.muted(` ${this.payloadsDir}\n`));
340
+ process.exit(1);
341
+ }
342
+
343
+ const menuChoices = [
344
+ new inquirer.Separator(colors.dim("─".repeat(55))),
345
+ ];
346
+
347
+ for (const folderName of folders) {
348
+ const key = folderName.toLowerCase();
349
+ const info = CATEGORY_MAP[key] || [`📂 ${folderName}`, "Custom payloads"];
350
+ const displayName = info[0];
351
+ const description = info[1];
352
+
353
+ menuChoices.push({
354
+ name: ` ${displayName} ${colors.dim("—")} ${colors.menuDescription(description)}`,
355
+ value: folderName,
356
+ short: displayName.replace(/[🔰🛡️🎭👁️🔥💎]/g, "").trim()
357
+ });
358
+ }
359
+
360
+ menuChoices.push(
361
+ new inquirer.Separator(colors.dim("─".repeat(55))),
362
+ {
363
+ name: ` ${colors.menuConfig("🎯")} ${colors.menuConfig.bold("Configure Target URL")}`,
364
+ value: "config_url",
365
+ short: "Configure URL"
366
+ },
367
+ {
368
+ name: ` ${colors.danger("🧹")} ${colors.danger.bold("Clean and Exit")}`,
369
+ value: "clean_exit",
370
+ short: "Clean"
371
+ },
372
+ {
373
+ name: ` ${colors.menuExit("❌")} ${colors.menuExit.bold("Exit")}`,
374
+ value: "exit",
375
+ short: "Exit"
376
+ }
377
+ );
378
+
379
+ const { action } = await inquirer.prompt([
380
+ {
381
+ type: "list",
382
+ name: "action",
383
+ prefix: colors.highlight2.bold("✸"),
384
+ message: colors.highlight2.bold("SELECT PAYLOAD CATEGORY"),
385
+ choices: menuChoices,
386
+ pageSize: 12,
387
+ loop: false
388
+ }
389
+ ]);
390
+
391
+ if (action === "exit") {
392
+ Logger.showExit();
393
+ await sleep(1500);
394
+ process.exit(0);
395
+ } else if (action === "clean_exit") {
396
+ await this.cleanExit();
397
+ continue;
398
+ } else if (action === "config_url") {
399
+ await this.promptForUrl();
400
+ continue;
401
+ } else {
402
+ this.category = action;
403
+ this.mode = null;
404
+ return;
405
+ }
406
+
407
+ } catch (error) {
408
+ if (error.message === "User force closed the prompt") {
409
+ Logger.showExit();
410
+ await sleep(1500);
411
+ process.exit(0);
412
+ }
413
+ console.log(colors.error(`Menu error: ${error.message}`));
414
+ await sleep(2000);
415
+ }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * 📂 Encontra o arquivo de payload na categoria
421
+ * @param {string} categoryDir - Diretório da categoria
422
+ * @returns {string|null} Caminho do arquivo de payload ou null
423
+ */
424
+ findPayloadFile(categoryDir) {
425
+ if (!fs.existsSync(categoryDir)) return null;
426
+
427
+ const files = fs.readdirSync(categoryDir);
428
+
429
+ const txtFile = files.find(f => f.endsWith(".txt"));
430
+ if (txtFile) return path.join(categoryDir, txtFile);
431
+
432
+ return null;
433
+ }
434
+
435
+ /**
436
+ * 📥 Carrega payloads do diretório da categoria
437
+ * @returns {Promise<void>}
438
+ */
439
+ async loadPayloads() {
440
+ const categoryDir = path.join(this.payloadsDir, this.category);
441
+
442
+ if (!fs.existsSync(categoryDir)) {
443
+ const availableCats = fs.existsSync(this.payloadsDir)
444
+ ? fs.readdirSync(this.payloadsDir).filter(d =>
445
+ fs.statSync(path.join(this.payloadsDir, d)).isDirectory()
446
+ ).join(", ")
447
+ : "NONE";
448
+
449
+ throw new Error(
450
+ `Category directory not found: ${categoryDir}\n` +
451
+ `Available categories: ${availableCats}`
452
+ );
453
+ }
454
+
455
+ const payloadPath = this.findPayloadFile(categoryDir);
456
+
457
+ if (!payloadPath) {
458
+ const files = fs.readdirSync(categoryDir);
459
+ throw new Error(
460
+ `No .txt file found in ${categoryDir}\n` +
461
+ `Files found: ${files.join(", ") || "NONE"}`
462
+ );
463
+ }
464
+
465
+ this.payloads = fs.readFileSync(payloadPath, "utf8")
466
+ .split("\n")
467
+ .map(l => l.trim())
468
+ .filter(Boolean);
469
+
470
+ if (this.payloads.length === 0) {
471
+ throw new Error("No payloads loaded. File is empty.");
472
+ }
473
+ }
474
+
475
+ /**
476
+ * 🛡️ Verifica se a resposta HTTP indica um bloqueio por WAF ou segurança
477
+ * @param {Object} response - Resposta do axios
478
+ * @returns {boolean} true se for um bloqueio
479
+ */
480
+ isSecurityBlock(response) {
481
+ if (response.status === 403 || response.status === 429) {
482
+ return true;
483
+ }
484
+
485
+ if (response.status >= 500 && response.status < 600) {
486
+ return true;
487
+ }
488
+
489
+ const bodyStr = typeof response.data === 'string' ? response.data.toLowerCase() : '';
490
+
491
+ if (bodyStr.includes('cloudflare') &&
492
+ (bodyStr.includes('ray id') ||
493
+ bodyStr.includes('just a moment') ||
494
+ bodyStr.includes('attention required') ||
495
+ bodyStr.includes('sorry, you have been blocked') ||
496
+ bodyStr.includes('you are unable to access'))) {
497
+ return true;
498
+ }
499
+
500
+ if (bodyStr.includes('access denied') &&
501
+ (bodyStr.includes('waf') || bodyStr.includes('firewall') || bodyStr.includes('security'))) {
502
+ return true;
503
+ }
504
+
505
+ return false;
506
+ }
507
+
508
+ /**
509
+ * 🔍 Verifica se o payload foi refletido na resposta de forma potencialmente perigosa
510
+ * @param {string} responseData - Corpo da resposta HTML/texto
511
+ * @param {string} payload - Payload injetado
512
+ * @returns {boolean} true se o payload foi refletido
513
+ */
514
+ isPayloadReflected(responseData, payload) {
515
+ if (!responseData || typeof responseData !== 'string') {
516
+ return false;
517
+ }
518
+
519
+ const htmlWithoutComments = responseData.replace(/<!--[\s\S]*?-->/g, '');
520
+
521
+ const htmlEntities = {
522
+ '&lt;': '<',
523
+ '&gt;': '>',
524
+ '&quot;': '"',
525
+ '&#x27;': "'",
526
+ '&#x2F;': '/',
527
+ '&amp;': '&'
528
+ };
529
+
530
+ let fullyEscapedPayload = payload;
531
+ for (const [entity, char] of Object.entries(htmlEntities)) {
532
+ const escapedChar = entity;
533
+ fullyEscapedPayload = fullyEscapedPayload.split(char).join(escapedChar);
534
+ }
535
+
536
+ if (fullyEscapedPayload !== payload &&
537
+ htmlWithoutComments.includes(fullyEscapedPayload) &&
538
+ !htmlWithoutComments.includes(payload)) {
539
+ return false;
540
+ }
541
+
542
+ const searchPayloads = [payload];
543
+
544
+ if (payload.includes('<') && payload.includes('>')) {
545
+ const encodedLt = payload.replace(/</g, '&lt;');
546
+ if (encodedLt !== payload) {
547
+ searchPayloads.push(encodedLt);
548
+ }
549
+ }
550
+
551
+ for (const searchPayload of searchPayloads) {
552
+ if (htmlWithoutComments.includes(searchPayload)) {
553
+ return true;
554
+ }
555
+ }
556
+
557
+ return false;
558
+ }
559
+
560
+ /**
561
+ * 🎯 Testa um payload individual contra a URL alvo
562
+ * @param {string} payload - Payload a ser testado
563
+ * @param {number} index - Índice do payload na lista
564
+ * @returns {Object} Resultado do teste
565
+ */
566
+ async testPayload(payload, index) {
567
+ const url = this.targetUrl.replace("[XSS]", encodeURIComponent(payload));
568
+ let vulnerable = false;
569
+ let dialogMessage = null;
570
+
571
+ try {
572
+ let response;
573
+
574
+ if (this.browserManager && this.mode === "playwright") {
575
+ response = await this.browserManager.request(url);
576
+
577
+ if (response && response.dialogMessage) {
578
+ vulnerable = true;
579
+ dialogMessage = response.dialogMessage;
580
+ }
581
+ } else {
582
+ response = await axios.get(url, {
583
+ timeout: this.config.scanner.timeout_ms,
584
+ headers: {
585
+ "User-Agent": this.config.scanner.user_agent,
586
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
587
+ "Accept-Language": "en-US,en;q=0.5"
588
+ },
589
+ validateStatus: () => true,
590
+ maxRedirects: 5
591
+ });
592
+
593
+ if (!this.isSecurityBlock(response)) {
594
+ const responseData = typeof response.data === 'string' ? response.data : '';
595
+ vulnerable = this.isPayloadReflected(responseData, payload);
596
+ }
597
+ }
598
+ } catch (err) {
599
+ // Request failed silently
600
+ }
601
+
602
+ return { payload, url, vulnerable, index: index + 1, dialogMessage };
603
+ }
604
+
605
+ /**
606
+ * ⏱️ Obtém o delay configurado em milissegundos
607
+ * @returns {number} Delay em milissegundos
608
+ */
609
+ getConfiguredDelay() {
610
+ if (this.config?.scanner?.delay_between_requests_ms !== undefined &&
611
+ this.config?.scanner?.delay_between_requests_ms !== null) {
612
+ return parseInt(this.config.scanner.delay_between_requests_ms) || 0;
613
+ }
614
+ return 0;
615
+ }
616
+
617
+ /**
618
+ * 🚀 Executa o scan completo
619
+ * @returns {Promise<void>}
620
+ */
621
+ async run() {
622
+ await this.initialize();
623
+
624
+ if (this.args.headed && this.mode === "playwright") {
625
+ if (!this.config.scanner) this.config.scanner = {};
626
+ this.config.scanner.headless = false;
627
+ this.browserManager.config.scanner.headless = false;
628
+ await this.browserManager.close();
629
+ await this.browserManager.launch();
630
+ }
631
+
632
+ this.results.scan_start = new Date().toISOString();
633
+
634
+ console.clear();
635
+
636
+ const displayMode = this.mode === "playwright" ? "🌐 Playwright" : "⚡ Axios";
637
+ Logger.showBanner(this.config, this.payloads.length, this.category, this.targetUrl);
638
+
639
+ console.log(colors.highlight(`\n ✸ Mode: ${displayMode}`));
640
+
641
+ if (this.mode === "playwright") {
642
+ if (this.config.scanner.headless === false) {
643
+ console.log(colors.warning(" 🖥️ Headed mode - browser window will be visible\n"));
644
+ }
645
+ }
646
+
647
+ Logger.log("info",
648
+ `Testing ${colors.primary.bold(String(this.payloads.length))} payloads from ${colors.highlight.bold(this.category)} category\n`
649
+ );
650
+
651
+ const spinner = ora({
652
+ text: colors.action("Initializing scan..."),
653
+ spinner: { interval: 80, frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] },
654
+ color: "cyan"
655
+ });
656
+
657
+ if (!this.args.verbose) spinner.start();
658
+
659
+ const concurrentLimit = this.mode === "playwright" ? 1 : 5;
660
+ let cursor = 0;
661
+
662
+ const configuredDelay = this.getConfiguredDelay();
663
+ const effectiveDelay = configuredDelay > 0 ? configuredDelay : 500;
664
+
665
+ while (cursor < this.payloads.length) {
666
+ const batch = [];
667
+ for (let i = cursor; i < cursor + concurrentLimit && i < this.payloads.length; i++) {
668
+ batch.push(this.testPayload(this.payloads[i], i));
669
+ }
670
+
671
+ const batchResults = await Promise.all(batch);
672
+
673
+ for (const result of batchResults) {
674
+ this.results.total_tests++;
675
+
676
+ if (!this.args.verbose && spinner.isSpinning) {
677
+ spinner.text = colors.action(
678
+ `${result.index}/${this.payloads.length} ${colors.primary("▸")} ${colors.text(result.payload.substring(0, 30))}`
679
+ );
680
+ }
681
+
682
+ if (result.vulnerable) {
683
+ this.results.vulns_found++;
684
+ this.results.findings.push(result);
685
+
686
+ if (spinner.isSpinning) spinner.stop();
687
+
688
+ const shortPayload = result.payload.length > 50
689
+ ? result.payload.substring(0, 47) + "..."
690
+ : result.payload;
691
+
692
+ Logger.log("vuln",
693
+ `#${String(result.index).padStart(4, "0")} ${colors.icon.arrow} ${colors.highlight(shortPayload)}`
694
+ );
695
+
696
+ if (!this.args.verbose) spinner.start();
697
+
698
+ } else if (this.args.verbose || this.config.output?.show_safe) {
699
+ if (spinner.isSpinning) spinner.stop();
700
+ Logger.log("safe", `#${String(result.index).padStart(4, "0")} ${colors.icon.success} ${colors.muted(result.payload.substring(0, 40))}`);
701
+ if (!this.args.verbose) spinner.start();
702
+ }
703
+ }
704
+
705
+ cursor += concurrentLimit;
706
+
707
+ if (cursor < this.payloads.length) {
708
+ await sleep(effectiveDelay);
709
+ }
710
+ }
711
+
712
+ if (spinner.isSpinning) {
713
+ spinner.succeed(colors.success("Scan completed"));
714
+ }
715
+
716
+ if (this.browserManager) {
717
+ await this.browserManager.close();
718
+ }
719
+
720
+ this.results.scan_end = new Date().toISOString();
721
+
722
+ const { textPath } = this.reporter.saveReport(this.results, this.targetUrl);
723
+ const displayPath = this.formatReportPath(textPath);
724
+ const duration = ((new Date(this.results.scan_end) - new Date(this.results.scan_start)) / 1000).toFixed(1);
725
+
726
+ Logger.showResults(
727
+ this.results,
728
+ this.targetUrl,
729
+ this.category,
730
+ duration,
731
+ displayPath,
732
+ this.reporter.reportDir
733
+ );
734
+
735
+ process.exit(this.results.vulns_found > 0 ? 1 : 0);
736
+ }
737
+
738
+ /**
739
+ * 📄 Formata o caminho do relatório para exibição
740
+ * Exibe caminho completo no Termux
741
+ * @param {string} absolutePath - Caminho absoluto do relatório
742
+ * @returns {string} Caminho formatado
743
+ */
744
+ formatReportPath(absolutePath) {
745
+ const normalized = absolutePath.replace(/\\/g, "/");
746
+
747
+ const packageBase = path.join(__dirname, "..", "..").replace(/\\/g, "/");
748
+
749
+ if (normalized.startsWith(packageBase)) {
750
+ let relative = normalized.substring(packageBase.length);
751
+ if (relative.startsWith("/")) relative = relative.substring(1);
752
+ return `[package]/${relative}`;
753
+ }
754
+
755
+ const cwd = process.cwd().replace(/\\/g, "/");
756
+ if (normalized.startsWith(cwd)) {
757
+ let relative = normalized.substring(cwd.length);
758
+ if (relative.startsWith("/")) relative = relative.substring(1);
759
+ return `./${relative}`;
760
+ }
761
+
762
+ return normalized;
763
+ }
764
+
765
+ /**
766
+ * 🔗 Trunca a URL para exibição compacta
767
+ * @param {string} url - URL completa
768
+ * @param {number} maxLength - Comprimento máximo
769
+ * @returns {string} URL truncada
770
+ */
771
+ truncateUrl(url, maxLength = 55) {
772
+ if (!url) return "N/A";
773
+ if (url.length <= maxLength) return url;
774
+ return url.substring(0, maxLength - 3) + "...";
775
+ }
776
+ }
777
+
778
+ module.exports = { XSSScanner };