1442 lines
61 KiB
JavaScript
1442 lines
61 KiB
JavaScript
(function () {
|
|
const { createElement, escapeHtml, formatBytes, generateId } =
|
|
window.NexusTerminal;
|
|
|
|
const tools = [
|
|
{
|
|
group: "Editor",
|
|
id: "hex",
|
|
name: "Hex Editor",
|
|
ico: "HX",
|
|
desc: "Editable hex view over the loaded working copy.",
|
|
},
|
|
{
|
|
group: "Triage",
|
|
id: "overview",
|
|
name: "Overview",
|
|
ico: "OV",
|
|
desc: "First-pass summary across hashes, entropy, strings, IOCs and carve candidates.",
|
|
},
|
|
{ group: "Triage", id: "hashes", name: "Hashes", ico: "#", desc: "CRC32, MD5, SHA-1, SHA-256 and SHA-512." },
|
|
{ group: "Triage", id: "entropy", name: "Entropy", ico: "EN", desc: "Windowed entropy for packed or encrypted regions." },
|
|
{ group: "Triage", id: "strings", name: "Strings", ico: "ST", desc: "ASCII and UTF-16 string extraction." },
|
|
{ group: "Triage", id: "iocs", name: "IOCs", ico: "IO", desc: "URLs, domains, paths, hashes and other indicators." },
|
|
{ group: "Find", id: "search", name: "Search", ico: "SR", desc: "Single text, hex or regular-expression search." },
|
|
{ group: "Find", id: "multi-search", name: "Multi-search", ico: "MS", desc: "Batch text, hex and regex searches." },
|
|
{ group: "Structure", id: "parse", name: "Structures", ico: "PX", desc: "Parsers for PE, ELF, Mach-O, ZIP, images, RIFF, ASN.1 and certs." },
|
|
{ group: "Structure", id: "disasm", name: "Disasm", ico: "DA", desc: "Heuristic disassembly for common CPU families." },
|
|
{ group: "Structure", id: "pdf", name: "PDF", ico: "PDF", desc: "PDF object, stream, xref and suspicious-surface inspection." },
|
|
{ group: "Structure", id: "squashfs", name: "SquashFS", ico: "SQ", desc: "SquashFS superblock and directory metadata." },
|
|
{ group: "Audio", id: "spectrogram-encode", name: "Spectrogram Encode", ico: "SE", desc: "Convert an image into WAV audio, or hide an image inside a carrier WAV/MP3." },
|
|
{ group: "Audio", id: "spectrogram-view", name: "Spectrogram View", ico: "SV", desc: "Render WAV or MP3 audio as a spectrogram image." },
|
|
{ group: "Compare", id: "diff", name: "Diff", ico: "DF", desc: "Changed byte regions against a compare file." },
|
|
{ group: "Recover", id: "carve", name: "Carve", ico: "CV", desc: "Embedded file carving candidates." },
|
|
{ group: "Recover", id: "keys", name: "Keys", ico: "KY", desc: "PEM, DER, OpenSSH and PKCS#12 artefact discovery." },
|
|
{ group: "Recover", id: "timestamps", name: "Timestamps", ico: "TS", desc: "UNIX, FILETIME, WebKit and OLE timestamp heuristics." },
|
|
{ group: "Recover", id: "stego", name: "Stego", ico: "SG", desc: "Appended payloads and embedded trailer formats." },
|
|
{ group: "Recover", id: "metadata", name: "Metadata", ico: "MD", desc: "PNG, JPEG and GIF textual metadata extraction." },
|
|
{ group: "Advanced", id: "binsnoop", name: "Binsnoop", ico: "BS", desc: "Deep signature scanning through the Binsnoop engine." },
|
|
{ group: "Advanced", id: "rules", name: "Rules", ico: "RL", desc: "YARA-like rule scans over the current document." },
|
|
{ group: "Edit", id: "patch-plan", name: "Patch Plan", ico: "PP", desc: "Validate patch scripts without modifying the working copy." },
|
|
{ group: "Edit", id: "patch-apply", name: "Patch Apply", ico: "PA", desc: "Apply a patch script to the working copy." },
|
|
{ group: "Edit", id: "transform", name: "Transforms", ico: "TR", desc: "Hex, Base64, URL, XOR, inflate and cipher pipelines." },
|
|
{ group: "Case", id: "case", name: "Case Export", ico: "CE", desc: "Build a case archive payload from the current working copy." },
|
|
];
|
|
|
|
const defaults = {
|
|
search: { mode: "text", query: "MZ", max: 1000 },
|
|
"multi-search": { spec: "text MZ\nhex 7f454c46\nregex https?://", max: 1000 },
|
|
parse: { kind: "auto" },
|
|
disasm: { arch: "x86_64", start: 0, length: 4096, max: 1000 },
|
|
entropy: { window: 4096, step: 4096 },
|
|
strings: { min: 4, max: 5000 },
|
|
iocs: { min: 5 },
|
|
carve: { max: 256 },
|
|
keys: { max: 200 },
|
|
timestamps: { max: 1000 },
|
|
rules: { source: 'rule mz { strings: $a = "MZ" condition: $a }', max: 1000 },
|
|
"patch-plan": { source: "set 0x00 90 90" },
|
|
"patch-apply": { source: "set 0x00 90 90" },
|
|
transform: { pipeline: "hex.encode", start: 0, length: 1024 },
|
|
"spectrogram-view": { fftSize: 2048, height: 1024, maxWidth: 1400, aspectRatio: 1, minHz: 0, maxHz: 0, brightness: 1, contrast: 1 },
|
|
hex: { start: 0, bytes: 2048, width: 16 },
|
|
};
|
|
|
|
class LeviathanWorkbench {
|
|
constructor(app) {
|
|
this.app = app;
|
|
this.state = null;
|
|
this.lastResults = new Map();
|
|
this.autoRunners = new Map();
|
|
this.hexSearches = new Map();
|
|
}
|
|
|
|
renderSidebar() {
|
|
const el = document.getElementById("sidebar-profiles");
|
|
if (!el) return;
|
|
el.innerHTML = "";
|
|
|
|
const actions = createElement("div", "leviathan-actions");
|
|
const load = createElement("button", "btn btn-primary btn-sm", {
|
|
type: "button",
|
|
textContent: "Load",
|
|
});
|
|
const save = createElement("button", "btn btn-secondary btn-sm", {
|
|
type: "button",
|
|
textContent: "Save",
|
|
});
|
|
load.addEventListener("click", () => this.loadFile());
|
|
save.addEventListener("click", () => this.saveFile());
|
|
actions.appendChild(load);
|
|
actions.appendChild(save);
|
|
el.appendChild(actions);
|
|
|
|
let lastGroup = "";
|
|
for (const tool of tools) {
|
|
if (tool.group !== lastGroup) {
|
|
el.appendChild(
|
|
createElement("div", "sidebar-section-header leviathan-group", {
|
|
textContent: tool.group,
|
|
}),
|
|
);
|
|
lastGroup = tool.group;
|
|
}
|
|
const item = createElement("div", "sidebar-item leviathan-tool", {
|
|
dataset: { toolId: tool.id },
|
|
title: tool.desc,
|
|
});
|
|
item.appendChild(createElement("span", "leviathan-tool-ico", { textContent: tool.ico }));
|
|
const label = createElement("span", "sidebar-item-label", { textContent: tool.name });
|
|
item.appendChild(label);
|
|
item.addEventListener("click", () => this.openTool(tool.id));
|
|
el.appendChild(item);
|
|
}
|
|
this._highlightActiveTool();
|
|
}
|
|
|
|
async loadFile() {
|
|
try {
|
|
const payload = await window.NexusTerminal.wailsService.LeviathanOpenFile();
|
|
this.applyState(payload);
|
|
this.refreshOpenHexTabs();
|
|
} catch (err) {
|
|
if (String(err || "").includes("cancel")) return;
|
|
this.app.notifications.error("Load failed: " + err);
|
|
}
|
|
}
|
|
|
|
async saveFile() {
|
|
try {
|
|
const path = await window.NexusTerminal.wailsService.LeviathanSaveWorking();
|
|
if (path) this.app.notifications.success("Saved working copy");
|
|
const state = await window.NexusTerminal.wailsService.LeviathanState();
|
|
this.applyState(state);
|
|
} catch (err) {
|
|
this.app.notifications.error("Save failed: " + err);
|
|
}
|
|
}
|
|
|
|
openTool(toolId, options = {}) {
|
|
const tool = tools.find((t) => t.id === toolId) || tools[0];
|
|
const existingId = options.reuseExisting
|
|
? this._findExistingToolSession(tool.id)
|
|
: null;
|
|
const sessionId = existingId || `leviathan-${tool.id}-${generateId(8)}`;
|
|
|
|
if (!existingId) {
|
|
const sessionInfo = {
|
|
id: sessionId,
|
|
profileId: `leviathan:${tool.id}`,
|
|
profileName: "Leviathan",
|
|
protocol: "leviathan",
|
|
host: "local",
|
|
port: 0,
|
|
state: "connected",
|
|
tabName: tool.name,
|
|
toolId: tool.id,
|
|
leviathanParams: options.params || null,
|
|
};
|
|
this.app.state.addSession(sessionInfo);
|
|
this.app.tabs.addTab(sessionInfo);
|
|
const panel = document.getElementById(`panel-${sessionId}`);
|
|
if (panel) this.renderToolPanel(panel, sessionId, tool);
|
|
} else {
|
|
this.app.tabs.setActive(existingId);
|
|
}
|
|
this._highlightActiveTool(tool.id);
|
|
}
|
|
|
|
renderToolPanel(panel, sessionId, tool) {
|
|
panel.classList.add("leviathan-panel");
|
|
panel.innerHTML = "";
|
|
|
|
const header = createElement("div", "leviathan-panel-header");
|
|
const titleWrap = createElement("div", "leviathan-title-wrap");
|
|
titleWrap.appendChild(createElement("span", "leviathan-tool-mark", { textContent: tool.ico }));
|
|
const heading = createElement("div", "leviathan-heading");
|
|
heading.appendChild(createElement("h2", "", { textContent: tool.name }));
|
|
heading.appendChild(createElement("span", "leviathan-eyebrow", { textContent: this._stateLabel() }));
|
|
titleWrap.appendChild(heading);
|
|
header.appendChild(titleWrap);
|
|
|
|
const headerActions = createElement("div", "leviathan-header-actions");
|
|
const undo = createElement("button", "btn btn-sm", { textContent: "Undo" });
|
|
const redo = createElement("button", "btn btn-sm", { textContent: "Redo" });
|
|
const revert = createElement("button", "btn btn-sm", { textContent: "Revert" });
|
|
undo.addEventListener("click", () => this._stateAction("LeviathanUndo", sessionId));
|
|
redo.addEventListener("click", () => this._stateAction("LeviathanRedo", sessionId));
|
|
revert.addEventListener("click", () => this._stateAction("LeviathanRevert", sessionId));
|
|
headerActions.appendChild(undo);
|
|
headerActions.appendChild(redo);
|
|
headerActions.appendChild(revert);
|
|
header.appendChild(headerActions);
|
|
panel.appendChild(header);
|
|
|
|
const body = createElement("div", "leviathan-panel-body");
|
|
panel.appendChild(body);
|
|
|
|
if (tool.id === "hex") {
|
|
this.renderHexEditor(body, sessionId);
|
|
} else if (tool.id === "search") {
|
|
this.renderSearchTool(body, sessionId);
|
|
} else if (tool.id === "spectrogram-encode") {
|
|
this.renderSpectrogramEncode(body, sessionId);
|
|
} else if (tool.id === "spectrogram-view") {
|
|
this.renderSpectrogramView(body, sessionId);
|
|
} else {
|
|
this.renderToolRunner(body, sessionId, tool);
|
|
}
|
|
}
|
|
|
|
renderHexEditor(body, sessionId) {
|
|
const { controls, output } = this._createWorkbench(body, "leviathan-hex-workbench", { topBar: true });
|
|
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
|
const initial = this._sessionParams(sessionId);
|
|
const start = this._input("start", String(initial.start ?? 0));
|
|
const bytes = this._input("bytes", String(initial.bytes ?? initial.length ?? 2048));
|
|
const width = this._selectInput("row", [
|
|
["16", "16 bytes"],
|
|
["32", "32 bytes"],
|
|
], String(initial.width || 16));
|
|
const search = createElement("button", "btn btn-sm", { type: "button", textContent: "Search" });
|
|
const prev = createElement("button", "btn btn-sm", { type: "button", textContent: "Prev" });
|
|
const next = createElement("button", "btn btn-sm", { type: "button", textContent: "Next" });
|
|
toolbar.append(start.row, bytes.row, width.row, search, prev, next);
|
|
|
|
const status = createElement("div", "leviathan-status", { textContent: this._stateLabel() });
|
|
const grid = createElement("div", "leviathan-hex-grid");
|
|
controls.append(toolbar, status);
|
|
output.appendChild(grid);
|
|
|
|
const load = async () => {
|
|
try {
|
|
status.textContent = "Loading hex...";
|
|
const result = await window.NexusTerminal.wailsService.LeviathanRunTool("hex", {
|
|
start: Number(start.input.value || 0),
|
|
bytes: Number(bytes.input.value || 2048),
|
|
width: Number(width.input.value || 16),
|
|
});
|
|
this.lastResults.set(sessionId, result);
|
|
this._renderHexRows(grid, result.rows || [], status, Number(width.input.value || 16), this._currentHexSearchHit(sessionId));
|
|
status.textContent = this._stateLabel();
|
|
} catch (err) {
|
|
status.textContent = String(err);
|
|
}
|
|
};
|
|
[start.input, bytes.input, width.input].forEach((input) => {
|
|
input.addEventListener("change", load);
|
|
});
|
|
search.addEventListener("click", () => this._showHexSearchModal(sessionId, start.input, width.input, load));
|
|
prev.addEventListener("click", () => this._gotoHexSearchHit(sessionId, -1, start.input, width.input, load));
|
|
next.addEventListener("click", () => this._gotoHexSearchHit(sessionId, 1, start.input, width.input, load));
|
|
load();
|
|
}
|
|
|
|
renderSearchTool(body, sessionId) {
|
|
const { controls, output } = this._createWorkbench(body, "leviathan-compact-workbench", { topBar: true });
|
|
const params = createElement("div", "leviathan-params");
|
|
const mode = this._selectInput("mode", [
|
|
["text", "String"],
|
|
["hex", "Hex"],
|
|
["regex", "Regex"],
|
|
], "text");
|
|
const query = this._input("query", "MZ");
|
|
const start = this._input("start", "0");
|
|
const length = this._input("length", "-1");
|
|
const max = this._input("max", "1000");
|
|
params.append(mode.row, query.row, start.row, length.row, max.row);
|
|
|
|
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
|
const search = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Search" });
|
|
const exportBtn = createElement("button", "btn btn-sm", { type: "button", textContent: "Export JSON" });
|
|
toolbar.append(search, exportBtn);
|
|
controls.append(params, toolbar);
|
|
|
|
const summary = createElement("div", "leviathan-summary");
|
|
const results = createElement("div", "leviathan-results");
|
|
output.append(summary, results);
|
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Search results will appear here." }));
|
|
|
|
const runSearch = async () => {
|
|
try {
|
|
results.innerHTML = "";
|
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Searching..." }));
|
|
const payload = {
|
|
mode: mode.input.value,
|
|
query: query.input.value,
|
|
start: Number(start.input.value || 0),
|
|
length: Number(length.input.value || -1),
|
|
max: Number(max.input.value || 1000),
|
|
};
|
|
const out = await window.NexusTerminal.wailsService.LeviathanRunTool("search", payload);
|
|
this.lastResults.set(sessionId, out);
|
|
this._renderResult(summary, results, out, "search");
|
|
} catch (err) {
|
|
results.innerHTML = "";
|
|
this._renderToolError(results, "search", err);
|
|
}
|
|
};
|
|
search.addEventListener("click", runSearch);
|
|
query.input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") runSearch();
|
|
});
|
|
exportBtn.addEventListener("click", async () => {
|
|
const out = this.lastResults.get(sessionId);
|
|
if (!out) return;
|
|
try {
|
|
await window.NexusTerminal.wailsService.LeviathanExportJSON("search.json", out);
|
|
this.app.notifications.success("Exported JSON");
|
|
} catch (err) {
|
|
this.app.notifications.error("Export failed: " + err);
|
|
}
|
|
});
|
|
this.autoRunners.set(sessionId, runSearch);
|
|
requestAnimationFrame(runSearch);
|
|
}
|
|
|
|
renderSpectrogramEncode(body, sessionId) {
|
|
const { controls, output } = this._createWorkbench(body);
|
|
const params = createElement("div", "leviathan-params");
|
|
const fields = [
|
|
this._paramInput(params, "durationSec", "Duration (s)", 5, { type: "number", min: "0.25", max: "60", step: "0.25" }),
|
|
this._paramInput(params, "sampleRate", "Sample rate", 44100, { type: "number", min: "8000", max: "96000", step: "1000" }),
|
|
this._paramInput(params, "minHz", "Min frequency", 200, { type: "number", min: "20", step: "10" }),
|
|
this._paramInput(params, "maxHz", "Max frequency", 20000, { type: "number", min: "120", step: "10" }),
|
|
this._paramInput(params, "bins", "Frequency bins", 160, { type: "number", min: "16", max: "384", step: "1" }),
|
|
this._paramInput(params, "columns", "Time columns", 768, { type: "number", min: "16", max: "2048", step: "1" }),
|
|
this._paramInput(params, "gain", "Output gain", 0.85, { type: "number", min: "0.05", max: "1", step: "0.05" }),
|
|
this._paramInput(params, "startSec", "Insert start (s)", 0, { type: "number", min: "0", step: "0.1" }),
|
|
this._paramInput(params, "mixLevel", "Insert mix", 0.35, { type: "number", min: "0.01", max: "1", step: "0.01" }),
|
|
this._paramInput(params, "preserveAspectRatio", "Preserve aspect", true, { type: "checkbox" }),
|
|
this._paramInput(params, "invert", "Invert image", true, { type: "checkbox" }),
|
|
];
|
|
|
|
const imagePicker = createElement("div", "leviathan-file-picker");
|
|
const chooseImage = createElement("button", "btn btn-sm", { type: "button", textContent: "Select Image" });
|
|
const imagePath = createElement("span", "leviathan-file-path", { textContent: "No insertion image selected" });
|
|
imagePicker.append(chooseImage, imagePath);
|
|
|
|
let selectedImagePath = "";
|
|
chooseImage.addEventListener("click", async () => {
|
|
try {
|
|
const path = await window.NexusTerminal.wailsService.SelectFile("Select Image to Insert", "");
|
|
if (!path) return;
|
|
selectedImagePath = path;
|
|
imagePath.textContent = path;
|
|
} catch (err) {
|
|
this.app.notifications.error("Image selection failed: " + err);
|
|
}
|
|
});
|
|
|
|
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
|
const encode = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Encode Loaded Image" });
|
|
const insert = createElement("button", "btn btn-sm", { type: "button", textContent: "Insert Into Loaded Audio" });
|
|
toolbar.append(encode, insert);
|
|
controls.append(params, imagePicker, toolbar);
|
|
|
|
const summary = createElement("div", "leviathan-summary");
|
|
const results = createElement("div", "leviathan-results");
|
|
output.append(summary, results);
|
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Load an image to encode, or load WAV/MP3 audio and select an image to insert." }));
|
|
|
|
const run = async (toolId) => {
|
|
try {
|
|
results.innerHTML = "";
|
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Rendering audio..." }));
|
|
const payload = {};
|
|
for (const field of fields) payload[field.key] = this._fieldValue(field);
|
|
if (toolId === "spectrogram-insert") {
|
|
if (!selectedImagePath) throw new Error("Select an image to insert first.");
|
|
payload.imagePath = selectedImagePath;
|
|
}
|
|
const out = await window.NexusTerminal.wailsService.LeviathanRunTool(toolId, payload);
|
|
this.lastResults.set(sessionId, out);
|
|
summary.innerHTML = "";
|
|
results.innerHTML = "";
|
|
this._renderSummaryPills(summary, out.summary || out);
|
|
results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) }));
|
|
if (out?.path) this.app.notifications.success("Saved " + out.path);
|
|
} catch (err) {
|
|
results.innerHTML = "";
|
|
this._renderToolError(results, toolId, err);
|
|
}
|
|
};
|
|
encode.addEventListener("click", () => run("spectrogram-encode"));
|
|
insert.addEventListener("click", () => run("spectrogram-insert"));
|
|
}
|
|
|
|
renderSpectrogramView(body, sessionId) {
|
|
const { controls, output } = this._createWorkbench(body);
|
|
const params = createElement("div", "leviathan-params");
|
|
const fields = [
|
|
this._paramInput(params, "fftSize", "FFT size", 2048, { type: "number", min: "256", max: "8192", step: "256" }),
|
|
this._paramInput(params, "height", "Base height", 1024, { type: "number", min: "64", max: "1024", step: "16" }),
|
|
this._paramInput(params, "maxWidth", "Max width", 1400, { type: "number", min: "64", max: "2400", step: "32" }),
|
|
this._paramInput(params, "aspectRatio", "Aspect ratio", 1, { type: "number", min: "0.25", max: "4", step: "0.25" }),
|
|
this._paramInput(params, "minHz", "Min frequency", 0, { type: "number", min: "0", step: "10" }),
|
|
this._paramInput(params, "maxHz", "Max frequency", 0, { type: "number", min: "0", step: "10" }),
|
|
this._paramInput(params, "brightness", "Brightness", 1, { type: "number", min: "0.1", max: "4", step: "0.1" }),
|
|
this._paramInput(params, "contrast", "Contrast", 1, { type: "number", min: "0.1", max: "4", step: "0.1" }),
|
|
];
|
|
|
|
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
|
const run = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Render Spectrogram" });
|
|
const exportBtn = createElement("button", "btn btn-sm", { type: "button", textContent: "Export JSON" });
|
|
toolbar.append(run, exportBtn);
|
|
controls.append(params, toolbar);
|
|
|
|
const summary = createElement("div", "leviathan-summary");
|
|
const results = createElement("div", "leviathan-results");
|
|
output.append(summary, results);
|
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a WAV or MP3 file, then render its spectrogram." }));
|
|
|
|
const render = async () => {
|
|
try {
|
|
results.innerHTML = "";
|
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Rendering spectrogram..." }));
|
|
const payload = {};
|
|
for (const field of fields) payload[field.key] = this._fieldValue(field);
|
|
const out = await window.NexusTerminal.wailsService.LeviathanRunTool("spectrogram-view", payload);
|
|
this.lastResults.set(sessionId, out);
|
|
summary.innerHTML = "";
|
|
results.innerHTML = "";
|
|
this._renderSummaryPills(summary, out.summary || out);
|
|
if (out?.pngBase64) {
|
|
const frame = createElement("div", "leviathan-spectrogram-frame");
|
|
const img = createElement("img", "leviathan-spectrogram-image", {
|
|
alt: "Rendered audio spectrogram",
|
|
});
|
|
img.src = `data:${out.mime || "image/png"};base64,${out.pngBase64}`;
|
|
frame.appendChild(img);
|
|
results.appendChild(frame);
|
|
} else {
|
|
results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) }));
|
|
}
|
|
} catch (err) {
|
|
results.innerHTML = "";
|
|
this._renderToolError(results, "spectrogram-view", err);
|
|
}
|
|
};
|
|
run.addEventListener("click", render);
|
|
exportBtn.addEventListener("click", async () => {
|
|
const out = this.lastResults.get(sessionId);
|
|
if (!out) return;
|
|
try {
|
|
await window.NexusTerminal.wailsService.LeviathanExportJSON("spectrogram-view.json", out);
|
|
this.app.notifications.success("Exported JSON");
|
|
} catch (err) {
|
|
this.app.notifications.error("Export failed: " + err);
|
|
}
|
|
});
|
|
this.autoRunners.set(sessionId, render);
|
|
requestAnimationFrame(render);
|
|
}
|
|
|
|
renderToolRunner(body, sessionId, tool) {
|
|
const toolbar = createElement("div", "leviathan-tool-toolbar");
|
|
const params = createElement("div", "leviathan-params");
|
|
const fields = this._renderParams(tool.id, params);
|
|
const autoRun = this._shouldAutoRunTool(tool.id, fields);
|
|
const topBar = fields.length === 0 || this._shouldUseTopBar(fields);
|
|
const { controls, output } = this._createWorkbench(
|
|
body,
|
|
topBar ? "leviathan-compact-workbench" : "",
|
|
{ topBar },
|
|
);
|
|
const run = createElement("button", "btn btn-sm btn-primary", { textContent: this._toolActionLabel(tool.id) });
|
|
const exportBtn = createElement("button", "btn btn-sm", { textContent: "Export JSON" });
|
|
if (!autoRun || fields.length) toolbar.append(run);
|
|
toolbar.append(exportBtn);
|
|
if (fields.length) controls.append(params);
|
|
controls.append(toolbar);
|
|
|
|
const summary = createElement("div", "leviathan-summary");
|
|
const results = createElement("div", "leviathan-results");
|
|
output.append(summary, results);
|
|
|
|
let running = false;
|
|
const runTool = async () => {
|
|
if (running) return;
|
|
running = true;
|
|
try {
|
|
if (tool.id === "diff" && !this.state?.compare) {
|
|
const compare = await window.NexusTerminal.wailsService.LeviathanOpenCompare();
|
|
this.applyState(compare);
|
|
}
|
|
results.innerHTML = "";
|
|
results.appendChild(createElement("div", "leviathan-empty", { textContent: "Running..." }));
|
|
const payload = {};
|
|
for (const field of fields) {
|
|
payload[field.key] = this._fieldValue(field);
|
|
}
|
|
const out = await window.NexusTerminal.wailsService.LeviathanRunTool(tool.id, payload);
|
|
this.lastResults.set(sessionId, out);
|
|
this._renderResult(summary, results, out, tool.id);
|
|
if (out && out.state) this.applyState(out.state);
|
|
} catch (err) {
|
|
results.innerHTML = "";
|
|
this._renderToolError(results, tool.id, err);
|
|
} finally {
|
|
running = false;
|
|
}
|
|
};
|
|
if (!autoRun || fields.length) run.addEventListener("click", runTool);
|
|
exportBtn.addEventListener("click", async () => {
|
|
const out = this.lastResults.get(sessionId);
|
|
if (!out) return;
|
|
try {
|
|
await window.NexusTerminal.wailsService.LeviathanExportJSON(`${tool.id}.json`, out);
|
|
this.app.notifications.success("Exported JSON");
|
|
} catch (err) {
|
|
this.app.notifications.error("Export failed: " + err);
|
|
}
|
|
});
|
|
if (autoRun) {
|
|
this.autoRunners.set(sessionId, runTool);
|
|
requestAnimationFrame(runTool);
|
|
} else {
|
|
this.autoRunners.delete(sessionId);
|
|
results.appendChild(
|
|
createElement("div", "leviathan-empty", {
|
|
textContent: "Ready.",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
_createWorkbench(body, extraClass = "", options = {}) {
|
|
body.classList.add("leviathan-workbench");
|
|
body.classList.toggle("leviathan-top-workbench", Boolean(options.topBar));
|
|
if (extraClass) body.classList.add(extraClass);
|
|
const controls = createElement("section", "leviathan-control-plane");
|
|
const output = createElement("section", "leviathan-output-plane");
|
|
body.append(controls, output);
|
|
return { controls, output };
|
|
}
|
|
|
|
applyState(payload) {
|
|
this.state = payload?.state || payload || null;
|
|
this._updateOpenHeaders();
|
|
}
|
|
|
|
destroyPanel(sessionId) {
|
|
this.lastResults.delete(sessionId);
|
|
this.autoRunners.delete(sessionId);
|
|
this.hexSearches.delete(sessionId);
|
|
}
|
|
|
|
handleTabFocus(sessionId) {
|
|
const session = this.app.state.getSession(sessionId);
|
|
if (session?.protocol !== "leviathan") return;
|
|
this._highlightActiveTool(session.toolId);
|
|
const runner = this.autoRunners.get(sessionId);
|
|
if (runner) runner();
|
|
}
|
|
|
|
refreshOpenHexTabs() {
|
|
for (const session of this.app.state.getAllSessions()) {
|
|
if (session.protocol !== "leviathan" || session.toolId !== "hex") continue;
|
|
const panel = document.getElementById(`panel-${session.id}`);
|
|
if (panel) this.renderToolPanel(panel, session.id, tools[0]);
|
|
}
|
|
}
|
|
|
|
_renderHexRows(grid, rows, status, rowWidth = 16, activeHit = null) {
|
|
grid.innerHTML = "";
|
|
grid.style.setProperty("--leviathan-hex-width", String(rowWidth));
|
|
if (!rows.length) {
|
|
grid.appendChild(createElement("div", "leviathan-empty", { textContent: "Load a file to inspect bytes." }));
|
|
return;
|
|
}
|
|
for (const row of rows) {
|
|
const line = createElement("div", "leviathan-hex-row");
|
|
line.appendChild(createElement("span", "leviathan-offset", { textContent: this._hexOffset(row.offset) }));
|
|
const bytes = createElement("span", "leviathan-byte-cells");
|
|
const cells = row.bytes || String(row.hex || "").split(/\s+/).filter(Boolean);
|
|
const ascii = createElement("span", "leviathan-ascii", { textContent: row.ascii || "" });
|
|
cells.forEach((value, idx) => {
|
|
const cellOffset = (row.offset || 0) + idx;
|
|
const hexValue = this._normaliseHexByte(value) || "00";
|
|
const cell = createElement("input", "leviathan-byte", {
|
|
value: hexValue,
|
|
maxlength: "2",
|
|
inputmode: "text",
|
|
autocomplete: "off",
|
|
spellcheck: "false",
|
|
"aria-label": `Byte ${this._hexOffset(cellOffset)}`,
|
|
dataset: {
|
|
offset: String(cellOffset),
|
|
committedHex: hexValue,
|
|
},
|
|
});
|
|
if (activeHit && cellOffset >= activeHit.offset && cellOffset < activeHit.offset + activeHit.length) {
|
|
cell.classList.add("is-search-hit");
|
|
}
|
|
this._wireHexByteCell(cell, bytes, ascii, idx, status);
|
|
bytes.appendChild(cell);
|
|
});
|
|
line.appendChild(bytes);
|
|
line.appendChild(ascii);
|
|
grid.appendChild(line);
|
|
}
|
|
}
|
|
|
|
_wireHexByteCell(cell, rowCells, ascii, asciiIndex, status) {
|
|
cell.addEventListener("focus", () => {
|
|
cell.select();
|
|
});
|
|
cell.addEventListener("keydown", (event) => {
|
|
if (event.key === "ArrowRight") {
|
|
event.preventDefault();
|
|
this._focusHexByte(cell, 1);
|
|
} else if (event.key === "ArrowLeft") {
|
|
event.preventDefault();
|
|
this._focusHexByte(cell, -1);
|
|
} else if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
this._finaliseHexByte(cell, rowCells, ascii, asciiIndex, status, { advance: 1 });
|
|
} else if (event.key === "Backspace" && cell.value.length === 0) {
|
|
event.preventDefault();
|
|
this._focusHexByte(cell, -1);
|
|
}
|
|
});
|
|
cell.addEventListener("input", () => {
|
|
this._handleHexByteInput(cell, rowCells, ascii, asciiIndex, status);
|
|
});
|
|
cell.addEventListener("paste", (event) => {
|
|
this._pasteHexBytes(event, cell, rowCells, ascii, asciiIndex, status);
|
|
});
|
|
cell.addEventListener("blur", () => {
|
|
this._finaliseHexByte(cell, rowCells, ascii, asciiIndex, status, { advance: 0 });
|
|
});
|
|
}
|
|
|
|
_handleHexByteInput(cell, rowCells, ascii, asciiIndex, status) {
|
|
const clean = this._normaliseHexByte(cell.value);
|
|
if (cell.value !== clean) cell.value = clean;
|
|
cell.classList.toggle("is-editing", clean.length === 1);
|
|
if (clean.length === 0) {
|
|
this._updateHexAsciiCell(ascii, asciiIndex, cell.dataset.committedHex || "00");
|
|
rowCells.classList.toggle("is-editing", Boolean(rowCells.querySelector(".leviathan-byte.is-editing")));
|
|
return;
|
|
}
|
|
if (clean.length === 2) {
|
|
this._updateHexAsciiCell(ascii, asciiIndex, clean);
|
|
this._commitHexByte(cell, ascii, asciiIndex, status);
|
|
this._focusHexByte(cell, 1);
|
|
}
|
|
rowCells.classList.toggle("is-editing", Boolean(rowCells.querySelector(".leviathan-byte.is-editing")));
|
|
}
|
|
|
|
_finaliseHexByte(cell, rowCells, ascii, asciiIndex, status, options = {}) {
|
|
let clean = this._normaliseHexByte(cell.value);
|
|
if (clean.length === 0) {
|
|
clean = cell.dataset.committedHex || "00";
|
|
} else if (clean.length === 1) {
|
|
clean = clean.padStart(2, "0");
|
|
}
|
|
cell.value = clean;
|
|
cell.classList.remove("is-editing");
|
|
rowCells.classList.toggle("is-editing", Boolean(rowCells.querySelector(".leviathan-byte.is-editing")));
|
|
this._updateHexAsciiCell(ascii, asciiIndex, clean);
|
|
this._commitHexByte(cell, ascii, asciiIndex, status);
|
|
if (options.advance) this._focusHexByte(cell, options.advance);
|
|
}
|
|
|
|
_pasteHexBytes(event, cell, rowCells, ascii, asciiIndex, status) {
|
|
const text = event.clipboardData?.getData("text/plain") || "";
|
|
const pasted = this._hexByteList(text);
|
|
if (!pasted.length) return;
|
|
event.preventDefault();
|
|
const scope = cell.closest(".leviathan-hex-grid") || rowCells;
|
|
const cells = Array.from(scope.querySelectorAll(".leviathan-byte"));
|
|
const startIndex = cells.indexOf(cell);
|
|
if (startIndex < 0) return;
|
|
pasted.forEach((hex, idx) => {
|
|
const target = cells[startIndex + idx];
|
|
if (!target) return;
|
|
const context = this._hexCellContext(target);
|
|
if (!context) return;
|
|
target.value = hex;
|
|
target.classList.remove("is-editing");
|
|
this._updateHexAsciiCell(context.ascii, context.index, hex);
|
|
this._commitHexByte(target, context.ascii, context.index, status);
|
|
});
|
|
const next = cells[Math.min(startIndex + pasted.length, cells.length - 1)];
|
|
if (next) {
|
|
next.focus();
|
|
next.select();
|
|
}
|
|
}
|
|
|
|
_commitHexByte(cell, ascii, asciiIndex, status) {
|
|
const hex = this._normaliseHexByte(cell.value);
|
|
if (hex.length !== 2) return;
|
|
if (hex === cell.dataset.committedHex || hex === cell.dataset.pendingHex) return;
|
|
const previous = cell.dataset.committedHex || "00";
|
|
const offset = Number(cell.dataset.offset || 0);
|
|
cell.dataset.pendingHex = hex;
|
|
cell.classList.add("is-saving");
|
|
window.NexusTerminal.wailsService
|
|
.LeviathanSetByte(offset, hex)
|
|
.then((payload) => {
|
|
if (cell.dataset.pendingHex === hex) {
|
|
delete cell.dataset.pendingHex;
|
|
cell.dataset.committedHex = hex;
|
|
cell.classList.remove("is-saving");
|
|
status.textContent = "Edited " + this._hexOffset(offset);
|
|
this.applyState(payload);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (cell.dataset.pendingHex === hex) {
|
|
delete cell.dataset.pendingHex;
|
|
cell.classList.remove("is-saving");
|
|
cell.value = previous;
|
|
this._updateHexAsciiCell(ascii, asciiIndex, previous);
|
|
status.textContent = String(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
_focusHexByte(cell, direction) {
|
|
const scope = cell.closest(".leviathan-hex-grid") || cell.closest(".leviathan-byte-cells");
|
|
if (!scope) return;
|
|
const cells = Array.from(scope.querySelectorAll(".leviathan-byte"));
|
|
const index = cells.indexOf(cell);
|
|
const next = cells[index + direction];
|
|
if (!next) return;
|
|
next.focus();
|
|
next.select();
|
|
}
|
|
|
|
_hexCellContext(cell) {
|
|
const row = cell.closest(".leviathan-hex-row");
|
|
if (!row) return null;
|
|
const ascii = row.querySelector(".leviathan-ascii");
|
|
const rowCells = Array.from(row.querySelectorAll(".leviathan-byte"));
|
|
const index = rowCells.indexOf(cell);
|
|
if (!ascii || index < 0) return null;
|
|
return { ascii, index };
|
|
}
|
|
|
|
_updateHexAsciiCell(ascii, index, hex) {
|
|
const chars = Array.from(ascii.textContent || "");
|
|
while (chars.length <= index) chars.push(" ");
|
|
chars[index] = this._hexByteToAscii(hex);
|
|
ascii.textContent = chars.join("");
|
|
}
|
|
|
|
_hexByteToAscii(hex) {
|
|
const value = Number.parseInt(hex, 16);
|
|
if (!Number.isFinite(value)) return ".";
|
|
return value >= 32 && value < 127 ? String.fromCharCode(value) : ".";
|
|
}
|
|
|
|
_normaliseHexByte(value) {
|
|
return String(value || "")
|
|
.replace(/[^0-9a-f]/gi, "")
|
|
.slice(0, 2)
|
|
.toLowerCase();
|
|
}
|
|
|
|
_hexByteList(value) {
|
|
const clean = String(value || "")
|
|
.replace(/0x/gi, "")
|
|
.replace(/[^0-9a-f]/gi, "")
|
|
.toLowerCase();
|
|
const bytes = [];
|
|
for (let i = 0; i + 1 < clean.length; i += 2) {
|
|
bytes.push(clean.slice(i, i + 2));
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
_renderParams(toolId, container) {
|
|
container.innerHTML = "";
|
|
const cfg = defaults[toolId] || {};
|
|
return Object.entries(cfg).map(([key, value]) => {
|
|
const row = createElement("label", "leviathan-param");
|
|
row.appendChild(createElement("span", "", { textContent: key }));
|
|
let input;
|
|
if (key === "source" || key === "spec" || key === "pipeline") {
|
|
input = createElement("textarea", "form-input");
|
|
} else if (key === "mode") {
|
|
input = createElement("select", "form-input");
|
|
["text", "hex", "regex"].forEach((v) => input.add(new Option(v, v)));
|
|
} else if (key === "kind") {
|
|
input = createElement("select", "form-input");
|
|
["auto", "png", "jpeg", "gif", "bmp", "zip", "elf", "pe", "macho", "riff", "asn1", "cert"].forEach((v) => input.add(new Option(v, v)));
|
|
} else if (key === "arch") {
|
|
input = createElement("select", "form-input");
|
|
["x86_64", "x86", "aarch64", "arm", "thumb", "mips", "mipsle"].forEach((v) => input.add(new Option(v, v)));
|
|
} else {
|
|
input = createElement("input", "form-input");
|
|
}
|
|
input.value = value;
|
|
row.appendChild(input);
|
|
container.appendChild(row);
|
|
return { key, input };
|
|
});
|
|
}
|
|
|
|
_shouldUseTopBar(fields) {
|
|
return fields.length <= 3 && !fields.some((field) => field.input.tagName === "TEXTAREA");
|
|
}
|
|
|
|
_shouldAutoRunTool(toolId, fields) {
|
|
const autoRunTools = new Set([
|
|
"overview",
|
|
"hashes",
|
|
"entropy",
|
|
"strings",
|
|
"iocs",
|
|
"search",
|
|
"multi-search",
|
|
"parse",
|
|
"disasm",
|
|
"pdf",
|
|
"squashfs",
|
|
"carve",
|
|
"keys",
|
|
"timestamps",
|
|
"stego",
|
|
"metadata",
|
|
"binsnoop",
|
|
"rules",
|
|
"patch-plan",
|
|
"transform",
|
|
"spectrogram-view",
|
|
]);
|
|
return autoRunTools.has(toolId);
|
|
}
|
|
|
|
_toolActionLabel(toolId) {
|
|
if (toolId === "case") return "Export Case";
|
|
if (toolId === "diff") return this.state?.compare ? "Compare" : "Load Compare";
|
|
return "Run";
|
|
}
|
|
|
|
_renderResult(summary, results, out, toolId = "") {
|
|
summary.innerHTML = "";
|
|
results.innerHTML = "";
|
|
this._renderSummaryPills(summary, this._flatSummary(out));
|
|
if (toolId === "overview") return this._renderOverview(results, out);
|
|
if (toolId === "hashes") return this._renderHashes(results, out);
|
|
if (toolId === "entropy") return this._renderEntropy(summary, results, out);
|
|
if (toolId === "search") return this._renderSearchResults(summary, results, out);
|
|
if (toolId === "strings") return this._renderStrings(results, out.items || []);
|
|
if (toolId === "binsnoop") return this._renderBinsnoop(summary, results, out);
|
|
if (toolId === "carve") return this._renderCarve(summary, results, out);
|
|
if (out?.tree) {
|
|
this._renderTree(results, out.tree);
|
|
return;
|
|
}
|
|
const arr = this._firstArray(out);
|
|
if (arr) {
|
|
this._renderTable(results, arr);
|
|
return;
|
|
}
|
|
results.appendChild(createElement("pre", "leviathan-json", { textContent: JSON.stringify(out, null, 2) }));
|
|
}
|
|
|
|
_renderOverview(container, out) {
|
|
const dashboard = createElement("div", "leviathan-dashboard");
|
|
dashboard.appendChild(this._metricCard("File", out?.name || this.state?.name || "No file"));
|
|
dashboard.appendChild(this._metricCard("Size", formatBytes(out?.size || 0)));
|
|
dashboard.appendChild(this._metricCard("Entropy mean", this._formatNumber(out?.entropy?.mean, 2)));
|
|
dashboard.appendChild(this._metricCard("Strings", out?.strings?.total ?? 0));
|
|
dashboard.appendChild(this._metricCard("Carve hits", out?.carve?.total ?? 0));
|
|
dashboard.appendChild(this._metricCard("Valid carve", out?.carve?.valid ?? 0));
|
|
container.appendChild(dashboard);
|
|
|
|
const sections = createElement("div", "leviathan-sections");
|
|
sections.appendChild(this._kvSection("Hashes", out?.hashes || {}, ["crc32", "md5", "sha1", "sha256", "sha512"]));
|
|
sections.appendChild(this._kvSection("IOC totals", out?.iocs || {}, Object.keys(out?.iocs || {}).sort()));
|
|
sections.appendChild(this._kvSection("Scan limits", out?.limits || {}, Object.keys(out?.limits || {}), true));
|
|
container.appendChild(sections);
|
|
}
|
|
|
|
_renderHashes(container, out) {
|
|
const rows = ["crc32", "md5", "sha1", "sha256", "sha512"]
|
|
.filter((key) => out?.[key])
|
|
.map((key) => ({ algorithm: key.toUpperCase(), digest: out[key] }));
|
|
this._renderTable(container, rows, ["algorithm", "digest"]);
|
|
}
|
|
|
|
_renderEntropy(summary, container, out) {
|
|
summary.innerHTML = "";
|
|
this._renderSummaryPills(summary, {
|
|
samples: Array.isArray(out?.samples) ? out.samples.length : 0,
|
|
mean: this._formatNumber(out?.mean, 3),
|
|
min: this._formatNumber(out?.min, 3),
|
|
max: this._formatNumber(out?.max, 3),
|
|
high: out?.highCount ?? 0,
|
|
});
|
|
|
|
const frame = createElement("div", "leviathan-entropy-frame");
|
|
const canvas = createElement("canvas", "leviathan-entropy-canvas", {
|
|
width: "1200",
|
|
height: "260",
|
|
});
|
|
frame.appendChild(canvas);
|
|
container.appendChild(frame);
|
|
|
|
const samples = (out?.samples || []).map((v) => Number(v) || 0);
|
|
const offsets = (out?.offsets || []).map((v) => Number(v) || 0);
|
|
const step = Number(out?.step || out?.windowSize || 4096);
|
|
let dragStart = null;
|
|
let dragEnd = null;
|
|
|
|
const pointerIndex = (event) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / Math.max(rect.width, 1)));
|
|
return Math.min(samples.length - 1, Math.max(0, Math.round(ratio * (samples.length - 1))));
|
|
};
|
|
const draw = () => this._drawEntropyCanvas(canvas, samples, dragStart, dragEnd);
|
|
|
|
canvas.addEventListener("mousedown", (event) => {
|
|
if (!samples.length) return;
|
|
dragStart = pointerIndex(event);
|
|
dragEnd = dragStart;
|
|
draw();
|
|
});
|
|
canvas.addEventListener("mousemove", (event) => {
|
|
if (dragStart == null) return;
|
|
dragEnd = pointerIndex(event);
|
|
draw();
|
|
});
|
|
canvas.addEventListener("mouseup", () => {
|
|
if (dragStart == null || dragEnd == null || !offsets.length) {
|
|
dragStart = null;
|
|
dragEnd = null;
|
|
draw();
|
|
return;
|
|
}
|
|
const lo = Math.min(dragStart, dragEnd);
|
|
const hi = Math.max(dragStart, dragEnd);
|
|
const start = offsets[lo] || 0;
|
|
const end = (offsets[hi] || start) + step;
|
|
dragStart = null;
|
|
dragEnd = null;
|
|
draw();
|
|
if (end > start) this.openHexRange(start, end - start);
|
|
});
|
|
canvas.addEventListener("mouseleave", () => {
|
|
if (dragStart == null) return;
|
|
dragStart = null;
|
|
dragEnd = null;
|
|
draw();
|
|
});
|
|
requestAnimationFrame(draw);
|
|
}
|
|
|
|
_renderSearchResults(summary, container, out) {
|
|
const hits = out?.hits || [];
|
|
summary.innerHTML = "";
|
|
this._renderSummaryPills(summary, { hits: hits.length });
|
|
this._renderTable(container, hits, ["offset", "length", "sample"]);
|
|
}
|
|
|
|
_renderStrings(container, rows) {
|
|
this._renderTable(container, rows, ["offset", "length", "encoding", "value"]);
|
|
}
|
|
|
|
_renderCarve(summary, container, out) {
|
|
const rows = out?.items || [];
|
|
summary.innerHTML = "";
|
|
this._renderSummaryPills(summary, {
|
|
hits: rows.length,
|
|
exportable: rows.filter((row) => this._rowRange(row).length > 0).length,
|
|
});
|
|
this._renderTable(container, rows, ["offset", "length", "kind", "extension", "confidence", "status"], {
|
|
exportRows: true,
|
|
exportName: (row) => this._foundFileName(row, "carve"),
|
|
});
|
|
}
|
|
|
|
_renderBinsnoop(summary, container, out) {
|
|
const results = out?.results || {};
|
|
const rows = results.file_map || results.fileMap || [];
|
|
summary.innerHTML = "";
|
|
this._renderSummaryPills(summary, {
|
|
hits: rows.length,
|
|
scanned: formatBytes(results.scanned_bytes || results.document_bytes || this.state?.size || 0),
|
|
mode: results.whole_file ? "whole file" : "range",
|
|
window: results.windowed ? formatBytes(results.window_bytes || 0) : "full",
|
|
});
|
|
this._renderTable(container, rows, ["offset", "size", "name", "description", "confidence"], {
|
|
exportRows: true,
|
|
exportName: (row) => this._foundFileName(row, "binsnoop"),
|
|
});
|
|
}
|
|
|
|
_drawEntropyCanvas(canvas, samples, dragStart, dragEnd) {
|
|
const ctx = canvas.getContext("2d");
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.fillStyle = "#101010";
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.08)";
|
|
ctx.lineWidth = 1;
|
|
for (let i = 1; i < 8; i++) {
|
|
const y = height - (i / 8) * height;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(width, y);
|
|
ctx.stroke();
|
|
}
|
|
if (dragStart != null && dragEnd != null) {
|
|
const lo = Math.min(dragStart, dragEnd);
|
|
const hi = Math.max(dragStart, dragEnd);
|
|
const x1 = (lo / Math.max(samples.length - 1, 1)) * width;
|
|
const x2 = (hi / Math.max(samples.length - 1, 1)) * width;
|
|
ctx.fillStyle = "rgba(78, 205, 196, 0.18)";
|
|
ctx.fillRect(x1, 0, Math.max(2, x2 - x1), height);
|
|
}
|
|
if (!samples.length) return;
|
|
ctx.strokeStyle = "#4ecdc4";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
samples.forEach((sample, index) => {
|
|
const x = (index / Math.max(samples.length - 1, 1)) * width;
|
|
const y = height - (Math.max(0, Math.min(8, sample)) / 8) * height;
|
|
if (index === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
ctx.stroke();
|
|
}
|
|
|
|
_renderTable(container, rows, preferredColumns = null, options = {}) {
|
|
const sourceRows = rows || [];
|
|
const maxRows = options.maxRows || (options.exportRows ? sourceRows.length : 1000);
|
|
rows = sourceRows.slice(0, maxRows);
|
|
if (!rows.length) {
|
|
container.appendChild(createElement("div", "leviathan-empty", { textContent: "No rows." }));
|
|
return;
|
|
}
|
|
const preferred = preferredColumns || ["offset", "length", "size", "kind", "name", "category", "value", "sample", "summary", "description", "iso", "type"];
|
|
const all = Array.from(new Set(rows.flatMap((row) => Object.keys(row || {}))));
|
|
const cols = [...preferred.filter((c) => all.includes(c)), ...all.filter((c) => !preferred.includes(c))].slice(0, 8);
|
|
if (options.exportRows) cols.push("export");
|
|
const table = createElement("table", "leviathan-table");
|
|
const thead = createElement("thead");
|
|
const headRow = createElement("tr");
|
|
cols.forEach((c) => headRow.appendChild(createElement("th", "", { textContent: c })));
|
|
thead.appendChild(headRow);
|
|
const tbody = createElement("tbody");
|
|
rows.forEach((row) => {
|
|
const tr = createElement("tr");
|
|
cols.forEach((c) => {
|
|
const td = createElement("td");
|
|
if (c === "export") {
|
|
const range = this._rowRange(row);
|
|
if (range.length > 0) {
|
|
const btn = createElement("button", "btn btn-xs leviathan-export-found", {
|
|
type: "button",
|
|
textContent: "Export",
|
|
});
|
|
btn.addEventListener("click", () => this._exportFoundRange(row, options.exportName));
|
|
td.appendChild(btn);
|
|
} else {
|
|
td.appendChild(createElement("span", "leviathan-muted", { textContent: "No length" }));
|
|
}
|
|
} else if (c === "offset" && row[c] != null) {
|
|
const offset = Number(row[c]);
|
|
const length = Number(row.length || row.size || 256);
|
|
const btn = createElement("button", "leviathan-offset-link", {
|
|
type: "button",
|
|
textContent: this._hexOffset(offset),
|
|
});
|
|
btn.addEventListener("click", () => this.openHexRange(offset, Math.max(256, length || 256)));
|
|
td.appendChild(btn);
|
|
} else {
|
|
td.textContent = this._formatCell(row[c]);
|
|
}
|
|
tr.appendChild(td);
|
|
});
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.append(thead, tbody);
|
|
container.appendChild(table);
|
|
if (sourceRows.length > rows.length) {
|
|
container.appendChild(createElement("div", "leviathan-table-note", {
|
|
textContent: `Showing ${rows.length} of ${sourceRows.length} rows.`,
|
|
}));
|
|
}
|
|
}
|
|
|
|
_rowRange(row) {
|
|
const offset = Number(row?.offset);
|
|
if (!Number.isFinite(offset) || offset < 0) return { offset: 0, length: 0 };
|
|
let length = Number(row.length ?? row.size ?? 0);
|
|
if ((!Number.isFinite(length) || length <= 0) && Number.isFinite(Number(row.end))) {
|
|
length = Number(row.end) - offset;
|
|
}
|
|
if ((!Number.isFinite(length) || length <= 0) && this.state?.size > offset) {
|
|
length = Number(this.state.size) - offset;
|
|
}
|
|
return {
|
|
offset,
|
|
length: Number.isFinite(length) ? Math.max(0, Math.floor(length)) : 0,
|
|
};
|
|
}
|
|
|
|
_foundFileName(row, prefix) {
|
|
const range = this._rowRange(row);
|
|
const label = String(row.kind || row.name || row.id || prefix || "found")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 48) || "found";
|
|
const ext = String(row.extension || row.ext || "bin")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "")
|
|
.slice(0, 12) || "bin";
|
|
return `${prefix || "found"}-${label}-${this._hexOffset(range.offset)}.${ext}`;
|
|
}
|
|
|
|
async _exportFoundRange(row, nameBuilder) {
|
|
const range = this._rowRange(row);
|
|
if (range.length <= 0) {
|
|
this.app.notifications.error("Export failed: finding has no readable range");
|
|
return;
|
|
}
|
|
const name = typeof nameBuilder === "function" ? nameBuilder(row) : this._foundFileName(row, "found");
|
|
try {
|
|
const path = await window.NexusTerminal.wailsService.LeviathanExportRange(name, range.offset, range.length);
|
|
if (path) this.app.notifications.success("Exported " + path);
|
|
} catch (err) {
|
|
this.app.notifications.error("Export failed: " + err);
|
|
}
|
|
}
|
|
|
|
_renderToolError(container, toolId, err) {
|
|
const message = String(err || "The tool could not complete.");
|
|
const isSquashMagic = toolId === "squashfs" && /magic|superblock|squash/i.test(message);
|
|
const state = createElement("div", isSquashMagic ? "leviathan-error-state leviathan-error-state-warning" : "leviathan-error-state");
|
|
const icon = createElement("div", "leviathan-error-icon", { "aria-hidden": "true" });
|
|
icon.appendChild(createElement("span", "", { textContent: "!" }));
|
|
const title = isSquashMagic ? "Not a SquashFS image" : "Tool could not run";
|
|
const body = isSquashMagic
|
|
? "The loaded file does not start with a valid SquashFS superblock. Try another file, or inspect this one in the Hex Editor."
|
|
: message;
|
|
state.appendChild(icon);
|
|
state.appendChild(createElement("strong", "", { textContent: title }));
|
|
state.appendChild(createElement("p", "", { textContent: body }));
|
|
container.appendChild(state);
|
|
}
|
|
|
|
_renderTree(container, root) {
|
|
const wrap = createElement("div", "leviathan-tree");
|
|
const walk = (node, depth = 0) => {
|
|
const row = createElement("div", "leviathan-tree-node");
|
|
row.style.paddingLeft = `${depth * 16}px`;
|
|
row.appendChild(createElement("strong", "", { textContent: node.name || "" }));
|
|
row.appendChild(createElement("span", "", { textContent: ` ${node.type || ""} ${node.offset != null ? this._hexOffset(node.offset) : ""} +${node.length || 0}` }));
|
|
if (node.value) row.appendChild(createElement("div", "", { textContent: node.value }));
|
|
wrap.appendChild(row);
|
|
(node.children || []).forEach((child) => walk(child, depth + 1));
|
|
};
|
|
walk(root);
|
|
container.appendChild(wrap);
|
|
}
|
|
|
|
_stateAction(method, sessionId) {
|
|
return window.NexusTerminal.wailsService[method]()
|
|
.then((state) => {
|
|
this.applyState(state);
|
|
const session = this.app.state.getSession(sessionId);
|
|
if (session?.toolId === "hex") {
|
|
const panel = document.getElementById(`panel-${sessionId}`);
|
|
if (panel) this.renderToolPanel(panel, sessionId, tools[0]);
|
|
}
|
|
})
|
|
.catch((err) => this.app.notifications.error(String(err)));
|
|
}
|
|
|
|
_input(label, value) {
|
|
const row = createElement("label", "leviathan-param-inline");
|
|
row.appendChild(createElement("span", "", { textContent: label }));
|
|
const input = createElement("input", "form-input", { value });
|
|
row.appendChild(input);
|
|
return { row, input };
|
|
}
|
|
|
|
_selectInput(label, options, value) {
|
|
const row = createElement("label", "leviathan-param-inline");
|
|
row.appendChild(createElement("span", "", { textContent: label }));
|
|
const input = createElement("select", "form-input");
|
|
options.forEach(([optionValue, text]) => input.add(new Option(text, optionValue)));
|
|
input.value = value;
|
|
row.appendChild(input);
|
|
return { row, input };
|
|
}
|
|
|
|
_paramInput(container, key, label, value, attrs = {}) {
|
|
const row = createElement("label", "leviathan-param");
|
|
row.appendChild(createElement("span", "", { textContent: label }));
|
|
const input = createElement("input", "form-input");
|
|
for (const [attr, attrValue] of Object.entries(attrs)) {
|
|
input.setAttribute(attr, attrValue);
|
|
}
|
|
if (attrs.type === "checkbox") {
|
|
input.checked = Boolean(value);
|
|
input.classList.add("leviathan-checkbox");
|
|
} else {
|
|
input.value = value;
|
|
}
|
|
row.appendChild(input);
|
|
container.appendChild(row);
|
|
return { key, input };
|
|
}
|
|
|
|
_fieldValue(field) {
|
|
if (field.input.type === "checkbox") {
|
|
return field.input.checked;
|
|
}
|
|
const raw = field.input.value;
|
|
const numeric = Number(raw);
|
|
const stringKeys = ["query", "source", "spec", "pipeline", "kind", "arch", "mode"];
|
|
return raw !== "" && Number.isFinite(numeric) && !stringKeys.includes(field.key)
|
|
? numeric
|
|
: raw;
|
|
}
|
|
|
|
openHexRange(start, length = 2048) {
|
|
const safeStart = Math.max(0, Math.floor(Number(start) || 0));
|
|
const safeLength = Math.max(256, Math.min(8192, Math.floor(Number(length) || 2048)));
|
|
this.openTool("hex", {
|
|
params: { start: safeStart, bytes: safeLength, width: 16 },
|
|
});
|
|
}
|
|
|
|
_sessionParams(sessionId) {
|
|
return this.app.state.getSession(sessionId)?.leviathanParams || {};
|
|
}
|
|
|
|
_currentHexSearchHit(sessionId) {
|
|
const state = this.hexSearches.get(sessionId);
|
|
if (!state || state.index < 0 || !state.hits?.length) return null;
|
|
const hit = state.hits[state.index];
|
|
return {
|
|
offset: Number(hit.offset || 0),
|
|
length: Number(hit.length || 1),
|
|
};
|
|
}
|
|
|
|
_gotoHexSearchHit(sessionId, direction, startInput, widthInput, load) {
|
|
const state = this.hexSearches.get(sessionId);
|
|
if (!state || !state.hits?.length) {
|
|
this._showHexSearchModal(sessionId, startInput, widthInput, load);
|
|
return;
|
|
}
|
|
state.index = (state.index + direction + state.hits.length) % state.hits.length;
|
|
const hit = state.hits[state.index];
|
|
const rowWidth = Number(widthInput.value || 16);
|
|
const offset = Number(hit.offset || 0);
|
|
startInput.value = String(Math.max(0, offset - (offset % rowWidth)));
|
|
load();
|
|
}
|
|
|
|
_showHexSearchModal(sessionId, startInput, widthInput, load) {
|
|
const backdrop = createElement("div", "modal-backdrop");
|
|
const modal = createElement("div", "modal modal-sm leviathan-search-modal");
|
|
const header = createElement("div", "modal-header");
|
|
header.appendChild(createElement("h3", "modal-title", { textContent: "Hex Search" }));
|
|
const closeBtn = createElement("button", "btn btn-icon btn-xs modal-close", {
|
|
type: "button",
|
|
textContent: "x",
|
|
});
|
|
header.appendChild(closeBtn);
|
|
|
|
const body = createElement("div", "modal-body");
|
|
const fields = createElement("div", "leviathan-modal-fields");
|
|
const mode = this._selectInput("mode", [
|
|
["text", "String"],
|
|
["hex", "Hex"],
|
|
["regex", "Regex"],
|
|
], "text");
|
|
const query = this._input("query", "");
|
|
fields.append(mode.row, query.row);
|
|
const status = createElement("div", "leviathan-status", { textContent: "" });
|
|
body.append(fields, status);
|
|
|
|
const footer = createElement("div", "modal-footer");
|
|
const cancel = createElement("button", "btn btn-sm", { type: "button", textContent: "Cancel" });
|
|
const run = createElement("button", "btn btn-sm btn-primary", { type: "button", textContent: "Search" });
|
|
footer.append(cancel, run);
|
|
modal.append(header, body, footer);
|
|
backdrop.appendChild(modal);
|
|
document.body.appendChild(backdrop);
|
|
|
|
const close = () => {
|
|
backdrop.classList.remove("is-visible");
|
|
modal.classList.remove("is-visible");
|
|
setTimeout(() => backdrop.remove(), 200);
|
|
};
|
|
const search = async () => {
|
|
try {
|
|
status.textContent = "Searching...";
|
|
const out = await window.NexusTerminal.wailsService.LeviathanRunTool("search", {
|
|
mode: mode.input.value,
|
|
query: query.input.value,
|
|
start: 0,
|
|
length: -1,
|
|
max: 5000,
|
|
});
|
|
const hits = (out.hits || []).map((hit) => ({
|
|
offset: Number(hit.offset || 0),
|
|
length: Number(hit.length || 1),
|
|
sample: hit.sample || "",
|
|
}));
|
|
this.hexSearches.set(sessionId, {
|
|
mode: mode.input.value,
|
|
query: query.input.value,
|
|
hits,
|
|
index: -1,
|
|
});
|
|
close();
|
|
if (hits.length) this._gotoHexSearchHit(sessionId, 1, startInput, widthInput, load);
|
|
} catch (err) {
|
|
status.textContent = String(err);
|
|
}
|
|
};
|
|
|
|
closeBtn.addEventListener("click", close);
|
|
cancel.addEventListener("click", close);
|
|
run.addEventListener("click", search);
|
|
query.input.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") search();
|
|
});
|
|
requestAnimationFrame(() => {
|
|
backdrop.classList.add("is-visible");
|
|
modal.classList.add("is-visible");
|
|
query.input.focus();
|
|
});
|
|
}
|
|
|
|
_metricCard(label, value) {
|
|
const item = createElement("div", "leviathan-metric");
|
|
item.appendChild(createElement("span", "", { textContent: label }));
|
|
item.appendChild(createElement("strong", "", { textContent: this._formatCell(value) }));
|
|
return item;
|
|
}
|
|
|
|
_kvSection(title, values, keys, bytes = false) {
|
|
const section = createElement("section", "leviathan-section");
|
|
section.appendChild(createElement("h3", "", { textContent: title }));
|
|
const list = createElement("div", "leviathan-kv-list");
|
|
keys.forEach((key) => {
|
|
if (values?.[key] == null) return;
|
|
const row = createElement("div", "leviathan-kv-row");
|
|
row.appendChild(createElement("span", "", { textContent: key }));
|
|
row.appendChild(createElement("strong", "", {
|
|
textContent: bytes ? formatBytes(Number(values[key]) || 0) : this._formatCell(values[key]),
|
|
}));
|
|
list.appendChild(row);
|
|
});
|
|
section.appendChild(list);
|
|
return section;
|
|
}
|
|
|
|
_formatNumber(value, digits = 2) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n.toFixed(digits) : "0";
|
|
}
|
|
|
|
_renderSummaryPills(container, values) {
|
|
Object.entries(values || {})
|
|
.slice(0, 10)
|
|
.forEach(([key, value]) => {
|
|
const item = createElement("div", "leviathan-pill");
|
|
item.appendChild(createElement("span", "", { textContent: key }));
|
|
item.appendChild(createElement("strong", "", { textContent: this._formatCell(value) }));
|
|
container.appendChild(item);
|
|
});
|
|
}
|
|
|
|
_findExistingToolSession(toolId) {
|
|
const session = this.app.state
|
|
.getAllSessions()
|
|
.find((s) => s.protocol === "leviathan" && s.toolId === toolId);
|
|
return session?.id || null;
|
|
}
|
|
|
|
_updateOpenHeaders() {
|
|
document.querySelectorAll(".leviathan-eyebrow").forEach((el) => {
|
|
el.textContent = this._stateLabel();
|
|
});
|
|
}
|
|
|
|
_highlightActiveTool(toolId) {
|
|
document.querySelectorAll(".leviathan-tool").forEach((el) => {
|
|
el.classList.toggle("sidebar-item-active", el.dataset.toolId === toolId);
|
|
});
|
|
}
|
|
|
|
_stateLabel() {
|
|
if (!this.state?.name) return "No file loaded";
|
|
const dirty = this.state.dirty ? "modified" : "clean";
|
|
return `${this.state.name} · ${formatBytes(this.state.size || 0)} · ${dirty}`;
|
|
}
|
|
|
|
_flatSummary(out) {
|
|
if (!out || typeof out !== "object") return {};
|
|
const summary = {};
|
|
for (const [key, value] of Object.entries(out)) {
|
|
if (Array.isArray(value)) summary[key] = value.length;
|
|
else if (value && typeof value === "object") {
|
|
if (key === "hashes") summary.hash = value.sha256 || value.md5 || "ready";
|
|
else summary[key] = Object.keys(value).length + " keys";
|
|
} else {
|
|
summary[key] = value;
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
_firstArray(out) {
|
|
if (!out || typeof out !== "object") return null;
|
|
for (const key of ["items", "hits", "all", "regions", "blocks", "entries", "metadata"]) {
|
|
if (Array.isArray(out[key])) return out[key];
|
|
}
|
|
if (out.results?.Results && Array.isArray(out.results.Results)) return out.results.Results;
|
|
return null;
|
|
}
|
|
|
|
_formatCell(value) {
|
|
return typeof value === "object" ? JSON.stringify(value) : String(value ?? "");
|
|
}
|
|
|
|
_hexOffset(value) {
|
|
return "0x" + Number(value || 0).toString(16).padStart(8, "0");
|
|
}
|
|
}
|
|
|
|
window.NexusTerminal = window.NexusTerminal || {};
|
|
window.NexusTerminal.LeviathanWorkbench = LeviathanWorkbench;
|
|
window.NexusTerminal.LeviathanTools = tools;
|
|
})();
|