penthera 1.6.1 → 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 +58 -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
@@ -0,0 +1,388 @@
1
+ // The centre: one scrolling stream. A project (or all of them) as a compact
2
+ // header, "Fix first" and the findings; a run as a transcript of tool output
3
+ // with inline Apply / Skip / Stop; and the prompt bar that starts actions.
4
+ import { h, clear, empty, sevBadge, diffBlock, announce } from "./dom.js";
5
+ import { api } from "./api.js";
6
+ import { state, set, projectOf, folderFor } from "./state.js";
7
+ import { createTyper } from "./typer.js";
8
+ import { stackedBar, sparkline, wordmark } from "./charts.js";
9
+ import { ago, duration, shortProject, plural, SEVERITIES } from "./format.js";
10
+ import { START, KINDS, NOTES, EMPTY } from "./copy.js";
11
+
12
+ const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
13
+ const SEV_RANK = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
14
+
15
+ function hostOf(raw) {
16
+ const v = String(raw || "").trim();
17
+ if (!v) return null;
18
+ try { return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`).hostname; } catch { return null; }
19
+ }
20
+
21
+ // ── Stream state that outlives a redraw: the live run's typer and SSE ──
22
+ let typer = null;
23
+ let es = null;
24
+ let pending = null;
25
+ let visible = [];
26
+
27
+ export function stopStream() {
28
+ es?.close();
29
+ es = null;
30
+ typer?.stop();
31
+ typer = null;
32
+ }
33
+
34
+ export function renderCenter(el, ctx) {
35
+ const { toggleLeft, toggleRight } = ctx;
36
+ const search = h("input", { type: "search", id: "q", placeholder: "Search /", "aria-label": "Search findings", value: state.query });
37
+ search.addEventListener("input", () => { set({ query: search.value }); });
38
+ const title = state.sel.kind === "all" ? "All projects"
39
+ : state.sel.kind === "project" ? shortProject(state.sel.key)
40
+ : state.sel.kind === "run" ? `Run :: ${state.sel.run?.kind || ""}` : "Fix loop run";
41
+ const toolbar = h("div", { class: "toolbar" },
42
+ h("button", { type: "button", class: "icon", "aria-label": "Toggle the sidebar ([)", title: "Sidebar [", onclick: toggleLeft }, "☰"),
43
+ h("span", { class: "title" }, title),
44
+ h("span", { class: "grow" }),
45
+ state.sel.kind === "all" || state.sel.kind === "project" ? search : null,
46
+ h("button", { type: "button", class: "icon", "aria-label": "Toggle the details panel (])", title: "Details ]", onclick: toggleRight }, "◫"),
47
+ );
48
+ const stream = h("div", { class: "stream", id: "stream" });
49
+ const col = h("div", { class: "col" });
50
+ stream.append(col);
51
+ clear(el).append(toolbar, stream, promptBar(ctx));
52
+
53
+ if (state.sel.kind === "run") return runView(col, ctx);
54
+ stopStream();
55
+ if (state.sel.kind === "past") return pastRunView(col, ctx);
56
+ return projectView(col, ctx);
57
+ }
58
+
59
+ /** Redraw only the prompt bar (a refresh while a run is on screen must not rebuild its transcript). */
60
+ export function renderBar(el, ctx) {
61
+ const old = el.querySelector(".bar");
62
+ if (old) old.replaceWith(promptBar(ctx));
63
+ }
64
+
65
+ /** Re-render only the findings list (search typing must not rebuild the toolbar). */
66
+ export function refreshList(ctx) {
67
+ const list = document.getElementById("findings-list");
68
+ if (list) fillFindings(list, ctx);
69
+ }
70
+
71
+ // ── Project (or all projects) ──────────────────────────────────────────
72
+
73
+ function projectView(col, ctx) {
74
+ const o = state.overview;
75
+ if (!o) return;
76
+ const all = state.sel.kind === "all";
77
+ const key = state.sel.key;
78
+ const p = all ? null : projectOf(key);
79
+
80
+ if (!o.projects.length && all) return startView(col, ctx);
81
+ if (!all && !p) return notScannedView(col, key, ctx);
82
+
83
+ const counts = all ? o.mix : p.counts;
84
+ const open = all ? o.open : p.open;
85
+ const fixed = all ? o.fixed : p.fixed;
86
+ const trend = all ? combinedTrend(o.projects) : p.trend.map((t) => t.total);
87
+ col.append(h("header", { class: "head" },
88
+ h("span", { class: `grade lg grade-${all ? "x" : p.grade}`, title: all ? "" : `score ${p.score}/100` }, all ? "∑" : p.grade),
89
+ h("h1", { title: key || "" }, all ? "All projects" : shortProject(key)),
90
+ h("div", { class: "meta" }, all ? `${plural(o.projects.length, "project")} · newest scan ${ago(o.lastScans[0]?.ts)}` : `${p.mode || "scan"} · last scan ${ago(p.lastScan)} · ${plural(p.trend.length, "scan")}`),
91
+ h("div", { class: "right-bits" },
92
+ stackedBar(counts, { width: 24 }),
93
+ sparkline(trend, { label: "open findings per scan" }),
94
+ h("div", { class: "counts" }, h("b", {}, String(open)), " open · ", h("span", { class: "ok" }, String(fixed)), " fixed"),
95
+ ),
96
+ ));
97
+
98
+ // Since the last scan (the old History page, condensed).
99
+ const since = h("div");
100
+ col.append(since);
101
+ if (!all && p.trend.length > 1) {
102
+ api.get(`/api/history?project=${encodeURIComponent(key)}`).then((hst) => {
103
+ if (state.sel.key !== key) return;
104
+ since.append(h("section", { class: "section" },
105
+ h("h2", {}, h("span", {}, "Since the last scan"), h("span", {}, `${hst.new.length} new · ${hst.fixed.length} fixed`)),
106
+ h("div", { class: "cols2" },
107
+ miniList(hst.new, "Nothing new."),
108
+ miniList(hst.fixed, "Nothing fixed since the last scan.", true),
109
+ )));
110
+ }).catch(() => {});
111
+ } else if (all && (o.columns.newest.length || o.columns.fixed.length)) {
112
+ since.append(h("section", { class: "section" },
113
+ h("h2", {}, h("span", {}, "Since the last scan"), h("span", {}, `${o.columns.newest.length} new · ${o.columns.fixed.length} fixed`)),
114
+ h("div", { class: "cols2" }, miniList(o.columns.newest, "Nothing new."), miniList(o.columns.fixed, "Nothing fixed.", true))));
115
+ }
116
+
117
+ const list = h("ul", { class: "rows", id: "findings-list", "aria-label": "Findings" });
118
+ col.append(h("section", { class: "section" }, h("h2", {}, h("span", {}, "Findings"), h("span", { id: "findings-count" })), list));
119
+ fillFindings(list, ctx);
120
+ }
121
+
122
+ function fillFindings(list, ctx) {
123
+ const all = state.sel.kind === "all";
124
+ const q = state.query.trim().toLowerCase();
125
+ const rows = state.findings
126
+ .filter((f) => !f.suppressed && (all || f.project === state.sel.key))
127
+ .filter((f) => !q || `${f.title} ${f.category} ${f.location} ${f.project}`.toLowerCase().includes(q))
128
+ .sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.title.localeCompare(b.title));
129
+ visible = rows;
130
+ clear(list);
131
+ let group = null;
132
+ for (const f of rows) {
133
+ const fixFirst = f.severity === "critical" || f.severity === "high";
134
+ const g = fixFirst ? "Fix first" : "Then";
135
+ if (g !== group) {
136
+ group = g;
137
+ list.append(h("li", { class: "group-h" }, g === "Fix first" ? `Fix first :: ${rows.filter((x) => x.severity === "critical" || x.severity === "high").length}` : `Then :: ${rows.filter((x) => !(x.severity === "critical" || x.severity === "high")).length}`));
138
+ }
139
+ const cur = state.finding && state.finding.id === f.id && state.finding.project === f.project;
140
+ list.append(h("li", {}, h("button", {
141
+ type: "button", class: ["row", fixFirst ? "b" : null], "aria-current": String(Boolean(cur)), dataset: { id: f.id, project: f.project },
142
+ onclick: () => ctx.openFinding(f),
143
+ },
144
+ sevBadge(f.severity),
145
+ h("span", { class: "t" }, f.title),
146
+ h("span", { class: "tag" }, all ? shortProject(f.project) : f.category || ""),
147
+ f.location ? h("span", { class: "where" }, f.location) : null)));
148
+ }
149
+ if (!rows.length) list.append(h("li", {}, empty(state.findings.length && q ? `${EMPTY.filtered}` : EMPTY.findings)));
150
+ const count = document.getElementById("findings-count");
151
+ if (count) count.textContent = q ? `${rows.length} match` : plural(rows.length, "open finding");
152
+ }
153
+
154
+ /** j / k: move the selection and open the finding in the right panel. */
155
+ export function moveFinding(step, ctx) {
156
+ if (!visible.length) return;
157
+ const i = visible.findIndex((f) => state.finding && f.id === state.finding.id && f.project === state.finding.project);
158
+ const next = visible[Math.max(0, Math.min(visible.length - 1, i < 0 ? 0 : i + step))];
159
+ ctx.openFinding(next);
160
+ const btn = document.querySelector(`#findings-list .row[data-id="${next.id}"]`);
161
+ btn?.focus({ preventScroll: false });
162
+ }
163
+
164
+ function miniList(rows, none, fixed = false) {
165
+ return rows.length
166
+ ? h("ul", { class: "mini" }, rows.slice(0, 8).map((r) => h("li", {}, h("span", { class: `sw sev-${r.severity}`, "aria-hidden": "true" }), h("span", { class: ["grow", fixed ? "muted" : null] }, fixed ? `✓ ${r.title}` : r.title))))
167
+ : h("p", { class: "empty" }, none);
168
+ }
169
+
170
+ function combinedTrend(projects) {
171
+ const len = Math.max(0, ...projects.map((p) => p.trend.length));
172
+ const out = [];
173
+ for (let i = 0; i < len; i++) out.push(projects.reduce((n, p) => n + ((p.trend[p.trend.length - len + i] || p.trend[0])?.total || 0), 0));
174
+ return out;
175
+ }
176
+
177
+ function startView(col, ctx) {
178
+ col.append(h("div", { class: "start" },
179
+ wordmark(),
180
+ h("p", { class: "muted" }, START.gate),
181
+ h("h1", {}, START.title),
182
+ h("p", {}, START.intro),
183
+ h("div", { class: "steps" }, START.steps.map((st, i) => h("div", { class: "step" },
184
+ h("span", { class: "n" }, `${i + 1}.`),
185
+ h("h3", {}, st.title),
186
+ h("button", { type: "button", class: "btn", onclick: () => ctx.focusBar(st.kind) }, "Choose ▸"),
187
+ h("p", {}, st.body)))),
188
+ h("p", { class: "muted" }, `${START.demo} `, h("code", {}, "penthera ui --demo"), "."),
189
+ ));
190
+ }
191
+
192
+ function notScannedView(col, key, ctx) {
193
+ col.append(h("div", { class: "start" },
194
+ h("h1", {}, shortProject(key)),
195
+ h("p", { class: "muted" }, key),
196
+ h("p", {}, "Not scanned yet. Pick what to run in the bar below; the folder is this project."),
197
+ h("div", { class: "actions" }, h("button", { type: "button", class: "btn", onclick: () => ctx.focusBar("scan") }, "Choose Scan ▸")),
198
+ ));
199
+ }
200
+
201
+ // ── A live run, as a transcript ─────────────────────────────────────────
202
+
203
+ function runView(col, ctx) {
204
+ const run = state.sel.run;
205
+ if (!run) return;
206
+ const status = h("span", { class: "chip" }, run.status);
207
+ const stop = h("button", { type: "button", class: "btn small", disabled: run.status !== "running" }, "Stop");
208
+ const lines = h("div", { class: "transcript", role: "log", "aria-live": "off", "aria-label": "Run output" });
209
+ const tail = h("div");
210
+ col.append(
211
+ h("div", { class: "run-head" },
212
+ h("h1", {}, `${({ scan: "Scan", deps: "Dependency check", agents: "AI tooling audit", loop: "Fix loop" })[run.kind] || run.kind}`),
213
+ h("span", { class: "muted" }, `${shortProject(run.repo || run.url)}${run.url && run.repo ? ` + ${run.url}` : ""} · ${ago(run.startedAt)}`),
214
+ h("span", { class: "grow" }), status, stop),
215
+ lines, tail,
216
+ );
217
+ const setStatus = (st) => {
218
+ status.textContent = st;
219
+ status.className = `chip ${st === "running" ? "on" : st === "awaiting you" ? "blue" : st === "error" ? "inv" : ""}`;
220
+ stop.disabled = !(st === "running" || st === "awaiting you");
221
+ };
222
+ setStatus(run.status === "running" ? "running" : run.status);
223
+ stop.addEventListener("click", async () => {
224
+ stop.disabled = true;
225
+ try { await api.post(`/api/runs/${run.id}/stop`); } catch (e) { typer?.push(`error: ${e.message}`, "bad"); }
226
+ });
227
+
228
+ stopStream();
229
+ typer = createTyper(lines);
230
+ pending = null;
231
+ es = new EventSource(`/api/runs/${run.id}/stream`);
232
+ const scroll = () => { const st = document.getElementById("stream"); if (st) st.scrollTop = st.scrollHeight; };
233
+ const onEvent = (ev) => {
234
+ const d = ev.data;
235
+ switch (ev.type) {
236
+ case "log": {
237
+ const t = d.text.replace(/^\n/, "");
238
+ const cls = /^penthera /.test(t) ? "cmd" : /verified and committed|done:|^(scan|deps|agents) done/.test(t) ? "ok"
239
+ : /failed verification|^error|not applied/.test(t) ? "bad" : /^\s*iteration \d+/.test(t) ? "hl" : /^\s{3,}/.test(t) || /\.\.\.$/.test(t) ? "dim" : null;
240
+ typer.push(cls === "dim" || cls === "bad" ? t.trim() : t, cls);
241
+ break;
242
+ }
243
+ case "approval": {
244
+ pending = d;
245
+ typer.flush();
246
+ tail.append(approvalDialog(run, d, typer));
247
+ setStatus("awaiting you");
248
+ scroll();
249
+ announce(`Fix ${d.index} of ${d.total} is waiting for you: ${d.plan.title}`);
250
+ break;
251
+ }
252
+ case "decision": {
253
+ pending = null;
254
+ clear(tail);
255
+ setStatus("running");
256
+ break;
257
+ }
258
+ case "status": {
259
+ if (d.status === "running") break;
260
+ setStatus(d.status);
261
+ clear(tail);
262
+ const sum = d.summary;
263
+ if (sum?.totals) {
264
+ const t = sum.totals;
265
+ typer.push(`done: ${sum.stopReason} after ${plural(t.iterations, "round")} (${duration(sum.durationMs)})`, "ok");
266
+ typer.push(`${t.verified} verified and committed :: ${t.failed} failed and reverted :: ${t.declined} skipped${sum.branch ? ` :: branch ${sum.branch}` : ""}`, t.failed ? "bad" : "ok");
267
+ }
268
+ tail.append(h("div", { class: "actions" },
269
+ run.repo || run.url ? h("button", { type: "button", class: "btn primary", onclick: () => ctx.refresh().then(() => set({ sel: { kind: "project", key: run.repo || run.url } })) }, "Open the project ▸") : null));
270
+ ctx.refresh();
271
+ announce(`Run ${d.status}`);
272
+ break;
273
+ }
274
+ default: break;
275
+ }
276
+ };
277
+ for (const type of ["log", "approval", "decision", "status"]) {
278
+ es.addEventListener(type, (m) => { try { onEvent(JSON.parse(m.data)); } catch { /* malformed event */ } });
279
+ }
280
+ es.addEventListener("end", () => { es?.close(); es = null; });
281
+ }
282
+
283
+ function approvalDialog(run, d, ty) {
284
+ const p = d.plan;
285
+ const decide = async (answer, box) => {
286
+ for (const b of box.querySelectorAll("button")) b.disabled = true;
287
+ try {
288
+ await api.post(`/api/runs/${run.id}/decision`, { approvalId: d.approvalId, answer });
289
+ } catch (e) {
290
+ ty.push(`error: ${e.message}`, "bad");
291
+ for (const b of box.querySelectorAll("button")) b.disabled = false;
292
+ }
293
+ };
294
+ const box = h("section", { class: "dialog", role: "alertdialog", "aria-labelledby": "ap-h", "aria-describedby": "ap-d" });
295
+ const apply = h("button", { type: "button", class: "btn primary", onclick: () => decide("approve", box) }, "Apply", h("span", { class: "k" }, "y"));
296
+ box.append(
297
+ h("div", { class: "dialog-bar" }, h("span", {}, `Fix ${d.index} of ${d.total}`)),
298
+ h("div", { class: "dialog-body" },
299
+ h("h3", { id: "ap-h" }, p.title),
300
+ h("p", { id: "ap-d", class: "muted" }, `${p.recipe}${p.framework ? ` (${p.framework})` : ""} · ${p.files.join(", ")} · ${d.memory ? `memory: ${d.memory.verified} verified, ${d.memory.failed} failed` : "no history for this recipe yet"}`),
301
+ h("div", {}, p.findings.map((f) => h("div", {}, sevBadge(f.severity), " ", h("span", { class: "t1" }, f.title)))),
302
+ diffBlock(p.diff),
303
+ p.redacted ? h("p", { class: "muted" }, "Secret values are masked here; the real patch uses them.") : null,
304
+ h("div", { class: "actions" },
305
+ h("button", { type: "button", class: "btn", onclick: () => decide("stop", box) }, "Stop", h("span", { class: "k" }, "q")),
306
+ h("button", { type: "button", class: "btn", onclick: () => decide("skip", box) }, "Skip", h("span", { class: "k" }, "n")),
307
+ apply),
308
+ ),
309
+ );
310
+ queueMicrotask(() => apply.focus({ preventScroll: true }));
311
+ return box;
312
+ }
313
+
314
+ /** y / n / q while a diff waits. */
315
+ export function answerPending(key) {
316
+ if (!pending) return false;
317
+ const btns = document.querySelectorAll(".dialog .actions button");
318
+ const i = { q: 0, n: 1, y: 2 }[key];
319
+ if (i == null || !btns[i] || btns[i].disabled) return false;
320
+ btns[i].click();
321
+ return true;
322
+ }
323
+
324
+ // ── A past loop run, rebuilt from its loopRun ───────────────────────────
325
+
326
+ function pastRunView(col, ctx) {
327
+ const l = state.sel.past;
328
+ if (!l) return;
329
+ col.append(
330
+ h("div", { class: "run-head" }, h("h1", {}, "Fix loop"), h("span", { class: "muted" }, `${shortProject(l.target.repo)} · ${ago(l.startedAt)} · ${duration(l.durationMs)}`)),
331
+ h("div", { class: "transcript" },
332
+ h("div", { class: "ln cmd" }, `penthera agent --loop --repo ${l.target.repo}${l.target.url ? ` --url ${l.target.url}` : ""}`),
333
+ l.iterations.flatMap((it) => [
334
+ h("div", { class: "ln hl" }, `iteration ${it.n}`),
335
+ it.findings ? h("div", { class: "ln dim" }, `scan: ${it.findings.total} findings (${it.findings.suppressed} suppressed), ${it.findings.fixable} fixable`) : null,
336
+ ...it.fixes.map((f) => h("div", { class: `ln ${f.status === "verified" ? "ok" : f.status === "failed" ? "bad" : "dim"}` },
337
+ `${f.status === "verified" ? "OK " : f.status === "failed" ? "!! " : "-- "}${f.title} :: ${f.decision}${f.commit ? ` :: ${f.commit.slice(0, 10)}` : ""}`)),
338
+ ]),
339
+ h("div", { class: "ln ok" }, `done: ${l.stopReason} :: ${l.totals.verified} verified, ${l.totals.failed} failed, ${l.totals.declined + l.totals.skipped} skipped`),
340
+ h("div", { class: "ln" }, `branch ${l.branch} :: nothing was pushed`),
341
+ ),
342
+ h("div", { class: "actions" }, h("button", { type: "button", class: "btn", onclick: () => set({ sel: { kind: "project", key: l.target.repo } }) }, "Open the project ▸")),
343
+ );
344
+ }
345
+
346
+ // ── The prompt bar ──────────────────────────────────────────────────────
347
+
348
+ function promptBar(ctx) {
349
+ const kind = state.mode;
350
+ const def = KINDS.find((k) => k.kind === kind);
351
+ const usesUrl = kind === "scan" || kind === "loop";
352
+ const input = h("input", { type: "text", id: "bar-input", autocomplete: "off", spellcheck: "false", placeholder: def.placeholder, "aria-label": usesUrl ? "Running app URL (optional)" : "Project folder", disabled: !usesUrl });
353
+ if (usesUrl && state.sel.kind === "project" && /^https?:/.test(state.sel.key || "")) input.value = state.sel.key;
354
+ const confirm = h("input", { type: "text", id: "bar-confirm", autocomplete: "off", spellcheck: "false", "aria-label": "Type the hostname to confirm" });
355
+ const confirmRow = h("div", { class: "confirm", hidden: true }, h("span", { id: "confirm-q" }), confirm);
356
+ const err = h("p", { class: "err", role: "alert", hidden: true });
357
+ const folder = folderFor(state.sel.kind === "project" ? state.sel.key : null);
358
+ // One primary action at a time: while a run is going, its dialog owns it.
359
+ const busy = state.runs.live.some((r) => r.status === "running");
360
+ const run = h("button", { type: "submit", class: busy ? "btn" : "btn primary", disabled: busy, title: busy ? "A run is in progress. Stop it or wait for it to finish." : null }, `${def.button} ▸`);
361
+ const sync = () => {
362
+ const host = usesUrl ? hostOf(input.value) : null;
363
+ const remote = host && !LOCAL.test(host);
364
+ confirmRow.hidden = !remote;
365
+ if (remote) document.getElementById("confirm-q").textContent = `${host} is not on this machine. Type its name to confirm you own it or may test it:`;
366
+ };
367
+ input.addEventListener("input", sync);
368
+ const form = h("form", { novalidate: true, "aria-label": "Start a run" }, input, run);
369
+ form.addEventListener("submit", async (e) => {
370
+ e.preventDefault();
371
+ err.hidden = true;
372
+ run.disabled = true;
373
+ try {
374
+ const r = await api.post("/api/runs", { kind, repo: folder, url: usesUrl ? input.value : "", confirm: confirm.value, maxIterations: 5 });
375
+ await ctx.refresh();
376
+ set({ sel: { kind: "run", key: r.id, run: r } });
377
+ } catch (ex) {
378
+ err.textContent = ex.message;
379
+ err.hidden = false;
380
+ run.disabled = false;
381
+ if (ex.status === 428) confirm.focus();
382
+ }
383
+ });
384
+ const modes = KINDS.map((k) => h("button", { type: "button", class: "mode", "aria-pressed": String(k.kind === kind), onclick: () => { set({ mode: k.kind }); ctx.focusBar(); } }, k.label));
385
+ return h("div", { class: "bar" }, h("div", { class: "bar-inner" },
386
+ h("div", { class: "modes", role: "group", "aria-label": "What to run" }, modes, h("span", { class: "note" }, `${shortProject(folder) || "no folder"} · ${NOTES[kind]}`)),
387
+ form, confirmRow, err));
388
+ }
@@ -1,79 +1,53 @@
1
1
  // Every user-facing sentence in one place, so the voice can be read and
2
- // proofed as a whole. Rules (voice.md): sentence case, "::" as connective,
3
- // no exclamation marks, verbs first on buttons, empty states name the
4
- // action that fills them, errors give the cause then the fix.
2
+ // proofed as a whole. Rules: sentence case, "::" as connective, no
3
+ // exclamation marks, verbs first on buttons, empty states name the action
4
+ // that fills them, errors give the cause then the fix.
5
5
 
6
- export const TAGLINE = "Open-source security for the apps and AI systems you ship"; // README headline; display lines take no period
7
-
8
- export const SCREENS = {
9
- overview: { title: "Overview", lede: "Where each project stands, from the reports and memory on this machine." },
10
- findings: { title: "Findings", lede: "Every open finding in the latest report per project. Pick one to see what it means and how to fix it." },
11
- run: { title: "Run", lede: "Scan, check dependencies, audit AI tooling, or let the agent fix things. Every diff waits for you. Nothing is pushed." },
12
- history: { title: "History", lede: "Compare two scans of one project :: what is new, what got fixed, what is still open." },
13
- deps: { title: "Dependencies", lede: "What your npm and Python packages can do, what changed, and what the install firewall let in." },
14
- ai: { title: "AI tooling", lede: "Skills, MCP servers, hooks and rule files :: the grade, hidden content, and drift against penthera.lock." },
15
- evidence: { title: "Evidence", lede: "Hashed, signable bundles for an auditor. Create one from the latest scan, verify any of them in one click." },
16
- settings: { title: "Settings", lede: "For this session. Nothing here is written to disk except your theme choice." },
17
- };
6
+ export const TAGLINE = "Open-source security for the apps and AI systems you ship"; // README headline
18
7
 
19
8
  export const START = {
20
- welcome: "Welcome",
21
- intro: "No reports yet. Start with a scan of something you own :: your localhost, a preview URL, or this repo. Each step writes a report into the reports folder, and this screen fills in.",
9
+ title: "Welcome",
10
+ intro: "No reports yet. Start with a scan of something you own :: your localhost, a preview URL, or this repo. Results land in the reports folder and this view fills in.",
22
11
  gate: "Only scan apps you own or have written permission to test.",
23
12
  steps: [
24
- { title: "Scan your app", body: "TLS, headers, cookies, CORS, auth, exposed APIs and leaked secrets. About a minute.", kind: "scan", action: "Scan app" },
25
- { title: "Check your dependencies", body: "What every npm package in the lockfile can do: install scripts, shell, network, obfuscated code.", kind: "deps", action: "Check dependencies" },
26
- { title: "Audit your AI tooling", body: "Skills, MCP servers, hooks and rule files: hidden instructions, unpinned servers, wide permissions.", kind: "agents", action: "Audit AI tooling" },
13
+ { kind: "scan", title: "Scan your app", body: "TLS, headers, cookies, CORS, auth, exposed APIs and leaked secrets." },
14
+ { kind: "deps", title: "Check your dependencies", body: "What every npm and Python package can do: install scripts, shell, network, obfuscated code." },
15
+ { kind: "agents", title: "Audit your AI tooling", body: "Skills, MCP servers, hooks and rule files: hidden instructions, unpinned servers, wide permissions." },
27
16
  ],
28
- demo: "Want to look around first? Stop this server and run",
17
+ demo: "To look around first, stop this server and run",
29
18
  };
30
19
 
31
- export const NEXT = {
32
- heading: "What next?",
33
- overview: [
34
- { label: "Read the most serious finding", why: "what it means, how sure we are, the fix", go: ["findings"] },
35
- { label: "Fix what can be fixed", why: "the agent proposes diffs, you approve each one", go: ["run", { kind: "loop" }] },
36
- { label: "Compare with the last scan", why: "new, fixed, still open", go: ["history"] },
37
- { label: "Create evidence for an auditor", why: "a hashed bundle plus the auditor report", go: ["evidence"] },
38
- ],
39
- scan: [
40
- { label: "Read the findings", why: "start with the most serious one", go: ["findings"] },
41
- { label: "Fix them with the agent", why: "you approve every diff", go: ["run", { kind: "loop" }] },
42
- { label: "Compare with the previous scan", go: ["history"] },
43
- ],
44
- loop: [
45
- { label: "Compare before and after", why: "what the loop fixed", go: ["history"] },
46
- { label: "Create evidence for an auditor", go: ["evidence"] },
47
- { label: "Back to the overview", go: ["overview"] },
48
- ],
49
- deps: [
50
- { label: "See the capability diff", go: ["deps"] },
51
- { label: "Back to the overview", go: ["overview"] },
52
- ],
53
- agents: [
54
- { label: "See the grade and hidden content", go: ["ai"] },
55
- { label: "Back to the overview", go: ["overview"] },
56
- ],
57
- };
58
-
59
- export const RUN_KINDS = [
60
- { kind: "scan", label: "Scan app", needs: "url-or-folder", button: "Run scan" },
61
- { kind: "deps", label: "Check dependencies", needs: "folder", button: "Check dependencies" },
62
- { kind: "agents", label: "Audit AI tooling", needs: "folder", button: "Audit AI tooling" },
63
- { kind: "loop", label: "Fix loop", needs: "folder", button: "Run as agent" },
20
+ export const KINDS = [
21
+ { kind: "scan", label: "Scan", button: "Scan", placeholder: "http://localhost:3000 (optional) :: the folder is the selected project" },
22
+ { kind: "deps", label: "Deps", button: "Check dependencies", placeholder: "reads package-lock.json, uv.lock, poetry.lock, .venv" },
23
+ { kind: "agents", label: "AI tooling", button: "Audit AI tooling", placeholder: "reads skills, MCP config, hooks and rule files" },
24
+ { kind: "loop", label: "Fix loop", button: "Run the fix loop", placeholder: "http://localhost:3000 (optional) :: every diff waits for you" },
64
25
  ];
65
26
 
27
+ export const NOTES = {
28
+ scan: "Contacts only the URL's host while offline is on. A folder-only scan contacts nothing.",
29
+ deps: "Reads lockfiles and installed packages. No network.",
30
+ agents: "Read-only. No network.",
31
+ loop: "Offline, on a local branch. Every diff waits for you. Nothing is pushed.",
32
+ };
33
+
66
34
  export const EMPTY = {
67
- findingsNone: "No findings in the reports folder yet. Run a scan from Run, or write one with penthera <url> -o reports/scan.json.",
68
- findingsFiltered: (which) => `Nothing matches :: ${which} hide${which.includes(",") ? "" : "s"} the rest. Clear them to see every finding.`,
69
- history: "No project has two scans yet. Scan the same target again and the comparison appears here.",
70
- deps: "No dependency report yet. Run Check dependencies, or penthera deps -o reports/deps.json.",
71
- ai: "No AI tooling report yet. Run Audit AI tooling, or penthera agents . -o reports/agents.json.",
72
- evidence: "No bundles yet. Create one from the latest scan of a project above.",
73
- memory: "No memory yet. The agent loop and scans with --memory write it to ~/.penthera/memory.",
74
- pastRuns: "No loop runs in the reports folder yet.",
35
+ projects: "No projects yet. Run a scan to add one.",
36
+ runs: "No runs yet.",
37
+ findings: "No open findings in the latest scan.",
38
+ filtered: "Nothing matches the filter. Press Esc to clear it.",
39
+ deps: "No dependency report yet. Run Deps from the bar below.",
40
+ ai: "No AI tooling report yet. Run AI tooling from the bar below.",
41
+ evidence: "No bundles yet. Create one from the latest scan.",
42
+ memory: "No memory yet. The fix loop and scans with --memory write it.",
43
+ changes: "No fixes applied yet. Run the fix loop to apply one.",
75
44
  };
76
45
 
77
46
  export const ERR = {
78
47
  session: "The session ended. Restart penthera ui and open the new one-time link it prints.",
79
48
  };
49
+
50
+ export const KEYS = [
51
+ ["/", "search findings"], ["j k", "next / previous finding"], ["[ ]", "toggle side panels"],
52
+ ["y n q", "apply, skip, stop a diff"], ["Esc", "close the detail"],
53
+ ];
@@ -54,22 +54,6 @@ export function put(el, ...children) {
54
54
  return el;
55
55
  }
56
56
 
57
- /**
58
- * A section as a window: pinstriped title bar with a close box, the title
59
- * centred on its plate, optional metadata on the right (attrs.meta).
60
- */
61
- export function panel(title, attrs, ...children) {
62
- const id = `p-${Math.random().toString(36).slice(2, 9)}`;
63
- return h("section", { class: ["panel", attrs?.class], "aria-labelledby": id },
64
- h("div", { class: "panel-bar" },
65
- h("span", { class: "box", "aria-hidden": "true" }),
66
- h("h2", { class: "panel-h", id }, title),
67
- attrs?.meta ? h("span", { class: "panel-meta" }, attrs.meta) : null,
68
- h("span", { class: "grip", "aria-hidden": "true" }),
69
- ),
70
- h("div", { class: "panel-body" }, ...children));
71
- }
72
-
73
57
  export const sevClass = (sev) => `sev sev-${sev || "info"}`;
74
58
  export const sevBadge = (sev) => h("span", { class: sevClass(sev) }, sev || "info");
75
59
 
@@ -96,25 +80,3 @@ export function diffBlock(diff) {
96
80
  }
97
81
  return pre;
98
82
  }
99
-
100
- /** A figure caption under a chart: what it is, where the numbers came from. */
101
- export function fig(left, right = "") {
102
- return h("p", { class: "fig" }, h("span", {}, left), h("span", {}, right));
103
- }
104
-
105
- /** The CLI's "What next?" block: numbered choices that navigate. */
106
- export function whatNext(items, go, heading = "What next?") {
107
- return h("section", { class: "next", "aria-label": heading },
108
- h("h2", {}, heading),
109
- h("ol", {}, items.map((it) => h("li", {},
110
- h("button", { type: "button", onclick: () => go(...it.go) }, it.label),
111
- it.why ? h("span", { class: "why" }, `:: ${it.why}`) : null,
112
- ))));
113
- }
114
-
115
- /** A window inside the window: pinstriped title bar, close and zoom boxes. */
116
- export function subwin(title, attrs, ...children) {
117
- return h("section", { class: ["subwin", attrs?.class], "aria-label": title },
118
- h("div", { class: "titlebar" }, h("span", { class: "box close", "aria-hidden": "true" }), h("span", { class: "title" }, title), h("span", { class: "box zoom", "aria-hidden": "true" })),
119
- ...children);
120
- }
@@ -0,0 +1,83 @@
1
+ // Left sidebar: brand, "+ New scan", projects with their grade, runs grouped
2
+ // by project (like the session list in Claude Code), and the footer gear.
3
+ import { h, s, clear } from "./dom.js";
4
+ import { state, set, projectKeys, projectOf } from "./state.js";
5
+ import { ago, shortProject } from "./format.js";
6
+ import { EMPTY } from "./copy.js";
7
+
8
+ const KIND_LABEL = { scan: "scan", deps: "deps", agents: "AI tooling", loop: "fix loop" };
9
+
10
+ export function renderLeft(el, { onNewScan, toggleLeft, theme }) {
11
+ const projects = state.overview?.projects || [];
12
+ const keys = projectKeys();
13
+ const totalOpen = projects.reduce((n, p) => n + p.open, 0);
14
+
15
+ const item = (attrs, ...kids) => h("button", { type: "button", class: "item", ...attrs }, ...kids);
16
+ const current = (kind, key) => String(state.sel.kind === kind && (key == null || state.sel.key === key));
17
+
18
+ const projectList = h("ul", { class: "side-list", "aria-label": "Projects" },
19
+ h("li", {}, item({ "aria-current": current("all"), onclick: () => set({ sel: { kind: "all", key: null } }) },
20
+ h("span", { class: "grade" }, "∑"), h("span", { class: "name" }, "All projects"), h("span", { class: "count" }, String(totalOpen)),
21
+ h("span", { class: "sub" }, `${projects.length} scanned · ${totalOpen} open`))),
22
+ keys.map((key) => {
23
+ const p = projectOf(key);
24
+ return h("li", {}, item({ "aria-current": current("project", key), title: key, onclick: () => set({ sel: { kind: "project", key } }) },
25
+ h("span", { class: `grade grade-${p?.grade || "x"}` }, p ? p.grade : "·"),
26
+ h("span", { class: "name" }, shortProject(key)),
27
+ h("span", { class: "count" }, p ? String(p.open) : ""),
28
+ h("span", { class: "sub" }, p ? `${p.mode || "scan"} · ${ago(p.lastScan)}` : "not scanned yet")));
29
+ }),
30
+ );
31
+ if (!keys.length) projectList.append(h("li", { class: "group-h" }, EMPTY.projects));
32
+
33
+ // Runs: live ones from this session first, then past loop runs, grouped by project.
34
+ const runs = [
35
+ ...state.runs.live.map((r) => ({ live: true, id: r.id, kind: r.kind, project: r.repo || r.url, status: r.status, at: r.startedAt, run: r })),
36
+ ...state.runs.past.map((l, i) => ({ live: false, id: `past-${i}`, kind: "loop", project: l.target.repo || l.target.url, status: l.stopReason, at: l.startedAt, past: l })),
37
+ ];
38
+ const groups = new Map();
39
+ for (const r of runs) {
40
+ const k = r.project || "unknown";
41
+ if (!groups.has(k)) groups.set(k, []);
42
+ groups.get(k).push(r);
43
+ }
44
+ const runList = h("ul", { class: "side-list", "aria-label": "Runs" });
45
+ for (const [project, list] of groups) {
46
+ runList.append(h("li", { class: "group-h", title: project }, shortProject(project)));
47
+ for (const r of list.slice(0, 6)) {
48
+ const kindSel = r.live ? "run" : "past";
49
+ const sum = r.past ? `${r.past.totals.verified} verified · ${r.past.findings.initial ?? "?"} → ${r.past.findings.final ?? "?"}` : r.status;
50
+ runList.append(h("li", {}, item({
51
+ "aria-current": current(kindSel, r.id),
52
+ onclick: () => set({ sel: { kind: kindSel, key: r.id, run: r.run || null, past: r.past || null } }),
53
+ },
54
+ h("span", { class: `dot ${r.live && r.status === "running" ? "live" : r.live ? "done" : ""}`, "aria-hidden": "true" }),
55
+ h("span", { class: "name" }, KIND_LABEL[r.kind] || r.kind),
56
+ h("span", { class: "count muted" }, ago(r.at)),
57
+ h("span", { class: "sub" }, sum || ""))));
58
+ }
59
+ }
60
+ if (!runs.length) runList.append(h("li", { class: "group-h" }, EMPTY.runs));
61
+
62
+ clear(el).append(
63
+ h("div", { class: "brand" },
64
+ s("svg", { width: 14, height: 16, viewBox: "0 0 7 8", "aria-hidden": "true", "shape-rendering": "crispEdges" },
65
+ s("path", { class: "px", d: "M0 0h5v1h1v1h1v2H6v1H5v1H2v2H0zM2 2v2h3V2z" })),
66
+ h("span", { class: "grow" }, "penthera"),
67
+ h("button", { type: "button", class: "icon", "aria-label": "Hide the sidebar ([)", title: "Hide the sidebar [", onclick: toggleLeft }, "⇤"),
68
+ ),
69
+ h("button", { type: "button", class: "btn new-scan", onclick: onNewScan }, "+ New scan"),
70
+ h("div", { class: "side-label" }, h("span", {}, "Projects")),
71
+ projectList,
72
+ h("div", { class: "side-label" }, h("span", {}, "Runs")),
73
+ runList,
74
+ h("div", { class: "side-foot" },
75
+ h("button", { type: "button", class: "icon", "aria-label": "Settings", title: "Settings", "aria-pressed": String(state.right === "settings"),
76
+ onclick: () => set({ right: state.right === "settings" ? "cards" : "settings", openRight: true }) }, "⚙"),
77
+ h("button", { type: "button", class: "icon", "aria-label": `Theme: ${theme.get()}. Switch`, title: "Switch theme", onclick: () => { theme.set(theme.get() === "light" ? "dark" : "light"); renderLeft(el, { onNewScan, toggleLeft, theme }); } }, theme.get() === "light" ? "◑" : "◐"),
78
+ h("span", { class: "grow" }),
79
+ h("span", {}, state.meta?.demo ? "demo · " : ""),
80
+ h("span", {}, `v${state.meta?.version || ""}`),
81
+ ),
82
+ );
83
+ }