penthera 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +158 -301
  3. package/bin/scan.js +2 -2
  4. package/docs/METHODOLOGY.md +236 -182
  5. package/docs/commands/README.md +2 -1
  6. package/docs/commands/init.md +114 -0
  7. package/docs/commands/scan.md +1 -1
  8. package/docs/compare/github.md +1 -1
  9. package/docs/dashboard.md +95 -152
  10. package/docs/examples/github-actions-scan.yml +1 -1
  11. package/docs/internal-audit.md +1 -1
  12. package/docs/plan.md +3 -2
  13. package/docs/references.bib +43 -33
  14. package/docs/references.md +92 -51
  15. package/lib/whitebox/frameworks.js +36 -0
  16. package/package.json +1 -1
  17. package/skills/penthera/SKILL.md +19 -5
  18. package/src/cli/deps.js +1 -1
  19. package/src/cli/detect.js +125 -0
  20. package/src/cli/onboarding.js +428 -6
  21. package/src/cli/prompt.js +42 -0
  22. package/src/cli.js +18 -5
  23. package/src/ui/api/data.js +30 -0
  24. package/src/ui/api/demo.js +13 -0
  25. package/src/ui/api/routes.js +1 -0
  26. package/src/ui/static/app.css +286 -560
  27. package/src/ui/static/index.html +8 -39
  28. package/src/ui/static/js/app.js +105 -107
  29. package/src/ui/static/js/center.js +388 -0
  30. package/src/ui/static/js/copy.js +36 -62
  31. package/src/ui/static/js/dom.js +0 -38
  32. package/src/ui/static/js/left.js +83 -0
  33. package/src/ui/static/js/right.js +270 -0
  34. package/src/ui/static/js/state.js +35 -0
  35. package/src/ui/static/js/views/ai.js +0 -68
  36. package/src/ui/static/js/views/deps.js +0 -96
  37. package/src/ui/static/js/views/evidence.js +0 -115
  38. package/src/ui/static/js/views/findings.js +0 -165
  39. package/src/ui/static/js/views/history.js +0 -92
  40. package/src/ui/static/js/views/overview.js +0 -184
  41. package/src/ui/static/js/views/run.js +0 -349
  42. package/src/ui/static/js/views/settings.js +0 -67
@@ -1,349 +0,0 @@
1
- import { h, put, panel, clear, empty, diffBlock, announce, sevBadge, subwin, whatNext } from "../dom.js";
2
- import { createTyper } from "../typer.js";
3
- import { bars } from "../charts.js";
4
- import { ago, duration, pct, shortProject } from "../format.js";
5
- import { SCREENS, RUN_KINDS, NEXT, EMPTY } from "../copy.js";
6
-
7
- const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
8
-
9
- function hostOf(raw) {
10
- const v = String(raw || "").trim();
11
- if (!v) return null;
12
- try { return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`).hostname; } catch { return null; }
13
- }
14
-
15
- export async function render(root, { api, params, meta, go }) {
16
- const [{ live, past }, mem] = await Promise.all([api.get("/api/runs"), api.get("/api/memory")]);
17
- const projects = meta.projects || [];
18
- const startKind = RUN_KINDS.some((k) => k.kind === params.get("kind")) ? params.get("kind") : "scan";
19
-
20
- // ── Form ──
21
- const radios = RUN_KINDS.map((k) => h("input", { type: "radio", name: "r-kind", value: k.kind, checked: k.kind === startKind }));
22
- const kindOf = () => radios.find((r) => r.checked)?.value || "scan";
23
- const repo = h("input", { type: "text", id: "r-repo", list: "r-projects", autocomplete: "off", spellcheck: "false", value: params.get("repo") || projects[0] || "", placeholder: "/path/to/project" });
24
- const url = h("input", { type: "text", id: "r-url", autocomplete: "off", spellcheck: "false", value: params.get("url") || "", placeholder: "http://localhost:3000" });
25
- const confirm = h("input", { type: "text", id: "r-confirm", autocomplete: "off", spellcheck: "false" });
26
- const confirmHint = h("span", { class: "hint" });
27
- const confirmWrap = h("label", { class: "field c12", for: "r-confirm", hidden: true }, h("span", {}, "Confirm the target"), confirm, confirmHint);
28
- const iters = h("input", { type: "number", id: "r-iter", min: "1", max: "50", value: "5" });
29
- const tests = h("input", { type: "checkbox", id: "r-tests" });
30
- const urlWrap = h("label", { class: "field c4", for: "r-url" }, h("span", {}, "Running app URL ", h("span", { class: "hint" }, "(optional)")), url);
31
- const iterWrap = h("label", { class: "field c2", for: "r-iter" }, h("span", {}, "Max rounds"), iters);
32
- const testsWrap = h("label", { class: "check", for: "r-tests" }, tests, "also run the project's tests to verify");
33
- const note = h("span", { class: "muted" });
34
- const start = h("button", { type: "submit", class: "btn primary" });
35
- const formErr = h("p", { class: "error", role: "alert", hidden: true });
36
-
37
- const NOTES = {
38
- scan: "Contacts only the URL's host (and nothing at all for a folder-only scan) while offline is on in Settings.",
39
- deps: "Reads package-lock.json and node_modules. No network.",
40
- agents: "Reads skills, MCP config, hooks and rule files. Read-only, no network.",
41
- loop: "Always offline :: works on a local branch :: every diff waits for you :: never pushes.",
42
- };
43
- const sync = () => {
44
- const k = kindOf();
45
- const def = RUN_KINDS.find((x) => x.kind === k);
46
- urlWrap.hidden = k === "deps" || k === "agents";
47
- iterWrap.hidden = k !== "loop";
48
- testsWrap.hidden = k !== "loop";
49
- note.textContent = NOTES[k];
50
- start.textContent = `${def.button} ▸`;
51
- const host = urlWrap.hidden ? null : hostOf(url.value);
52
- const remote = host && !LOCAL.test(host);
53
- confirmWrap.hidden = !remote;
54
- if (remote) {
55
- confirm.placeholder = host;
56
- confirmHint.textContent = `${host} is not on this machine. Type its name to confirm you own it or have written permission to test it.`;
57
- }
58
- };
59
- url.addEventListener("input", sync);
60
- for (const r of radios) r.addEventListener("change", sync);
61
-
62
- const form = h("form", { class: "form-grid", novalidate: true },
63
- h("div", { class: "c12" }, h("div", { class: "seg", role: "radiogroup", "aria-label": "What to run" },
64
- RUN_KINDS.map((k, i) => h("label", {}, radios[i], h("span", {}, k.label))),
65
- )),
66
- h("label", { class: "field c6", for: "r-repo" }, h("span", {}, "Project folder"), repo, h("datalist", { id: "r-projects" }, projects.map((p) => h("option", { value: p })))),
67
- urlWrap,
68
- iterWrap,
69
- confirmWrap,
70
- h("div", { class: "c12 filters" }, testsWrap, note),
71
- h("div", { class: "c12" }, start, formErr),
72
- );
73
-
74
- // ── Terminal ──
75
- const term = h("div", { class: "term", role: "log", "aria-live": "off", "aria-label": "Run output", tabindex: "0" });
76
- const statusChip = h("span", { class: "chip" }, "idle");
77
- const stopBtn = h("button", { type: "button", class: "btn", disabled: true }, "Stop");
78
- const approvalSlot = h("div");
79
- const nextSlot = h("div");
80
- const timeline = h("ol", { class: "timeline", "aria-label": "Rounds" });
81
- const runInfo = h("div", { class: "muted" }, "tty :: idle");
82
- const ttyTitle = h("span", { class: "title" }, "penthera :: tty");
83
-
84
- const termWin = subwin("Terminal", { class: "span-8" },
85
- h("div", { class: "term-head" }, runInfo, h("div", { class: "filters" }, statusChip, stopBtn)),
86
- term,
87
- approvalSlot,
88
- );
89
- termWin.querySelector(".titlebar .title").replaceWith(ttyTitle);
90
-
91
- put(root,
92
- h("h1", {}, SCREENS.run.title),
93
- h("p", { class: "lede" }, SCREENS.run.lede),
94
- h("div", { class: "grid" },
95
- panel("What to run", { class: "span-12" }, form),
96
- termWin,
97
- h("div", { class: "span-4 grid" },
98
- panel("Rounds", { class: "span-12" }, timeline),
99
- panel("Memory", { class: "span-12" }, memoryPanel(mem.projects)),
100
- ),
101
- h("div", { class: "span-12" }, nextSlot),
102
- panel("Past loop runs", { class: "span-12" }, pastRuns(past)),
103
- ),
104
- );
105
-
106
- let typer = createTyper(term);
107
- let es = null;
108
- let run = null;
109
- let pending = null;
110
- const its = new Map();
111
-
112
- timeline.append(h("li", {}, h("span", { class: "muted" }, "Rounds appear here when the fix loop runs.")));
113
- typer.push("Ready. Pick what to run, check the folder, and press the button.", "dim");
114
- typer.push("The fix loop stops at every diff :: [y] approve [n] skip [q] stop.", "dim");
115
-
116
- function setStatus(st) {
117
- statusChip.textContent = st;
118
- statusChip.className = `chip ${st === "running" ? "on" : st === "awaiting you" ? "blue" : st === "error" ? "inv" : ""}`;
119
- stopBtn.disabled = !(st === "running" || st === "awaiting you");
120
- }
121
-
122
- function itemFor(n, of) {
123
- if (!its.has(n)) {
124
- const fixes = h("ul", { class: "fixes" });
125
- const head = h("div", { class: "t1" }, `Round ${n}`, of ? h("span", { class: "muted" }, ` of ${of}`) : null);
126
- const sub = h("div", { class: "muted" }, "scanning…");
127
- const li = h("li", {}, head, sub, fixes);
128
- timeline.append(li);
129
- its.set(n, { li, sub, fixes, current: null });
130
- }
131
- return its.get(n);
132
- }
133
- let lastIt = null;
134
- const mark = (li, txt, cls) => { li.firstChild.textContent = txt; li.firstChild.className = cls; };
135
-
136
- function onEvent(ev) {
137
- const d = ev.data;
138
- switch (ev.type) {
139
- case "log": {
140
- const t = d.text.replace(/^\n/, "");
141
- const cls = /^penthera /.test(t) ? "cmd" : /verified and committed|done:|^(scan|deps|agents) done/.test(t) ? "ok"
142
- : /failed verification|^error|not applied/.test(t) ? "bad" : /^\s*iteration \d+/.test(t) ? "hl" : /^\s{3,}/.test(t) || /\.\.\.$/.test(t) ? "dim" : null;
143
- typer.push(cls === "dim" || cls === "bad" ? t.trim() : t, cls);
144
- break;
145
- }
146
- case "iteration": lastIt = itemFor(d.n, d.of); break;
147
- case "scanned": if (lastIt) lastIt.sub.textContent = `${d.findings} findings :: ${d.fixable} fixable :: ${d.suppressed} suppressed`; break;
148
- case "approval": {
149
- pending = d;
150
- const it = itemFor(d.iteration);
151
- const li = h("li", {}, h("span", { class: "blue" }, "??"), h("span", { class: "t" }, d.plan.title));
152
- it.fixes.append(li);
153
- it.current = li;
154
- showApproval(d);
155
- setStatus("awaiting you");
156
- announce(`Diff ${d.index} of ${d.total} is waiting for you: ${d.plan.title}`);
157
- break;
158
- }
159
- case "decision": {
160
- pending = null;
161
- clear(approvalSlot);
162
- const it = lastIt || itemFor(1);
163
- const m = { y: ["..", "muted"], n: ["--", "muted"], skip: ["~~", "muted"], quit: ["xx", "bad"] }[d.answer] || ["..", "muted"];
164
- if (it.current) mark(it.current, ...m);
165
- if (d.answer !== "y") it.current = null;
166
- setStatus("running");
167
- break;
168
- }
169
- case "fix": {
170
- if (lastIt?.current) {
171
- const [txt, cls] = d.status === "verified" ? ["OK", "ok"] : d.status === "failed" ? ["!!", "bad"] : ["--", "muted"];
172
- mark(lastIt.current, txt, cls);
173
- lastIt.current = null;
174
- }
175
- break;
176
- }
177
- case "status": {
178
- if (d.status === "running") { setStatus("running"); break; }
179
- setStatus(d.status);
180
- clear(approvalSlot);
181
- start.disabled = false;
182
- const sum = d.summary;
183
- if (sum?.totals) {
184
- const t = sum.totals;
185
- typer.push(`done: ${sum.stopReason} after ${t.iterations} round${t.iterations === 1 ? "" : "s"} (${duration(sum.durationMs)})`, "ok");
186
- typer.push(`${t.verified} verified and committed :: ${t.failed} failed and reverted :: ${t.declined} skipped`, t.failed ? "bad" : "ok");
187
- for (const it of its.values()) if (it.sub.textContent === "scanning…") it.sub.textContent = "nothing left to fix";
188
- }
189
- if (d.status === "done" || d.status === "stopped") {
190
- clear(nextSlot).append(whatNext(NEXT[run.kind] || NEXT.scan, go));
191
- }
192
- announce(`Run ${d.status}`);
193
- break;
194
- }
195
- default: break;
196
- }
197
- }
198
-
199
- function showApproval(d) {
200
- typer.flush();
201
- const p = d.plan;
202
- const decide = async (answer) => {
203
- for (const b of approvalSlot.querySelectorAll("button")) b.disabled = true;
204
- try {
205
- await api.post(`/api/runs/${run.id}/decision`, { approvalId: d.approvalId, answer });
206
- } catch (e) {
207
- typer.push(`error: ${e.message}`, "bad");
208
- for (const b of approvalSlot.querySelectorAll("button")) b.disabled = false;
209
- }
210
- };
211
- const approve = h("button", { type: "button", class: "btn primary", onclick: () => decide("approve") }, "Apply this fix", h("span", { class: "k" }, "y"));
212
- clear(approvalSlot).append(h("section", { class: "alert", role: "alertdialog", "aria-labelledby": "ap-h", "aria-describedby": "ap-d" },
213
- h("div", { class: "icon", "aria-hidden": "true" }, "?"),
214
- h("h3", { id: "ap-h" }, `Apply fix ${d.index} of ${d.total}?`),
215
- h("p", { id: "ap-d", class: "t1" }, p.title),
216
- h("p", { class: "muted" }, d.memory ? `Memory :: this recipe verified ${d.memory.verified} times, failed ${d.memory.failed} on this stack.` : "Memory :: no history for this recipe yet."),
217
- h("div", { class: "body" },
218
- h("dl", { class: "kv" },
219
- h("dt", {}, "recipe"), h("dd", {}, `${p.recipe}${p.framework ? ` (${p.framework})` : ""} :: confidence ${p.confidence}`),
220
- h("dt", {}, "files"), h("dd", {}, p.files.join(", ")),
221
- h("dt", {}, "fixes"), h("dd", {}, p.findings.map((f) => h("div", {}, sevBadge(f.severity), " ", f.title))),
222
- ),
223
- diffBlock(p.diff),
224
- p.redacted ? h("p", { class: "muted" }, "Secret values are masked here; the real patch uses them.") : null,
225
- ),
226
- h("div", { class: "actions" },
227
- h("button", { type: "button", class: "btn", onclick: () => decide("stop") }, "Stop the loop", h("span", { class: "k" }, "q")),
228
- h("button", { type: "button", class: "btn", onclick: () => decide("skip") }, "Skip", h("span", { class: "k" }, "n")),
229
- approve,
230
- ),
231
- ));
232
- approve.focus({ preventScroll: true });
233
- approvalSlot.scrollIntoView({ block: "nearest" });
234
- }
235
-
236
- function attach(r) {
237
- run = r;
238
- const what = RUN_KINDS.find((k) => k.kind === r.kind)?.label.toLowerCase() || r.kind;
239
- runInfo.textContent = `${what} :: ${r.repo ? shortProject(r.repo) : ""}${r.url ? ` + ${r.url}` : ""} :: started ${ago(r.startedAt)}`;
240
- ttyTitle.textContent = `penthera :: tty :: ${what}`;
241
- es?.close();
242
- es = new EventSource(`/api/runs/${r.id}/stream`);
243
- for (const type of ["log", "iteration", "scanned", "approval", "decision", "fix", "status"]) {
244
- es.addEventListener(type, (m) => { try { onEvent(JSON.parse(m.data)); } catch { /* malformed event */ } });
245
- }
246
- es.addEventListener("end", () => { es.close(); es = null; });
247
- }
248
-
249
- form.addEventListener("submit", async (e) => {
250
- e.preventDefault();
251
- formErr.hidden = true;
252
- start.disabled = true;
253
- const kind = kindOf();
254
- try {
255
- const r = await api.post("/api/runs", {
256
- kind, repo: repo.value, url: urlWrap.hidden ? "" : url.value, confirm: confirm.value,
257
- maxIterations: Number(iters.value), runTests: kind === "loop" && tests.checked,
258
- });
259
- typer.stop();
260
- clear(term);
261
- clear(timeline);
262
- clear(nextSlot);
263
- its.clear();
264
- lastIt = null;
265
- typer = createTyper(term);
266
- attach(r);
267
- } catch (err) {
268
- formErr.textContent = err.message;
269
- formErr.hidden = false;
270
- start.disabled = false;
271
- if (err.status === 428) confirm.focus();
272
- }
273
- });
274
-
275
- stopBtn.addEventListener("click", async () => {
276
- if (!run) return;
277
- stopBtn.disabled = true;
278
- try { await api.post(`/api/runs/${run.id}/stop`); } catch (e) { typer.push(`error: ${e.message}`, "bad"); }
279
- });
280
-
281
- const onKey = (e) => {
282
- if (!pending || e.metaKey || e.ctrlKey || e.altKey) return;
283
- if (/^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || "")) return;
284
- const btns = approvalSlot.querySelectorAll(".actions button");
285
- const map = { q: 0, n: 1, y: 2 };
286
- if (e.key in map && btns[map[e.key]] && !btns[map[e.key]].disabled) { e.preventDefault(); btns[map[e.key]].click(); }
287
- };
288
- document.addEventListener("keydown", onKey);
289
-
290
- // Re-attach to a run that is still going (navigated away and back).
291
- const running = live.find((r) => r.status === "running");
292
- if (running) {
293
- start.disabled = true;
294
- typer.stop();
295
- clear(term);
296
- typer = createTyper(term);
297
- attach(running);
298
- }
299
- sync();
300
-
301
- return () => {
302
- es?.close();
303
- typer.stop();
304
- document.removeEventListener("keydown", onKey);
305
- };
306
- }
307
-
308
- function memoryPanel(projects) {
309
- if (!projects.length) return empty(EMPTY.memory);
310
- const recipes = new Map();
311
- for (const p of projects) for (const r of p.recipes) {
312
- const cur = recipes.get(r.recipe) || { verified: 0, failed: 0 };
313
- cur.verified += r.verified;
314
- cur.failed += r.failed;
315
- recipes.set(r.recipe, cur);
316
- }
317
- const events = projects.reduce((n, p) => n + p.events, 0);
318
- const verified = projects.reduce((n, p) => n + p.verifiedFixes, 0);
319
- const failed = projects.reduce((n, p) => n + p.failedFixes, 0);
320
- const broken = projects.filter((p) => p.chain !== "ok");
321
- return h("div", {},
322
- h("dl", { class: "kv" },
323
- h("dt", {}, "projects"), h("dd", {}, String(projects.length)),
324
- h("dt", {}, "events"), h("dd", {}, String(events)),
325
- h("dt", {}, "chain"), h("dd", { class: broken.length ? "bad" : "ok" }, broken.length ? `${broken.length} broken :: ignored there` : "[OK] sha-256 hash chain"),
326
- h("dt", {}, "fixes"), h("dd", {}, h("span", { class: "ok" }, `${verified} verified`), ` :: ${failed} failed`),
327
- ),
328
- h("p", { class: "label" }, "Recipe success"),
329
- bars([...recipes].map(([recipe, r]) => ({ label: recipe, value: r.verified })), { width: 10 }),
330
- h("p", { class: "panel-note" }, [...recipes].map(([recipe, r]) => `${recipe} ${pct(r.verified / Math.max(1, r.verified + r.failed))}`).join(" :: ")),
331
- );
332
- }
333
-
334
- function pastRuns(past) {
335
- if (!past.length) return empty(EMPTY.pastRuns);
336
- return h("div", { class: "table-wrap" }, h("table", {},
337
- h("thead", {}, h("tr", {}, ...["When", "Project", "Findings", "Verified", "Reverted", "Skipped", "Stopped because", "Branch"].map((t, i) => h("th", { class: [i >= 3 && i <= 5 ? "num" : null, i === 7 ? "hide-narrow" : null] }, t)))),
338
- h("tbody", {}, past.map((l) => h("tr", {},
339
- h("td", { class: "muted" }, ago(l.startedAt)),
340
- h("td", { class: "trunc t1", title: l.target.repo || "" }, shortProject(l.target.repo)),
341
- h("td", { class: "t1" }, `${l.findings.initial ?? "?"} → ${l.findings.final ?? "?"}`),
342
- h("td", { class: "num ok" }, String(l.totals.verified)),
343
- h("td", { class: ["num", l.totals.failed ? "bad" : "muted"] }, String(l.totals.failed)),
344
- h("td", { class: "num muted" }, String(l.totals.declined + l.totals.skipped)),
345
- h("td", { class: "muted" }, l.stopReason || "-"),
346
- h("td", { class: "muted hide-narrow" }, l.branch || "-"),
347
- ))),
348
- ));
349
- }
@@ -1,67 +0,0 @@
1
- import { h, put, panel, empty, announce } from "../dom.js";
2
- import { SCREENS } from "../copy.js";
3
-
4
- export async function render(root, { api, meta, refreshMeta, theme }) {
5
- const [settings, mem] = await Promise.all([api.get("/api/settings"), api.get("/api/memory")]);
6
-
7
- const sw = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(settings.offline), "aria-describedby": "egress-help" },
8
- h("span", {}, "Offline mode"), h("span", { class: "state" }, settings.offline ? "[ON]" : "[OFF]"));
9
- const help = h("p", { id: "egress-help", class: "muted" });
10
- const paint = (offline) => {
11
- sw.setAttribute("aria-checked", String(offline));
12
- sw.lastChild.textContent = offline ? "[ON]" : "[OFF]";
13
- help.textContent = offline
14
- ? "Scans started here contact only their target. No recon sources, no Retire.js download (the vendored snapshot is used)."
15
- : "Scans started here may also contact recon sources (certificate logs, web archives) and download the latest Retire.js database.";
16
- };
17
- paint(settings.offline);
18
- sw.addEventListener("click", async () => {
19
- try {
20
- const s = await api.post("/api/settings", { offline: sw.getAttribute("aria-checked") !== "true" });
21
- paint(s.offline);
22
- announce(`Offline ${s.offline ? "on" : "off"}`);
23
- await refreshMeta?.();
24
- } catch (e) {
25
- help.textContent = e.message;
26
- }
27
- });
28
-
29
- put(root,
30
- h("h1", {}, "Settings"),
31
- h("p", { class: "lede" }, "For this dashboard session. Nothing here is written to disk."),
32
- h("div", { class: "grid" },
33
- panel("Egress", { class: "span-6" }, sw, help,
34
- h("p", { class: "panel-note" }, "The agent loop is always offline and limited to its URL host, whatever this says. The dashboard itself never contacts the network.")),
35
- panel("Where data comes from", { class: "span-6" },
36
- h("dl", { class: "kv" },
37
- h("dt", {}, "reports"), h("dd", {}, meta.reportsDir),
38
- h("dt", {}, "memory"), h("dd", {}, meta.memoryRoot),
39
- h("dt", {}, "projects"), h("dd", {}, meta.projects.map((p) => h("div", {}, p))),
40
- ),
41
- h("p", { class: "panel-note" }, "Only these folders and ~/.penthera are read. Move memory with PENTHERA_HOME; add folders with penthera ui --project <dir> --reports <dir>."),
42
- ),
43
- panel("Memory chains", { class: "span-12" },
44
- mem.projects.length ? h("div", { class: "table-wrap" }, h("table", {},
45
- h("thead", {}, h("tr", {}, ...["Project", "Chain", "Events", "Scans", "Suppressions", "Median time to fix"].map((t, i) => h("th", { class: i >= 2 ? "num" : null }, t)))),
46
- h("tbody", {}, mem.projects.map((p) => h("tr", {},
47
- h("td", { class: "trunc", title: p.label }, p.label),
48
- h("td", { class: p.chain === "ok" ? "ok" : "bad" }, p.chain === "ok" ? "[OK]" : `[BROKEN @ line ${p.brokenAt}]`),
49
- h("td", { class: "num" }, String(p.events)),
50
- h("td", { class: "num" }, String(p.scans)),
51
- h("td", { class: "num" }, String(p.suppressions)),
52
- h("td", { class: "num" }, p.medianTimeToFixDays == null ? "-" : `${p.medianTimeToFixDays}d`),
53
- ))),
54
- )) : empty("No memory yet."),
55
- ),
56
- panel("Session security", { class: "span-12" },
57
- h("dl", { class: "kv" },
58
- h("dt", {}, "bound to"), h("dd", {}, `${location.host} (127.0.0.1 only)`),
59
- h("dt", {}, "session"), h("dd", {}, "one-time URL token swapped for an HttpOnly, SameSite=Strict cookie"),
60
- h("dt", {}, "requests"), h("dd", {}, "Host header must be 127.0.0.1 or localhost (DNS rebinding); state changes need this page's Origin"),
61
- h("dt", {}, "page"), h("dd", {}, "strict CSP, no inline script, no framing, no CORS, no third-party anything"),
62
- h("dt", {}, "data"), h("dd", {}, meta.demo ? "--demo: fake data, nothing read from disk" : "local reports and memory"),
63
- ),
64
- ),
65
- ),
66
- );
67
- }