mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-10 06:31:59 +00:00
- Implemented methods for fetching AI stats, training history, and recent experiences. - Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling. - Included helper methods for querying the database and sending JSON responses. - Integrated model metadata extraction for visualization purposes.
802 lines
28 KiB
JavaScript
802 lines
28 KiB
JavaScript
/**
|
|
* Web Enum page module.
|
|
* Displays web enumeration/directory brute-force results with filtering,
|
|
* sorting, pagination, detail modal, and JSON/CSV export.
|
|
* Endpoint: GET /api/webenum/results?page=N&limit=M
|
|
*/
|
|
import { ResourceTracker } from '../core/resource-tracker.js';
|
|
import { api } from '../core/api.js';
|
|
import { el, $, $$, empty } from '../core/dom.js';
|
|
import { t } from '../core/i18n.js';
|
|
|
|
const PAGE = 'web-enum';
|
|
const MAX_PAGES_FETCH = 200;
|
|
const FETCH_LIMIT = 500;
|
|
const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500, 0]; // 0 = All
|
|
const ANSI_RE = /[\x00-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g;
|
|
|
|
/* ── state ── */
|
|
let tracker = null;
|
|
let allData = [];
|
|
let filteredData = [];
|
|
let currentPage = 1;
|
|
let itemsPerPage = 50;
|
|
let sortField = 'scan_date';
|
|
let sortDirection = 'desc';
|
|
let exactStatusFilter = null;
|
|
let serverTotal = 0;
|
|
let fetchedLimit = false;
|
|
|
|
/* filter state */
|
|
let searchText = '';
|
|
let filterHost = '';
|
|
let filterStatusFamily = '';
|
|
let filterPort = '';
|
|
let filterDate = '';
|
|
let searchDebounceId = null;
|
|
|
|
/* ── lifecycle ── */
|
|
export async function mount(container) {
|
|
tracker = new ResourceTracker(PAGE);
|
|
container.appendChild(buildShell());
|
|
await fetchAllData();
|
|
}
|
|
|
|
export function unmount() {
|
|
if (searchDebounceId != null) clearTimeout(searchDebounceId);
|
|
searchDebounceId = null;
|
|
if (tracker) { tracker.cleanupAll(); tracker = null; }
|
|
allData = [];
|
|
filteredData = [];
|
|
currentPage = 1;
|
|
itemsPerPage = 50;
|
|
sortField = 'scan_date';
|
|
sortDirection = 'desc';
|
|
exactStatusFilter = null;
|
|
serverTotal = 0;
|
|
fetchedLimit = false;
|
|
searchText = '';
|
|
filterHost = '';
|
|
filterStatusFamily = '';
|
|
filterPort = '';
|
|
filterDate = '';
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Shell
|
|
══════════════════════════════════════════════════════════════ */
|
|
function buildShell() {
|
|
return el('div', { class: 'webenum-container' }, [
|
|
/* stats bar */
|
|
el('div', { class: 'stats-bar', id: 'we-stats' }, [
|
|
statItem('we-stat-total', 'Total Results'),
|
|
statItem('we-stat-hosts', 'Unique Hosts'),
|
|
statItem('we-stat-success', 'Success (2xx)'),
|
|
statItem('we-stat-errors', 'Errors (4xx/5xx)'),
|
|
]),
|
|
/* controls row */
|
|
el('div', { class: 'webenum-controls' }, [
|
|
/* text search */
|
|
el('div', { class: 'global-search-container' }, [
|
|
el('input', {
|
|
type: 'text', class: 'global-search-input', id: 'we-search',
|
|
placeholder: t('common.search') || 'Search host, IP, directory, status\u2026',
|
|
oninput: onSearchInput,
|
|
}),
|
|
el('button', { class: 'clear-global-button', onclick: clearSearch }, ['\u2716']),
|
|
]),
|
|
el('div', { class: 'webenum-main-actions' }, [
|
|
el('button', { class: 'vuln-btn', onclick: () => fetchAllData() }, ['Refresh']),
|
|
]),
|
|
/* dropdown filters */
|
|
el('div', { class: 'webenum-filters' }, [
|
|
buildSelect('we-filter-host', 'All Hosts', onHostFilter),
|
|
buildSelect('we-filter-status', 'All Status', onStatusFamilyFilter),
|
|
buildSelect('we-filter-port', 'All Ports', onPortFilter),
|
|
el('input', {
|
|
type: 'date', class: 'webenum-date-input', id: 'we-filter-date',
|
|
onchange: onDateFilter,
|
|
}),
|
|
]),
|
|
/* export buttons */
|
|
el('div', { class: 'webenum-export-btns' }, [
|
|
el('button', { class: 'vuln-btn', onclick: () => exportData('json') }, ['Export JSON']),
|
|
el('button', { class: 'vuln-btn', onclick: () => exportData('csv') }, ['Export CSV']),
|
|
]),
|
|
]),
|
|
/* status legend chips */
|
|
el('div', { class: 'webenum-status-legend', id: 'we-status-legend' }),
|
|
/* table container */
|
|
el('div', { class: 'webenum-table-wrap', id: 'we-table-wrap' }),
|
|
/* pagination */
|
|
el('div', { class: 'webenum-pagination', id: 'we-pagination' }),
|
|
/* detail modal */
|
|
el('div', { class: 'vuln-modal', id: 'we-modal', onclick: onModalBackdrop }, [
|
|
el('div', { class: 'vuln-modal-content' }, [
|
|
el('div', { class: 'vuln-modal-header' }, [
|
|
el('span', { class: 'vuln-modal-title', id: 'we-modal-title' }),
|
|
el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']),
|
|
]),
|
|
el('div', { class: 'vuln-modal-body', id: 'we-modal-body' }),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
function statItem(id, label) {
|
|
return el('div', { class: 'stat-item' }, [
|
|
el('span', { class: 'stat-value', id }, ['0']),
|
|
el('span', { class: 'stat-label' }, [label]),
|
|
]);
|
|
}
|
|
|
|
function buildSelect(id, defaultLabel, handler) {
|
|
return el('select', { class: 'webenum-filter-select', id, onchange: handler }, [
|
|
el('option', { value: '' }, [defaultLabel]),
|
|
]);
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Data fetching — paginate through all server pages
|
|
══════════════════════════════════════════════════════════════ */
|
|
async function fetchAllData() {
|
|
const loading = $('#we-table-wrap');
|
|
if (loading) {
|
|
empty(loading);
|
|
loading.appendChild(el('div', { class: 'page-loading' }, [t('common.loading') || 'Loading\u2026']));
|
|
}
|
|
|
|
const ac = tracker ? tracker.trackAbortController() : null;
|
|
const signal = ac ? ac.signal : undefined;
|
|
|
|
let accumulated = [];
|
|
let page = 1;
|
|
serverTotal = 0;
|
|
fetchedLimit = false;
|
|
|
|
try {
|
|
while (page <= MAX_PAGES_FETCH) {
|
|
const url = `/api/webenum/results?page=${page}&limit=${FETCH_LIMIT}`;
|
|
const data = await api.get(url, { signal, timeout: 15000 });
|
|
|
|
const results = Array.isArray(data.results) ? data.results : [];
|
|
if (data.total != null) serverTotal = data.total;
|
|
|
|
if (results.length === 0) break;
|
|
|
|
accumulated = accumulated.concat(results);
|
|
|
|
/* all fetched */
|
|
if (serverTotal > 0 && accumulated.length >= serverTotal) break;
|
|
/* page was not full — last page */
|
|
if (results.length < FETCH_LIMIT) break;
|
|
|
|
page++;
|
|
}
|
|
|
|
if (page > MAX_PAGES_FETCH) fetchedLimit = true;
|
|
} catch (err) {
|
|
if (err.name === 'ApiError' && err.message === 'Aborted') return;
|
|
console.warn(`[${PAGE}] fetch error:`, err.message);
|
|
} finally {
|
|
if (ac && tracker) tracker.removeAbortController(ac);
|
|
}
|
|
|
|
allData = accumulated.map(normalizeRow);
|
|
populateFilterDropdowns();
|
|
applyFilters();
|
|
}
|
|
|
|
/* ── row normalization ── */
|
|
function normalizeRow(row) {
|
|
const host = (row.host || row.hostname || '').toString();
|
|
let directory = (row.directory || '').toString().replace(ANSI_RE, '');
|
|
return {
|
|
id: row.id,
|
|
host: host,
|
|
ip: (row.ip || '').toString(),
|
|
mac: (row.mac || '').toString(),
|
|
port: row.port != null ? Number(row.port) : 0,
|
|
directory: directory,
|
|
status: row.status != null ? Number(row.status) : 0,
|
|
size: row.size != null ? Number(row.size) : 0,
|
|
scan_date: row.scan_date || '',
|
|
response_time: row.response_time != null ? Number(row.response_time) : 0,
|
|
content_type: (row.content_type || '').toString(),
|
|
};
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Filter dropdowns — populate from unique values
|
|
══════════════════════════════════════════════════════════════ */
|
|
function populateFilterDropdowns() {
|
|
populateSelect('we-filter-host', 'All Hosts',
|
|
[...new Set(allData.map(r => r.host).filter(Boolean))].sort());
|
|
|
|
const families = [...new Set(allData.map(r => statusFamily(r.status)).filter(Boolean))].sort();
|
|
populateSelect('we-filter-status', 'All Status', families);
|
|
|
|
const ports = [...new Set(allData.map(r => r.port).filter(p => p > 0))].sort((a, b) => a - b);
|
|
populateSelect('we-filter-port', 'All Ports', ports.map(String));
|
|
}
|
|
|
|
function populateSelect(id, defaultLabel, options) {
|
|
const sel = $(`#${id}`);
|
|
if (!sel) return;
|
|
const current = sel.value;
|
|
empty(sel);
|
|
sel.appendChild(el('option', { value: '' }, [defaultLabel]));
|
|
options.forEach(opt => {
|
|
sel.appendChild(el('option', { value: opt }, [opt]));
|
|
});
|
|
if (current && options.includes(current)) sel.value = current;
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Filter & sort pipeline
|
|
══════════════════════════════════════════════════════════════ */
|
|
function applyFilters() {
|
|
const needle = searchText.toLowerCase();
|
|
|
|
filteredData = allData.filter(row => {
|
|
/* exact status chip filter */
|
|
if (exactStatusFilter != null && row.status !== exactStatusFilter) return false;
|
|
|
|
/* text search */
|
|
if (needle) {
|
|
const hay = `${row.host} ${row.ip} ${row.directory} ${row.status}`.toLowerCase();
|
|
if (!hay.includes(needle)) return false;
|
|
}
|
|
|
|
/* host dropdown */
|
|
if (filterHost && row.host !== filterHost) return false;
|
|
|
|
/* status family dropdown */
|
|
if (filterStatusFamily && statusFamily(row.status) !== filterStatusFamily) return false;
|
|
|
|
/* port dropdown */
|
|
if (filterPort && String(row.port) !== filterPort) return false;
|
|
|
|
/* date filter */
|
|
if (filterDate) {
|
|
const rowDate = (row.scan_date || '').substring(0, 10);
|
|
if (rowDate !== filterDate) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
applySort();
|
|
currentPage = 1;
|
|
updateStats();
|
|
renderStatusLegend();
|
|
renderTable();
|
|
renderPagination();
|
|
}
|
|
|
|
function applySort() {
|
|
const dir = sortDirection === 'asc' ? 1 : -1;
|
|
const field = sortField;
|
|
|
|
filteredData.sort((a, b) => {
|
|
let va = a[field];
|
|
let vb = b[field];
|
|
|
|
if (va == null) va = '';
|
|
if (vb == null) vb = '';
|
|
|
|
if (typeof va === 'number' && typeof vb === 'number') {
|
|
return (va - vb) * dir;
|
|
}
|
|
|
|
/* date string comparison */
|
|
if (field === 'scan_date') {
|
|
const da = new Date(va).getTime() || 0;
|
|
const db = new Date(vb).getTime() || 0;
|
|
return (da - db) * dir;
|
|
}
|
|
|
|
return String(va).localeCompare(String(vb)) * dir;
|
|
});
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Stats bar
|
|
══════════════════════════════════════════════════════════════ */
|
|
function updateStats() {
|
|
const totalLabel = fetchedLimit
|
|
? `${filteredData.length} (truncated)`
|
|
: String(filteredData.length);
|
|
setStatVal('we-stat-total', totalLabel);
|
|
setStatVal('we-stat-hosts', new Set(filteredData.map(r => r.host || r.ip)).size);
|
|
setStatVal('we-stat-success', filteredData.filter(r => r.status >= 200 && r.status < 300).length);
|
|
setStatVal('we-stat-errors', filteredData.filter(r => r.status >= 400).length);
|
|
}
|
|
|
|
function setStatVal(id, val) {
|
|
const e = $(`#${id}`);
|
|
if (e) e.textContent = String(val);
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Status legend chips
|
|
══════════════════════════════════════════════════════════════ */
|
|
function renderStatusLegend() {
|
|
const container = $('#we-status-legend');
|
|
if (!container) return;
|
|
empty(container);
|
|
|
|
/* gather unique status codes from current allData (unfiltered view) */
|
|
const codes = [...new Set(allData.map(r => r.status))].sort((a, b) => a - b);
|
|
if (codes.length === 0) return;
|
|
|
|
codes.forEach(code => {
|
|
const count = allData.filter(r => r.status === code).length;
|
|
const isActive = exactStatusFilter === code;
|
|
const chip = el('span', {
|
|
class: `webenum-status-chip ${statusClass(code)} ${isActive ? 'active' : ''}`,
|
|
onclick: () => {
|
|
if (exactStatusFilter === code) {
|
|
exactStatusFilter = null;
|
|
} else {
|
|
exactStatusFilter = code;
|
|
}
|
|
/* clear active class on all chips, re-apply via full filter cycle */
|
|
$$('.webenum-status-chip', container).forEach(c => c.classList.remove('active'));
|
|
applyFilters();
|
|
},
|
|
}, [`${code} (${count})`]);
|
|
container.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Table rendering
|
|
══════════════════════════════════════════════════════════════ */
|
|
function renderTable() {
|
|
const wrap = $('#we-table-wrap');
|
|
if (!wrap) return;
|
|
empty(wrap);
|
|
|
|
if (filteredData.length === 0) {
|
|
wrap.appendChild(emptyState('No web enumeration results found'));
|
|
return;
|
|
}
|
|
|
|
/* current page slice */
|
|
const pageData = getPageSlice();
|
|
|
|
/* column definitions */
|
|
const columns = [
|
|
{ key: 'host', label: 'Host' },
|
|
{ key: 'ip', label: 'IP' },
|
|
{ key: 'port', label: 'Port' },
|
|
{ key: 'directory', label: 'Directory' },
|
|
{ key: 'status', label: 'Status' },
|
|
{ key: 'size', label: 'Size' },
|
|
{ key: 'scan_date', label: 'Scan Date' },
|
|
{ key: '_actions', label: 'Actions' },
|
|
];
|
|
|
|
/* thead */
|
|
const headerCells = columns.map(col => {
|
|
if (col.key === '_actions') {
|
|
return el('th', {}, [col.label]);
|
|
}
|
|
const isSorted = sortField === col.key;
|
|
const arrow = isSorted ? (sortDirection === 'asc' ? ' \u25B2' : ' \u25BC') : '';
|
|
return el('th', {
|
|
class: `sortable ${isSorted ? 'sort-' + sortDirection : ''}`,
|
|
style: 'cursor:pointer;user-select:none;',
|
|
onclick: () => onSortColumn(col.key),
|
|
}, [col.label + arrow]);
|
|
});
|
|
|
|
const thead = el('thead', {}, [el('tr', {}, headerCells)]);
|
|
|
|
/* tbody */
|
|
const rows = pageData.map(row => {
|
|
const url = buildUrl(row);
|
|
return el('tr', {
|
|
class: 'webenum-row',
|
|
style: 'cursor:pointer;',
|
|
onclick: (e) => {
|
|
/* ignore if click was on an anchor */
|
|
if (e.target.tagName === 'A') return;
|
|
showDetailModal(row);
|
|
},
|
|
}, [
|
|
el('td', {}, [row.host || '-']),
|
|
el('td', {}, [row.ip || '-']),
|
|
el('td', {}, [row.port ? String(row.port) : '-']),
|
|
el('td', { class: 'webenum-dir-cell', title: row.directory }, [row.directory || '/']),
|
|
el('td', {}, [statusBadge(row.status)]),
|
|
el('td', {}, [formatSize(row.size)]),
|
|
el('td', {}, [formatDate(row.scan_date)]),
|
|
el('td', {}, [
|
|
url
|
|
? el('a', {
|
|
href: url, target: '_blank', rel: 'noopener noreferrer',
|
|
class: 'webenum-link', title: url,
|
|
onclick: (e) => e.stopPropagation(),
|
|
}, ['Open'])
|
|
: el('span', { class: 'muted' }, ['-']),
|
|
]),
|
|
]);
|
|
});
|
|
|
|
const tbody = el('tbody', {}, rows);
|
|
const table = el('table', { class: 'webenum-table' }, [thead, tbody]);
|
|
wrap.appendChild(el('div', { class: 'table-inner' }, [table]));
|
|
}
|
|
|
|
function getPageSlice() {
|
|
if (itemsPerPage === 0) return filteredData; // All
|
|
const start = (currentPage - 1) * itemsPerPage;
|
|
return filteredData.slice(start, start + itemsPerPage);
|
|
}
|
|
|
|
function getTotalPages() {
|
|
if (itemsPerPage === 0) return 1;
|
|
return Math.max(1, Math.ceil(filteredData.length / itemsPerPage));
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Pagination
|
|
══════════════════════════════════════════════════════════════ */
|
|
function renderPagination() {
|
|
const pag = $('#we-pagination');
|
|
if (!pag) return;
|
|
empty(pag);
|
|
|
|
const total = getTotalPages();
|
|
|
|
/* per-page selector */
|
|
const perPageSel = el('select', { class: 'webenum-filter-select webenum-perpage', onchange: onPerPageChange }, []);
|
|
PER_PAGE_OPTIONS.forEach(n => {
|
|
const label = n === 0 ? 'All' : String(n);
|
|
const opt = el('option', { value: String(n) }, [label]);
|
|
if (n === itemsPerPage) opt.selected = true;
|
|
perPageSel.appendChild(opt);
|
|
});
|
|
pag.appendChild(el('div', { class: 'webenum-perpage-wrap' }, [
|
|
el('span', { class: 'stat-label' }, ['Per page:']),
|
|
perPageSel,
|
|
]));
|
|
|
|
if (total <= 1 && itemsPerPage !== 0) {
|
|
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
|
|
`${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
|
|
]));
|
|
return;
|
|
}
|
|
|
|
if (itemsPerPage === 0) {
|
|
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
|
|
`Showing all ${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`,
|
|
]));
|
|
return;
|
|
}
|
|
|
|
/* Prev */
|
|
pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1)));
|
|
|
|
/* numbered buttons */
|
|
const start = Math.max(1, currentPage - 2);
|
|
const end = Math.min(total, start + 4);
|
|
for (let i = start; i <= end; i++) {
|
|
pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage));
|
|
}
|
|
|
|
/* Next */
|
|
pag.appendChild(pageBtn('Next', currentPage < total, () => changePage(currentPage + 1)));
|
|
|
|
/* info */
|
|
pag.appendChild(el('span', { class: 'vuln-page-info' }, [
|
|
`Page ${currentPage} of ${total} (${filteredData.length} results)`,
|
|
]));
|
|
}
|
|
|
|
function pageBtn(label, enabled, onclick, active = false) {
|
|
return el('button', {
|
|
class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`,
|
|
onclick: enabled ? onclick : null,
|
|
disabled: !enabled,
|
|
}, [label]);
|
|
}
|
|
|
|
function changePage(p) {
|
|
const total = getTotalPages();
|
|
currentPage = Math.max(1, Math.min(total, p));
|
|
renderTable();
|
|
renderPagination();
|
|
const wrap = $('#we-table-wrap');
|
|
if (wrap) wrap.scrollTop = 0;
|
|
}
|
|
|
|
function onPerPageChange(e) {
|
|
itemsPerPage = parseInt(e.target.value, 10);
|
|
currentPage = 1;
|
|
renderTable();
|
|
renderPagination();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Sort handler
|
|
══════════════════════════════════════════════════════════════ */
|
|
function onSortColumn(key) {
|
|
if (sortField === key) {
|
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
sortField = key;
|
|
sortDirection = 'asc';
|
|
}
|
|
applySort();
|
|
renderTable();
|
|
renderPagination();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Filter handlers
|
|
══════════════════════════════════════════════════════════════ */
|
|
function onSearchInput(e) {
|
|
if (searchDebounceId != null) clearTimeout(searchDebounceId);
|
|
const val = e.target.value;
|
|
searchDebounceId = tracker
|
|
? tracker.trackTimeout(() => {
|
|
searchText = val;
|
|
applyFilters();
|
|
const btn = e.target.nextElementSibling;
|
|
if (btn) btn.classList.toggle('show', val.length > 0);
|
|
}, 300)
|
|
: setTimeout(() => {
|
|
searchText = val;
|
|
applyFilters();
|
|
}, 300);
|
|
}
|
|
|
|
function clearSearch() {
|
|
const inp = $('#we-search');
|
|
if (inp) inp.value = '';
|
|
searchText = '';
|
|
applyFilters();
|
|
const btn = inp ? inp.nextElementSibling : null;
|
|
if (btn) btn.classList.remove('show');
|
|
}
|
|
|
|
function onHostFilter(e) {
|
|
filterHost = e.target.value;
|
|
applyFilters();
|
|
}
|
|
|
|
function onStatusFamilyFilter(e) {
|
|
filterStatusFamily = e.target.value;
|
|
/* clear exact chip filter when dropdown changes */
|
|
exactStatusFilter = null;
|
|
applyFilters();
|
|
}
|
|
|
|
function onPortFilter(e) {
|
|
filterPort = e.target.value;
|
|
applyFilters();
|
|
}
|
|
|
|
function onDateFilter(e) {
|
|
filterDate = e.target.value || '';
|
|
applyFilters();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Detail modal
|
|
══════════════════════════════════════════════════════════════ */
|
|
function showDetailModal(row) {
|
|
const modal = $('#we-modal');
|
|
const title = $('#we-modal-title');
|
|
const body = $('#we-modal-body');
|
|
if (!modal || !title || !body) return;
|
|
|
|
const url = buildUrl(row);
|
|
|
|
title.textContent = `${row.host || row.ip}${row.directory || '/'}`;
|
|
empty(body);
|
|
|
|
const fields = [
|
|
['Host', row.host],
|
|
['IP', row.ip],
|
|
['MAC', row.mac],
|
|
['Port', row.port],
|
|
['Directory', row.directory],
|
|
['Status', row.status],
|
|
['Size', formatSize(row.size)],
|
|
['Content-Type', row.content_type],
|
|
['Response Time', row.response_time ? row.response_time + ' ms' : '-'],
|
|
['Scan Date', formatDate(row.scan_date)],
|
|
['URL', url || 'N/A'],
|
|
];
|
|
|
|
fields.forEach(([label, value]) => {
|
|
body.appendChild(el('div', { class: 'modal-detail-section' }, [
|
|
el('div', { class: 'modal-section-title' }, [label]),
|
|
el('div', { class: 'modal-section-text' }, [
|
|
label === 'Status'
|
|
? statusBadge(value)
|
|
: String(value != null ? value : '-'),
|
|
]),
|
|
]));
|
|
});
|
|
|
|
/* action buttons */
|
|
const actions = el('div', { class: 'webenum-modal-actions' }, []);
|
|
|
|
if (url) {
|
|
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
}}, ['Open URL']));
|
|
|
|
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => {
|
|
copyText(url);
|
|
}}, ['Copy URL']));
|
|
}
|
|
|
|
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'json') }, ['Export JSON']));
|
|
actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'csv') }, ['Export CSV']));
|
|
|
|
body.appendChild(actions);
|
|
modal.classList.add('show');
|
|
}
|
|
|
|
function closeModal() {
|
|
const modal = $('#we-modal');
|
|
if (modal) modal.classList.remove('show');
|
|
}
|
|
|
|
function onModalBackdrop(e) {
|
|
if (e.target.classList.contains('vuln-modal')) closeModal();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Export — JSON & CSV
|
|
══════════════════════════════════════════════════════════════ */
|
|
function exportData(format) {
|
|
const data = filteredData.length > 0 ? filteredData : allData;
|
|
if (data.length === 0) return;
|
|
|
|
const dateStr = new Date().toISOString().split('T')[0];
|
|
|
|
if (format === 'json') {
|
|
const json = JSON.stringify(data, null, 2);
|
|
downloadBlob(json, `webenum_results_${dateStr}.json`, 'application/json');
|
|
} else {
|
|
const csv = buildCSV(data);
|
|
downloadBlob(csv, `webenum_results_${dateStr}.csv`, 'text/csv');
|
|
}
|
|
}
|
|
|
|
function exportSingleResult(row, format) {
|
|
const dateStr = new Date().toISOString().split('T')[0];
|
|
if (format === 'json') {
|
|
downloadBlob(JSON.stringify(row, null, 2), `webenum_${row.host}_${dateStr}.json`, 'application/json');
|
|
} else {
|
|
downloadBlob(buildCSV([row]), `webenum_${row.host}_${dateStr}.csv`, 'text/csv');
|
|
}
|
|
}
|
|
|
|
function buildCSV(data) {
|
|
const headers = ['Host', 'IP', 'MAC', 'Port', 'Directory', 'Status', 'Size', 'Content-Type', 'Response Time', 'Scan Date', 'URL'];
|
|
const rows = [headers.join(',')];
|
|
data.forEach(r => {
|
|
const url = buildUrl(r) || '';
|
|
const values = [
|
|
r.host, r.ip, r.mac, r.port, r.directory, r.status,
|
|
r.size, r.content_type, r.response_time, r.scan_date, url,
|
|
].map(v => {
|
|
const s = String(v != null ? v : '');
|
|
return s.includes(',') || s.includes('"') || s.includes('\n')
|
|
? `"${s.replace(/"/g, '""')}"` : s;
|
|
});
|
|
rows.push(values.join(','));
|
|
});
|
|
return rows.join('\n');
|
|
}
|
|
|
|
function downloadBlob(content, filename, type) {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════
|
|
Helpers
|
|
══════════════════════════════════════════════════════════════ */
|
|
|
|
/** Status family string: '2xx', '3xx', '4xx', '5xx' */
|
|
function statusFamily(code) {
|
|
code = Number(code) || 0;
|
|
if (code >= 200 && code < 300) return '2xx';
|
|
if (code >= 300 && code < 400) return '3xx';
|
|
if (code >= 400 && code < 500) return '4xx';
|
|
if (code >= 500) return '5xx';
|
|
return '';
|
|
}
|
|
|
|
/** CSS class for status code */
|
|
function statusClass(code) {
|
|
code = Number(code) || 0;
|
|
if (code >= 200 && code < 300) return 'status-2xx';
|
|
if (code >= 300 && code < 400) return 'status-3xx';
|
|
if (code >= 400 && code < 500) return 'status-4xx';
|
|
if (code >= 500) return 'status-5xx';
|
|
return '';
|
|
}
|
|
|
|
/** Status badge element */
|
|
function statusBadge(code) {
|
|
return el('span', { class: `webenum-status-badge ${statusClass(code)}` }, [String(code)]);
|
|
}
|
|
|
|
/** Build full URL from row data */
|
|
function buildUrl(row) {
|
|
if (!row.host && !row.ip) return '';
|
|
const hostname = row.host || row.ip;
|
|
const port = Number(row.port) || 80;
|
|
const proto = port === 443 ? 'https' : 'http';
|
|
const portPart = (port === 80 || port === 443) ? '' : `:${port}`;
|
|
const dir = row.directory || '/';
|
|
return `${proto}://${hostname}${portPart}${dir}`;
|
|
}
|
|
|
|
/** Format byte size to human-readable */
|
|
function formatSize(bytes) {
|
|
bytes = Number(bytes) || 0;
|
|
if (bytes === 0) return '0 B';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
|
}
|
|
|
|
/** Format date string */
|
|
function formatDate(d) {
|
|
if (!d) return '-';
|
|
try {
|
|
const date = new Date(d);
|
|
if (isNaN(date.getTime())) return String(d);
|
|
return date.toLocaleDateString();
|
|
} catch {
|
|
return String(d);
|
|
}
|
|
}
|
|
|
|
/** Empty state */
|
|
function emptyState(msg) {
|
|
return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [
|
|
el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\uD83D\uDD0D']),
|
|
msg,
|
|
]);
|
|
}
|
|
|
|
/** Copy text to clipboard */
|
|
function copyText(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
|
} else {
|
|
fallbackCopy(text);
|
|
}
|
|
}
|
|
|
|
function fallbackCopy(text) {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand('copy'); } catch { /* noop */ }
|
|
document.body.removeChild(ta);
|
|
}
|