Files
BenjiTrapp-transportable-de…/webui/static/js/app.js
T
2026-06-09 14:17:19 +02:00

3034 lines
130 KiB
JavaScript

/**
* Detonation Chamber - Unified Web UI
* Frontend logic for the tracing/analysis interface
*/
// --- State ---
let state = {
alerts: [],
processes: {},
selectedProcess: null,
activeTab: 'dashboard',
detailOpen: false,
detailHistory: [],
sessionStart: null,
serviceStatus: {},
rustinelInfo: null,
// RTRACE Console state
rtraceSelectedPid: null,
rtraceActiveDetailTab: 'live',
rtraceEvents: [], // All events for the selected process
// Hex Editor state
hexData: null,
hexFileSize: 0,
hexFilePath: '',
hexOffset: 0,
hexSelectedByte: -1,
};
// --- Initialization ---
document.addEventListener('DOMContentLoaded', () => {
initTabs();
initUpload();
initHexDropZone();
initRtraceTabs();
initGraphControls();
refreshAll();
setInterval(refreshAlerts, 5000);
setInterval(refreshDashboard, 10000);
refreshDashboard();
});
// --- Tab navigation ---
function initTabs() {
document.querySelectorAll('.sidebar-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
switchTab(target);
});
});
}
function switchTab(tabName) {
state.activeTab = tabName;
document.querySelectorAll('.sidebar-tabs .tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab[data-tab="${tabName}"]`).classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
if (tabName === 'sysmon' && sysmonEvents.length === 0) {
refreshSysmon();
}
if (tabName === 'tracing') {
renderRtraceConsole();
}
if (tabName === 'graph') {
graphRefresh();
}
if (tabName === 'submit') {
refreshSubmissions();
}
}
// --- Data fetching ---
async function refreshAll() {
await Promise.all([refreshAlerts(), refreshProcesses(), refreshDashboard()]);
}
async function refreshDashboard() {
try {
const [statusResp, rustinelResp] = await Promise.all([
fetch('/api/status'),
fetch('/api/rustinel'),
]);
if (statusResp.ok) {
state.serviceStatus = await statusResp.json();
updateServiceStatus(state.serviceStatus);
}
if (rustinelResp.ok) {
state.rustinelInfo = await rustinelResp.json();
}
renderDashboard();
const timeEl = document.getElementById('dashboard-time');
if (timeEl) timeEl.textContent = 'Updated ' + new Date().toLocaleTimeString('en-GB');
} catch (e) {
console.error('Failed to refresh dashboard:', e);
}
}
async function refreshAlerts() {
try {
const resp = await fetch('/api/alerts');
if (resp.ok) {
state.alerts = await resp.json();
if (state.alerts.length) {
const times = state.alerts.map(a => new Date(a.timestamp).getTime()).filter(t => !isNaN(t));
state.sessionStart = times.length ? Math.min(...times) : null;
}
renderTimeline();
if (state.activeTab === 'tracing') {
renderRtraceConsole();
}
}
} catch (e) {
console.error('Failed to fetch alerts:', e);
}
}
async function refreshProcesses() {
try {
const resp = await fetch('/api/processes');
if (resp.ok) {
state.processes = await resp.json();
renderProcessList();
}
} catch (e) {
console.error('Failed to fetch processes:', e);
}
}
async function refreshStatus() {
try {
const resp = await fetch('/api/status');
if (resp.ok) {
const status = await resp.json();
state.serviceStatus = status;
updateServiceStatus(status);
}
} catch (e) {
console.error('Failed to fetch status:', e);
}
}
// --- Dashboard Rendering ---
function renderDashboard() {
const container = document.getElementById('dashboard-grid');
if (!container) return;
const status = state.serviceStatus;
const rustinel = state.rustinelInfo || {};
const cards = [];
// Rustinel Card
const rOnline = rustinel.online || status.rustinel?.online || false;
const rRules = rustinel.rules || {};
cards.push(`
<div class="service-card rustinel-card ${rOnline ? '' : 'offline'}" onclick="openRustinelDetail()">
<div class="service-card-glow"></div>
<div class="service-card-header">
<div class="service-card-title">
<div class="service-icon rustinel">R</div>
<h3>Rustinel</h3>
</div>
<span class="service-status-badge ${rOnline ? 'online' : 'offline'}">${rOnline ? 'Online' : 'Offline'}</span>
</div>
<div class="service-card-desc">Sigma/YARA/IOC detection engine via ETW.${rustinel.version ? `<br>${escapeHtml(rustinel.version)}` : ''}</div>
<div class="service-card-metrics">
<div class="service-metric"><div class="service-metric-value ${rRules.sigma === 0 ? 'zero' : ''}">${rRules.sigma || 0}</div><div class="service-metric-label">SIGMA</div></div>
<div class="service-metric"><div class="service-metric-value ${rRules.yara === 0 ? 'zero' : ''}">${rRules.yara || 0}</div><div class="service-metric-label">YARA</div></div>
<div class="service-metric"><div class="service-metric-value ${rustinel.alerts_count === 0 ? 'zero' : ''}">${rustinel.alerts_count || 0}</div><div class="service-metric-label">ALERTS</div></div>
</div>
<div class="service-card-actions">
<button class="btn btn-sm" onclick="event.stopPropagation(); openRustinelDetail()">Details</button>
<button class="btn btn-sm" onclick="event.stopPropagation(); switchTab('tracing')">Trace Console</button>
</div>
</div>
`);
// DetonatorAgent Card
const aOnline = status.detonator_agent?.online || false;
const aInUse = status.detonator_agent?.data?.in_use || false;
cards.push(`
<div class="service-card agent-card ${aOnline ? '' : 'offline'}" onclick="openAgentDetail()">
<div class="service-card-glow"></div>
<div class="service-card-header">
<div class="service-card-title"><div class="service-icon agent">D</div><h3>DetonatorAgent</h3></div>
<span class="service-status-badge ${aOnline ? 'online' : 'offline'}">${aOnline ? 'Online' : 'Offline'}</span>
</div>
<div class="service-card-desc">.NET execution agent. Detonates samples and collects EDR telemetry on port 8080.</div>
<div class="service-card-metrics">
<div class="service-metric"><div class="service-metric-value">${aOnline ? '8080' : '--'}</div><div class="service-metric-label">PORT</div></div>
<div class="service-metric"><div class="service-metric-value ${aInUse ? '' : 'zero'}">${aInUse ? 'Yes' : 'No'}</div><div class="service-metric-label">IN USE</div></div>
<div class="service-metric"><div class="service-metric-value">${aOnline ? 'Fibratus' : '--'}</div><div class="service-metric-label">EDR</div></div>
</div>
<div class="service-card-actions">
<button class="btn btn-sm" onclick="event.stopPropagation(); openAgentDetail()">Details</button>
<button class="btn btn-sm" onclick="event.stopPropagation(); switchTab('submit')">Submit Sample</button>
</div>
</div>
`);
// LitterBox Card
const lOnline = status.litterbox?.online || false;
cards.push(`
<div class="service-card litterbox-card ${lOnline ? '' : 'offline'}" onclick="openLitterboxDetail()">
<div class="service-card-glow"></div>
<div class="service-card-header">
<div class="service-card-title"><div class="service-icon litterbox">L</div><h3>LitterBox</h3></div>
<span class="service-status-badge ${lOnline ? 'online' : 'offline'}">${lOnline ? 'Online' : 'Offline'}</span>
</div>
<div class="service-card-desc">Self-hosted payload analysis sandbox. Static analysis, memory scanning, YARA.</div>
<div class="service-card-metrics">
<div class="service-metric"><div class="service-metric-value">${lOnline ? '1337' : '--'}</div><div class="service-metric-label">PORT</div></div>
<div class="service-metric"><div class="service-metric-value">PE-Sieve</div><div class="service-metric-label">SCANNER</div></div>
<div class="service-metric"><div class="service-metric-value">MCP</div><div class="service-metric-label">LLM API</div></div>
</div>
<div class="service-card-actions">
<button class="btn btn-sm" onclick="event.stopPropagation(); openLitterboxDetail()">Details</button>
<button class="btn btn-sm" onclick="event.stopPropagation(); window.open('http://localhost:1337', '_blank')">Open UI</button>
</div>
</div>
`);
// Sysmon Card
const sOnline = status.sysmon?.online || false;
cards.push(`
<div class="service-card sysmon-card ${sOnline ? '' : 'offline'}" onclick="switchTab('sysmon'); refreshSysmon();">
<div class="service-card-glow"></div>
<div class="service-card-header">
<div class="service-card-title"><div class="service-icon sysmon">S</div><h3>Sysmon</h3></div>
<span class="service-status-badge ${sOnline ? 'online' : 'offline'}">${sOnline ? 'Online' : 'Offline'}</span>
</div>
<div class="service-card-desc">System Monitor v15.14. Logs process creation, network, file, registry, DNS events.</div>
<div class="service-card-metrics">
<div class="service-metric"><div class="service-metric-value">${sOnline ? 'Sysmon64' : '--'}</div><div class="service-metric-label">SERVICE</div></div>
<div class="service-metric"><div class="service-metric-value">ETW</div><div class="service-metric-label">SOURCE</div></div>
<div class="service-metric"><div class="service-metric-value">SwiftOnSec</div><div class="service-metric-label">CONFIG</div></div>
</div>
<div class="service-card-actions">
<button class="btn btn-sm" onclick="event.stopPropagation(); switchTab('sysmon'); refreshSysmon();">View Events</button>
</div>
</div>
`);
container.innerHTML = cards.join('');
}
// =============================================
// RUSTINEL TRACE ANALYSIS CONSOLE
// =============================================
function initRtraceTabs() {
document.querySelectorAll('.rtrace-detail-tabs .rtrace-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.rtab;
state.rtraceActiveDetailTab = target;
document.querySelectorAll('.rtrace-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderRtraceEventTable();
});
});
// Process dropdown change
const dropdown = document.getElementById('rtrace-process-select');
if (dropdown) {
dropdown.addEventListener('change', () => {
const pid = dropdown.value;
if (pid) {
selectRtraceProcess(parseInt(pid));
}
});
}
}
function renderRtraceConsole() {
renderRtraceProcessDropdown();
renderRtraceTimeline();
renderRtraceProcessTree();
if (state.rtraceSelectedPid) {
updateRtraceInfoBar();
renderRtraceDetailTabs();
renderRtraceEventTable();
}
}
function renderRtraceProcessDropdown() {
const dropdown = document.getElementById('rtrace-process-select');
if (!dropdown) return;
const procs = Object.values(state.processes);
let html = '<option value="">-- select process --</option>';
procs.forEach(proc => {
const hasExited = !!proc.exit_time;
const alertCount = (proc.alerts || []).length;
const status = hasExited ? 'stopped' : 'running';
const selected = state.rtraceSelectedPid == proc.pid ? 'selected' : '';
html += `<option value="${proc.pid}" ${selected}>${escapeHtml(proc.name || 'unknown')} &mdash; ${status} (${alertCount} ev)</option>`;
});
dropdown.innerHTML = html;
}
function selectRtraceProcess(pid) {
state.rtraceSelectedPid = pid;
state.selectedProcess = pid;
// Show detail content, hide placeholder
const placeholder = document.getElementById('rtrace-detail-placeholder');
const content = document.getElementById('rtrace-detail-content');
if (placeholder) placeholder.style.display = 'none';
if (content) content.style.display = 'flex';
// Update dropdown
const dropdown = document.getElementById('rtrace-process-select');
if (dropdown) dropdown.value = pid;
// Update info bar
updateRtraceInfoBar();
// Highlight in tree
document.querySelectorAll('.rtrace-tree-item').forEach(el => el.classList.remove('active'));
const treeItem = document.querySelector(`.rtrace-tree-item[data-pid="${pid}"]`);
if (treeItem) treeItem.classList.add('active');
// Render tabs and events
renderRtraceDetailTabs();
renderRtraceEventTable();
renderProcessList();
}
function updateRtraceInfoBar() {
const proc = state.processes[state.rtraceSelectedPid] || state.processes[String(state.rtraceSelectedPid)];
if (!proc) return;
const hasExited = !!proc.exit_time;
const alertCount = (proc.alerts || []).length;
const childCount = (proc.children || []).length + 1;
const duration = computeLifespan(proc.first_seen, proc.exit_time || proc.last_seen || new Date().toISOString());
// Calculate verdict score based on threats
const threats = proc.activity?.threats || 0;
let verdictScore = Math.min(100, threats * 10);
let verdictClass = verdictScore >= 50 ? '' : verdictScore > 0 ? '' : 'unknown';
if (verdictScore === 0) verdictClass = 'clean';
document.getElementById('rtrace-proc-name').textContent = proc.name || 'unknown';
const verdictBadge = document.getElementById('rtrace-verdict-badge');
verdictBadge.textContent = verdictScore > 0 ? `Malicious \u00B7 ${verdictScore}/100` : 'Clean';
verdictBadge.className = `rtrace-verdict-badge ${verdictClass}`;
document.getElementById('rtrace-tag-status').textContent = hasExited ? 'stopped' : 'running';
document.getElementById('rtrace-stat-procs').textContent = `${childCount} processes`;
document.getElementById('rtrace-stat-events').textContent = `${alertCount} events`;
document.getElementById('rtrace-stat-duration').textContent = duration || '0m 0s';
document.getElementById('rtrace-path').textContent = proc.image || proc.command_line || '--';
// Update severity bar
updateRtraceSeverityBar(proc);
}
function updateRtraceSeverityBar(proc) {
const sevCountsEl = document.getElementById('rtrace-sev-counts');
const enginesEl = document.getElementById('rtrace-engines');
const rulesEl = document.getElementById('rtrace-top-rules');
if (!sevCountsEl) return;
const alerts = proc.alerts || [];
// Count severities
const sevCounts = { critical: 0, high: 0, medium: 0, low: 0 };
const engines = {};
const rules = {};
alerts.forEach(a => {
const sev = (a.severity || 'unknown').toLowerCase();
if (sevCounts[sev] !== undefined) sevCounts[sev]++;
const eng = (a.engine || 'unknown').toLowerCase();
engines[eng] = (engines[eng] || 0) + 1;
const rule = a.rule_name || '';
if (rule) rules[rule] = (rules[rule] || { count: 0, sev: sev });
if (rule) rules[rule].count++;
});
// Render severity pills
let sevHtml = '';
if (sevCounts.critical > 0) sevHtml += `<span class="rtrace-sev-pill critical"><span class="sev-dot"></span>${sevCounts.critical} Critical</span>`;
if (sevCounts.high > 0) sevHtml += `<span class="rtrace-sev-pill high"><span class="sev-dot"></span>${sevCounts.high} High</span>`;
if (sevCounts.medium > 0) sevHtml += `<span class="rtrace-sev-pill medium"><span class="sev-dot"></span>${sevCounts.medium} Medium</span>`;
if (sevCounts.low > 0) sevHtml += `<span class="rtrace-sev-pill low"><span class="sev-dot"></span>${sevCounts.low} Low</span>`;
if (!sevHtml) sevHtml = '<span style="font-size:10px;color:var(--text-muted);">No detections</span>';
sevCountsEl.innerHTML = sevHtml;
// Render engine chips
let engHtml = '';
for (const [eng, count] of Object.entries(engines)) {
engHtml += `<span class="rtrace-engine-chip">${escapeHtml(eng)} (${count})</span>`;
}
enginesEl.innerHTML = engHtml;
// Render top rules (max 4, sorted by count)
const sortedRules = Object.entries(rules).sort((a, b) => b[1].count - a[1].count).slice(0, 4);
let ruleHtml = '';
sortedRules.forEach(([name, info]) => {
const sevClass = info.sev === 'critical' ? ' critical' : info.sev === 'high' ? ' high' : '';
ruleHtml += `<span class="rtrace-rule-chip${sevClass}" title="${escapeHtml(name)}">${escapeHtml(name)} (${info.count})</span>`;
});
rulesEl.innerHTML = ruleHtml;
}
function renderRtraceTimeline() {
const container = document.getElementById('rtrace-timeline-bar');
const rangeEl = document.getElementById('rtrace-timeline-range');
if (!container || !state.alerts.length) {
if (container) container.innerHTML = '<div class="rtrace-timeline-cursor" id="rtrace-timeline-cursor"></div>';
if (rangeEl) rangeEl.textContent = '';
return;
}
// Category config: lane order and colors
const lanes = [
{ key: 'critical', label: 'CRIT', color: '#ef4444' },
{ key: 'process', label: 'PROC', color: '#3b82f6' },
{ key: 'network', label: 'NET', color: '#22c55e' },
{ key: 'dns', label: 'DNS', color: '#a78bfa' },
{ key: 'file', label: 'FILE', color: '#f97316' },
{ key: 'registry', label: 'REG', color: '#f472b6' },
];
// Parse all alert timestamps and categorize
const sorted = [...state.alerts].sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
const timestamps = sorted.map(a => new Date(a.timestamp).getTime()).filter(t => !isNaN(t));
if (!timestamps.length) {
container.innerHTML = '<div class="rtrace-timeline-cursor" id="rtrace-timeline-cursor"></div>';
return;
}
const tMin = Math.min(...timestamps);
const tMax = Math.max(...timestamps);
const duration = tMax - tMin || 1; // avoid div by zero
// Show time range
if (rangeEl) {
const startStr = new Date(tMin).toLocaleTimeString();
const endStr = new Date(tMax).toLocaleTimeString();
const durSec = Math.round(duration / 1000);
const durStr = durSec >= 60 ? `${Math.floor(durSec/60)}m ${durSec%60}s` : `${durSec}s`;
rangeEl.textContent = `${startStr} \u2014 ${endStr} (${durStr})`;
}
// Categorize each alert into a lane
function getLaneKey(alert) {
// High/Critical severity always goes to the CRIT lane
const sev = (alert.severity || '').toLowerCase();
if (sev === 'critical' || sev === 'high') return 'critical';
const cat = (Array.isArray(alert.category) ? alert.category[0] : alert.category || '').toLowerCase();
if (cat === 'dns') return 'dns';
if (cat === 'network') return 'network';
if (cat === 'file') return 'file';
if (cat === 'registry') return 'registry';
if (cat === 'process') return 'process';
return 'process'; // default
}
// Group alerts by lane
const laneEvents = {};
lanes.forEach(l => { laneEvents[l.key] = []; });
sorted.forEach(alert => {
const t = new Date(alert.timestamp).getTime();
if (isNaN(t)) return;
const key = getLaneKey(alert);
if (laneEvents[key]) {
laneEvents[key].push({ t, alert });
}
});
// Render lanes
const containerWidth = container.clientWidth || 600;
let html = '';
lanes.forEach(lane => {
const events = laneEvents[lane.key];
if (!events.length && lane.key !== 'process') {
// Skip empty lanes (but always show process lane)
return;
}
html += `<div class="rtrace-timeline-lane">`;
html += `<span class="rtrace-timeline-lane-label">${lane.label}</span>`;
// Cluster nearby events to avoid overlapping marks
// Group events within 0.5% of timeline width
const clusterThreshold = duration * 0.005;
const clusters = [];
events.forEach(ev => {
if (clusters.length && (ev.t - clusters[clusters.length-1].tEnd) < clusterThreshold) {
clusters[clusters.length-1].count++;
clusters[clusters.length-1].tEnd = ev.t;
} else {
clusters.push({ tStart: ev.t, tEnd: ev.t, count: 1 });
}
});
clusters.forEach(cluster => {
const leftPct = ((cluster.tStart - tMin) / duration) * 100;
const widthPct = Math.max(0.4, ((cluster.tEnd - cluster.tStart) / duration) * 100 + 0.4);
const opacity = Math.min(1, 0.5 + (cluster.count / 10));
html += `<div class="rtrace-timeline-event" style="left:${leftPct}%;width:${widthPct}%;background:${lane.color};opacity:${opacity};" title="${lane.label}: ${cluster.count} event${cluster.count>1?'s':''} at ${new Date(cluster.tStart).toLocaleTimeString()}"></div>`;
});
html += `</div>`;
});
html += `<div class="rtrace-timeline-cursor" id="rtrace-timeline-cursor"></div>`;
container.innerHTML = html;
// Mouse tracking for cursor line
container.addEventListener('mousemove', function(e) {
const cursor = document.getElementById('rtrace-timeline-cursor');
if (cursor) {
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
cursor.style.left = x + 'px';
cursor.style.opacity = '0.8';
}
});
container.addEventListener('mouseleave', function() {
const cursor = document.getElementById('rtrace-timeline-cursor');
if (cursor) cursor.style.opacity = '0';
});
}
function renderRtraceProcessTree() {
const container = document.getElementById('rtrace-tree-list');
const countEl = document.getElementById('rtrace-tree-count');
if (!container) return;
const procs = Object.values(state.processes);
if (countEl) countEl.textContent = procs.length;
if (!procs.length) {
container.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:11px;">No processes tracked yet. Submit a sample to begin.</div>';
return;
}
// Sort by first_seen
const sorted = [...procs].sort((a, b) => (a.first_seen || '').localeCompare(b.first_seen || ''));
// Compute max severity per process
function getMaxSeverity(proc) {
const alerts = proc.alerts || [];
let max = 'low';
const order = { 'critical': 4, 'high': 3, 'medium': 2, 'low': 1, 'unknown': 0 };
alerts.forEach(a => {
const sev = (a.severity || 'unknown').toLowerCase();
if ((order[sev] || 0) > (order[max] || 0)) max = sev;
});
return max;
}
let html = '';
sorted.forEach(proc => {
const relTime = formatRelativeTime(proc.first_seen);
const isActive = state.rtraceSelectedPid == proc.pid;
const indent = proc.parent_pid && state.processes[proc.parent_pid] ? '<span class="tree-indent"></span>' : '';
const threats = proc.activity?.threats || 0;
const maxSev = getMaxSeverity(proc);
const sevDot = threats > 0 ? `<span class="ev-sev-dot ${maxSev}" style="width:6px;height:6px;display:inline-block;"></span>` : '';
html += `<div class="rtrace-tree-item ${isActive ? 'active' : ''}" data-pid="${proc.pid}" onclick="selectRtraceProcess(${proc.pid})">
${sevDot}
<span class="tree-time">${relTime}</span>
${indent}<span class="tree-pid">${proc.pid}</span>
<span class="tree-name">${escapeHtml(proc.name || 'unknown')}</span>
${threats > 0 ? `<span class="tree-threat-count">${threats}</span>` : ''}
</div>`;
});
container.innerHTML = html;
}
function renderRtraceDetailTabs() {
const proc = state.processes[state.rtraceSelectedPid] || state.processes[String(state.rtraceSelectedPid)];
if (!proc) return;
const act = proc.activity || {};
// Update tab counts
const tabCountMap = {
'http': act.http || 0,
'connections': act.network || 0,
'dns': act.dns || 0,
'files': act.file || 0,
'registry': act.registry || 0,
'artifacts': act.artifacts || 0,
'modules': act.modules || 0,
};
document.querySelectorAll('.rtrace-tab').forEach(tab => {
const rtab = tab.dataset.rtab;
const countEl = tab.querySelector('.rtrace-tab-count');
if (countEl && tabCountMap[rtab] !== undefined) {
countEl.textContent = tabCountMap[rtab];
}
});
}
function renderRtraceEventTable() {
const container = document.getElementById('rtrace-event-table-body');
if (!container) return;
const proc = state.processes[state.rtraceSelectedPid] || state.processes[String(state.rtraceSelectedPid)];
if (!proc) {
container.innerHTML = '<div style="padding:20px;color:var(--text-muted);">Select a process to view events.</div>';
return;
}
// Get alerts for this process (and children)
let events = (proc.alerts || []).slice();
// Include children's events too
(proc.children || []).forEach(childPid => {
const child = state.processes[childPid] || state.processes[String(childPid)];
if (child && child.alerts) {
events = events.concat(child.alerts);
}
});
// Filter by active detail tab
const activeTab = state.rtraceActiveDetailTab;
if (activeTab === 'verdict') {
// Verdict tab: render summary view instead of event table
renderRtraceVerdictView(container, proc, events);
return;
} else if (activeTab === 'dns') {
events = events.filter(e => {
const cat = (Array.isArray(e.category) ? e.category[0] : e.category || '').toLowerCase();
const raw = e.raw || {};
const action = (raw.event?.action || '').toLowerCase();
return cat === 'dns' || action === 'dns_query' || action.includes('dns') || !!raw.dns;
});
} else if (activeTab === 'http') {
events = events.filter(e => {
const raw = e.raw || {};
const cat = (Array.isArray(e.category) ? e.category[0] : e.category || '').toLowerCase();
const destPort = raw.destination?.port || raw.network?.destination?.port || '';
const action = (raw.event?.action || '').toLowerCase();
// HTTP = network connections on ports 80/443, or explicit http data
return (cat === 'network' && (destPort == 80 || destPort == 443 || destPort == 8080 || destPort == 8443))
|| !!raw.http || !!raw.url || action.includes('http');
});
} else if (activeTab === 'files') {
events = events.filter(e => {
const cat = (Array.isArray(e.category) ? e.category[0] : e.category || '').toLowerCase();
const raw = e.raw || {};
const action = (raw.event?.action || '').toLowerCase();
return cat === 'file' || action.includes('file') || !!raw.file;
});
} else if (activeTab === 'registry') {
events = events.filter(e => {
const cat = (Array.isArray(e.category) ? e.category[0] : e.category || '').toLowerCase();
const raw = e.raw || {};
const action = (raw.event?.action || '').toLowerCase();
return cat === 'registry' || action.startsWith('registry') || !!raw.registry;
});
} else if (activeTab === 'connections') {
events = events.filter(e => {
const cat = (Array.isArray(e.category) ? e.category[0] : e.category || '').toLowerCase();
const raw = e.raw || {};
const action = (raw.event?.action || '').toLowerCase();
return cat === 'network' || action === 'connection_attempted' || action === 'network_connect'
|| action.includes('connect') || !!raw.network;
});
} else if (activeTab === 'modules') {
events = events.filter(e => {
const raw = e.raw || {};
const cat = (Array.isArray(e.category) ? e.category[0] : e.category || '').toLowerCase();
const action = (raw.event?.action || '').toLowerCase();
return cat === 'process' || action === 'load' || action === 'image_load'
|| action === 'image_loaded' || action.includes('module') || action.includes('dll');
});
} else if (activeTab === 'artifacts') {
events = events.filter(e => {
const engine = (e.engine || '').toLowerCase();
const raw = e.raw || {};
const action = (raw.event?.action || '').toLowerCase();
// Artifacts = YARA/IOC matches, dropped files, or suspicious scripts
return engine === 'yara' || engine === 'ioc'
|| action.includes('drop') || action.includes('write')
|| (e.rule_name && (e.rule_name.toLowerCase().includes('artifact')
|| e.rule_name.toLowerCase().includes('drop')));
});
}
// 'live' tab: no filter (shows all events)
// Sort by timestamp
events.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
if (!events.length) {
container.innerHTML = '<div style="padding:20px;color:var(--text-muted);font-size:11px;">No events for this filter.</div>';
return;
}
let html = '';
events.forEach((ev, idx) => {
const relTime = formatRelativeTime(ev.timestamp);
const raw = ev.raw || {};
const action = raw.event?.action || ev.engine || 'event';
const actionShort = action.replace('_', ' ').split(' ')[0];
const actionClass = getActionBadgeClass(actionShort);
const severity = (ev.severity || 'unknown').toLowerCase();
const ruleName = ev.rule_name || '';
const engine = (ev.engine || '').toUpperCase();
const pid = ev.pid || '?';
const procName = ev.process_name || '';
const details = getEventDetails(ev);
const sevRowClass = (severity === 'critical' || severity === 'high') ? ` sev-${severity}` : '';
html += `<div class="rtrace-event-row${sevRowClass}" onclick="openAlertDetail(${state.alerts.indexOf(ev) >= 0 ? state.alerts.indexOf(ev) : 0})" title="${escapeHtml(ruleName)}\n${escapeHtml(ev.rule_description || '')}">
<span class="ev-sev"><span class="ev-sev-dot ${severity}"></span></span>
<span class="ev-time">${relTime}</span>
<span class="ev-action">
<span class="rtrace-action-badge ${actionClass}">${escapeHtml(actionShort)}</span>
</span>
<span class="ev-rule"><span class="ev-rule-name">${escapeHtml(ruleName)}</span>${engine ? `<span class="ev-engine-tag">${engine}</span>` : ''}</span>
<span class="ev-pid">${pid}</span>
<span class="ev-process">${escapeHtml(procName)}</span>
<span class="ev-details">${escapeHtml(details)}</span>
</div>`;
});
container.innerHTML = html;
}
// --- Verdict Summary View ---
function renderRtraceVerdictView(container, proc, events) {
const act = proc.activity || {};
const threats = act.threats || 0;
const verdictScore = Math.min(100, threats * 10);
// Collect MITRE techniques
const techniques = new Set();
const tactics = new Set();
const engines = {};
const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
events.forEach(ev => {
(ev.tags || []).forEach(tag => {
if (tag.startsWith('attack.t')) techniques.add(tag.replace('attack.', '').toUpperCase());
else if (tag.startsWith('attack.')) tactics.add(tag.replace('attack.', '').toUpperCase());
});
const eng = ev.engine || 'unknown';
engines[eng] = (engines[eng] || 0) + 1;
const sev = (ev.severity || 'low').toLowerCase();
if (severityCounts[sev] !== undefined) severityCounts[sev]++;
});
let scoreColor = verdictScore >= 70 ? 'var(--accent-red)' : verdictScore >= 40 ? 'var(--accent-orange)' : verdictScore > 0 ? 'var(--accent-yellow)' : 'var(--accent-green)';
let scoreLabel = verdictScore >= 70 ? 'Malicious' : verdictScore >= 40 ? 'Suspicious' : verdictScore > 0 ? 'Low Risk' : 'Clean';
let html = `<div style="padding:16px;">`;
// Score display
html += `<div style="display:flex;align-items:center;gap:20px;margin-bottom:20px;padding:16px;background:var(--bg-card);border:1px solid var(--border-primary);border-radius:var(--radius-lg);">
<div style="text-align:center;">
<div style="font-size:36px;font-weight:700;color:${scoreColor};">${verdictScore}</div>
<div style="font-size:10px;color:var(--text-muted);">/ 100</div>
</div>
<div>
<div style="font-size:14px;font-weight:700;color:${scoreColor};">${scoreLabel}</div>
<div style="font-size:11px;color:var(--text-secondary);margin-top:4px;">${threats} detection${threats !== 1 ? 's' : ''} triggered across ${Object.keys(engines).length} engine${Object.keys(engines).length !== 1 ? 's' : ''}</div>
</div>
</div>`;
// Severity breakdown
html += `<div style="margin-bottom:16px;">
<div style="font-size:10px;font-weight:600;color:var(--text-muted);letter-spacing:1px;margin-bottom:8px;">SEVERITY BREAKDOWN</div>
<div style="display:flex;gap:8px;">
${severityCounts.critical > 0 ? `<span class="sev-badge critical">${severityCounts.critical} Critical</span>` : ''}
${severityCounts.high > 0 ? `<span class="sev-badge high">${severityCounts.high} High</span>` : ''}
${severityCounts.medium > 0 ? `<span class="sev-badge medium">${severityCounts.medium} Medium</span>` : ''}
${severityCounts.low > 0 ? `<span class="sev-badge low">${severityCounts.low} Low</span>` : ''}
${threats === 0 ? '<span style="color:var(--text-muted);font-size:11px;">No detections</span>' : ''}
</div>
</div>`;
// Engine breakdown
if (Object.keys(engines).length > 0) {
html += `<div style="margin-bottom:16px;">
<div style="font-size:10px;font-weight:600;color:var(--text-muted);letter-spacing:1px;margin-bottom:8px;">DETECTION ENGINES</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">`;
for (const [eng, count] of Object.entries(engines)) {
html += `<span style="font-size:10px;padding:3px 8px;border-radius:var(--radius);background:var(--bg-counter);border:1px solid var(--border-primary);color:var(--text-primary);">${escapeHtml(eng.toUpperCase())} <strong>${count}</strong></span>`;
}
html += `</div></div>`;
}
// MITRE ATT&CK
if (tactics.size > 0 || techniques.size > 0) {
html += `<div style="margin-bottom:16px;">
<div style="font-size:10px;font-weight:600;color:var(--text-muted);letter-spacing:1px;margin-bottom:8px;">MITRE ATT&CK</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;">`;
[...tactics].sort().forEach(t => { html += `<span class="tag tactic">${t}</span>`; });
[...techniques].sort().forEach(t => { html += `<span class="tag technique">${t}</span>`; });
html += `</div></div>`;
}
// Activity summary
html += `<div style="margin-bottom:16px;">
<div style="font-size:10px;font-weight:600;color:var(--text-muted);letter-spacing:1px;margin-bottom:8px;">ACTIVITY SUMMARY</div>
<div class="activity-grid" style="grid-template-columns:repeat(4,1fr);">
${activityCounter('FILE', act.file)}
${activityCounter('NETWORK', act.network)}
${activityCounter('DNS', act.dns)}
${activityCounter('HTTP', act.http)}
${activityCounter('REGISTRY', act.registry)}
${activityCounter('MODULES', act.modules)}
${activityCounter('ARTIFACTS', act.artifacts)}
${activityCounter('THREATS', act.threats)}
</div>
</div>`;
html += `</div>`;
container.innerHTML = html;
}
function getActionBadgeClass(action) {
const a = action.toLowerCase();
if (a === 'miss' || a === 'error' || a === 'fail') return 'miss';
if (a === 'load' || a === 'image') return 'load';
if (a === 'create' || a === 'new') return 'create';
if (a === 'write' || a === 'modify') return 'write';
if (a === 'connect' || a === 'network') return 'connect';
if (a === 'query' || a === 'dns') return 'query';
if (a === 'set' || a === 'registry') return 'set';
if (a === 'terminate' || a === 'exit') return 'terminate';
return 'default';
}
function getEventDetails(ev) {
const raw = ev.raw || {};
// Try structured fields first
if (raw.file?.path) return raw.file.path;
if (raw.dns?.question?.name) return `Query: ${raw.dns.question.name}`;
if (raw.registry?.path) return raw.registry.path;
if (raw.destination?.ip) {
const port = raw.destination?.port || '';
return `${raw.destination.ip}${port ? ':' + port : ''}`;
}
if (raw.network?.destination?.ip) {
const port = raw.network.destination?.port || '';
return `${raw.network.destination.ip}${port ? ':' + port : ''}`;
}
// ECS-style fields (flat dotted keys from Rustinel)
const procExe = raw['process.executable'] || raw.process?.executable || '';
const procCmd = raw['process.command_line'] || raw.process?.command_line || '';
const matchSummary = raw['edr.match']?.summary || (typeof raw['edr.match'] === 'string' ? raw['edr.match'] : '');
const targetImage = raw['edr.process.target_image'] || '';
if (matchSummary) return matchSummary;
if (targetImage) return `Target: ${targetImage}`;
if (procCmd && procCmd.length > 5) return procCmd;
if (procExe) return procExe;
if (ev.command_line) return ev.command_line;
if (ev.process_image) return ev.process_image;
// Fibratus-style: events array
if (raw.events && raw.events.length) {
const firstEv = raw.events[0];
if (firstEv.params?.exe) return firstEv.params.exe;
if (firstEv.params?.cmdline) return firstEv.params.cmdline;
if (firstEv.params?.file_name) return firstEv.params.file_name;
}
// Fallback: rule description or truncated JSON
if (ev.rule_description) return ev.rule_description;
if (ev.rule_name) return ev.rule_name;
// Last resort: compact JSON excerpt
const jsonStr = JSON.stringify(raw);
return jsonStr.length > 140 ? jsonStr.substring(0, 140) + '...' : jsonStr;
}
function clearStoppedProcesses() {
// Filter out stopped processes from view (client-side only)
const procs = Object.values(state.processes);
procs.forEach(proc => {
if (proc.exit_time) {
delete state.processes[proc.pid];
delete state.processes[String(proc.pid)];
}
});
renderRtraceConsole();
renderProcessList();
}
function clearAllTracing() {
state.alerts = [];
state.processes = {};
state.rtraceSelectedPid = null;
const placeholder = document.getElementById('rtrace-detail-placeholder');
const content = document.getElementById('rtrace-detail-content');
if (placeholder) placeholder.style.display = 'flex';
if (content) content.style.display = 'none';
renderRtraceConsole();
renderProcessList();
}
// =============================================
// HEX EDITOR
// =============================================
function initHexDropZone() {
const zone = document.getElementById('hex-drop-zone');
const input = document.getElementById('hex-file-input');
if (!zone || !input) return;
zone.addEventListener('dragover', e => {
e.preventDefault();
zone.classList.add('dragover');
});
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
hexUploadFile(e.dataTransfer.files[0]);
}
});
input.addEventListener('change', () => {
if (input.files.length) {
hexUploadFile(input.files[0]);
input.value = ''; // reset so same file can be re-selected
}
});
}
async function hexUploadFile(file) {
const zone = document.getElementById('hex-drop-zone');
const fileInfo = document.getElementById('hex-file-info');
const bytesPerPage = parseInt(document.getElementById('hex-bytes-per-page').value) || 512;
// Show loading state
zone.classList.add('loading');
zone.querySelector('p').textContent = `Uploading ${file.name} (${formatSize(file.size)})...`;
const formData = new FormData();
formData.append('file', file);
formData.append('bytes', bytesPerPage);
try {
const resp = await fetch('/api/file/hex/upload', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) {
fileInfo.innerHTML = `<span class="hex-info-item" style="color:var(--accent-red);">Error: ${escapeHtml(data.error)}</span>`;
zone.classList.remove('loading');
zone.querySelector('p').textContent = 'Drop a file here or browse';
return;
}
// Store the server-side path for pagination
state.hexFilePath = data.path;
state.hexFileSize = data.size || 0;
state.hexOffset = 0;
state.hexData = data.raw_bytes || null;
state.hexSelectedByte = -1;
// Update path input so pagination works
document.getElementById('hex-filepath').value = data.path;
document.getElementById('hex-offset').value = 0;
// Collapse drop zone and show file info
zone.classList.add('has-file');
zone.classList.remove('loading');
zone.querySelector('p').innerHTML = `<strong>${escapeHtml(data.filename || file.name)}</strong> (${formatSize(data.size)}) <span class="hex-change-file" onclick="hexResetDropZone()">change</span>`;
// Update file info bar
fileInfo.innerHTML = `
<span class="hex-info-item"><strong>File:</strong> ${escapeHtml(data.filename || file.name)}</span>
<span class="hex-info-item"><strong>Size:</strong> ${formatSize(data.size)}</span>
<span class="hex-info-item"><strong>Showing:</strong> ${data.bytes_shown} bytes from offset 0x00000000</span>
`;
// Render hex dump
renderHexView(data.hex, 0, data.bytes_shown);
// Update status bar
document.getElementById('hex-status-size').textContent = `Size: ${formatSize(data.size)}`;
document.getElementById('hex-status-offset').textContent = `Offset: 0x00000000`;
document.getElementById('hex-inspector').classList.add('visible');
} catch (e) {
fileInfo.innerHTML = `<span class="hex-info-item" style="color:var(--accent-red);">Upload failed: ${escapeHtml(e.message)}</span>`;
zone.classList.remove('loading');
zone.querySelector('p').textContent = 'Drop a file here or browse';
}
}
function hexResetDropZone() {
const zone = document.getElementById('hex-drop-zone');
zone.classList.remove('has-file', 'loading');
zone.querySelector('p').innerHTML = 'Drop a file here or <span class="hex-browse-link" onclick="document.getElementById(\'hex-file-input\').click()">browse</span>';
}
function hexOpenFile(path) {
// Switch to hex tab and load a specific file
switchTab('hexeditor');
document.getElementById('hex-filepath').value = path;
document.getElementById('hex-offset').value = 0;
// Collapse drop zone
const zone = document.getElementById('hex-drop-zone');
zone.classList.add('has-file');
zone.querySelector('p').innerHTML = `<strong>${escapeHtml(path.split('\\').pop())}</strong> <span class="hex-change-file" onclick="hexResetDropZone()">change</span>`;
hexLoad();
}
async function hexLoad() {
const filepath = document.getElementById('hex-filepath').value.trim();
const offset = parseInt(document.getElementById('hex-offset').value) || 0;
const bytesPerPage = parseInt(document.getElementById('hex-bytes-per-page').value) || 512;
if (!filepath) {
alert('Enter a file path to load.');
return;
}
state.hexFilePath = filepath;
state.hexOffset = offset;
try {
const resp = await fetch(`/api/file/hex?path=${encodeURIComponent(filepath)}&offset=${offset}&bytes=${bytesPerPage}`);
const data = await resp.json();
if (data.error) {
document.getElementById('hex-file-info').innerHTML = `<span class="hex-info-item" style="color:var(--accent-red);">Error: ${escapeHtml(data.error)}</span>`;
return;
}
state.hexFileSize = data.size || 0;
state.hexData = data.raw_bytes || null;
state.hexSelectedByte = -1;
// Update file info
document.getElementById('hex-file-info').innerHTML = `
<span class="hex-info-item"><strong>File:</strong> ${escapeHtml(filepath.split('\\').pop())}</span>
<span class="hex-info-item"><strong>Size:</strong> ${formatSize(data.size)}</span>
<span class="hex-info-item"><strong>Showing:</strong> ${data.bytes_shown} bytes from offset 0x${offset.toString(16).padStart(8, '0')}</span>
`;
// Render hex dump
renderHexView(data.hex, offset, data.bytes_shown);
// Update status bar
document.getElementById('hex-status-size').textContent = `Size: ${formatSize(data.size)}`;
document.getElementById('hex-status-offset').textContent = `Offset: 0x${offset.toString(16).padStart(8, '0')}`;
// Show inspector
document.getElementById('hex-inspector').classList.add('visible');
} catch (e) {
document.getElementById('hex-file-info').innerHTML = `<span class="hex-info-item" style="color:var(--accent-red);">Failed: ${escapeHtml(e.message)}</span>`;
}
}
function renderHexView(hexDump, baseOffset, bytesShown) {
const offsetCol = document.getElementById('hex-offset-col');
const hexView = document.getElementById('hex-view');
const asciiCol = document.getElementById('hex-ascii-col');
if (!hexDump) {
offsetCol.innerHTML = '';
hexView.innerHTML = '<div style="padding:20px;color:var(--text-muted);">No data loaded</div>';
asciiCol.innerHTML = '';
return;
}
// Parse hex dump lines
const lines = hexDump.split('\n');
let offsetHtml = '';
let hexHtml = '';
let asciiHtml = '';
let validLineIdx = 0; // Separate counter for actual data lines
lines.forEach((line) => {
if (!line.trim()) return;
// Parse line: "00000000 4d 5a 90 00 ... |MZ..............|"
const match = line.match(/^([0-9a-f]+)\s+(.+?)\s+\|(.+)\|$/i);
if (!match) {
// Fallback: just display raw
offsetHtml += line.substring(0, 8) + '\n';
hexHtml += line.substring(10) + '\n';
validLineIdx++;
return;
}
const offsetStr = match[1];
const hexPart = match[2];
const asciiPart = match[3];
offsetHtml += offsetStr + '\n';
// Render hex bytes as clickable spans
const hexBytes = hexPart.trim().split(/\s+/);
let lineHexHtml = '';
hexBytes.forEach((byte, byteIdx) => {
if (byte === '') return;
const globalIdx = (validLineIdx * 16) + byteIdx;
const isNull = byte === '00';
lineHexHtml += `<span class="hex-byte${isNull ? ' null-byte' : ''}" data-idx="${globalIdx}" onclick="hexSelectByte(${globalIdx})">${byte}</span>`;
// Add gap between bytes 7 and 8
if (byteIdx === 7) lineHexHtml += '<span class="hex-gap"></span>';
});
hexHtml += lineHexHtml + '\n';
// Render ASCII
let lineAsciiHtml = '';
for (let i = 0; i < asciiPart.length; i++) {
const ch = asciiPart[i];
const globalIdx = (validLineIdx * 16) + i;
const isPrintable = ch !== '.';
lineAsciiHtml += `<span class="ascii-char${isPrintable ? '' : ' non-printable'}" data-idx="${globalIdx}" onclick="hexSelectByte(${globalIdx})">${escapeHtml(ch)}</span>`;
}
asciiHtml += lineAsciiHtml + '\n';
validLineIdx++;
});
offsetCol.innerHTML = offsetHtml;
hexView.innerHTML = hexHtml;
asciiCol.innerHTML = asciiHtml;
}
function hexSelectByte(idx) {
state.hexSelectedByte = idx;
// Clear previous selection
document.querySelectorAll('.hex-byte.selected, .ascii-char.selected').forEach(el => el.classList.remove('selected'));
// Highlight new selection
document.querySelectorAll(`[data-idx="${idx}"]`).forEach(el => el.classList.add('selected'));
// Update status bar
const byteEl = document.querySelector(`.hex-byte[data-idx="${idx}"]`);
if (byteEl) {
const byteVal = parseInt(byteEl.textContent, 16);
const globalOffset = state.hexOffset + idx;
document.getElementById('hex-status-offset').textContent = `Offset: 0x${globalOffset.toString(16).padStart(8, '0')}`;
document.getElementById('hex-status-selection').textContent = `Selected: byte ${idx}`;
document.getElementById('hex-status-value').textContent = `Value: 0x${byteEl.textContent} (${byteVal})`;
// Update inspector
updateHexInspector(idx);
}
}
function updateHexInspector(idx) {
// Get surrounding bytes from the displayed hex view
const allBytes = document.querySelectorAll('.hex-byte');
const bytes = [];
for (let i = idx; i < Math.min(idx + 8, allBytes.length); i++) {
bytes.push(parseInt(allBytes[i].textContent, 16));
}
if (bytes.length === 0) return;
// Int8 / UInt8
const uint8 = bytes[0];
const int8 = uint8 > 127 ? uint8 - 256 : uint8;
document.getElementById('hex-insp-int8').textContent = int8;
document.getElementById('hex-insp-uint8').textContent = uint8;
// Int16 LE / UInt16 LE
if (bytes.length >= 2) {
const uint16 = bytes[0] | (bytes[1] << 8);
const int16 = uint16 > 32767 ? uint16 - 65536 : uint16;
document.getElementById('hex-insp-int16le').textContent = int16;
document.getElementById('hex-insp-uint16le').textContent = uint16;
}
// Int32 LE / UInt32 LE
if (bytes.length >= 4) {
const uint32 = (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)) >>> 0;
const int32 = uint32 > 2147483647 ? uint32 - 4294967296 : uint32;
document.getElementById('hex-insp-int32le').textContent = int32;
document.getElementById('hex-insp-uint32le').textContent = uint32;
}
// Float32
if (bytes.length >= 4) {
const buf = new ArrayBuffer(4);
const view = new DataView(buf);
bytes.slice(0, 4).forEach((b, i) => view.setUint8(i, b));
document.getElementById('hex-insp-float32').textContent = view.getFloat32(0, true).toPrecision(6);
}
// Float64
if (bytes.length >= 8) {
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
bytes.slice(0, 8).forEach((b, i) => view.setUint8(i, b));
document.getElementById('hex-insp-float64').textContent = view.getFloat64(0, true).toPrecision(10);
}
// ASCII
const asciiStr = bytes.slice(0, 8).map(b => (b >= 32 && b < 127) ? String.fromCharCode(b) : '.').join('');
document.getElementById('hex-insp-ascii').textContent = asciiStr;
// UTF-16 LE
if (bytes.length >= 2) {
let utf16 = '';
for (let i = 0; i < Math.min(bytes.length - 1, 8); i += 2) {
const code = bytes[i] | (bytes[i + 1] << 8);
utf16 += code >= 32 && code < 127 ? String.fromCharCode(code) : '.';
}
document.getElementById('hex-insp-utf16').textContent = utf16;
}
}
function hexPrevPage() {
const bytesPerPage = parseInt(document.getElementById('hex-bytes-per-page').value) || 512;
const newOffset = Math.max(0, state.hexOffset - bytesPerPage);
document.getElementById('hex-offset').value = newOffset;
state.hexOffset = newOffset;
hexLoad();
}
function hexNextPage() {
const bytesPerPage = parseInt(document.getElementById('hex-bytes-per-page').value) || 512;
const newOffset = state.hexOffset + bytesPerPage;
if (newOffset < state.hexFileSize) {
document.getElementById('hex-offset').value = newOffset;
state.hexOffset = newOffset;
hexLoad();
}
}
// =============================================
// DETAIL PANELS (kept from original)
// =============================================
// --- Detail Panels for Dashboard Services ---
async function openAgentDetail() {
pushDetailHistory('agent', 0);
setDetailHeader('Service', 'background:rgba(249,115,22,0.15);color:var(--accent-orange)', 'DetonatorAgent', '');
setDetailBody('<div class="muted">Loading...</div>');
showDetail();
const online = state.serviceStatus.detonator_agent?.online;
let html = `<div class="detail-fields">
<div class="detail-field"><span class="field-label">Status</span><span class="field-value" style="color:${online ? 'var(--accent-green)' : 'var(--accent-red)'}">${online ? 'Running' : 'Stopped'}</span></div>
<div class="detail-field"><span class="field-label">Port</span><span class="field-value">8080</span></div>
<div class="detail-field"><span class="field-label">Framework</span><span class="field-value">.NET 8.0</span></div>
<div class="detail-field"><span class="field-label">Install Dir</span><span class="field-value mono">C:\\DetonatorAgent</span></div>
<div class="detail-field"><span class="field-label">EDR Plugin</span><span class="field-value">Fibratus</span></div>
</div>`;
setDetailBody(html);
}
async function openLitterboxDetail() {
pushDetailHistory('litterbox', 0);
setDetailHeader('Service', 'background:rgba(167,139,250,0.15);color:var(--accent-purple)', 'LitterBox', '');
setDetailBody('<div class="muted">Loading...</div>');
showDetail();
const online = state.serviceStatus.litterbox?.online;
let html = `<div class="detail-fields">
<div class="detail-field"><span class="field-label">Status</span><span class="field-value" style="color:${online ? 'var(--accent-green)' : 'var(--accent-red)'}">${online ? 'Running' : 'Stopped'}</span></div>
<div class="detail-field"><span class="field-label">Port</span><span class="field-value">1337</span></div>
<div class="detail-field"><span class="field-label">Install Dir</span><span class="field-value mono">C:\\LitterBox</span></div>
</div>`;
setDetailBody(html);
}
async function openRustinelDetail() {
pushDetailHistory('rustinel', 0);
setDetailHeader('Engine', 'background:rgba(34,211,238,0.15);color:var(--accent-cyan)', 'Rustinel', 'loading...');
setDetailBody('<div class="muted">Fetching Rustinel status...</div>');
showDetail();
try {
const resp = await fetch('/api/rustinel');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const info = await resp.json();
renderRustinelDetail(info);
} catch (e) {
setDetailBody(`<div class="muted">Failed to fetch Rustinel info: ${escapeHtml(e.message)}</div>`);
}
}
function renderRustinelDetail(info) {
const online = info.online;
const statusColor = online ? 'var(--accent-green)' : 'var(--accent-red)';
setDetailHeader('Engine', 'background:rgba(34,211,238,0.15);color:var(--accent-cyan)', 'Rustinel', '');
let html = `<div class="detail-fields">
<div class="detail-field"><span class="field-label">Version</span><span class="field-value">${escapeHtml(info.version || 'unknown')}</span></div>
<div class="detail-field"><span class="field-label">Status</span><span class="field-value" style="color:${statusColor}">${online ? 'running' : 'stopped'}</span></div>
<div class="detail-field"><span class="field-label">Install Dir</span><span class="field-value mono">${escapeHtml(info.install_dir || '')}</span></div>
<div class="detail-field"><span class="field-label">Alerts Dir</span><span class="field-value mono">${escapeHtml(info.alerts_dir || '')}</span></div>
<div class="detail-field"><span class="field-label">Alerts Total</span><span class="field-value">${info.alerts_count || 0}</span></div>
</div>`;
const rules = info.rules || {};
html += `<div class="detail-section">
<div class="detail-section-title">DETECTION RULES</div>
<div class="activity-grid" style="grid-template-columns: repeat(3, 1fr);">
<div class="activity-counter"><div class="counter-label">SIGMA</div><div class="counter-value">${rules.sigma || 0}</div></div>
<div class="activity-counter"><div class="counter-label">YARA</div><div class="counter-value">${rules.yara || 0}</div></div>
<div class="activity-counter"><div class="counter-label">IOC</div><div class="counter-value">${((rules.ioc?.hashes||0)+(rules.ioc?.ips||0)+(rules.ioc?.domains||0))}</div></div>
</div>
</div>`;
const providers = info.etw_providers || [];
if (providers.length > 0) {
html += `<div class="detail-section"><div class="detail-section-title">ETW PROVIDERS (${providers.length})</div><div class="detail-fields">`;
providers.forEach(p => {
html += `<div class="detail-field"><span class="field-label" style="font-size:10px">${escapeHtml(p.name.replace('Microsoft-Windows-', ''))}</span><span class="field-value" style="font-size:10px;color:var(--text-muted)">kw: ${escapeHtml(p.keywords)}</span></div>`;
});
html += `</div></div>`;
}
setDetailBody(html);
}
// --- Alert Detail ---
function openAlertDetail(idx) {
const alert = state.alerts[idx];
if (!alert) return;
pushDetailHistory('alert', idx);
const severity = (alert.severity || 'unknown').toLowerCase();
const severityColors = { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#22c55e' };
setDetailHeader(
severity.charAt(0).toUpperCase() + severity.slice(1),
`background:${severityColors[severity] || '#64748b'}20;color:${severityColors[severity] || '#94a3b8'}`,
alert.rule_name || 'Unknown Rule',
formatRelativeTime(alert.timestamp)
);
let html = '';
if (alert.rule_description) {
html += `<div class="alert-description">${escapeHtml(alert.rule_description)}</div>`;
}
const tags = (alert.tags || []).map(tag => {
if (tag.startsWith('attack.t')) return `<span class="tag technique">${tag.replace('attack.', '').toUpperCase()}</span>`;
if (tag.startsWith('attack.')) return `<span class="tag tactic">${tag.replace('attack.', '').toUpperCase()}</span>`;
return `<span class="tag mitre">${escapeHtml(tag)}</span>`;
}).join('');
// --- Structured fields ---
html += `<div class="detail-fields">
<div class="detail-field"><span class="field-label">Severity</span><span class="field-value"><span class="ev-sev-dot ${severity}" style="display:inline-block;width:8px;height:8px;vertical-align:middle;margin-right:4px;"></span>${severity.charAt(0).toUpperCase() + severity.slice(1)}</span></div>
<div class="detail-field"><span class="field-label">Engine</span><span class="field-value">${escapeHtml((alert.engine || 'unknown').toUpperCase())}</span></div>
<div class="detail-field"><span class="field-label">Rule</span><span class="field-value mono">${escapeHtml(alert.rule_name || '')}</span></div>
<div class="detail-field"><span class="field-label">PID</span><span class="field-value">${alert.pid || 'N/A'}</span></div>
<div class="detail-field"><span class="field-label">Process</span><span class="field-value">${escapeHtml(alert.process_name || '')}</span></div>
<div class="detail-field"><span class="field-label">Image</span><span class="field-value mono">${escapeHtml(alert.process_image || '')}</span></div>
<div class="detail-field"><span class="field-label">Category</span><span class="field-value">${formatCategory(alert.category)}</span></div>
${alert.command_line ? `<div class="detail-field"><span class="field-label">Command</span><span class="field-value mono">${escapeHtml(alert.command_line)}</span></div>` : ''}
${alert.parent_name ? `<div class="detail-field"><span class="field-label">Parent</span><span class="field-value">${alert.parent_pid || '?'} (${escapeHtml(alert.parent_name)})</span></div>` : ''}
${alert.parent_command_line ? `<div class="detail-field"><span class="field-label">Parent Cmd</span><span class="field-value mono">${escapeHtml(alert.parent_command_line)}</span></div>` : ''}
${alert.user ? `<div class="detail-field"><span class="field-label">User</span><span class="field-value">${escapeHtml(alert.user)}</span></div>` : ''}
${tags ? `<div class="detail-field"><span class="field-label">ATT&CK</span><span class="field-value">${tags}</span></div>` : ''}
<div class="detail-field"><span class="field-label">Timestamp</span><span class="field-value">${alert.timestamp || ''}</span></div>
</div>`;
// --- Parsed raw event as structured sections ---
const raw = alert.raw || {};
html += renderStructuredRawEvent(raw);
setDetailBody(html);
showDetail();
}
function renderStructuredRawEvent(raw) {
let html = '';
// Group ECS fields into logical sections
const sections = {
'Match Details': {},
'Event': {},
'Process': {},
'Host': {},
'Rule': {},
'Other': {},
};
// Categorize each key
const flatEntries = flattenObject(raw);
flatEntries.forEach(([key, value]) => {
if (key.startsWith('edr.match') || key.startsWith('edr.rule')) {
sections['Match Details'][key] = value;
} else if (key.startsWith('event.') || key === '@timestamp') {
sections['Event'][key] = value;
} else if (key.startsWith('process.')) {
sections['Process'][key] = value;
} else if (key.startsWith('host.') || key.startsWith('agent.')) {
sections['Host'][key] = value;
} else if (key.startsWith('rule.')) {
sections['Rule'][key] = value;
} else {
sections['Other'][key] = value;
}
});
// Render each non-empty section
for (const [title, fields] of Object.entries(sections)) {
const entries = Object.entries(fields);
if (!entries.length) continue;
html += `<div class="detail-section">
<div class="detail-section-title">${title.toUpperCase()}</div>
<div class="raw-structured">`;
entries.forEach(([key, value]) => {
const displayValue = formatRawValue(value);
const isImportant = key.includes('severity') || key.includes('rule.name') || key.includes('match') || key.includes('executable') || key.includes('command_line');
html += `<div class="raw-field${isImportant ? ' important' : ''}">
<span class="raw-key">${escapeHtml(key)}</span>
<span class="raw-value">${displayValue}</span>
</div>`;
});
html += `</div></div>`;
}
// Collapsible full JSON (for copy/paste)
html += `<div class="detail-section">
<div class="detail-section-title raw-json-toggle" onclick="this.parentElement.classList.toggle('expanded')">
RAW JSON <span style="font-weight:400;font-size:9px;color:var(--text-muted);margin-left:6px;">(click to expand)</span>
</div>
<div class="raw-json-collapsible"><pre class="raw-json-pretty">${syntaxHighlightJson(JSON.stringify(raw, null, 2))}</pre></div>
</div>`;
return html;
}
function flattenObject(obj, prefix = '', result = []) {
if (obj === null || obj === undefined) return result;
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
flattenObject(value, fullKey, result);
} else {
result.push([fullKey, value]);
}
}
return result;
}
function formatRawValue(value) {
if (value === null || value === undefined) return '<span class="raw-null">null</span>';
if (typeof value === 'boolean') return `<span class="raw-bool">${value}</span>`;
if (typeof value === 'number') return `<span class="raw-num">${value}</span>`;
if (Array.isArray(value)) {
if (value.length === 0) return '<span class="raw-null">[]</span>';
// Render arrays inline if simple, or as list if complex
if (value.every(v => typeof v === 'string' || typeof v === 'number')) {
return value.map(v => `<span class="raw-str">${escapeHtml(String(v))}</span>`).join(', ');
}
return `<span class="raw-str">${escapeHtml(JSON.stringify(value))}</span>`;
}
// Strings
const str = String(value);
// Color paths
if (str.match(/^[A-Z]:\\/i) || str.startsWith('/')) {
return `<span class="raw-path">${escapeHtml(str)}</span>`;
}
// Color IPs
if (str.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/)) {
return `<span class="raw-ip">${escapeHtml(str)}</span>`;
}
return `<span class="raw-str">${escapeHtml(str)}</span>`;
}
function syntaxHighlightJson(json) {
return json
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?)/g, function(match) {
let cls = 'json-str';
if (match.endsWith(':')) {
cls = 'json-key';
match = match.slice(0, -1) + '<span class="json-colon">:</span>';
}
return `<span class="${cls}">${match}</span>`;
})
.replace(/\b(true|false)\b/g, '<span class="json-bool">$1</span>')
.replace(/\b(null)\b/g, '<span class="json-null">$1</span>')
.replace(/\b(-?\d+\.?\d*)\b/g, '<span class="json-num">$1</span>');
}
// --- Process Detail ---
function openProcessDetail(pid) {
state.selectedProcess = pid;
renderProcessList();
pushDetailHistory('process', pid);
const proc = state.processes[pid] || state.processes[String(pid)];
if (!proc) {
setDetailHeader(`PID ${pid}`, 'background:rgba(59,130,246,0.2);color:#3b82f6', `Process ${pid}`, '');
setDetailBody(`<div class="muted">No detailed information available for PID ${pid}.</div>`);
showDetail();
return;
}
const hasExited = !!proc.exit_time;
const statusText = hasExited ? 'exited' : 'running';
setDetailHeader(`PID ${proc.pid}`, 'background:rgba(59,130,246,0.2);color:#3b82f6', proc.name || 'unknown', statusText);
let html = `<div class="detail-fields">
<div class="detail-field"><span class="field-label">Image</span><span class="field-value mono">${escapeHtml(proc.image || '')}</span></div>
<div class="detail-field"><span class="field-label">Command line</span><span class="field-value mono">${escapeHtml(proc.command_line || '')}</span></div>
<div class="detail-field"><span class="field-label">User</span><span class="field-value">${escapeHtml(proc.user || '')}</span></div>
<div class="detail-field"><span class="field-label">Parent</span><span class="field-value">${proc.parent_pid || 'N/A'} ${proc.parent_name ? '(' + escapeHtml(proc.parent_name) + ')' : ''}</span></div>
<div class="detail-field"><span class="field-label">Started</span><span class="field-value">${formatRelativeTime(proc.first_seen)}</span></div>
${hasExited ? `<div class="detail-field"><span class="field-label">Exited</span><span class="field-value">${formatRelativeTime(proc.exit_time)}</span></div>` : ''}
</div>`;
const act = proc.activity || {};
html += `<div class="detail-section"><div class="detail-section-title">ACTIVITY</div>
<div class="activity-grid">
${activityCounter('FILE', act.file)}
${activityCounter('NETWORK', act.network)}
${activityCounter('DNS', act.dns)}
${activityCounter('REGISTRY', act.registry)}
${activityCounter('MODULES', act.modules)}
${activityCounter('THREATS', act.threats)}
</div></div>`;
const procAlerts = proc.alerts || [];
if (procAlerts.length > 0) {
html += `<div class="detail-section"><div class="detail-section-title">ALERTS (${procAlerts.length})</div>`;
procAlerts.forEach(alert => {
const sev = (alert.severity || 'unknown').toLowerCase();
const alertIdx = state.alerts.findIndex(a => a.id === alert.id);
html += `<div class="child-card" onclick="openAlertDetail(${alertIdx >= 0 ? alertIdx : 0})">
<div class="child-header">
<span class="event-severity ${sev}">${alert.severity}</span>
<span class="child-name" style="margin-left:8px">${escapeHtml(alert.rule_name || '')}</span>
</div>
</div>`;
});
html += `</div>`;
}
setDetailBody(html);
showDetail();
}
// --- Render: Process list (sidebar) ---
function renderProcessList() {
const container = document.getElementById('process-list');
const count = document.getElementById('process-count');
const procs = Object.values(state.processes);
count.textContent = procs.length;
if (!procs.length) {
container.innerHTML = '<div class="muted" style="padding:8px;font-size:10px;">No processes tracked</div>';
return;
}
container.innerHTML = procs.map(proc => {
const hasThreats = (proc.activity?.threats || 0) > 0;
const isActive = state.selectedProcess === proc.pid;
return `<div class="process-item ${isActive ? 'active' : ''}" onclick="openProcessDetail(${proc.pid})">
<div class="proc-icon ${hasThreats ? 'threat' : ''}"></div>
<span class="proc-name">${escapeHtml(proc.name || 'unknown')}</span>
<span class="proc-pid">${proc.pid}</span>
</div>`;
}).join('');
}
// --- Render: Timeline (sidebar) ---
function renderTimeline() {
const container = document.getElementById('timeline');
if (!state.alerts.length) {
container.innerHTML = '';
return;
}
const maxBars = 60;
const alerts = state.alerts.slice(0, maxBars);
container.innerHTML = alerts.map(alert => {
const sev = (alert.severity || 'low').toLowerCase();
const width = 30 + Math.random() * 70;
return `<div class="timeline-bar severity-${sev}" style="width:${width}%" title="${escapeHtml(alert.rule_name || '')}"></div>`;
}).join('');
}
// --- Render: Service status ---
function updateServiceStatus(status) {
setStatus('status-rustinel', status.rustinel?.online);
setStatus('status-sysmon', status.sysmon?.online);
setStatus('status-agent', status.detonator_agent?.online);
setStatus('status-litterbox', status.litterbox?.online);
setStatus('status-fibratus', status.rustinel?.online);
}
function setStatus(elementId, online) {
const el = document.getElementById(elementId);
if (el) {
el.classList.toggle('online', !!online);
el.classList.toggle('offline', !online);
}
}
// --- Detail Panel: Navigation ---
function pushDetailHistory(type, id) {
const last = state.detailHistory[state.detailHistory.length - 1];
if (last && last.type === type && last.id === id) return;
state.detailHistory.push({ type, id });
}
function goDetailBack() {
if (state.detailHistory.length > 1) {
state.detailHistory.pop();
const prev = state.detailHistory.pop();
if (prev.type === 'process') openProcessDetail(prev.id);
else if (prev.type === 'rustinel') openRustinelDetail();
else if (prev.type === 'agent') openAgentDetail();
else if (prev.type === 'litterbox') openLitterboxDetail();
else if (prev.type === 'alert') openAlertDetail(prev.id);
} else {
closeDetail();
}
}
function setDetailHeader(badgeText, badgeStyle, title, meta) {
const headerLeft = document.querySelector('.detail-header-left');
if (headerLeft) {
headerLeft.innerHTML = `
<button class="btn btn-sm" onclick="goDetailBack()">&lt; Back</button>
<span class="detail-badge" id="detail-badge" style="${badgeStyle}">${badgeText}</span>
<span class="detail-title" id="detail-title">${escapeHtml(title)}</span>`;
}
const metaEl = document.getElementById('detail-meta');
if (metaEl) metaEl.textContent = meta || '';
}
function setDetailBody(html) {
document.getElementById('detail-body').innerHTML = html;
}
function showDetail() {
document.getElementById('detail-panel').classList.remove('hidden');
state.detailOpen = true;
}
function closeDetail() {
document.getElementById('detail-panel').classList.add('hidden');
state.detailOpen = false;
state.selectedProcess = null;
state.detailHistory = [];
renderProcessList();
}
// --- Upload / Submit ---
function initUpload() {
const zone = document.getElementById('upload-zone');
const input = document.getElementById('file-input');
const btn = document.getElementById('submit-btn');
zone.addEventListener('click', () => input.click());
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
handleFileSelect(e.dataTransfer.files[0]);
}
});
input.addEventListener('change', () => {
if (input.files.length) handleFileSelect(input.files[0]);
});
btn.addEventListener('click', submitSample);
}
function handleFileSelect(file) {
const zone = document.getElementById('upload-zone');
zone.classList.add('has-file');
zone.querySelector('p').textContent = `${file.name} (${formatSize(file.size)})`;
document.getElementById('submit-btn').disabled = false;
}
async function submitSample() {
const input = document.getElementById('file-input');
const target = document.getElementById('submit-target').value;
const btn = document.getElementById('submit-btn');
const result = document.getElementById('submit-result');
if (!input.files.length) return;
btn.disabled = true;
btn.textContent = 'Detonating...';
result.className = 'submit-result visible';
result.innerHTML = '<div>Submitting sample...</div>';
const formData = new FormData();
formData.append('file', input.files[0]);
formData.append('target', target);
try {
const resp = await fetch('/api/submit', { method: 'POST', body: formData });
const data = await resp.json();
if (resp.ok) {
result.className = 'submit-result visible success';
let html = `<div class="detonation-header"><div class="detonation-filename">${escapeHtml(input.files[0].name)}</div></div>`;
if (data.file_info) {
html += `<div class="detail-fields"><div class="detail-field"><span class="field-label">SHA-256</span><span class="field-value mono">${escapeHtml(data.file_info.sha256)}</span></div></div>`;
}
if (data.agent) {
const agentOk = data.agent.status >= 200 && data.agent.status < 400;
html += `<div style="margin-top:8px;"><strong>Agent:</strong> <span style="color:${agentOk ? 'var(--accent-green)' : 'var(--accent-red)'}">${agentOk ? 'Success' : 'Failed'}</span> (HTTP ${data.agent.status})</div>`;
if (data.agent.data?.pid) html += `<div>PID: ${data.agent.data.pid}</div>`;
}
if (data.litterbox) {
const lbOk = data.litterbox.status >= 200 && data.litterbox.status < 400;
html += `<div style="margin-top:8px;"><strong>LitterBox:</strong> <span style="color:${lbOk ? 'var(--accent-green)' : 'var(--accent-red)'}">${lbOk ? 'Uploaded' : 'Failed'}</span></div>`;
}
result.innerHTML = html;
// Refresh alerts after a delay
setTimeout(refreshAlerts, 3000);
} else {
result.className = 'submit-result visible error';
result.textContent = `Error: ${JSON.stringify(data)}`;
}
} catch (e) {
result.className = 'submit-result visible error';
result.textContent = `Network error: ${e.message}`;
}
btn.disabled = false;
btn.textContent = 'Detonate';
// Refresh submissions list after successful submit
refreshSubmissions();
}
// --- Submissions History ---
async function refreshSubmissions() {
const container = document.getElementById('submissions-list');
if (!container) return;
try {
const resp = await fetch('/api/submissions');
if (!resp.ok) {
container.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:11px;">Failed to load submissions.</div>';
return;
}
const submissions = await resp.json();
if (!submissions.length) {
container.innerHTML = '<div style="padding:12px;color:var(--text-muted);font-size:11px;">No samples submitted yet. Use the form above to detonate a sample.</div>';
return;
}
let html = '<table class="submissions-table"><thead><tr>';
html += '<th>Time</th><th>Filename</th><th>SHA-256</th><th>Size</th><th>Target</th><th>Status</th><th>Actions</th>';
html += '</tr></thead><tbody>';
submissions.forEach(sub => {
const ts = sub.timestamp ? new Date(sub.timestamp).toLocaleString('en-GB', {hour12: false, day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit', second:'2-digit'}) : '--';
const agentBadge = sub.agent_status === 'success'
? '<span class="badge badge-green">Agent OK</span>'
: sub.agent_status === 'failed'
? '<span class="badge badge-red">Agent Fail</span>'
: '';
const lbBadge = sub.litterbox_status === 'success'
? '<span class="badge badge-green">LB OK</span>'
: sub.litterbox_status === 'failed'
? '<span class="badge badge-red">LB Fail</span>'
: '';
const pid = sub.agent_pid ? `<span class="badge badge-dim">PID ${sub.agent_pid}</span>` : '';
const shortHash = sub.sha256 ? sub.sha256.substring(0, 12) + '...' : '--';
const actions = sub.file_path
? `<button class="btn btn-xs" onclick="hexOpenFile('${escapeHtml(sub.file_path.replace(/\\/g, '\\\\'))}')" title="Open in Hex Editor">Hex</button>`
: '';
html += `<tr>`;
html += `<td class="td-time">${ts}</td>`;
html += `<td class="td-filename" title="${escapeHtml(sub.filename || '')}">${escapeHtml(sub.filename || '--')}</td>`;
html += `<td class="td-hash mono" title="${escapeHtml(sub.sha256 || '')}">${shortHash}</td>`;
html += `<td class="td-size">${sub.size ? formatSize(sub.size) : '--'}</td>`;
html += `<td class="td-target">${escapeHtml(sub.target || '--')}</td>`;
html += `<td class="td-status">${agentBadge} ${lbBadge} ${pid}</td>`;
html += `<td class="td-actions">${actions}</td>`;
html += `</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div style="padding:12px;color:var(--accent-red);font-size:11px;">Error: ${escapeHtml(e.message)}</div>`;
}
}
// =============================================
// PROCESS ROLLUP GRAPH
// =============================================
const graphState = {
nodes: [],
edges: [],
camera: { x: 0, y: 0, zoom: 1 },
dragging: null,
panning: false,
panStart: { x: 0, y: 0 },
hoveredNode: null,
selectedNode: null,
animFrame: null,
initialized: false,
timeRangeSeconds: 0, // 0 = all time
searchQuery: '', // search filter for process/filename
};
async function graphRefresh() {
// Fetch process tree + sysmon network/DNS data in parallel
const [procResp, sysmonNetResp, sysmonDnsResp, sysmonInjectResp] = await Promise.all([
fetch('/api/processes'),
fetch('/api/sysmon?event_id=3&max=300'),
fetch('/api/sysmon?event_id=22&max=200'),
fetch('/api/sysmon?event_id=8&max=100'),
]);
let processes = {};
let networkEvents = [];
let dnsEvents = [];
let injectEvents = [];
if (procResp.ok) processes = await procResp.json();
if (sysmonNetResp.ok) networkEvents = await sysmonNetResp.json();
if (sysmonDnsResp.ok) dnsEvents = await sysmonDnsResp.json();
if (sysmonInjectResp.ok) injectEvents = await sysmonInjectResp.json();
// Also use the alerts already in state for network info
const networkAlerts = (state.alerts || []).filter(a => {
const cat = (Array.isArray(a.category) ? a.category[0] : a.category || '').toLowerCase();
return cat === 'network';
});
buildGraph(processes, networkEvents, dnsEvents, injectEvents, networkAlerts);
if (!graphState.initialized) {
initGraphCanvas();
graphState.initialized = true;
}
graphFitView();
renderGraph();
}
function buildGraph(processes, networkEvents, dnsEvents, injectEvents, networkAlerts) {
const nodes = [];
const edges = [];
const nodeMap = {};
const showNetwork = document.getElementById('graph-show-network')?.checked;
const showDns = document.getElementById('graph-show-dns')?.checked;
const showFiles = document.getElementById('graph-show-files')?.checked;
const showRegistry = document.getElementById('graph-show-registry')?.checked;
const showDetonatedOnly = document.getElementById('graph-show-detonated')?.checked;
// Time range filtering
const timeRange = graphState.timeRangeSeconds;
let cutoffTime = null;
if (timeRange > 0) {
cutoffTime = new Date(Date.now() - timeRange * 1000).toISOString();
}
function isInTimeRange(timestamp) {
if (!cutoffTime || !timestamp) return true;
return timestamp >= cutoffTime;
}
// Filter sysmon events by time
if (cutoffTime) {
networkEvents = networkEvents.filter(e => isInTimeRange(e.timestamp));
dnsEvents = dnsEvents.filter(e => isInTimeRange(e.timestamp));
injectEvents = injectEvents.filter(e => isInTimeRange(e.timestamp));
networkAlerts = networkAlerts.filter(a => isInTimeRange(a.timestamp));
}
// 1. Create process nodes - only include interesting ones
// (has alerts OR is parent/child of one that does OR has sysmon network/dns activity)
// Also apply time range filter to processes
const sysmonPids = new Set();
networkEvents.forEach(ev => { if (ev.pid) sysmonPids.add(String(ev.pid)); });
dnsEvents.forEach(ev => { if (ev.pid) sysmonPids.add(String(ev.pid)); });
injectEvents.forEach(ev => {
if (ev.pid) sysmonPids.add(String(ev.pid));
if (ev.source_pid) sysmonPids.add(String(ev.source_pid));
if (ev.target_pid) sysmonPids.add(String(ev.target_pid));
});
// First pass: identify processes with alerts in the time range
const alertPids = new Set();
for (const [pid, proc] of Object.entries(processes)) {
if ((proc.activity?.threats || 0) > 0) {
// Check if any of this process's alerts are in time range
if (cutoffTime) {
const hasRecentAlert = (proc.alerts || []).some(a => isInTimeRange(a.timestamp));
if (hasRecentAlert) alertPids.add(pid);
} else {
alertPids.add(pid);
}
}
}
// Also include processes that were active in the time range (first_seen or last_seen)
if (cutoffTime) {
for (const [pid, proc] of Object.entries(processes)) {
if (isInTimeRange(proc.last_seen) || isInTimeRange(proc.first_seen)) {
if (sysmonPids.has(pid)) alertPids.add(pid); // only if they have sysmon activity
}
}
}
// Second pass: include parents/children of alert processes + sysmon-active processes
const includePids = new Set([...alertPids, ...sysmonPids]);
for (const pid of [...alertPids]) {
const proc = processes[pid];
if (proc?.parent_pid && processes[proc.parent_pid]) includePids.add(String(proc.parent_pid));
(proc?.children || []).forEach(c => includePids.add(String(c)));
}
// Search filter: narrow down to processes matching query + their parents/children
const searchQuery = graphState.searchQuery;
if (searchQuery) {
const matchedPids = new Set();
for (const [pid, proc] of Object.entries(processes)) {
if (!includePids.has(pid)) continue;
const name = (proc.name || '').toLowerCase();
const image = (proc.image || '').toLowerCase();
const cmdline = (proc.command_line || '').toLowerCase();
const pidStr = String(pid);
if (name.includes(searchQuery) || image.includes(searchQuery) || cmdline.includes(searchQuery) || pidStr.includes(searchQuery)) {
matchedPids.add(pid);
}
}
// Include parents and children of matched processes for context
const expandedPids = new Set(matchedPids);
for (const pid of matchedPids) {
const proc = processes[pid];
if (proc?.parent_pid && processes[proc.parent_pid]) expandedPids.add(String(proc.parent_pid));
(proc?.children || []).forEach(c => { if (includePids.has(String(c))) expandedPids.add(String(c)); });
}
// Replace includePids with search-filtered set
includePids.clear();
for (const pid of expandedPids) includePids.add(pid);
}
// Detonated-only filter: narrow to processes that have detonation results
if (showDetonatedOnly) {
const detonatedPids = new Set();
for (const [pid, proc] of Object.entries(processes)) {
if (!includePids.has(pid)) continue;
if (proc.detonated) detonatedPids.add(pid);
}
// Include parents/children of detonated processes for context
const expandedDet = new Set(detonatedPids);
for (const pid of detonatedPids) {
const proc = processes[pid];
if (proc?.parent_pid && processes[proc.parent_pid]) expandedDet.add(String(proc.parent_pid));
(proc?.children || []).forEach(c => { if (includePids.has(String(c))) expandedDet.add(String(c)); });
}
includePids.clear();
for (const pid of expandedDet) includePids.add(pid);
}
for (const [pid, proc] of Object.entries(processes)) {
if (!includePids.has(pid)) continue;
const threats = proc.activity?.threats || 0;
const maxSev = getNodeMaxSeverity(proc);
const node = {
id: `proc_${pid}`,
type: 'process',
pid: pid,
label: proc.name || 'unknown',
image: proc.image || '',
cmdline: proc.command_line || '',
user: proc.user || '',
threats: threats,
severity: maxSev,
children: proc.children || [],
parentPid: proc.parent_pid,
activity: proc.activity || {},
firstSeen: proc.first_seen || '',
exited: !!proc.exit_time,
detonated: !!proc.detonated,
detonationSources: proc.detonation_sources || [],
x: 0, y: 0, vx: 0, vy: 0,
radius: Math.max(14, Math.min(30, 14 + threats * 2)),
};
nodes.push(node);
nodeMap[pid] = node;
}
// 2. Create parent-child edges
for (const node of nodes) {
if (node.parentPid && nodeMap[node.parentPid]) {
edges.push({
source: `proc_${node.parentPid}`,
target: node.id,
type: 'spawn',
label: 'spawned',
});
}
}
// 3. Network connection nodes (from Sysmon event 3)
if (showNetwork) {
const netTargets = {}; // deduplicate by ip:port
networkEvents.forEach(ev => {
const key = `${ev.dst_ip}:${ev.dst_port}`;
if (!netTargets[key]) {
netTargets[key] = { ip: ev.dst_ip, port: ev.dst_port, hostname: ev.dst_hostname || '', pids: new Set(), protocol: ev.protocol || 'tcp' };
}
if (ev.pid) netTargets[key].pids.add(String(ev.pid));
});
// Also add network info from alerts
networkAlerts.forEach(a => {
const raw = a.raw || {};
const ip = raw.destination?.ip || raw.network?.destination?.ip || '';
const port = raw.destination?.port || raw.network?.destination?.port || '';
if (ip) {
const key = `${ip}:${port}`;
if (!netTargets[key]) {
netTargets[key] = { ip, port, hostname: '', pids: new Set(), protocol: 'tcp' };
}
if (a.pid) netTargets[key].pids.add(String(a.pid));
}
});
for (const [key, info] of Object.entries(netTargets)) {
const nodeId = `net_${key}`;
nodes.push({
id: nodeId,
type: 'network',
label: info.hostname || info.ip,
ip: info.ip,
port: info.port,
protocol: info.protocol,
x: 0, y: 0, vx: 0, vy: 0,
radius: 10,
});
info.pids.forEach(pid => {
if (nodeMap[pid]) {
edges.push({ source: `proc_${pid}`, target: nodeId, type: 'network', label: `${info.protocol}:${info.port}` });
}
});
}
}
// 4. DNS nodes (from Sysmon event 22)
if (showDns) {
const dnsTargets = {};
dnsEvents.forEach(ev => {
const query = ev.query || '';
if (!query) return;
if (!dnsTargets[query]) {
dnsTargets[query] = { query, result: ev.result || '', pids: new Set() };
}
if (ev.pid) dnsTargets[query].pids.add(String(ev.pid));
});
for (const [query, info] of Object.entries(dnsTargets)) {
const nodeId = `dns_${query}`;
nodes.push({
id: nodeId,
type: 'dns',
label: query,
result: info.result,
x: 0, y: 0, vx: 0, vy: 0,
radius: 8,
});
info.pids.forEach(pid => {
if (nodeMap[pid]) {
edges.push({ source: `proc_${pid}`, target: nodeId, type: 'dns', label: 'query' });
}
});
}
}
// 5. Injection edges (from Sysmon event 8: CreateRemoteThread)
injectEvents.forEach(ev => {
const srcPid = String(ev.source_pid || ev.pid);
const tgtPid = String(ev.target_pid);
if (srcPid && tgtPid && nodeMap[srcPid] && nodeMap[tgtPid]) {
edges.push({ source: `proc_${srcPid}`, target: `proc_${tgtPid}`, type: 'inject', label: 'inject' });
}
});
// 6. File nodes (from alerts with category=file)
if (showFiles) {
const fileTargets = {};
(state.alerts || []).forEach(a => {
const cat = (Array.isArray(a.category) ? a.category[0] : a.category || '').toLowerCase();
if (cat !== 'file') return;
const raw = a.raw || {};
const path = raw.file?.path || raw['file.path'] || '';
if (!path || !a.pid) return;
if (!fileTargets[path]) fileTargets[path] = { path, pids: new Set() };
fileTargets[path].pids.add(String(a.pid));
});
for (const [path, info] of Object.entries(fileTargets)) {
const nodeId = `file_${path}`;
const shortName = path.split('\\').pop() || path.split('/').pop() || path;
nodes.push({ id: nodeId, type: 'file', label: shortName, fullPath: path, x: 0, y: 0, vx: 0, vy: 0, radius: 7 });
info.pids.forEach(pid => {
if (nodeMap[pid]) edges.push({ source: `proc_${pid}`, target: nodeId, type: 'file', label: 'write' });
});
}
}
// 7. Registry nodes
if (showRegistry) {
const regTargets = {};
(state.alerts || []).forEach(a => {
const cat = (Array.isArray(a.category) ? a.category[0] : a.category || '').toLowerCase();
if (cat !== 'registry') return;
const raw = a.raw || {};
const path = raw.registry?.path || raw['registry.path'] || '';
if (!path || !a.pid) return;
const shortKey = path.split('\\').slice(-2).join('\\') || path;
if (!regTargets[shortKey]) regTargets[shortKey] = { path, pids: new Set() };
regTargets[shortKey].pids.add(String(a.pid));
});
for (const [key, info] of Object.entries(regTargets)) {
const nodeId = `reg_${key}`;
nodes.push({ id: nodeId, type: 'registry', label: key, fullPath: info.path, x: 0, y: 0, vx: 0, vy: 0, radius: 7 });
info.pids.forEach(pid => {
if (nodeMap[pid]) edges.push({ source: `proc_${pid}`, target: nodeId, type: 'registry', label: 'modify' });
});
}
}
// Apply layout
const layout = document.getElementById('graph-layout')?.value || 'hierarchy';
if (layout === 'hierarchy') {
applyHierarchyLayout(nodes, edges, nodeMap);
} else {
applyForceLayout(nodes, edges);
}
graphState.nodes = nodes;
graphState.edges = edges;
// Update counter
const countEl = document.getElementById('graph-node-count');
if (countEl) {
let label = `${nodes.length} nodes, ${edges.length} edges`;
if (graphState.searchQuery) label += ` (filtered: "${graphState.searchQuery}")`;
countEl.textContent = label;
}
}
function getNodeMaxSeverity(proc) {
let max = 'low';
const order = { critical: 4, high: 3, medium: 2, low: 1, unknown: 0 };
(proc.alerts || []).forEach(a => {
const sev = (a.severity || 'unknown').toLowerCase();
if ((order[sev] || 0) > (order[max] || 0)) max = sev;
});
return max;
}
function applyHierarchyLayout(nodes, edges, nodeMap) {
// Build tree levels from process parent relationships
const procNodes = nodes.filter(n => n.type === 'process');
const otherNodes = nodes.filter(n => n.type !== 'process');
// Find roots (no parent or parent not in nodeMap)
const roots = procNodes.filter(n => !n.parentPid || !nodeMap[n.parentPid]);
const visited = new Set();
let col = 0;
function layoutTree(node, depth) {
if (visited.has(node.id)) return;
visited.add(node.id);
node.x = depth * 200;
node.y = col * 80;
col++;
// Find children
const children = procNodes.filter(n => n.parentPid && `proc_${n.parentPid}` === node.id && !visited.has(n.id));
children.forEach(child => layoutTree(child, depth + 1));
}
roots.forEach(root => layoutTree(root, 0));
// Any orphans
procNodes.filter(n => !visited.has(n.id)).forEach(n => { n.x = 0; n.y = col * 80; col++; });
// Place non-process nodes around their connected process
otherNodes.forEach(node => {
const connEdge = edges.find(e => e.target === node.id || e.source === node.id);
if (connEdge) {
const parentId = connEdge.source === node.id ? connEdge.target : connEdge.source;
const parent = nodes.find(n => n.id === parentId);
if (parent) {
const angle = Math.random() * Math.PI * 2;
const dist = 100 + Math.random() * 60;
node.x = parent.x + Math.cos(angle) * dist;
node.y = parent.y + Math.sin(angle) * dist;
return;
}
}
node.x = Math.random() * 600;
node.y = Math.random() * 400;
});
}
function applyForceLayout(nodes, edges) {
// Initial random placement
nodes.forEach((n, i) => {
n.x = Math.cos(i * 0.7) * (150 + i * 10);
n.y = Math.sin(i * 0.7) * (150 + i * 10);
});
// Run force simulation for N iterations
const nodeIndex = {};
nodes.forEach(n => { nodeIndex[n.id] = n; });
for (let iter = 0; iter < 120; iter++) {
const alpha = 0.3 * (1 - iter / 120);
// Repulsion between all nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x;
const dy = nodes[j].y - nodes[i].y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = 3000 / (dist * dist);
const fx = (dx / dist) * force * alpha;
const fy = (dy / dist) * force * alpha;
nodes[i].x -= fx;
nodes[i].y -= fy;
nodes[j].x += fx;
nodes[j].y += fy;
}
}
// Attraction along edges
edges.forEach(e => {
const src = nodeIndex[e.source];
const tgt = nodeIndex[e.target];
if (!src || !tgt) return;
const dx = tgt.x - src.x;
const dy = tgt.y - src.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const idealDist = e.type === 'spawn' ? 150 : 120;
const force = (dist - idealDist) * 0.01 * alpha;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
src.x += fx;
src.y += fy;
tgt.x -= fx;
tgt.y -= fy;
});
}
}
function initGraphCanvas() {
const canvas = document.getElementById('graph-canvas');
const container = document.getElementById('graph-container');
if (!canvas || !container) return;
function resize() {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
renderGraph();
}
resize();
window.addEventListener('resize', resize);
// Mouse interactions
let lastMouse = { x: 0, y: 0 };
canvas.addEventListener('mousedown', e => {
const pos = screenToWorld(e.offsetX, e.offsetY);
const node = findNodeAt(pos.x, pos.y);
if (node) {
graphState.dragging = node;
graphState.selectedNode = node;
showGraphDetail(node);
} else {
graphState.panning = true;
graphState.panStart = { x: e.offsetX, y: e.offsetY };
graphState.selectedNode = null;
hideGraphDetail();
}
lastMouse = { x: e.offsetX, y: e.offsetY };
});
canvas.addEventListener('mousemove', e => {
const pos = screenToWorld(e.offsetX, e.offsetY);
if (graphState.dragging) {
graphState.dragging.x = pos.x;
graphState.dragging.y = pos.y;
renderGraph();
} else if (graphState.panning) {
const dx = e.offsetX - graphState.panStart.x;
const dy = e.offsetY - graphState.panStart.y;
graphState.camera.x += dx;
graphState.camera.y += dy;
graphState.panStart = { x: e.offsetX, y: e.offsetY };
renderGraph();
} else {
// Hover detection
const node = findNodeAt(pos.x, pos.y);
if (node !== graphState.hoveredNode) {
graphState.hoveredNode = node;
showGraphTooltip(node, e.offsetX, e.offsetY);
renderGraph();
}
}
lastMouse = { x: e.offsetX, y: e.offsetY };
});
canvas.addEventListener('mouseup', () => {
graphState.dragging = null;
graphState.panning = false;
});
canvas.addEventListener('mouseleave', () => {
graphState.dragging = null;
graphState.panning = false;
graphState.hoveredNode = null;
hideGraphTooltip();
renderGraph();
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const oldZoom = graphState.camera.zoom;
graphState.camera.zoom = Math.max(0.1, Math.min(5, oldZoom * zoomFactor));
// Zoom toward mouse position
const mx = e.offsetX;
const my = e.offsetY;
graphState.camera.x = mx - (mx - graphState.camera.x) * (graphState.camera.zoom / oldZoom);
graphState.camera.y = my - (my - graphState.camera.y) * (graphState.camera.zoom / oldZoom);
renderGraph();
});
}
function screenToWorld(sx, sy) {
return {
x: (sx - graphState.camera.x) / graphState.camera.zoom,
y: (sy - graphState.camera.y) / graphState.camera.zoom,
};
}
function worldToScreen(wx, wy) {
return {
x: wx * graphState.camera.zoom + graphState.camera.x,
y: wy * graphState.camera.zoom + graphState.camera.y,
};
}
function findNodeAt(wx, wy) {
for (let i = graphState.nodes.length - 1; i >= 0; i--) {
const n = graphState.nodes[i];
const dx = wx - n.x;
const dy = wy - n.y;
if (dx * dx + dy * dy < n.radius * n.radius) return n;
}
return null;
}
function graphFitView() {
const nodes = graphState.nodes;
if (!nodes.length) return;
const canvas = document.getElementById('graph-canvas');
if (!canvas) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
nodes.forEach(n => {
minX = Math.min(minX, n.x - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
minY = Math.min(minY, n.y - n.radius);
maxY = Math.max(maxY, n.y + n.radius);
});
const padding = 60;
const w = maxX - minX + padding * 2;
const h = maxY - minY + padding * 2;
const zoom = Math.min(canvas.width / w, canvas.height / h, 2);
graphState.camera.zoom = zoom;
graphState.camera.x = canvas.width / 2 - (minX + maxX) / 2 * zoom;
graphState.camera.y = canvas.height / 2 - (minY + maxY) / 2 * zoom;
renderGraph();
}
function renderGraph() {
const canvas = document.getElementById('graph-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const { nodes, edges, camera } = graphState;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(camera.x, camera.y);
ctx.scale(camera.zoom, camera.zoom);
const nodeColors = {
process: '#3b82f6',
network: '#22c55e',
dns: '#a78bfa',
file: '#f97316',
registry: '#f472b6',
};
const edgeColors = {
spawn: '#475569',
network: '#22c55e',
dns: '#a78bfa',
inject: '#ef4444',
file: '#f97316',
registry: '#f472b6',
};
// Build node index for edge lookup
const nodeIndex = {};
nodes.forEach(n => { nodeIndex[n.id] = n; });
// Draw edges
edges.forEach(edge => {
const src = nodeIndex[edge.source];
const tgt = nodeIndex[edge.target];
if (!src || !tgt) return;
ctx.beginPath();
ctx.moveTo(src.x, src.y);
ctx.lineTo(tgt.x, tgt.y);
ctx.strokeStyle = edgeColors[edge.type] || '#475569';
ctx.lineWidth = edge.type === 'inject' ? 2 : 1;
if (edge.type === 'inject') {
ctx.setLineDash([4, 3]);
} else if (edge.type !== 'spawn') {
ctx.setLineDash([2, 2]);
} else {
ctx.setLineDash([]);
}
ctx.globalAlpha = 0.6;
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
// Arrow head for spawn edges
if (edge.type === 'spawn') {
const angle = Math.atan2(tgt.y - src.y, tgt.x - src.x);
const headLen = 8;
const arrX = tgt.x - Math.cos(angle) * tgt.radius;
const arrY = tgt.y - Math.sin(angle) * tgt.radius;
ctx.beginPath();
ctx.moveTo(arrX, arrY);
ctx.lineTo(arrX - headLen * Math.cos(angle - 0.4), arrY - headLen * Math.sin(angle - 0.4));
ctx.lineTo(arrX - headLen * Math.cos(angle + 0.4), arrY - headLen * Math.sin(angle + 0.4));
ctx.closePath();
ctx.fillStyle = edgeColors[edge.type];
ctx.globalAlpha = 0.7;
ctx.fill();
ctx.globalAlpha = 1;
}
});
// Draw nodes
nodes.forEach(node => {
const isHovered = graphState.hoveredNode === node;
const isSelected = graphState.selectedNode === node;
let color = nodeColors[node.type] || '#64748b';
// Override color for malicious processes
if (node.type === 'process' && node.threats > 0) {
if (node.severity === 'critical') color = '#ef4444';
else if (node.severity === 'high') color = '#f97316';
else if (node.severity === 'medium') color = '#eab308';
}
const r = node.radius * (isHovered ? 1.2 : 1);
// Glow for malicious
if (node.type === 'process' && node.threats > 0) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 4, 0, Math.PI * 2);
ctx.fillStyle = color + '20';
ctx.fill();
}
// Node circle
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = color + '30';
ctx.fill();
ctx.strokeStyle = isSelected ? '#fff' : color;
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.stroke();
// Icon/shape based on type
ctx.fillStyle = color;
if (node.type === 'process') {
// Draw process icon (small square)
const s = r * 0.4;
ctx.fillRect(node.x - s, node.y - s, s * 2, s * 2);
} else if (node.type === 'network') {
// Draw network icon (diamond)
ctx.beginPath();
const d = r * 0.5;
ctx.moveTo(node.x, node.y - d);
ctx.lineTo(node.x + d, node.y);
ctx.lineTo(node.x, node.y + d);
ctx.lineTo(node.x - d, node.y);
ctx.closePath();
ctx.fill();
} else if (node.type === 'dns') {
// Dot
ctx.beginPath();
ctx.arc(node.x, node.y, r * 0.35, 0, Math.PI * 2);
ctx.fill();
} else {
// Small triangle for file/registry
ctx.beginPath();
const t = r * 0.4;
ctx.moveTo(node.x, node.y - t);
ctx.lineTo(node.x + t, node.y + t);
ctx.lineTo(node.x - t, node.y + t);
ctx.closePath();
ctx.fill();
}
// Label
ctx.font = `${node.type === 'process' ? '10' : '8'}px monospace`;
ctx.fillStyle = isHovered ? '#fff' : '#94a3b8';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const labelY = node.y + r + 4;
ctx.fillText(node.label.length > 20 ? node.label.substring(0, 18) + '..' : node.label, node.x, labelY);
// Threat count badge
if (node.type === 'process' && node.threats > 0) {
const bx = node.x + r * 0.7;
const by = node.y - r * 0.7;
ctx.beginPath();
ctx.arc(bx, by, 7, 0, Math.PI * 2);
ctx.fillStyle = '#ef4444';
ctx.fill();
ctx.font = 'bold 7px monospace';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(node.threats), bx, by);
}
});
ctx.restore();
}
function showGraphTooltip(node, sx, sy) {
const tooltip = document.getElementById('graph-tooltip');
if (!tooltip) return;
if (!node) { hideGraphTooltip(); return; }
let html = `<div class="tt-title">${escapeHtml(node.label)}</div>`;
if (node.type === 'process') {
html += `<div class="tt-field"><span>PID:</span> ${node.pid}</div>`;
if (node.image) html += `<div class="tt-field"><span>Image:</span> ${escapeHtml(node.image)}</div>`;
if (node.threats) html += `<div class="tt-field"><span>Threats:</span> ${node.threats}</div>`;
} else if (node.type === 'network') {
html += `<div class="tt-field"><span>IP:</span> ${node.ip}:${node.port}</div>`;
html += `<div class="tt-field"><span>Protocol:</span> ${node.protocol || 'tcp'}</div>`;
} else if (node.type === 'dns') {
html += `<div class="tt-field"><span>Query:</span> ${escapeHtml(node.label)}</div>`;
if (node.result) html += `<div class="tt-field"><span>Result:</span> ${escapeHtml(node.result)}</div>`;
} else if (node.type === 'file') {
html += `<div class="tt-field"><span>Path:</span> ${escapeHtml(node.fullPath || node.label)}</div>`;
} else if (node.type === 'registry') {
html += `<div class="tt-field"><span>Key:</span> ${escapeHtml(node.fullPath || node.label)}</div>`;
}
tooltip.innerHTML = html;
tooltip.style.display = 'block';
tooltip.style.left = (sx + 16) + 'px';
tooltip.style.top = (sy - 10) + 'px';
}
function hideGraphTooltip() {
const tooltip = document.getElementById('graph-tooltip');
if (tooltip) tooltip.style.display = 'none';
}
function showGraphDetail(node) {
const panel = document.getElementById('graph-detail-panel');
const header = document.getElementById('graph-detail-header');
const body = document.getElementById('graph-detail-body');
if (!panel || !header || !body) return;
panel.classList.add('visible');
let headerText = '';
let html = '';
if (node.type === 'process') {
headerText = `${node.label} (PID ${node.pid})`;
html += `<div class="gd-field"><span class="gd-label">Image</span><span class="gd-value">${escapeHtml(node.image)}</span></div>`;
if (node.cmdline) html += `<div class="gd-field"><span class="gd-label">Cmdline</span><span class="gd-value gd-cmdline">${escapeHtml(node.cmdline)}</span></div>`;
if (node.user) html += `<div class="gd-field"><span class="gd-label">User</span><span class="gd-value">${escapeHtml(node.user)}</span></div>`;
html += `<div class="gd-field"><span class="gd-label">Status</span><span class="gd-value">${node.exited ? '<span style="color:#94a3b8">Exited</span>' : '<span style="color:#4ade80">Running</span>'}</span></div>`;
html += `<div class="gd-field"><span class="gd-label">First seen</span><span class="gd-value">${node.firstSeen || '--'}</span></div>`;
if (node.parentPid) {
const parentNode = graphState.nodes.find(n => n.type === 'process' && String(n.pid) === String(node.parentPid));
if (parentNode) {
html += `<div class="gd-field"><span class="gd-label">Parent</span><span class="gd-value"><a class="gd-link" data-node-id="${parentNode.id}">${escapeHtml(parentNode.label)} (${parentNode.pid})</a></span></div>`;
} else {
html += `<div class="gd-field"><span class="gd-label">Parent PID</span><span class="gd-value">${node.parentPid}</span></div>`;
}
}
// Activity summary
const act = node.activity || {};
const totalActivity = (act.threats||0) + (act.network||0) + (act.dns||0) + (act.file||0) + (act.registry||0);
html += `<div class="gd-section">Activity <span class="gd-count">${totalActivity} events</span></div>`;
html += `<div class="gd-activity-grid">`;
html += `<div class="gd-activity-cell ${(act.threats||0) > 0 ? 'critical' : ''}"><span class="gd-act-num">${act.threats || 0}</span><span class="gd-act-label">Threats</span></div>`;
html += `<div class="gd-activity-cell"><span class="gd-act-num">${act.network || 0}</span><span class="gd-act-label">Network</span></div>`;
html += `<div class="gd-activity-cell"><span class="gd-act-num">${act.dns || 0}</span><span class="gd-act-label">DNS</span></div>`;
html += `<div class="gd-activity-cell"><span class="gd-act-num">${act.file || 0}</span><span class="gd-act-label">File</span></div>`;
html += `<div class="gd-activity-cell"><span class="gd-act-num">${act.registry || 0}</span><span class="gd-act-label">Registry</span></div>`;
html += `<div class="gd-activity-cell"><span class="gd-act-num">${act.injection || 0}</span><span class="gd-act-label">Injection</span></div>`;
html += `</div>`;
// Connections from this node
const connections = graphState.edges.filter(e => e.source === node.id || e.target === node.id);
const netConns = connections.filter(e => e.type === 'network');
const dnsConns = connections.filter(e => e.type === 'dns');
// Children tree - recursive with details
const childConns = connections.filter(e => e.type === 'spawn' && e.source === node.id);
if (childConns.length) {
html += `<div class="gd-section">Children Processes <span class="gd-count">${childConns.length}</span></div>`;
html += buildChildrenTree(node.id, 0);
}
if (netConns.length) {
html += `<div class="gd-section">Network Connections <span class="gd-count">${netConns.length}</span></div>`;
html += `<div class="gd-conn-list">`;
netConns.forEach(e => {
const target = graphState.nodes.find(n => n.id === e.target);
if (target) html += `<div class="gd-conn-item"><span class="gd-conn-icon net"></span><span class="gd-conn-text">${escapeHtml(target.ip || target.label)}:${target.port || ''}</span><span class="gd-conn-proto">${target.protocol || 'tcp'}</span></div>`;
});
html += `</div>`;
}
if (dnsConns.length) {
html += `<div class="gd-section">DNS Queries <span class="gd-count">${dnsConns.length}</span></div>`;
html += `<div class="gd-conn-list">`;
dnsConns.forEach(e => {
const target = graphState.nodes.find(n => n.id === e.target);
if (target) html += `<div class="gd-conn-item"><span class="gd-conn-icon dns"></span><span class="gd-conn-text">${escapeHtml(target.label)}</span>${target.result ? `<span class="gd-conn-proto">${escapeHtml(target.result)}</span>` : ''}</div>`;
});
html += `</div>`;
}
} else if (node.type === 'network') {
headerText = `Network: ${node.ip}:${node.port}`;
html += `<div class="gd-field"><span class="gd-label">IP</span><span class="gd-value">${node.ip}</span></div>`;
html += `<div class="gd-field"><span class="gd-label">Port</span><span class="gd-value">${node.port}</span></div>`;
html += `<div class="gd-field"><span class="gd-label">Protocol</span><span class="gd-value">${node.protocol || 'tcp'}</span></div>`;
const conns = graphState.edges.filter(e => e.target === node.id);
if (conns.length) {
html += `<div class="gd-section">Connected from <span class="gd-count">${conns.length}</span></div>`;
conns.forEach(e => {
const src = graphState.nodes.find(n => n.id === e.source);
if (src) html += `<div class="gd-conn-item"><span class="gd-conn-icon proc"></span><a class="gd-link" data-node-id="${src.id}">${escapeHtml(src.label)} (${src.pid})</a></div>`;
});
}
} else if (node.type === 'dns') {
headerText = `DNS: ${node.label}`;
html += `<div class="gd-field"><span class="gd-label">Query</span><span class="gd-value">${escapeHtml(node.label)}</span></div>`;
if (node.result) html += `<div class="gd-field"><span class="gd-label">Result</span><span class="gd-value">${escapeHtml(node.result)}</span></div>`;
const conns = graphState.edges.filter(e => e.target === node.id);
if (conns.length) {
html += `<div class="gd-section">Queried by <span class="gd-count">${conns.length}</span></div>`;
conns.forEach(e => {
const src = graphState.nodes.find(n => n.id === e.source);
if (src) html += `<div class="gd-conn-item"><span class="gd-conn-icon proc"></span><a class="gd-link" data-node-id="${src.id}">${escapeHtml(src.label)} (${src.pid})</a></div>`;
});
}
} else {
headerText = `${node.type}: ${node.label}`;
if (node.fullPath) html += `<div class="gd-field"><span class="gd-label">Path</span><span class="gd-value">${escapeHtml(node.fullPath)}</span></div>`;
}
header.textContent = headerText;
body.innerHTML = html;
// Wire up clickable links in the detail panel
body.querySelectorAll('.gd-link[data-node-id]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetNode = graphState.nodes.find(n => n.id === link.dataset.nodeId);
if (targetNode) {
graphState.selectedNode = targetNode;
showGraphDetail(targetNode);
renderGraph();
}
});
});
// Wire up collapsible child entries
body.querySelectorAll('.gd-child-header').forEach(hdr => {
hdr.addEventListener('click', () => {
const entry = hdr.closest('.gd-child-entry');
if (entry) entry.classList.toggle('expanded');
});
});
// Wire up "focus" links to navigate to a child node in the graph
body.querySelectorAll('.gd-child-focus[data-node-id]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const targetNode = graphState.nodes.find(n => n.id === btn.dataset.nodeId);
if (targetNode) {
graphState.selectedNode = targetNode;
showGraphDetail(targetNode);
renderGraph();
}
});
});
}
function buildChildrenTree(parentNodeId, depth) {
if (depth > 4) return '<div class="gd-child-truncated">... (depth limit)</div>';
const childEdges = graphState.edges.filter(e => e.type === 'spawn' && e.source === parentNodeId);
if (!childEdges.length) return '';
let html = `<div class="gd-children-tree depth-${depth}">`;
childEdges.forEach(e => {
const child = graphState.nodes.find(n => n.id === e.target);
if (!child) return;
const act = child.activity || {};
const threats = act.threats || 0;
const severityClass = threats > 0 ? (child.severity === 'critical' ? 'critical' : 'high') : '';
const grandchildEdges = graphState.edges.filter(gc => gc.type === 'spawn' && gc.source === child.id);
const hasChildren = grandchildEdges.length > 0;
html += `<div class="gd-child-entry ${severityClass}">`;
html += `<div class="gd-child-header">`;
html += `<span class="gd-child-expand">${hasChildren ? '&#9654;' : '&#8226;'}</span>`;
html += `<span class="gd-child-name">${escapeHtml(child.label)}</span>`;
html += `<span class="gd-child-pid">PID ${child.pid}</span>`;
if (threats > 0) html += `<span class="gd-child-threats">${threats}</span>`;
html += `<span class="gd-child-focus" data-node-id="${child.id}" title="Focus this node">&#8599;</span>`;
html += `</div>`;
// Collapsible detail body
html += `<div class="gd-child-body">`;
if (child.image) html += `<div class="gd-child-detail"><span class="gd-child-dlabel">Image:</span> ${escapeHtml(child.image)}</div>`;
if (child.cmdline) html += `<div class="gd-child-detail gd-cmdline"><span class="gd-child-dlabel">Cmd:</span> ${escapeHtml(child.cmdline)}</div>`;
html += `<div class="gd-child-detail"><span class="gd-child-dlabel">Status:</span> ${child.exited ? 'Exited' : 'Running'}</div>`;
if (child.firstSeen) html += `<div class="gd-child-detail"><span class="gd-child-dlabel">First seen:</span> ${child.firstSeen}</div>`;
// Activity mini-summary
const actTotal = (act.network||0) + (act.dns||0) + (act.file||0) + (act.registry||0);
if (actTotal > 0 || threats > 0) {
html += `<div class="gd-child-activity">`;
if (threats > 0) html += `<span class="gd-mini-badge threat">${threats} threats</span>`;
if (act.network > 0) html += `<span class="gd-mini-badge net">${act.network} net</span>`;
if (act.dns > 0) html += `<span class="gd-mini-badge dns">${act.dns} dns</span>`;
if (act.file > 0) html += `<span class="gd-mini-badge file">${act.file} file</span>`;
if (act.registry > 0) html += `<span class="gd-mini-badge reg">${act.registry} reg</span>`;
html += `</div>`;
}
// Recurse into grandchildren
if (hasChildren) {
html += buildChildrenTree(child.id, depth + 1);
}
html += `</div>`; // .gd-child-body
html += `</div>`; // .gd-child-entry
});
html += `</div>`;
return html;
}
function hideGraphDetail() {
const panel = document.getElementById('graph-detail-panel');
if (panel) panel.classList.remove('visible');
}
function initGraphControls() {
// Re-render graph when toggles change
['graph-show-network', 'graph-show-dns', 'graph-show-files', 'graph-show-registry', 'graph-show-detonated'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', () => { if (state.activeTab === 'graph') graphRefresh(); });
});
const layoutEl = document.getElementById('graph-layout');
if (layoutEl) layoutEl.addEventListener('change', () => { if (state.activeTab === 'graph') graphRefresh(); });
// Time range buttons
document.querySelectorAll('.graph-time-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.graph-time-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
graphState.timeRangeSeconds = parseInt(btn.dataset.seconds) || 0;
if (state.activeTab === 'graph') graphRefresh();
});
});
// Search input
const searchInput = document.getElementById('graph-search');
const searchClear = document.getElementById('graph-search-clear');
let searchDebounce = null;
if (searchInput) {
searchInput.addEventListener('input', () => {
const val = searchInput.value.trim();
if (searchClear) searchClear.classList.toggle('visible', val.length > 0);
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
graphState.searchQuery = val.toLowerCase();
if (state.activeTab === 'graph') graphRefresh();
}, 250);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
searchInput.value = '';
graphState.searchQuery = '';
if (searchClear) searchClear.classList.remove('visible');
if (state.activeTab === 'graph') graphRefresh();
}
});
}
if (searchClear) {
searchClear.addEventListener('click', () => {
if (searchInput) searchInput.value = '';
graphState.searchQuery = '';
searchClear.classList.remove('visible');
if (state.activeTab === 'graph') graphRefresh();
});
}
}
// --- Sysmon Events Tab ---
let sysmonEvents = [];
let sysmonStats = null;
async function refreshSysmon() {
const eventType = document.getElementById('sysmon-filter-type')?.value || '';
const pidFilter = document.getElementById('sysmon-filter-pid')?.value || '';
const maxEvents = document.getElementById('sysmon-max-events')?.value || '100';
let url = `/api/sysmon?max=${maxEvents}`;
if (eventType) url += `&event_id=${eventType}`;
if (pidFilter) url += `&pid=${pidFilter}`;
try {
const [eventsResp, statsResp] = await Promise.all([
fetch(url),
fetch('/api/sysmon/stats'),
]);
if (eventsResp.ok) {
sysmonEvents = await eventsResp.json();
renderSysmonTable();
}
if (statsResp.ok) {
sysmonStats = await statsResp.json();
renderSysmonStats();
}
} catch (e) {
console.error('Sysmon fetch error:', e);
}
}
function renderSysmonStats() {
const container = document.getElementById('sysmon-stats');
if (!container || !sysmonStats || !sysmonStats.stats) return;
const stats = sysmonStats.stats;
const total = stats.reduce((sum, s) => sum + s.count, 0);
let html = `<div class="sysmon-stats-bar"><span class="stats-total">${total} events (last 500)</span>`;
stats.sort((a, b) => b.count - a.count);
stats.forEach(s => {
const typeClass = getSysmonTypeClass(s.event_id);
html += `<span class="stats-chip ${typeClass}" onclick="filterSysmonByType('${s.event_id}')">${s.name} <strong>${s.count}</strong></span>`;
});
html += `</div>`;
container.innerHTML = html;
}
function filterSysmonByType(eventId) {
const select = document.getElementById('sysmon-filter-type');
if (select) { select.value = eventId; refreshSysmon(); }
}
function renderSysmonTable() {
const container = document.getElementById('sysmon-table');
if (!container) return;
if (!sysmonEvents || !sysmonEvents.length) {
container.innerHTML = '<div class="empty-state">No Sysmon events found</div>';
return;
}
if (sysmonEvents[0]?.error) {
container.innerHTML = `<div class="empty-state">Error: ${escapeHtml(sysmonEvents[0].error)}</div>`;
return;
}
let html = `<table class="sysmon-events-table"><thead><tr><th>Time</th><th>Type</th><th>PID</th><th>Image</th><th>Details</th></tr></thead><tbody>`;
sysmonEvents.forEach(ev => {
const typeClass = getSysmonTypeClass(String(ev.event_id));
const time = ev.timestamp ? formatSysmonTime(ev.timestamp) : '';
const image = ev.image ? ev.image.split('\\').pop() : '';
const details = getSysmonDetails(ev);
html += `<tr onclick="showSysmonDetail(${JSON.stringify(ev).replace(/"/g, '&quot;')})">
<td class="col-time">${time}</td>
<td><span class="type-badge ${typeClass}">${escapeHtml(ev.type || '')}</span></td>
<td class="col-pid">${ev.pid || ''}</td>
<td class="col-image">${escapeHtml(image)}</td>
<td class="col-details">${escapeHtml(details)}</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
function getSysmonDetails(ev) {
switch (ev.event_id) {
case 1: return ev.commandline ? truncate(ev.commandline, 80) : '';
case 3: return `${ev.dst_ip || ''}:${ev.dst_port || ''}`;
case 5: return 'Process terminated';
case 7: return ev.loaded_image ? ev.loaded_image.split('\\').pop() : '';
case 11: return ev.target ? truncate(ev.target, 80) : '';
case 22: return ev.query || '';
default: return '';
}
}
function getSysmonTypeClass(eventId) {
const classes = { '1': 'type-process', '3': 'type-network', '5': 'type-terminate', '7': 'type-imageload', '8': 'type-injection', '10': 'type-access', '11': 'type-file', '12': 'type-registry', '13': 'type-registry', '22': 'type-dns' };
return classes[eventId] || 'type-other';
}
function formatSysmonTime(isoStr) {
try { const d = new Date(isoStr); return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0'); } catch { return isoStr; }
}
function truncate(str, max) {
return str && str.length > max ? str.substring(0, max) + '...' : (str || '');
}
function showSysmonDetail(ev) {
let html = `<div class="detail-section"><div class="detail-section-title">${escapeHtml(ev.type || 'Event')} - PID ${ev.pid || '?'}</div>
<div class="detail-fields">
<div class="detail-field"><span class="field-label">Timestamp</span><span class="field-value">${escapeHtml(ev.timestamp || '')}</span></div>
<div class="detail-field"><span class="field-label">Event ID</span><span class="field-value">${ev.event_id}</span></div>
<div class="detail-field"><span class="field-label">PID</span><span class="field-value">${ev.pid || ''}</span></div>
<div class="detail-field"><span class="field-label">Image</span><span class="field-value">${escapeHtml(ev.image || '')}</span></div>
</div></div>`;
setDetailHeader('Sysmon', 'background:rgba(34,197,94,0.15);color:var(--accent-green)', ev.type || 'Event', '');
setDetailBody(html);
showDetail();
}
// --- Utilities ---
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatTime(ts) {
if (!ts) return '';
try { const d = new Date(ts); return isNaN(d.getTime()) ? ts.substring(0, 19) : d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } catch { return ts.substring(0, 19); }
}
function formatRelativeTime(ts) {
if (!ts || !state.sessionStart) return formatTime(ts);
try {
const t = new Date(ts).getTime();
if (isNaN(t)) return formatTime(ts);
const diff = (t - state.sessionStart) / 1000;
if (diff < 0) return '+0.000s';
return `+${diff.toFixed(3)}s`;
} catch { return formatTime(ts); }
}
function computeLifespan(startTs, endTs) {
if (!startTs || !endTs) return '';
try {
const start = new Date(startTs).getTime();
const end = new Date(endTs).getTime();
if (isNaN(start) || isNaN(end)) return '';
const diffMs = end - start;
if (diffMs < 0) return '0ms';
if (diffMs < 1000) return `${diffMs}ms`;
const diffS = diffMs / 1000;
if (diffS < 60) return `${diffS.toFixed(1)}s`;
const mins = Math.floor(diffS / 60);
const secs = Math.floor(diffS % 60);
return `${mins}m ${secs}s`;
} catch { return ''; }
}
function formatSize(bytes) {
if (!bytes || 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(1) + ' MB';
}
function formatCategory(cat) {
if (Array.isArray(cat)) return cat.map(c => c.charAt(0).toUpperCase() + c.slice(1)).join(', ');
if (typeof cat === 'string') return cat.charAt(0).toUpperCase() + cat.slice(1);
return '';
}
function activityCounter(label, value) {
const v = value || 0;
const highlight = (label === 'THREATS' && v > 0) ? ' threats' : (label === 'INJECTION' && v > 0) ? ' injection' : '';
return `<div class="activity-counter${highlight ? ' ' + highlight : ''}"><div class="counter-label">${label}</div><div class="counter-value ${v === 0 ? 'zero' : ''}">${v}</div></div>`;
}
// --- Keyboard shortcuts ---
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && state.detailOpen) closeDetail();
if (e.key === 'Backspace' && state.detailOpen && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
e.preventDefault();
goDetailBack();
}
});