Skip to content

Commit 183da48

Browse files
committed
Merge testing: scan launcher improvements
2 parents 053a943 + b75d2d9 commit 183da48

2 files changed

Lines changed: 161 additions & 43 deletions

File tree

internal/api/ui/pages/launcher.js

Lines changed: 145 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -42,46 +42,49 @@
4242
bucket_list: 'Bucket list',
4343
};
4444

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.
4548
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.' }],
4851
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).' },
5255
],
5356
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.' },
5558
],
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.' }],
5962
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.' },
6265
],
6366
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.' },
7073
],
7174
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).' },
7982
],
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.' }],
8184
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.' },
8588
],
8689
};
8790

@@ -265,17 +268,20 @@
265268
defs.forEach((d) => {
266269
const target = d.advanced ? advanced : essential;
267270
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))}"` : '';
268273
let field = '';
269274
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' : ''}>`;
271276
} 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>`;
273278
} 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}>`;
275280
} 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}>`;
277282
}
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>`);
279285
});
280286

281287
if (sel.value === 'dns_dangling') {
@@ -360,6 +366,13 @@
360366
}
361367

362368
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+
};
363376
btn.disabled = true;
364377
const bodies = buildScanRequestBodies(spec, mode, raw);
365378
if (!bodies.length) {
@@ -368,30 +381,53 @@
368381
return;
369382
}
370383

384+
const bodyLabel = (b) => b.domain || b.subdomain || b.url || b.repo || b.bucket || '(target)';
385+
const total = bodies.length;
386+
371387
try {
372388
const scanIds = [];
373-
const failures = [];
389+
const failures = []; // { target, error }
374390
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)');
376395
try {
377396
const result = await window.apiPost(`/scan/${spec.path}`, { ...body, ...flags });
378397
if (result && result.scan_id) scanIds.push(result.scan_id);
398+
else failures.push({ target: label, error: 'no scan_id returned' });
379399
} catch (e) {
380-
failures.push(e.message || 'failed');
400+
failures.push({ target: label, error: e.message || 'failed' });
381401
}
382402
}
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)');
384409
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.');
385416
}
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');
389425
}
390-
singleInput.value = '';
391-
listInput.value = '';
426+
updateLaunchPreview();
392427
window.loadStats();
393428
window.loadScans();
394429
} catch (e) {
430+
setStatus('', '');
395431
window.showToast('error', 'Failed to start scan', e.message);
396432
} finally {
397433
btn.disabled = false;
@@ -432,11 +468,78 @@
432468
}
433469
}
434470

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+
435537
window.LauncherPage = {
436538
syncLaunchPlaceholder,
437539
renderLaunchFlags,
438540
updateLaunchPreview,
439541
triggerScan,
440542
handleLaunchFileUpload,
543+
cloneScanToLauncher,
441544
};
442545
})();

0 commit comments

Comments
 (0)