|
42 | 42 | bucket_list: 'Bucket list', |
43 | 43 | }; |
44 | 44 |
|
| 45 | + // Flag definitions. `default` is shown (as a select/bool value, or as a |
| 46 | + // "default: X" placeholder for number/text so an empty field uses the server |
| 47 | + // default). `help` renders as a tooltip + a one-line hint under the field. |
45 | 48 | const SCAN_FLAG_DEFS = { |
46 | | - domain_scan: [{ key: 'skip_ffuf', label: 'Skip FFuf', type: 'bool', advanced: false }], |
47 | | - subdomain_scan: [{ key: 'skip_ffuf', label: 'Skip FFuf', type: 'bool', advanced: false }], |
| 49 | + domain_scan: [{ key: 'skip_ffuf', label: 'Skip FFuf', type: 'bool', advanced: false, help: 'Skip the directory-fuzzing phase to finish faster.' }], |
| 50 | + subdomain_scan: [{ key: 'skip_ffuf', label: 'Skip FFuf', type: 'bool', advanced: false, help: 'Skip the directory-fuzzing phase to finish faster.' }], |
48 | 51 | asr: [ |
49 | | - { key: 'mode', label: 'ASR Mode', type: 'select', options: ['5', '4', '3', '2', '1'], advanced: false }, |
50 | | - { key: 'threads', label: 'Threads', type: 'number', min: 1, advanced: true }, |
51 | | - { key: 'resolvers', label: 'Resolvers file path', type: 'text', advanced: true }, |
| 52 | + { key: 'mode', label: 'ASR Mode', type: 'select', options: ['5', '4', '3', '2', '1'], default: '5', advanced: false, help: '5 = deepest (passive + brute + permute + resolve); 1 = fastest/lightest.' }, |
| 53 | + { key: 'threads', label: 'Threads', type: 'number', min: 1, default: 50, advanced: true, help: 'Concurrent DNS/HTTP workers.' }, |
| 54 | + { key: 'resolvers', label: 'Resolvers file path', type: 'text', advanced: true, help: 'Path to a custom DNS resolvers list on the server (optional).' }, |
52 | 55 | ], |
53 | 56 | recon: [ |
54 | | - { key: 'threads', label: 'Threads', type: 'number', min: 1, max: 500, advanced: false }, |
| 57 | + { key: 'threads', label: 'Threads', type: 'number', min: 1, max: 500, default: 100, advanced: false, help: 'Concurrency for subdomain enumeration.' }, |
55 | 58 | ], |
56 | | - nuclei: [{ key: 'mode', label: 'Mode', type: 'select', options: ['full', 'cves', 'panels', 'default-logins', 'vulnerabilities'], advanced: false }], |
57 | | - dns: [{ key: 'dns_type', label: 'DNS type', type: 'select', options: ['takeover', 'dangling-ip'], advanced: false }], |
58 | | - dns_dangling: [{ key: 'dns_type', label: 'DNS type', type: 'select', options: ['dangling-ip', 'takeover'], advanced: false }], |
| 59 | + nuclei: [{ key: 'mode', label: 'Mode', type: 'select', options: ['full', 'cves', 'panels', 'default-logins', 'vulnerabilities'], default: 'full', advanced: false, help: 'Which template set to run. Runs recon first automatically if live hosts are missing.' }], |
| 60 | + dns: [{ key: 'dns_type', label: 'DNS type', type: 'select', options: ['takeover', 'dangling-ip'], default: 'takeover', advanced: false, help: 'takeover = CNAME/NS takeover checks; dangling-ip = dangling A-record checks.' }], |
| 61 | + dns_dangling: [{ key: 'dns_type', label: 'DNS type', type: 'select', options: ['dangling-ip', 'takeover'], default: 'dangling-ip', advanced: false, help: 'dangling-ip = dangling A-record checks.' }], |
59 | 62 | s3: [ |
60 | | - { key: 'region', label: 'Region (optional)', type: 'text', advanced: false }, |
61 | | - { key: 'threads', label: 'Threads (reserved)', type: 'number', min: 1, advanced: true }, |
| 63 | + { key: 'region', label: 'Region (optional)', type: 'text', advanced: false, help: 'AWS region for the bucket, e.g. us-east-1. Leave empty to auto-detect.' }, |
| 64 | + { key: 'threads', label: 'Threads (reserved)', type: 'number', min: 1, advanced: true, help: 'Reserved for future concurrent bucket probing.' }, |
62 | 65 | ], |
63 | 66 | ffuf: [ |
64 | | - { key: 'threads', label: 'Threads', type: 'number', min: 1, advanced: false }, |
65 | | - { key: 'recursion', label: 'Enable recursion', type: 'bool', advanced: false }, |
66 | | - { key: 'recursion_depth', label: 'Recursion depth', type: 'number', min: 1, advanced: true }, |
67 | | - { key: 'bypass_403', label: 'Bypass 403 checks', type: 'bool', advanced: true }, |
68 | | - { key: 'extensions', label: 'Extensions (csv)', type: 'text', advanced: true }, |
69 | | - { key: 'wordlist', label: 'Wordlist path', type: 'text', advanced: true }, |
| 67 | + { key: 'threads', label: 'Threads', type: 'number', min: 1, default: 40, advanced: false, help: 'Concurrent fuzzing requests.' }, |
| 68 | + { key: 'recursion', label: 'Enable recursion', type: 'bool', advanced: false, help: 'Recurse into discovered directories.' }, |
| 69 | + { key: 'recursion_depth', label: 'Recursion depth', type: 'number', min: 1, default: 1, advanced: true, help: 'Max recursion depth when recursion is enabled.' }, |
| 70 | + { key: 'bypass_403', label: 'Bypass 403 checks', type: 'bool', advanced: true, help: 'Try header/path tricks to bypass 403 responses on hits.' }, |
| 71 | + { key: 'extensions', label: 'Extensions (csv)', type: 'text', advanced: true, help: 'Comma-separated, e.g. php,bak,zip,json.' }, |
| 72 | + { key: 'wordlist', label: 'Wordlist path', type: 'text', advanced: true, help: 'Server-side wordlist path. Empty = built-in quick_fuzz list.' }, |
70 | 73 | ], |
71 | 74 | zerodays: [ |
72 | | - { key: 'threads', label: 'Threads', type: 'number', min: 1, advanced: false }, |
73 | | - { key: 'dos_test', label: 'Enable DoS test', type: 'bool', advanced: true }, |
74 | | - { key: 'enable_source_exposure', label: 'Enable source exposure', type: 'bool', advanced: true }, |
75 | | - { key: 'silent', label: 'Silent mode', type: 'bool', advanced: true }, |
76 | | - { key: 'cves', label: 'CVEs (csv)', type: 'text', advanced: true }, |
77 | | - { key: 'mongodb_host', label: 'MongoDB host', type: 'text', advanced: true }, |
78 | | - { key: 'mongodb_port', label: 'MongoDB port', type: 'number', min: 1, advanced: true }, |
| 75 | + { key: 'threads', label: 'Threads', type: 'number', min: 1, default: 20, advanced: false, help: 'Concurrent CVE probes.' }, |
| 76 | + { key: 'dos_test', label: 'Enable DoS test', type: 'bool', advanced: true, help: 'Includes DoS checks — only run on assets you own / have permission to disrupt.' }, |
| 77 | + { key: 'enable_source_exposure', label: 'Enable source exposure', type: 'bool', advanced: true, help: 'Probe React2Shell source-map / source exposure.' }, |
| 78 | + { key: 'silent', label: 'Silent mode', type: 'bool', advanced: true, help: 'Only emit confirmed-vulnerable hosts.' }, |
| 79 | + { key: 'cves', label: 'CVEs (csv)', type: 'text', advanced: true, help: 'Limit to specific CVE IDs, comma-separated. Empty = all supported.' }, |
| 80 | + { key: 'mongodb_host', label: 'MongoDB host', type: 'text', advanced: true, help: 'Target host for the MongoDB CVE-2025-14847 check.' }, |
| 81 | + { key: 'mongodb_port', label: 'MongoDB port', type: 'number', min: 1, default: 27017, advanced: true, help: 'MongoDB port (default 27017).' }, |
79 | 82 | ], |
80 | | - backup: [{ key: 'threads', label: 'Threads', type: 'number', min: 1, advanced: false }], |
| 83 | + backup: [{ key: 'threads', label: 'Threads', type: 'number', min: 1, default: 20, advanced: false, help: 'Concurrent backup-file probes.' }], |
81 | 84 | misconfig: [ |
82 | | - { key: 'service_id', label: 'Service filter', type: 'text', advanced: false }, |
83 | | - { key: 'delay', label: 'Delay ms', type: 'number', min: 0, advanced: true }, |
84 | | - { key: 'permutations', label: 'Enable permutations', type: 'bool', advanced: true }, |
| 85 | + { key: 'service_id', label: 'Service filter', type: 'text', advanced: false, help: 'Limit to one service id (e.g. jenkins). Empty = all 100+ checks.' }, |
| 86 | + { key: 'delay', label: 'Delay ms', type: 'number', min: 0, default: 0, advanced: true, help: 'Delay between requests in milliseconds.' }, |
| 87 | + { key: 'permutations', label: 'Enable permutations', type: 'bool', advanced: true, help: 'Also test path permutations per service.' }, |
85 | 88 | ], |
86 | 89 | }; |
87 | 90 |
|
|
265 | 268 | defs.forEach((d) => { |
266 | 269 | const target = d.advanced ? advanced : essential; |
267 | 270 | const id = `flag-${d.key}`; |
| 271 | + const titleAttr = d.help ? ` title="${esc(d.help)}"` : ''; |
| 272 | + const ph = d.default != null ? ` placeholder="default: ${esc(String(d.default))}"` : ''; |
268 | 273 | let field = ''; |
269 | 274 | if (d.type === 'bool') { |
270 | | - field = `<input id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="bool" type="checkbox">`; |
| 275 | + field = `<input id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="bool" type="checkbox"${d.default === true ? ' checked' : ''}>`; |
271 | 276 | } else if (d.type === 'select') { |
272 | | - field = `<select id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="select">${(d.options || []).map((v) => `<option value="${esc(v)}">${esc(v)}</option>`).join('')}</select>`; |
| 277 | + field = `<select id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="select">${(d.options || []).map((v) => `<option value="${esc(v)}"${String(d.default) === String(v) ? ' selected' : ''}>${esc(v)}</option>`).join('')}</select>`; |
273 | 278 | } else if (d.type === 'number') { |
274 | | - field = `<input id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="number" type="number" ${d.min != null ? `min="${d.min}"` : ''}>`; |
| 279 | + field = `<input id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="number" type="number" ${d.min != null ? `min="${d.min}"` : ''}${ph}>`; |
275 | 280 | } else { |
276 | | - field = `<input id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="text" type="text">`; |
| 281 | + field = `<input id="${id}" data-flag-key="${esc(d.key)}" data-flag-type="text" type="text"${ph}>`; |
277 | 282 | } |
278 | | - target.insertAdjacentHTML('beforeend', `<div class="launch-flag-item"><label for="${id}">${esc(d.label)}</label>${field}</div>`); |
| 283 | + const helpLine = d.help ? `<div class="launch-flag-help" style="font-size:10px;color:var(--text-muted);line-height:1.3;margin-top:2px">${esc(d.help)}</div>` : ''; |
| 284 | + target.insertAdjacentHTML('beforeend', `<div class="launch-flag-item"><label for="${id}"${titleAttr}>${esc(d.label)}</label>${field}${helpLine}</div>`); |
279 | 285 | }); |
280 | 286 |
|
281 | 287 | if (sel.value === 'dns_dangling') { |
|
360 | 366 | } |
361 | 367 |
|
362 | 368 | const btn = document.getElementById('launch-btn'); |
| 369 | + const statusEl = document.getElementById('launch-status'); |
| 370 | + const setStatus = (txt, color) => { |
| 371 | + if (!statusEl) return; |
| 372 | + statusEl.textContent = txt; |
| 373 | + statusEl.style.color = color || 'var(--text-muted)'; |
| 374 | + statusEl.style.display = txt ? '' : 'none'; |
| 375 | + }; |
363 | 376 | btn.disabled = true; |
364 | 377 | const bodies = buildScanRequestBodies(spec, mode, raw); |
365 | 378 | if (!bodies.length) { |
|
368 | 381 | return; |
369 | 382 | } |
370 | 383 |
|
| 384 | + const bodyLabel = (b) => b.domain || b.subdomain || b.url || b.repo || b.bucket || '(target)'; |
| 385 | + const total = bodies.length; |
| 386 | + |
371 | 387 | try { |
372 | 388 | const scanIds = []; |
373 | | - const failures = []; |
| 389 | + const failures = []; // { target, error } |
374 | 390 | const flags = collectFlagValues(); |
375 | | - for (const body of bodies) { |
| 391 | + for (let i = 0; i < bodies.length; i++) { |
| 392 | + const body = bodies[i]; |
| 393 | + const label = bodyLabel(body); |
| 394 | + if (total > 1) setStatus(`Launching ${i + 1}/${total}… ${label}`, 'var(--accent-cyan)'); |
376 | 395 | try { |
377 | 396 | const result = await window.apiPost(`/scan/${spec.path}`, { ...body, ...flags }); |
378 | 397 | if (result && result.scan_id) scanIds.push(result.scan_id); |
| 398 | + else failures.push({ target: label, error: 'no scan_id returned' }); |
379 | 399 | } catch (e) { |
380 | | - failures.push(e.message || 'failed'); |
| 400 | + failures.push({ target: label, error: e.message || 'failed' }); |
381 | 401 | } |
382 | 402 | } |
383 | | - if (scanIds.length) { |
| 403 | + |
| 404 | + const failList = failures.slice(0, 5).map((f) => `${f.target}: ${f.error}`).join('\n'); |
| 405 | + const moreFails = failures.length > 5 ? `\n…and ${failures.length - 5} more` : ''; |
| 406 | + |
| 407 | + if (scanIds.length && !failures.length) { |
| 408 | + setStatus(`Started ${scanIds.length} scan${scanIds.length > 1 ? 's' : ''}.`, 'var(--accent-emerald)'); |
384 | 409 | window.showToast('success', 'Scan started', `${scanIds.length} started${scanIds.length === 1 ? ` (ID: ${scanIds[0]})` : ''}`); |
| 410 | + } else if (scanIds.length && failures.length) { |
| 411 | + setStatus(`${scanIds.length} started · ${failures.length} failed (failed targets kept below to retry).`, '#f59e0b'); |
| 412 | + window.showToast('error', `${failures.length} of ${total} failed`, failList + moreFails); |
| 413 | + } else { |
| 414 | + setStatus(`All ${total} launch${total > 1 ? 'es' : ''} failed.`, 'var(--accent-red)'); |
| 415 | + window.showToast('error', 'All launches failed', failList || 'No scans started.'); |
385 | 416 | } |
386 | | - if (failures.length) { |
387 | | - const firstError = failures[0] ? ` First error: ${failures[0]}` : ''; |
388 | | - window.showToast('error', 'Some launches failed', `${failures.length} failed.${firstError}`); |
| 417 | + |
| 418 | + // Clear on full success; on partial failure keep just the failed targets |
| 419 | + // in the bulk box so the user can fix + relaunch them. |
| 420 | + if (!failures.length) { |
| 421 | + singleInput.value = ''; |
| 422 | + listInput.value = ''; |
| 423 | + } else if (mode.endsWith('_list')) { |
| 424 | + listInput.value = failures.map((f) => f.target).join('\n'); |
389 | 425 | } |
390 | | - singleInput.value = ''; |
391 | | - listInput.value = ''; |
| 426 | + updateLaunchPreview(); |
392 | 427 | window.loadStats(); |
393 | 428 | window.loadScans(); |
394 | 429 | } catch (e) { |
| 430 | + setStatus('', ''); |
395 | 431 | window.showToast('error', 'Failed to start scan', e.message); |
396 | 432 | } finally { |
397 | 433 | btn.disabled = false; |
|
432 | 468 | } |
433 | 469 | } |
434 | 470 |
|
| 471 | + // launcherKeyForScanType maps a stored scan_type (the backend path, possibly |
| 472 | + // with a mode suffix like "nuclei-full") back to a Quick-Launcher type key. |
| 473 | + // Keys that actually have an <option> in the Quick Launcher select |
| 474 | + // (kept in sync with scans-page.js renderScans). A clone must resolve to one |
| 475 | + // of these, otherwise the <select> silently falls back to its first option. |
| 476 | + const LAUNCH_DROPDOWN_KEYS = new Set([ |
| 477 | + 'recon', 'domain_scan', 'subdomain_scan', 'asr', 'urls', 'tech', 'nuclei', 'ports', |
| 478 | + 'dns', 'dns_dangling', 'dns_cf1016', 's3', 'github', 'github_org', 'js', 'reflection', |
| 479 | + 'gf', 'backup', 'misconfig', 'zerodays', 'ffuf', |
| 480 | + ]); |
| 481 | + |
| 482 | + function launcherKeyForScanType(scanType) { |
| 483 | + const st = String(scanType || '').toLowerCase().trim(); |
| 484 | + if (!st) return ''; |
| 485 | + const aliases = { |
| 486 | + domain_run: 'domain_scan', |
| 487 | + subdomain_run: 'subdomain_scan', |
| 488 | + github_scan: 'github', |
| 489 | + 'dns-takeover': 'dns', // dropdown "dns (takeover)" runs the same takeover scan |
| 490 | + 'dns-cf1016': 'dns_cf1016', |
| 491 | + 'dns-dangling-ip': 'dns_dangling', |
| 492 | + }; |
| 493 | + let key = ''; |
| 494 | + if (aliases[st]) key = aliases[st]; |
| 495 | + else if (st.startsWith('nuclei')) key = 'nuclei'; // nuclei-full, nuclei-cves, … |
| 496 | + else if (LAUNCH_DROPDOWN_KEYS.has(st)) key = st; |
| 497 | + else { |
| 498 | + for (const [k, spec] of Object.entries(LAUNCH_SCAN_TYPES)) { |
| 499 | + if (spec.path === st) { key = k; break; } |
| 500 | + } |
| 501 | + } |
| 502 | + // Only return a key the dropdown can actually select; otherwise '' so the |
| 503 | + // caller fills the target and prompts the user to pick a type. |
| 504 | + return LAUNCH_DROPDOWN_KEYS.has(key) ? key : ''; |
| 505 | + } |
| 506 | + |
| 507 | + // cloneScanToLauncher prefills the Quick Scan Launcher from a past scan so the |
| 508 | + // user can tweak type/mode/flags before relaunching (unlike Rescan, which |
| 509 | + // re-runs the exact same command). Original flags aren't persisted, so only |
| 510 | + // type + target are restored. |
| 511 | + function cloneScanToLauncher(scanType, target) { |
| 512 | + const key = launcherKeyForScanType(scanType); |
| 513 | + const spec = key ? LAUNCH_SCAN_TYPES[key] : null; |
| 514 | + const tgt = String(target || '').trim(); |
| 515 | + window.state = window.state || {}; |
| 516 | + window.state.scanLaunchUI = window.state.scanLaunchUI || {}; |
| 517 | + const lui = window.state.scanLaunchUI; |
| 518 | + if (spec) { |
| 519 | + lui.scanType = key; |
| 520 | + lui.targetMode = (spec.modes && spec.modes[0]) || 'domain'; |
| 521 | + } |
| 522 | + lui.target = tgt; |
| 523 | + lui.targetList = ''; |
| 524 | + // renderScans focuses + scrolls the launcher after it (re)renders, which is |
| 525 | + // robust to the async loadScans fetch (a fixed setTimeout would race it). |
| 526 | + lui._pendingCloneFocus = true; |
| 527 | + if (typeof window.navigateTo === 'function') window.navigateTo('scans'); |
| 528 | + if (spec) { |
| 529 | + window.showToast && window.showToast('info', 'Cloned to launcher', `${key} → ${tgt} — adjust flags/mode, then Launch.`); |
| 530 | + } else { |
| 531 | + window.showToast && window.showToast('info', 'Pick a scan type', `Target filled in — choose a scan type, then Launch (original type "${scanType}" isn't a standalone launcher option).`); |
| 532 | + } |
| 533 | + } |
| 534 | + |
| 535 | + window.cloneScanToLauncher = cloneScanToLauncher; |
| 536 | + |
435 | 537 | window.LauncherPage = { |
436 | 538 | syncLaunchPlaceholder, |
437 | 539 | renderLaunchFlags, |
438 | 540 | updateLaunchPreview, |
439 | 541 | triggerScan, |
440 | 542 | handleLaunchFileUpload, |
| 543 | + cloneScanToLauncher, |
441 | 544 | }; |
442 | 545 | })(); |
0 commit comments