Add synchronized DER view and investigation bundles
This commit is contained in:
parent
216afb6c18
commit
f5e4ece6e7
@ -80,6 +80,19 @@ It preserves the original certificate for offline export, lists every extension
|
||||
status and Key Usage directly from the signed certificate encoding. Parsing is
|
||||
dependency-free and guarded by input-size, node-count, and nesting limits.
|
||||
|
||||
The deeper explorer synchronizes the ASN.1 structure with a hexadecimal view:
|
||||
selecting a field highlights its complete encoded byte range, while selecting a
|
||||
byte resolves to the narrowest enclosing ASN.1 node.
|
||||
|
||||
Live investigations can be exported as versioned
|
||||
`.browsec-investigation.json` bundles and reopened offline. A bundle contains
|
||||
the original public DER certificates, normalized facts, findings, journal,
|
||||
verdict, and policy snapshot, together with an explicit no-private-keys and
|
||||
no-session-secrets declaration. Import is limited to 10 MiB; it reparses every
|
||||
certificate, recomputes every DER SHA-256 fingerprint, and rejects inconsistent
|
||||
CA, Key Usage, critical-extension, or fingerprint claims. Recorded decisions
|
||||
remain evidence in the bundle and are not silently installed into local policy.
|
||||
|
||||
Authority scopes identify the exact DER-encoded CA certificate with
|
||||
`authorityCertificateSha256`. TrustLab verifies that it appears in the active
|
||||
chain, has CA Basic Constraints in the supplied facts, and permits
|
||||
|
||||
@ -5,3 +5,4 @@ export { TrustPolicyOverlay } from "./policy-overlay.js";
|
||||
export { TrustRunner } from "./runner.js";
|
||||
export { parseTlsTarget, probeTls } from "./tls-probe.js";
|
||||
export { exploreCertificateDer } from "./der-explorer.js";
|
||||
export { createInvestigationBundle, parseInvestigationBundle } from "./investigation-bundle.js";
|
||||
|
||||
88
trustlab/src/investigation-bundle.js
Normal file
88
trustlab/src/investigation-bundle.js
Normal file
@ -0,0 +1,88 @@
|
||||
import { exploreCertificateDer } from "./der-explorer.js";
|
||||
import { createTlsFacts } from "./protocol.js";
|
||||
|
||||
export const INVESTIGATION_FORMAT = "org.browsec.investigation";
|
||||
export const INVESTIGATION_VERSION = 1;
|
||||
export const MAX_BUNDLE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function createInvestigationBundle(report, result, policySnapshot) {
|
||||
if (!report?.facts) throw new TypeError("Investigation report must contain TLS facts");
|
||||
return Object.freeze({
|
||||
format: INVESTIGATION_FORMAT,
|
||||
version: INVESTIGATION_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
tool: { name: "Browsec TrustLab", engine: "Velvet Hammer", version: "0.1.0" },
|
||||
evidence: {
|
||||
observedAt: report.observedAt,
|
||||
facts: report.facts,
|
||||
findings: report.findings ?? [],
|
||||
caveats: report.caveats ?? [],
|
||||
},
|
||||
decision: result ? { verdict: result.verdict, journal: result.journal } : undefined,
|
||||
policySnapshot: policySnapshot ?? undefined,
|
||||
privacy: {
|
||||
containsPrivateKeys: false,
|
||||
containsTlsSessionSecrets: false,
|
||||
statement: "This bundle contains public certificates and diagnostic metadata only.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseInvestigationBundle(text) {
|
||||
if (typeof text !== "string") throw new TypeError("Investigation bundle must be JSON text");
|
||||
if (new TextEncoder().encode(text).length > MAX_BUNDLE_BYTES) throw new RangeError("Investigation bundle exceeds 10 MiB");
|
||||
const bundle = JSON.parse(text);
|
||||
if (bundle?.format !== INVESTIGATION_FORMAT || bundle?.version !== INVESTIGATION_VERSION) {
|
||||
throw new TypeError("Unsupported Browsec investigation bundle");
|
||||
}
|
||||
if (bundle.privacy?.containsPrivateKeys !== false || bundle.privacy?.containsTlsSessionSecrets !== false) {
|
||||
throw new TypeError("Investigation bundle does not carry the required no-secrets declaration");
|
||||
}
|
||||
const suppliedFacts = bundle.evidence?.facts;
|
||||
const facts = createTlsFacts({
|
||||
...suppliedFacts,
|
||||
presentedChain: await Promise.all((suppliedFacts?.presentedChain ?? []).map(normalizeEmbeddedDer)),
|
||||
constructedChain: await Promise.all((suppliedFacts?.constructedChain ?? []).map(normalizeEmbeddedDer)),
|
||||
});
|
||||
if (!Array.isArray(bundle.evidence?.findings) || !Array.isArray(bundle.evidence?.caveats)) {
|
||||
throw new TypeError("Investigation evidence lists are malformed");
|
||||
}
|
||||
return {
|
||||
...bundle,
|
||||
evidence: { ...bundle.evidence, facts },
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeEmbeddedDer(certificate) {
|
||||
if (typeof certificate.derBase64 !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(certificate.derBase64)) {
|
||||
throw new TypeError("Every bundled certificate must contain original DER bytes");
|
||||
}
|
||||
const bytes = decodeBase64(certificate.derBase64);
|
||||
const explored = exploreCertificateDer(bytes);
|
||||
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
||||
const sha256 = [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
if (sha256 !== certificate.sha256) throw new TypeError("Bundled certificate fingerprint does not match its DER bytes");
|
||||
if (explored.byteLength !== certificate.der?.byteLength) throw new TypeError("Bundled DER metadata does not match its bytes");
|
||||
if (explored.derived.isCa !== certificate.isCa) throw new TypeError("Bundled CA assertion does not match its DER bytes");
|
||||
if (JSON.stringify(explored.derived.keyUsages) !== JSON.stringify(certificate.keyUsages)) {
|
||||
throw new TypeError("Bundled Key Usage does not match its DER bytes");
|
||||
}
|
||||
if (JSON.stringify(explored.derived.unsupportedCriticalExtensions) !== JSON.stringify(certificate.unsupportedCriticalExtensions ?? [])) {
|
||||
throw new TypeError("Bundled critical-extension assertion does not match its DER bytes");
|
||||
}
|
||||
return {
|
||||
...certificate,
|
||||
isCa: explored.derived.isCa,
|
||||
keyUsages: explored.derived.keyUsages,
|
||||
unsupportedCriticalExtensions: explored.derived.unsupportedCriticalExtensions,
|
||||
der: explored,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
if (typeof atob === "function") {
|
||||
const binary = atob(value);
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
}
|
||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||
}
|
||||
@ -123,6 +123,7 @@ function normalizeProbe(input) {
|
||||
|
||||
const errors = [...new Set(findings.map((item) => item.code))];
|
||||
return {
|
||||
observedAt: new Date().toISOString(),
|
||||
facts: {
|
||||
schemaVersion: 0,
|
||||
connectionId: randomUUID(),
|
||||
|
||||
65
trustlab/test/investigation-bundle.test.js
Normal file
65
trustlab/test/investigation-bundle.test.js
Normal file
@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import { createInvestigationBundle, parseInvestigationBundle } from "../src/investigation-bundle.js";
|
||||
import { exploreCertificateDer } from "../src/der-explorer.js";
|
||||
|
||||
test("investigation bundles round-trip original DER and normalized evidence", async () => {
|
||||
const derBytes = bytes("30023000");
|
||||
const der = exploreCertificateDer(derBytes);
|
||||
const certificate = {
|
||||
subject: "CN=Offline Test",
|
||||
sha256: sha256(derBytes),
|
||||
isCa: false,
|
||||
keyUsages: [],
|
||||
selfSigned: false,
|
||||
derBase64: Buffer.from(derBytes).toString("base64"),
|
||||
der,
|
||||
};
|
||||
const report = {
|
||||
observedAt: "2026-08-16T00:00:00.000Z",
|
||||
facts: {
|
||||
connectionId: "offline-test",
|
||||
hostname: "offline.test",
|
||||
port: 443,
|
||||
validation: "failure",
|
||||
errors: ["unknown-issuer"],
|
||||
failure: { code: "unknown-issuer", check: "trust-anchor", summary: "Unknown", certificateSha256: certificate.sha256 },
|
||||
presentedChain: [certificate],
|
||||
constructedChain: [certificate],
|
||||
tls: {},
|
||||
},
|
||||
findings: [],
|
||||
caveats: [],
|
||||
};
|
||||
const bundle = createInvestigationBundle(report);
|
||||
const parsed = await parseInvestigationBundle(JSON.stringify(bundle));
|
||||
assert.equal(parsed.evidence.facts.hostname, "offline.test");
|
||||
assert.equal(parsed.evidence.facts.presentedChain[0].derBase64, certificate.derBase64);
|
||||
assert.equal(parsed.privacy.containsPrivateKeys, false);
|
||||
});
|
||||
|
||||
test("bundle import rejects altered DER-derived authority facts and secret-bearing declarations", async () => {
|
||||
const derBytes = bytes("30023000");
|
||||
const der = exploreCertificateDer(derBytes);
|
||||
const base = {
|
||||
format: "org.browsec.investigation",
|
||||
version: 1,
|
||||
privacy: { containsPrivateKeys: false, containsTlsSessionSecrets: false },
|
||||
evidence: {
|
||||
facts: {
|
||||
connectionId: "tampered", hostname: "offline.test", port: 443, validation: "failure", errors: [],
|
||||
presentedChain: [{ subject: "x", sha256: sha256(derBytes), isCa: true, keyUsages: [], selfSigned: false, derBase64: Buffer.from(derBytes).toString("base64"), der }],
|
||||
constructedChain: [], tls: {},
|
||||
},
|
||||
findings: [], caveats: [],
|
||||
},
|
||||
};
|
||||
await assert.rejects(parseInvestigationBundle(JSON.stringify(base)), /CA assertion/);
|
||||
base.privacy.containsPrivateKeys = true;
|
||||
await assert.rejects(parseInvestigationBundle(JSON.stringify(base)), /no-secrets declaration/);
|
||||
});
|
||||
|
||||
function bytes(hex) { return Uint8Array.from(hex.match(/../g).map((pair) => Number.parseInt(pair, 16))); }
|
||||
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
||||
@ -15,7 +15,7 @@ import {
|
||||
createVillageCommunityPlugin,
|
||||
} from "../plugins/demo-plugins.js";
|
||||
import { TrustRunner } from "../src/index.js";
|
||||
import { chainRows, subjectName, verdictCopy } from "../ui/model.js";
|
||||
import { chainRows, flattenDerTree, smallestDerNodeAt, subjectName, verdictCopy } from "../ui/model.js";
|
||||
|
||||
test("UI model identifies the failed end of an unknown-authority chain", () => {
|
||||
const rows = chainRows(unknownLocalAuthority);
|
||||
@ -46,6 +46,19 @@ test("certificate display names prefer the common name", () => {
|
||||
assert.equal(subjectName("O=Nameless"), "O=Nameless");
|
||||
});
|
||||
|
||||
test("DER byte selection resolves to the narrowest ASN.1 node", () => {
|
||||
const tree = {
|
||||
type: "SEQUENCE", offset: 0, endOffset: 12, children: [
|
||||
{ type: "INTEGER", offset: 2, endOffset: 5, children: [] },
|
||||
{ type: "OCTET STRING", offset: 5, endOffset: 12, children: [] },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(flattenDerTree(tree).map((item) => item.depth), [0, 1, 1]);
|
||||
assert.equal(smallestDerNodeAt(tree, 3).type, "INTEGER");
|
||||
assert.equal(smallestDerNodeAt(tree, 8).type, "OCTET STRING");
|
||||
assert.equal(smallestDerNodeAt(tree, 12), undefined);
|
||||
});
|
||||
|
||||
test("UI plugins produce attributed evidence and a local Boolean verdict", async () => {
|
||||
const plugins = [
|
||||
configure(createFirefoxValidationPlugin(), "advisor"),
|
||||
@ -98,6 +111,8 @@ test("security surface contains immutable-frame and simulation labels", async ()
|
||||
assert.match(html, /TRUSTLAB · SYNTHETIC/);
|
||||
assert.match(html, /cannot alter browser or system trust/);
|
||||
assert.match(html, /Live TLS target/);
|
||||
assert.match(html, /Export investigation/);
|
||||
assert.match(html, /Import investigation/);
|
||||
assert.match(html, /Decision target/);
|
||||
assert.match(html, /Effective and consumed local rules/);
|
||||
});
|
||||
|
||||
@ -14,7 +14,8 @@ import {
|
||||
} from "../plugins/demo-plugins.js";
|
||||
import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.js";
|
||||
import { TrustPolicyOverlay, TrustRunner } from "../src/index.js";
|
||||
import { chainRows, subjectName, verdictCopy } from "./model.js";
|
||||
import { createInvestigationBundle, MAX_BUNDLE_BYTES, parseInvestigationBundle } from "../src/investigation-bundle.js";
|
||||
import { chainRows, flattenDerTree, smallestDerNodeAt, subjectName, verdictCopy } from "./model.js";
|
||||
|
||||
const scenarios = {
|
||||
"unknown-local": {
|
||||
@ -62,6 +63,8 @@ const state = {
|
||||
pendingDecision: undefined,
|
||||
communityEnabled: true,
|
||||
liveReport: undefined,
|
||||
lastResult: undefined,
|
||||
source: "synthetic",
|
||||
};
|
||||
|
||||
const policyOverlay = new TrustPolicyOverlay();
|
||||
@ -72,6 +75,8 @@ const elements = {
|
||||
probeTarget: document.querySelector("#probe-target"),
|
||||
probeSubmit: document.querySelector("#probe-submit"),
|
||||
probeMessage: document.querySelector("#probe-message"),
|
||||
exportBundle: document.querySelector("#export-bundle"),
|
||||
importBundle: document.querySelector("#import-bundle"),
|
||||
frameSeal: document.querySelector("#frame-seal"),
|
||||
community: document.querySelector("#community-enabled"),
|
||||
target: document.querySelector("#trust-target"),
|
||||
@ -113,21 +118,7 @@ elements.probeForm.addEventListener("submit", async (event) => {
|
||||
});
|
||||
const report = await response.json();
|
||||
if (!response.ok) throw new Error(report.error ?? `Probe failed (${response.status})`);
|
||||
state.liveReport = report;
|
||||
scenarios.live = {
|
||||
label: `Live: ${report.facts.hostname}:${report.facts.port}`,
|
||||
facts: report.facts,
|
||||
probePlugins: [configure(createProbeEvidencePlugin(report), "advisor")],
|
||||
};
|
||||
let option = [...elements.scenario.options].find((item) => item.value === "live");
|
||||
if (!option) {
|
||||
option = new Option(scenarios.live.label, "live");
|
||||
elements.scenario.prepend(option);
|
||||
} else option.textContent = scenarios.live.label;
|
||||
state.scenario = "live";
|
||||
elements.scenario.value = "live";
|
||||
state.pendingDecision = undefined;
|
||||
renderTrustTargets(report.facts);
|
||||
activateReport(report, "live");
|
||||
elements.probeMessage.textContent = `Received ${report.facts.presentedChain.length} certificate(s).`;
|
||||
await render();
|
||||
} catch (error) {
|
||||
@ -137,6 +128,45 @@ elements.probeForm.addEventListener("submit", async (event) => {
|
||||
elements.probeSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
elements.exportBundle.addEventListener("click", () => {
|
||||
if (!state.liveReport) return;
|
||||
const bundle = createInvestigationBundle(
|
||||
state.liveReport,
|
||||
state.lastResult,
|
||||
policyOverlay.snapshot(),
|
||||
);
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${state.liveReport.facts.hostname}-${Date.now()}.browsec-investigation.json`;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
});
|
||||
elements.importBundle.addEventListener("change", async () => {
|
||||
const file = elements.importBundle.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
if (file.size > MAX_BUNDLE_BYTES) throw new RangeError("Investigation bundle exceeds 10 MiB");
|
||||
const bundle = await parseInvestigationBundle(await file.text());
|
||||
const report = {
|
||||
observedAt: bundle.evidence.observedAt,
|
||||
facts: bundle.evidence.facts,
|
||||
findings: bundle.evidence.findings,
|
||||
caveats: bundle.evidence.caveats,
|
||||
};
|
||||
policyOverlay.clear();
|
||||
activateReport(report, "offline");
|
||||
elements.probeMessage.className = "probe-message";
|
||||
elements.probeMessage.textContent = `Opened offline investigation created ${bundle.createdAt}.`;
|
||||
await render();
|
||||
} catch (error) {
|
||||
elements.probeMessage.className = "probe-message error";
|
||||
elements.probeMessage.textContent = error.message;
|
||||
} finally {
|
||||
elements.importBundle.value = "";
|
||||
}
|
||||
});
|
||||
elements.target.addEventListener("change", () => {
|
||||
elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:");
|
||||
if (elements.includeSubdomains.disabled) elements.includeSubdomains.checked = false;
|
||||
@ -207,6 +237,7 @@ async function render() {
|
||||
);
|
||||
}
|
||||
const result = await new TrustRunner({ plugins }).evaluate(facts);
|
||||
state.lastResult = result;
|
||||
const decidedByOverlay = result.journal.entries.some(
|
||||
(entry) =>
|
||||
entry.pluginId === "org.browsec.local-policy-overlay" &&
|
||||
@ -219,8 +250,28 @@ async function render() {
|
||||
renderRules(policyOverlay.snapshot());
|
||||
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
|
||||
elements.frameSeal.textContent = state.scenario === "live"
|
||||
? "TRUSTLAB · LIVE PROBE"
|
||||
? `TRUSTLAB · ${state.source === "offline" ? "OFFLINE EVIDENCE" : "LIVE PROBE"}`
|
||||
: "TRUSTLAB · SYNTHETIC";
|
||||
elements.exportBundle.hidden = state.scenario !== "live";
|
||||
}
|
||||
|
||||
function activateReport(report, source) {
|
||||
state.liveReport = report;
|
||||
state.source = source;
|
||||
scenarios.live = {
|
||||
label: `${source === "offline" ? "Offline" : "Live"}: ${report.facts.hostname}:${report.facts.port}`,
|
||||
facts: report.facts,
|
||||
probePlugins: [configure(createProbeEvidencePlugin(report), "advisor")],
|
||||
};
|
||||
let option = [...elements.scenario.options].find((item) => item.value === "live");
|
||||
if (!option) {
|
||||
option = new Option(scenarios.live.label, "live");
|
||||
elements.scenario.prepend(option);
|
||||
} else option.textContent = scenarios.live.label;
|
||||
state.scenario = "live";
|
||||
elements.scenario.value = "live";
|
||||
state.pendingDecision = undefined;
|
||||
renderTrustTargets(report.facts);
|
||||
}
|
||||
|
||||
function renderStatus(result, hasLocalDecision) {
|
||||
@ -384,20 +435,76 @@ function renderDerExplorer(certificate) {
|
||||
}
|
||||
details.append(extensions);
|
||||
|
||||
const tree = document.createElement("details");
|
||||
tree.className = "der-tree";
|
||||
tree.append(node("summary", "ASN.1 structure and byte ranges"), renderDerNode(certificate.der.tree));
|
||||
details.append(tree);
|
||||
details.append(renderSynchronizedDer(certificate));
|
||||
return details;
|
||||
}
|
||||
|
||||
function renderDerNode(item) {
|
||||
const list = document.createElement("ul");
|
||||
const entry = document.createElement("li");
|
||||
entry.append(node("code", `${item.type} · ${item.offset}–${item.endOffset - 1} · value ${item.valueLength} B`));
|
||||
if (item.children.length) entry.append(...item.children.map(renderDerNode));
|
||||
list.append(entry);
|
||||
return list;
|
||||
function renderSynchronizedDer(certificate) {
|
||||
const explorer = document.createElement("details");
|
||||
explorer.className = "der-tree";
|
||||
explorer.append(node("summary", "Synchronized ASN.1 structure ↔ hexadecimal bytes"));
|
||||
const panes = document.createElement("div");
|
||||
panes.className = "der-panes";
|
||||
const structure = document.createElement("div");
|
||||
structure.className = "der-structure";
|
||||
const hexView = document.createElement("div");
|
||||
hexView.className = "der-hex";
|
||||
const selection = node("p", "Select a field or byte.", "der-selection");
|
||||
const bytes = decodeBase64(certificate.derBase64);
|
||||
const flatNodes = flattenDerTree(certificate.der.tree);
|
||||
|
||||
for (const item of flatNodes) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "der-node";
|
||||
button.style.setProperty("--der-depth", item.depth);
|
||||
button.dataset.offset = item.offset;
|
||||
button.dataset.end = item.endOffset;
|
||||
button.textContent = `${item.type} · ${item.offset}–${item.endOffset - 1} · value ${item.valueLength} B`;
|
||||
button.addEventListener("click", () => selectDerRange(explorer, item, selection));
|
||||
structure.append(button);
|
||||
}
|
||||
bytes.forEach((value, offset) => {
|
||||
const byte = document.createElement("button");
|
||||
byte.type = "button";
|
||||
byte.className = "der-byte";
|
||||
byte.dataset.byteOffset = offset;
|
||||
byte.textContent = value.toString(16).padStart(2, "0");
|
||||
byte.title = `Byte ${offset}`;
|
||||
byte.addEventListener("click", () => {
|
||||
const item = smallestDerNodeAt(certificate.der.tree, offset);
|
||||
if (item) selectDerRange(explorer, item, selection, offset);
|
||||
});
|
||||
hexView.append(byte);
|
||||
});
|
||||
panes.append(structure, hexView);
|
||||
explorer.append(selection, panes);
|
||||
return explorer;
|
||||
}
|
||||
|
||||
function selectDerRange(explorer, item, selection, exactByte) {
|
||||
explorer.querySelectorAll(".der-node.selected, .der-byte.selected, .der-byte.focused")
|
||||
.forEach((element) => element.classList.remove("selected", "focused"));
|
||||
const selectedNode = [...explorer.querySelectorAll(".der-node")].find(
|
||||
(element) => Number(element.dataset.offset) === item.offset && Number(element.dataset.end) === item.endOffset,
|
||||
);
|
||||
selectedNode?.classList.add("selected");
|
||||
explorer.querySelectorAll(".der-byte").forEach((element) => {
|
||||
const offset = Number(element.dataset.byteOffset);
|
||||
if (offset >= item.offset && offset < item.endOffset) element.classList.add("selected");
|
||||
if (offset === exactByte) element.classList.add("focused");
|
||||
});
|
||||
selection.textContent = `${item.type}: bytes ${item.offset}–${item.endOffset - 1}; header ${item.headerLength} B, value ${item.valueLength} B${exactByte === undefined ? "" : `; selected byte ${exactByte}`}.`;
|
||||
selectedNode?.scrollIntoView({ block: "nearest" });
|
||||
const focusedByte = exactByte === undefined
|
||||
? explorer.querySelector(`[data-byte-offset="${item.offset}"]`)
|
||||
: explorer.querySelector(`[data-byte-offset="${exactByte}"]`);
|
||||
focusedByte?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
|
||||
function decodeBase64(value) {
|
||||
const binary = atob(value);
|
||||
return [...binary].map((character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function renderJournal(result) {
|
||||
|
||||
@ -32,6 +32,14 @@
|
||||
<p id="probe-message" class="probe-message" aria-live="polite"></p>
|
||||
</form>
|
||||
|
||||
<div class="bundle-actions">
|
||||
<button id="export-bundle" class="button quiet" type="button" hidden>Export investigation</button>
|
||||
<label class="button quiet import-button">
|
||||
Import investigation
|
||||
<input id="import-bundle" type="file" accept="application/json,.json,.browsec-investigation.json">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="toggle">
|
||||
<input id="community-enabled" type="checkbox" checked>
|
||||
<span>Village community evidence</span>
|
||||
|
||||
@ -51,6 +51,24 @@ export function chainRows(facts) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function flattenDerTree(root) {
|
||||
const nodes = [];
|
||||
const visit = (node, depth) => {
|
||||
nodes.push({ ...node, depth });
|
||||
node.children.forEach((child) => visit(child, depth + 1));
|
||||
};
|
||||
visit(root, 0);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function smallestDerNodeAt(root, byteOffset) {
|
||||
return flattenDerTree(root)
|
||||
.filter((node) => byteOffset >= node.offset && byteOffset < node.endOffset)
|
||||
.sort((left, right) =>
|
||||
(left.endOffset - left.offset) - (right.endOffset - right.offset),
|
||||
)[0];
|
||||
}
|
||||
|
||||
function edgeDescription(facts, certificate, index, chainLength) {
|
||||
if (certificate.sha256 === facts.failure?.certificateSha256) {
|
||||
const messages = {
|
||||
|
||||
@ -114,6 +114,10 @@ select {
|
||||
.probe-row .button { padding-inline: 0.8rem; }
|
||||
.probe-message { min-height: 1.2em; margin: 0.45rem 0 0; color: var(--ink-muted); font-size: 0.72rem; }
|
||||
.probe-message.error { color: var(--red); }
|
||||
.bundle-actions { display: grid; gap: 0.45rem; margin-top: 0.65rem; }
|
||||
.bundle-actions .button { width: 100%; font-size: 0.72rem; text-align: center; }
|
||||
.import-button { display: block; cursor: pointer; }
|
||||
.import-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
@ -248,8 +252,28 @@ select {
|
||||
.der-extensions span { color: var(--ink-muted); font-size: 0.68rem; }
|
||||
.der-extensions pre { max-height: 12rem; margin: 0.25rem 0 0; overflow: auto; color: #b8cad2; font-size: 0.68rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.der-tree { margin-top: 0.8rem; }
|
||||
.der-tree ul { margin: 0.3rem 0 0; padding-left: 1.15rem; list-style: none; border-left: 1px solid #304751; }
|
||||
.der-tree code { color: #9db1ba; font-size: 0.66rem; }
|
||||
.der-selection { margin: 0.6rem 0; color: var(--ink-muted); font-size: 0.7rem; }
|
||||
.der-panes { display: grid; grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.15fr); gap: 0.7rem; }
|
||||
.der-structure, .der-hex { height: 24rem; overflow: auto; border: 1px solid var(--line); background: #071116; }
|
||||
.der-structure { padding: 0.35rem; }
|
||||
.der-node {
|
||||
display: block;
|
||||
width: calc(100% - min(calc(var(--der-depth) * 0.7rem), 8rem));
|
||||
margin-left: min(calc(var(--der-depth) * 0.7rem), 8rem);
|
||||
padding: 0.25rem 0.35rem;
|
||||
border: 0;
|
||||
border-left: 1px solid #38505b;
|
||||
color: #aebfc7;
|
||||
background: transparent;
|
||||
font: 0.65rem/1.25 ui-monospace, monospace;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.der-node:hover, .der-node.selected { color: #071319; background: var(--cyan); }
|
||||
.der-hex { display: grid; grid-template-columns: repeat(16, 2.15rem); align-content: start; padding: 0.5rem; }
|
||||
.der-byte { padding: 0.2rem 0; border: 0; color: #aebfc7; background: transparent; font: 0.68rem ui-monospace, monospace; cursor: pointer; }
|
||||
.der-byte:hover, .der-byte.focused { color: #071319; background: var(--amber); }
|
||||
.der-byte.selected:not(.focused) { color: #071319; background: var(--cyan); }
|
||||
|
||||
.journal { display: grid; gap: 0.65rem; }
|
||||
.journal-entry {
|
||||
@ -313,6 +337,7 @@ select {
|
||||
.certificate { grid-template-columns: 1fr; }
|
||||
.certificate code, .edge-label { grid-column: 1; grid-row: auto; }
|
||||
.der-derived { grid-template-columns: 1fr; }
|
||||
.der-panes { grid-template-columns: 1fr; }
|
||||
.journal-entry { grid-template-columns: 4.5rem 1fr; }
|
||||
.journal-entry code { display: none; }
|
||||
.decision-bar { flex-wrap: wrap; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user