631 lines
23 KiB
JavaScript
631 lines
23 KiB
JavaScript
import {
|
|
conflictingCommunityAdvice,
|
|
explicitlyDistrustedAuthority,
|
|
expiredLeafCertificate,
|
|
hostnameMismatch,
|
|
unknownLocalAuthority,
|
|
validPublicCertificate,
|
|
} from "../fixtures/tls.js";
|
|
import {
|
|
createCommunityAdvicePlugin,
|
|
createFirefoxValidationPlugin,
|
|
createUserDecisionPlugin,
|
|
createVillageCommunityPlugin,
|
|
} from "../plugins/demo-plugins.js";
|
|
import { createPolicyOverlayPlugin } from "../plugins/policy-overlay-plugin.js";
|
|
import { TrustPolicyOverlay, TrustRunner } from "../src/index.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": {
|
|
label: "Unknown village authority",
|
|
facts: unknownLocalAuthority,
|
|
},
|
|
"valid-public": {
|
|
label: "Valid conventional path",
|
|
facts: validPublicCertificate,
|
|
},
|
|
"expired-leaf": {
|
|
label: "Expired server certificate",
|
|
facts: expiredLeafCertificate,
|
|
},
|
|
"hostname-mismatch": {
|
|
label: "Hostname mismatch",
|
|
facts: hostnameMismatch,
|
|
},
|
|
"distrusted-authority": {
|
|
label: "Explicitly distrusted authority",
|
|
facts: explicitlyDistrustedAuthority,
|
|
},
|
|
"conflicting-advice": {
|
|
label: "Conflicting community advice",
|
|
facts: conflictingCommunityAdvice,
|
|
plugins: [
|
|
configure(createCommunityAdvicePlugin({
|
|
id: "community.archivists",
|
|
name: "Regional archivists",
|
|
trusted: true,
|
|
message: "The archivists recognize this exact certificate and recommend trust.",
|
|
}), "advisor"),
|
|
configure(createCommunityAdvicePlugin({
|
|
id: "community.network-watch",
|
|
name: "Independent network watch",
|
|
trusted: false,
|
|
message: "The network observers report an unexpected certificate change.",
|
|
}), "advisor"),
|
|
],
|
|
},
|
|
};
|
|
|
|
const state = {
|
|
scenario: "unknown-local",
|
|
pendingDecision: undefined,
|
|
communityEnabled: true,
|
|
liveReport: undefined,
|
|
lastResult: undefined,
|
|
source: "synthetic",
|
|
};
|
|
|
|
const policyOverlay = new TrustPolicyOverlay();
|
|
|
|
const elements = {
|
|
scenario: document.querySelector("#scenario"),
|
|
probeForm: document.querySelector("#probe-form"),
|
|
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"),
|
|
lifetime: document.querySelector("#trust-lifetime"),
|
|
includeSubdomains: document.querySelector("#include-subdomains"),
|
|
status: document.querySelector("#status"),
|
|
identity: document.querySelector("#identity"),
|
|
chain: document.querySelector("#chain"),
|
|
pathPanel: document.querySelector("#path-panel"),
|
|
paths: document.querySelector("#paths"),
|
|
journal: document.querySelector("#journal"),
|
|
rules: document.querySelector("#rules"),
|
|
trust: document.querySelector("#trust"),
|
|
reject: document.querySelector("#reject"),
|
|
clear: document.querySelector("#clear-decision"),
|
|
};
|
|
|
|
for (const [value, scenario] of Object.entries(scenarios)) {
|
|
const option = document.createElement("option");
|
|
option.value = value;
|
|
option.textContent = scenario.label;
|
|
elements.scenario.append(option);
|
|
}
|
|
|
|
elements.scenario.addEventListener("change", () => {
|
|
state.scenario = elements.scenario.value;
|
|
state.pendingDecision = undefined;
|
|
renderTrustTargets(scenarios[state.scenario].facts);
|
|
render();
|
|
});
|
|
elements.probeForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
elements.probeSubmit.disabled = true;
|
|
elements.probeMessage.className = "probe-message";
|
|
elements.probeMessage.textContent = "Opening a read-only TLS connection…";
|
|
try {
|
|
const response = await fetch("/api/probe", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ target: elements.probeTarget.value }),
|
|
});
|
|
const report = await response.json();
|
|
if (!response.ok) throw new Error(report.error ?? `Probe failed (${response.status})`);
|
|
activateReport(report, "live");
|
|
elements.probeMessage.textContent = `Received ${report.facts.presentedChain.length} certificate(s).`;
|
|
await render();
|
|
} catch (error) {
|
|
elements.probeMessage.className = "probe-message error";
|
|
elements.probeMessage.textContent = error.message;
|
|
} finally {
|
|
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,
|
|
pathAnalysis: bundle.evidence.pathAnalysis,
|
|
};
|
|
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;
|
|
});
|
|
elements.community.addEventListener("change", () => {
|
|
state.communityEnabled = elements.community.checked;
|
|
render();
|
|
});
|
|
elements.trust.addEventListener("click", () => {
|
|
state.pendingDecision = true;
|
|
render();
|
|
});
|
|
elements.reject.addEventListener("click", () => {
|
|
state.pendingDecision = false;
|
|
render();
|
|
});
|
|
elements.clear.addEventListener("click", () => {
|
|
state.pendingDecision = undefined;
|
|
policyOverlay.clear();
|
|
render();
|
|
});
|
|
|
|
async function render() {
|
|
const scenario = scenarios[state.scenario];
|
|
const facts = scenario.facts;
|
|
const evidencePlugins = scenario.probePlugins
|
|
? [...scenario.probePlugins]
|
|
: [configure(createFirefoxValidationPlugin(), "advisor")];
|
|
if (state.communityEnabled) {
|
|
evidencePlugins.push(
|
|
...(scenario.plugins ?? [
|
|
configure(createVillageCommunityPlugin(), "advisor"),
|
|
]),
|
|
);
|
|
}
|
|
|
|
let decisionApplied = false;
|
|
if (state.pendingDecision !== undefined) {
|
|
policyOverlay.clear();
|
|
const userPlugin = createUserDecisionPlugin(state.pendingDecision, {
|
|
target: elements.target.value.startsWith("authority:")
|
|
? "authority"
|
|
: "certificate",
|
|
authorityCertificateSha256: elements.target.value.startsWith("authority:")
|
|
? elements.target.value.slice("authority:".length)
|
|
: undefined,
|
|
lifetime: elements.lifetime.value,
|
|
includeSubdomains: elements.includeSubdomains.checked,
|
|
});
|
|
const decisionResult = await new TrustRunner({
|
|
plugins: [
|
|
...evidencePlugins,
|
|
configure(userPlugin, "decision-authority"),
|
|
],
|
|
}).evaluate(facts);
|
|
policyOverlay.remember(decisionResult.verdict, {
|
|
pluginId: userPlugin.manifest.id,
|
|
pluginName: userPlugin.manifest.name,
|
|
});
|
|
state.pendingDecision = undefined;
|
|
decisionApplied = true;
|
|
}
|
|
|
|
const plugins = [...evidencePlugins];
|
|
if (policyOverlay.match(facts)) {
|
|
plugins.push(
|
|
configure(createPolicyOverlayPlugin(policyOverlay), "decision-authority"),
|
|
);
|
|
}
|
|
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" &&
|
|
entry.kind === "resolution",
|
|
);
|
|
renderStatus(result, decisionApplied || decidedByOverlay);
|
|
renderIdentity(result);
|
|
renderChain(result);
|
|
renderPathAnalysis(state.scenario === "live" ? state.liveReport?.pathAnalysis : undefined, facts);
|
|
renderJournal(result);
|
|
renderRules(policyOverlay.snapshot());
|
|
elements.clear.hidden = policyOverlay.snapshot().rules.length === 0;
|
|
elements.frameSeal.textContent = state.scenario === "live"
|
|
? `TRUSTLAB · ${state.source === "offline" ? "OFFLINE EVIDENCE" : "LIVE PROBE"}`
|
|
: "TRUSTLAB · SYNTHETIC";
|
|
elements.exportBundle.hidden = state.scenario !== "live";
|
|
}
|
|
|
|
function renderPathAnalysis(analysis, facts) {
|
|
elements.pathPanel.hidden = !analysis;
|
|
if (!analysis) return;
|
|
const certificates = new Map(
|
|
[...facts.presentedChain, ...facts.constructedChain].map((certificate) => [certificate.sha256, certificate]),
|
|
);
|
|
const pathCards = analysis.paths.map((path, index) => {
|
|
const article = document.createElement("article");
|
|
article.className = `path-card status-${path.status}`;
|
|
article.append(
|
|
node("span", path.status, "entry-kind"),
|
|
node("h3", `Candidate path ${index + 1}`),
|
|
node("p", path.certificateSha256
|
|
.map((fingerprint) => subjectName(certificates.get(fingerprint)?.subject))
|
|
.join(" → ")),
|
|
node("code", [formatCode(path.terminalReason), path.structuralTerminalReason && path.structuralTerminalReason !== path.terminalReason ? `ends at: ${formatCode(path.structuralTerminalReason)}` : undefined, path.trustSource].filter(Boolean).join(" · ")),
|
|
);
|
|
if (path.validationFailures?.length) {
|
|
article.append(node("p", `Validation: ${path.validationFailures.map((failure) => formatCode(failure.code)).join("; ")}`));
|
|
}
|
|
return article;
|
|
});
|
|
const rejected = analysis.edges.filter((edge) =>
|
|
!edge.accepted && (edge.issuerNameMatches || edge.authorityKeyMatches === true || edge.signatureValid),
|
|
);
|
|
if (rejected.length) {
|
|
const heading = node("h3", "Rejected issuer edges", "path-subheading");
|
|
pathCards.push(heading, ...rejected.map((edge) => {
|
|
const article = document.createElement("article");
|
|
article.className = "path-card status-rejected";
|
|
article.append(
|
|
node("span", "rejected", "entry-kind"),
|
|
node("h3", `${subjectName(certificates.get(edge.childSha256)?.subject)} → ${subjectName(certificates.get(edge.issuerSha256)?.subject)}`),
|
|
node("p", edge.reasons.map(formatCode).join("; ")),
|
|
);
|
|
return article;
|
|
}));
|
|
}
|
|
elements.paths.replaceChildren(...pathCards);
|
|
}
|
|
|
|
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) {
|
|
const copy = verdictCopy(result, hasLocalDecision);
|
|
elements.status.dataset.state = result.verdict.trusted ? "trusted" : "not-trusted";
|
|
elements.status.replaceChildren(
|
|
node("p", copy.eyebrow, "eyebrow"),
|
|
node("h1", copy.title),
|
|
node("p", copy.detail, "status-detail"),
|
|
);
|
|
}
|
|
|
|
function renderRules(snapshot) {
|
|
if (snapshot.history.length === 0) {
|
|
elements.rules.replaceChildren(node("p", "No local trust rules.", "empty"));
|
|
return;
|
|
}
|
|
const activeIds = new Set(snapshot.rules.map((rule) => rule.id));
|
|
elements.rules.replaceChildren(
|
|
...snapshot.history.map((rule) => {
|
|
const article = document.createElement("article");
|
|
article.className = "journal-entry kind-resolution";
|
|
const status = activeIds.has(rule.id) ? "active" : "inactive";
|
|
article.append(
|
|
node("span", status, "entry-kind"),
|
|
node("h3", describeScope(rule.scope)),
|
|
node(
|
|
"p",
|
|
`${rule.trusted ? "Trusted" : "Not trusted"} · ${describeLifetime(rule.lifetime)} · ${rule.sourcePluginName}`,
|
|
),
|
|
node("code", rule.id),
|
|
);
|
|
return article;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function describeScope(scope) {
|
|
return scope.kind === "certificate-for-host"
|
|
? `Exact certificate ${shortFingerprint(scope.certificateSha256)} for ${scope.hostname}:${scope.port}`
|
|
: `${scope.includeSubdomains ? "Authority for namespace" : "Authority for host"} ${scope.hostname} · ${shortFingerprint(scope.authorityCertificateSha256)}`;
|
|
}
|
|
|
|
function describeLifetime(lifetime) {
|
|
const labels = {
|
|
connection: "next connection",
|
|
session: "browser session",
|
|
persistent: "until revoked",
|
|
until: `until ${lifetime.expiresAt}`,
|
|
};
|
|
return labels[lifetime.kind];
|
|
}
|
|
|
|
function renderIdentity(result) {
|
|
elements.identity.replaceChildren(
|
|
definition("Requested host", result.facts.hostname),
|
|
definition("Port", String(result.facts.port)),
|
|
definition("Conventional result", result.facts.validation),
|
|
definition("TLS", result.facts.tls.version ?? "Unknown"),
|
|
);
|
|
}
|
|
|
|
function createProbeEvidencePlugin(report) {
|
|
return {
|
|
manifest: {
|
|
manifestVersion: 1,
|
|
trustApiVersion: "0.1",
|
|
id: "org.browsec.live-probe",
|
|
name: "Live TLS probe",
|
|
version: "0.1.0",
|
|
supportedModes: ["advisor"],
|
|
capabilities: {},
|
|
},
|
|
hooks: {
|
|
collectEvidence() {
|
|
return {
|
|
entries: [
|
|
...(report.facts.validation === "success" ? [{
|
|
kind: "vote",
|
|
code: "conventional-validation-succeeded",
|
|
message: "The local OpenSSL validator accepted this certificate path.",
|
|
data: { trusted: true },
|
|
}] : []),
|
|
...report.findings.map((finding) => ({
|
|
kind: finding.check === "trust-anchor" ? "warning" : "evidence",
|
|
code: finding.code,
|
|
message: finding.summary,
|
|
data: { certificateSha256: finding.certificateSha256 },
|
|
})),
|
|
...report.caveats.map((message) => ({
|
|
kind: "warning",
|
|
code: "probe-chain-source-limitation",
|
|
message,
|
|
})),
|
|
],
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderChain(result) {
|
|
elements.chain.replaceChildren(
|
|
...chainRows(result.facts).map((certificate, index, all) => {
|
|
const item = document.createElement("li");
|
|
item.className = certificate.failed ? "certificate failed" : "certificate";
|
|
item.append(
|
|
node("span", certificate.role, "certificate-role"),
|
|
node("strong", certificate.name),
|
|
node("code", certificate.sha256 ?? "No fingerprint"),
|
|
node("span", certificate.edge, "edge-label"),
|
|
);
|
|
if (certificate.der) item.append(renderDerExplorer(certificate));
|
|
item.setAttribute("aria-label", `${certificate.role}: ${certificate.name}. ${certificate.edge}`);
|
|
if (index < all.length - 1) item.dataset.linked = "true";
|
|
return item;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function renderDerExplorer(certificate) {
|
|
const details = document.createElement("details");
|
|
details.className = "der-explorer";
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = `Certificate internals · ${certificate.der.byteLength} DER bytes · ${certificate.der.extensions.length} extensions`;
|
|
details.append(summary);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "der-actions";
|
|
const download = document.createElement("a");
|
|
download.className = "button quiet";
|
|
download.textContent = "Save original .der";
|
|
download.download = `${certificate.sha256}.der`;
|
|
download.href = `data:application/pkix-cert;base64,${certificate.derBase64}`;
|
|
actions.append(download);
|
|
details.append(actions);
|
|
|
|
const derived = document.createElement("dl");
|
|
derived.className = "der-derived";
|
|
derived.append(
|
|
definition("Basic Constraints", certificate.der.derived.isCa
|
|
? `CA: true${certificate.der.derived.pathLength === undefined ? "" : `, path length: ${certificate.der.derived.pathLength}`}`
|
|
: "CA: false"),
|
|
definition("Key Usage", certificate.der.derived.keyUsages.join(", ") || "Not asserted"),
|
|
definition("Unsupported critical extensions", certificate.der.derived.unsupportedCriticalExtensions.join(", ") || "None"),
|
|
);
|
|
details.append(derived);
|
|
|
|
const extensions = document.createElement("div");
|
|
extensions.className = "der-extensions";
|
|
for (const extension of certificate.der.extensions) {
|
|
const row = document.createElement("article");
|
|
if (extension.critical) row.className = extension.supported ? "critical" : "critical unknown";
|
|
row.append(
|
|
node("strong", extension.name),
|
|
node("code", extension.oid),
|
|
node("span", `${extension.critical ? "critical" : "non-critical"} · bytes ${extension.offset}–${extension.offset + extension.length - 1}`),
|
|
node("pre", extension.decoded ? JSON.stringify(extension.decoded, null, 2) : extension.valueHex),
|
|
);
|
|
extensions.append(row);
|
|
}
|
|
details.append(extensions);
|
|
|
|
details.append(renderSynchronizedDer(certificate));
|
|
return details;
|
|
}
|
|
|
|
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) {
|
|
if (result.journal.entries.length === 0) {
|
|
elements.journal.replaceChildren(node("p", "No plugin findings.", "empty"));
|
|
return;
|
|
}
|
|
|
|
elements.journal.replaceChildren(
|
|
...result.journal.entries.map((entry) => {
|
|
const article = document.createElement("article");
|
|
article.className = `journal-entry kind-${entry.kind}`;
|
|
article.append(
|
|
node("span", entry.kind, "entry-kind"),
|
|
node("h3", entry.pluginName),
|
|
node("p", entry.message ?? formatCode(entry.code)),
|
|
node("code", entry.code ?? entry.id),
|
|
);
|
|
return article;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function definition(term, value) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.append(node("dt", term), node("dd", value));
|
|
return wrapper;
|
|
}
|
|
|
|
function node(tag, text, className) {
|
|
const element = document.createElement(tag);
|
|
element.textContent = text;
|
|
if (className) element.className = className;
|
|
return element;
|
|
}
|
|
|
|
function formatCode(value = "") {
|
|
return value.replaceAll("-", " ");
|
|
}
|
|
|
|
function configure(plugin, mode) {
|
|
return { plugin, mode };
|
|
}
|
|
|
|
function renderTrustTargets(facts) {
|
|
const current = elements.target.value;
|
|
const options = [new Option("Exact certificate for this host", "certificate")];
|
|
const seen = new Set();
|
|
for (const certificate of facts.constructedChain) {
|
|
if (
|
|
!certificate.isCa ||
|
|
!certificate.keyUsages.includes("keyCertSign") ||
|
|
certificate.unsupportedCriticalExtensions?.length > 0 ||
|
|
seen.has(certificate.sha256)
|
|
) {
|
|
continue;
|
|
}
|
|
seen.add(certificate.sha256);
|
|
options.push(
|
|
new Option(
|
|
`Authority: ${subjectName(certificate.subject)} · ${shortFingerprint(certificate.sha256)}`,
|
|
`authority:${certificate.sha256}`,
|
|
),
|
|
);
|
|
}
|
|
elements.target.replaceChildren(...options);
|
|
if (options.some((option) => option.value === current)) elements.target.value = current;
|
|
elements.includeSubdomains.disabled = !elements.target.value.startsWith("authority:");
|
|
}
|
|
|
|
function shortFingerprint(fingerprint) {
|
|
return fingerprint.length > 18
|
|
? `${fingerprint.slice(0, 8)}…${fingerprint.slice(-8)}`
|
|
: fingerprint;
|
|
}
|
|
|
|
renderTrustTargets(scenarios[state.scenario].facts);
|
|
render();
|