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
@@ -12,45 +12,14 @@
12
12
  <script type="module" src="/js/app.js"></script>
13
13
  </head>
14
14
  <body>
15
- <a class="skip" href="#main">Skip to content</a>
16
- <header class="menubar">
17
- <span class="mb-mark">
18
- <svg width="14" height="16" viewBox="0 0 7 8" aria-hidden="true" shape-rendering="crispEdges"><path class="px" d="M0 0h5v1h1v1h1v2H6v1H5v1H2v2H0zM2 2v2h3V2z"/></svg>
19
- penthera
20
- </span>
21
- <nav aria-label="Screens">
22
- <ol>
23
- <li><a href="#/overview" data-view="overview"><span class="key">1</span>Overview</a></li>
24
- <li><a href="#/findings" data-view="findings"><span class="key">2</span>Findings</a></li>
25
- <li><a href="#/run" data-view="run"><span class="key">3</span>Run</a></li>
26
- <li><a href="#/history" data-view="history"><span class="key">4</span>History</a></li>
27
- <li><a href="#/deps" data-view="deps"><span class="key">5</span>Dependencies</a></li>
28
- <li><a href="#/ai" data-view="ai"><span class="key">6</span>AI tooling</a></li>
29
- <li><a href="#/evidence" data-view="evidence"><span class="key">7</span>Evidence</a></li>
30
- <li><a href="#/settings" data-view="settings"><span class="key">8</span>Settings</a></li>
31
- </ol>
32
- </nav>
33
- <div class="mb-right">
34
- <span id="mb-chips"></span>
35
- <button type="button" class="themebtn" id="theme-btn" aria-label="Switch theme">◐ dark</button>
36
- <span class="mb-clock" id="mb-clock" aria-hidden="true"></span>
37
- </div>
38
- </header>
39
- <div class="desktop">
40
- <div class="window">
41
- <div class="titlebar"><span class="box close" aria-hidden="true"></span><span class="title" id="win-title">penthera :: overview</span><span class="box zoom" aria-hidden="true"></span></div>
42
- <div class="prompt" id="prompt"><span class="user">penthera@127.0.0.1</span>:<span class="path">~/overview</span>$ <span id="prompt-cmd"></span><span class="cursor" aria-hidden="true"></span></div>
43
- <main class="main" id="main" tabindex="-1">
44
- <noscript><p>penthera ui needs JavaScript: every screen reads from the local API on this machine.</p></noscript>
45
- </main>
46
- <footer class="status">
47
- <span><kbd>1</kbd>-<kbd>8</kbd> screens</span>
48
- <span><kbd>/</kbd> filter findings</span>
49
- <span><kbd>j</kbd> <kbd>k</kbd> move in tables</span>
50
- <span><kbd>y</kbd> <kbd>n</kbd> <kbd>q</kbd> approve, skip, stop a diff</span>
51
- <span id="status-bind"></span>
52
- </footer>
53
- </div>
15
+ <a class="skip" href="#center">Skip to content</a>
16
+ <div class="app" id="app">
17
+ <aside class="left" id="left" aria-label="Projects and runs"></aside>
18
+ <main class="center" id="center" tabindex="-1">
19
+ <noscript><p class="pad">penthera ui needs JavaScript: every view reads from the local API on this machine.</p></noscript>
20
+ </main>
21
+ <aside class="right" id="right" aria-label="Details"></aside>
22
+ <div class="scrim" id="scrim" hidden></div>
54
23
  </div>
55
24
  <div class="sr" role="status" aria-live="polite" id="announce"></div>
56
25
  </body>
@@ -1,138 +1,136 @@
1
- // penthera ui: hash router, keyboard shortcuts, the menu bar and window chrome.
1
+ // penthera ui: one view. Left: projects and runs. Centre: the selected project
2
+ // or run. Right: cards, or the open finding, or settings.
2
3
  import { h, clear, announce } from "./dom.js";
3
4
  import { api } from "./api.js";
4
- import { typeInto } from "./typer.js";
5
5
  import { patternDefs } from "./charts.js";
6
- import { SCREENS, ERR } from "./copy.js";
7
- import * as overview from "./views/overview.js";
8
- import * as findings from "./views/findings.js";
9
- import * as run from "./views/run.js";
10
- import * as history from "./views/history.js";
11
- import * as deps from "./views/deps.js";
12
- import * as ai from "./views/ai.js";
13
- import * as evidence from "./views/evidence.js";
14
- import * as settings from "./views/settings.js";
6
+ import { state, set, on } from "./state.js";
7
+ import { renderLeft } from "./left.js";
8
+ import { renderCenter, renderBar, refreshList, moveFinding, answerPending } from "./center.js";
9
+ import { renderRight } from "./right.js";
10
+ import { ERR } from "./copy.js";
15
11
 
16
- const VIEWS = { overview, findings, run, history, deps, ai, evidence, settings };
17
- const ORDER = Object.keys(VIEWS);
18
12
  const THEME_KEY = "penthera-ui-theme";
13
+ const PANELS_KEY = "penthera-ui-panels";
14
+ const app = document.getElementById("app");
15
+ const left = document.getElementById("left");
16
+ const center = document.getElementById("center");
17
+ const right = document.getElementById("right");
18
+ const scrim = document.getElementById("scrim");
19
19
 
20
- const main = document.getElementById("main");
21
- const ctx = { api, meta: null, go, params: new URLSearchParams(), theme: { get: getTheme, set: setTheme } };
22
- let cleanup = null;
23
- let renderSeq = 0;
20
+ // ── Theme: one system, [data-theme] on <html>; theme.js set it before paint ──
21
+ const theme = {
22
+ get: () => (document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"),
23
+ set(t) {
24
+ document.documentElement.setAttribute("data-theme", t);
25
+ try { localStorage.setItem(THEME_KEY, t); } catch { /* storage off: this page only */ }
26
+ announce(`${t === "light" ? "Light" : "Dark"} theme`);
27
+ },
28
+ };
24
29
 
25
- function parseHash() {
26
- const raw = location.hash.replace(/^#\/?/, "");
27
- const [name, query = ""] = raw.split("?");
28
- const alias = name === "agent" ? "run" : name;
29
- return { name: VIEWS[alias] ? alias : "overview", params: new URLSearchParams(query) };
30
+ // ── Side panels: [ and ] toggle; drawers below 1180px / 760px ──
31
+ const narrowRight = () => window.matchMedia("(max-width: 1180px)").matches;
32
+ const narrowLeft = () => window.matchMedia("(max-width: 760px)").matches;
33
+ function savedPanels() {
34
+ try { return JSON.parse(localStorage.getItem(PANELS_KEY) || "null"); } catch { return null; }
30
35
  }
31
-
32
- export function go(name, params = {}) {
33
- const q = new URLSearchParams(params).toString();
34
- const next = `#/${name}${q ? `?${q}` : ""}`;
35
- if (location.hash === next) render();
36
- else location.hash = next;
36
+ function applyPanels({ left: l, right: r }) {
37
+ app.classList.toggle("no-left", !l);
38
+ app.classList.toggle("no-right", !r);
39
+ scrim.hidden = !((r && narrowRight()) || (l && narrowLeft()));
40
+ if (!narrowRight()) {
41
+ try { localStorage.setItem(PANELS_KEY, JSON.stringify({ left: l, right: r })); } catch { /* per-viewer convenience only */ }
42
+ }
37
43
  }
44
+ const panels = { left: true, right: true, ...(savedPanels() || {}) };
45
+ if (narrowLeft()) panels.left = false;
46
+ if (narrowRight()) panels.right = false;
47
+ applyPanels(panels);
48
+ function toggleLeft() { panels.left = !panels.left; applyPanels(panels); }
49
+ function toggleRight() { panels.right = !panels.right; applyPanels(panels); }
50
+ scrim.addEventListener("click", () => {
51
+ if (narrowLeft()) panels.left = false;
52
+ if (narrowRight()) panels.right = false;
53
+ applyPanels(panels);
54
+ });
38
55
 
39
- // ── Theme: one system, [data-theme] on <html>. theme.js set it before paint. ──
40
- function getTheme() { return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; }
41
- function setTheme(t) {
42
- document.documentElement.setAttribute("data-theme", t);
43
- try { localStorage.setItem(THEME_KEY, t); } catch { /* storage off: the choice lasts this page only */ }
44
- paintThemeButton();
45
- announce(`${t === "light" ? "Light" : "Dark"} theme`);
46
- }
47
- function paintThemeButton() {
48
- const b = document.getElementById("theme-btn");
49
- const t = getTheme();
50
- b.textContent = t === "light" ? "◑ light" : "◐ dark";
51
- b.setAttribute("aria-label", `Theme: ${t}. Switch to ${t === "light" ? "dark" : "light"}`);
52
- }
53
- document.getElementById("theme-btn").addEventListener("click", () => setTheme(getTheme() === "light" ? "dark" : "light"));
54
- paintThemeButton();
56
+ // ── Context the renderers share ──
57
+ const ctx = {
58
+ theme,
59
+ toggleLeft,
60
+ toggleRight,
61
+ refresh,
62
+ refreshMeta: async () => { state.meta = await api.get("/api/meta"); },
63
+ redrawRight: () => renderRight(right, ctx),
64
+ openFinding(f) {
65
+ set({ finding: { id: f.id, project: f.project }, right: "finding" });
66
+ if (!panels.right) { panels.right = true; applyPanels(panels); }
67
+ for (const b of document.querySelectorAll("#findings-list .row")) {
68
+ b.setAttribute("aria-current", String(b.dataset.id === f.id && b.dataset.project === f.project));
69
+ }
70
+ },
71
+ focusBar(kind) {
72
+ if (kind && kind !== state.mode) set({ mode: kind });
73
+ if (narrowLeft() && panels.left) { panels.left = false; applyPanels(panels); }
74
+ const input = document.getElementById("bar-input");
75
+ (input && !input.disabled ? input : document.querySelector(".bar .btn.primary"))?.focus();
76
+ },
77
+ };
55
78
 
56
- function clock() {
57
- const d = new Date();
58
- document.getElementById("mb-clock").textContent = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
79
+ async function refresh() {
80
+ const [overview, findings, runs] = await Promise.all([api.get("/api/overview"), api.get("/api/findings"), api.get("/api/runs")]);
81
+ set({ overview, findings: findings.findings, runs });
59
82
  }
60
- clock();
61
- setInterval(clock, 15_000);
62
83
 
63
- async function render() {
64
- const seq = ++renderSeq;
65
- const { name, params } = parseHash();
66
- ctx.params = params;
67
- for (const a of document.querySelectorAll(".menubar nav a")) {
68
- if (a.dataset.view === name) a.setAttribute("aria-current", "page");
69
- else a.removeAttribute("aria-current");
84
+ // ── Redraw on state changes, one column at a time ──
85
+ on((patch) => {
86
+ const keys = Object.keys(patch);
87
+ if (keys.includes("query") && keys.length === 1) { refreshList(ctx); return; }
88
+ if (keys.some((k) => ["overview", "runs", "sel", "meta", "right"].includes(k))) renderLeft(left, { onNewScan: () => ctx.focusBar("scan"), toggleLeft, theme });
89
+ if (keys.some((k) => ["overview", "findings", "sel", "mode"].includes(k))) {
90
+ // A live transcript is rebuilt only when the selection changes; data refreshes redraw the bar.
91
+ if (state.sel.kind === "run" && !keys.includes("sel")) renderBar(center, ctx);
92
+ else renderCenter(center, ctx);
93
+ if (keys.includes("sel") && narrowLeft() && panels.left) { panels.left = false; applyPanels(panels); }
70
94
  }
71
- const title = SCREENS[name].title;
72
- document.title = `${title} :: penthera ui`;
73
- document.getElementById("win-title").textContent = `penthera :: ${title.toLowerCase()}`;
74
- document.querySelector("#prompt .path").textContent = `~/${name}`;
75
- typeInto(document.getElementById("prompt-cmd"), `penthera ui --view ${name}`);
76
-
77
- if (cleanup) { try { cleanup(); } catch { /* view already gone */ } cleanup = null; }
78
- clear(main);
79
- const root = h("div"); // no enter animation: screens switch from the keyboard (1-8)
80
- main.append(root);
81
- try {
82
- const c = await VIEWS[name].render(root, ctx);
83
- if (seq !== renderSeq) { c?.(); return; }
84
- cleanup = c || null;
85
- } catch (e) {
86
- if (seq !== renderSeq) return;
87
- clear(root).append(
88
- h("h1", {}, title),
89
- h("p", { class: "error", role: "alert" }, e.message),
90
- e.status === 401 ? h("p", { class: "muted" }, ERR.session) : null,
91
- );
95
+ if (keys.some((k) => ["sel", "right", "finding"].includes(k)) || (keys.includes("runs") && state.right === "cards")) {
96
+ if (patch.openRight && !panels.right) { panels.right = true; applyPanels(panels); }
97
+ renderRight(right, ctx);
92
98
  }
93
- announce(`${title} screen`);
94
- }
95
-
96
- function chrome(meta) {
97
- // Only real elements go into the bar: a null child would render as the text "null".
98
- const chips = [
99
- meta.demo ? h("span", { class: "chip inv", title: "Fake data seeded by --demo" }, "DEMO DATA") : null,
100
- h("span", { class: meta.settings.offline ? "chip on" : "chip" }, meta.settings.offline ? "offline" : "online"),
101
- ].filter(Boolean);
102
- clear(document.getElementById("mb-chips")).append(...chips);
103
- document.getElementById("status-bind").textContent = `v${meta.version} :: bound to ${location.host} :: no network :: no telemetry`;
104
- }
99
+ });
105
100
 
101
+ // ── Keyboard ──
106
102
  function isTyping(e) {
107
103
  const t = e.target;
108
104
  return t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
109
105
  }
110
-
111
106
  document.addEventListener("keydown", (e) => {
112
- if (e.metaKey || e.ctrlKey || e.altKey || isTyping(e)) return;
113
- const n = Number(e.key);
114
- if (Number.isInteger(n) && n >= 1 && n <= ORDER.length) {
115
- e.preventDefault();
116
- go(ORDER[n - 1]);
117
- main.focus({ preventScroll: true });
107
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
108
+ if (e.key === "Escape") {
109
+ const q = document.getElementById("q");
110
+ if (document.activeElement === q && q.value) { q.value = ""; set({ query: "" }); return; }
111
+ if (state.right !== "cards") { set({ right: "cards", finding: null }); return; }
112
+ if ((narrowRight() && panels.right) || (narrowLeft() && panels.left)) scrim.click();
113
+ return;
118
114
  }
115
+ if (isTyping(e)) return;
116
+ if (answerPending(e.key)) { e.preventDefault(); return; }
117
+ if (e.key === "/") { const q = document.getElementById("q"); if (q) { e.preventDefault(); q.focus(); } return; }
118
+ if (e.key === "j" || e.key === "k") { e.preventDefault(); moveFinding(e.key === "j" ? 1 : -1, ctx); return; }
119
+ if (e.key === "[") { e.preventDefault(); toggleLeft(); return; }
120
+ if (e.key === "]") { e.preventDefault(); toggleRight(); }
119
121
  });
120
122
 
121
- window.addEventListener("hashchange", render);
122
-
123
123
  (async function boot() {
124
124
  document.body.append(patternDefs());
125
125
  try {
126
- ctx.meta = await api.get("/api/meta");
127
- chrome(ctx.meta);
126
+ state.meta = await api.get("/api/meta");
127
+ await refresh();
128
+ document.title = `penthera ui${state.meta.demo ? " :: demo" : ""}`;
128
129
  } catch (e) {
129
- clear(main).append(
130
+ clear(center).append(h("div", { class: "col" },
130
131
  h("h1", {}, "Not signed in"),
131
- h("p", { class: "error", role: "alert" }, e.message),
132
- h("p", { class: "muted" }, "Run penthera ui and open the one-time link it prints. The link works once."),
133
- );
134
- return;
132
+ h("p", { class: "bad", role: "alert" }, e.message),
133
+ h("p", { class: "muted" }, e.status === 401 ? ERR.session : "Run penthera ui and open the one-time link it prints. The link works once."),
134
+ ));
135
135
  }
136
- ctx.refreshMeta = async () => { ctx.meta = await api.get("/api/meta"); chrome(ctx.meta); };
137
- render();
138
136
  })();