penthera 1.7.0 → 1.8.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 (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
@@ -0,0 +1,170 @@
1
+ // The menu bar: every action in one place, each with its shortcut written
2
+ // next to it, and the status on the right (offline or online, the live run,
3
+ // a diff waiting for you). The logo is the first menu, like the Apple menu:
4
+ // the horizontal wordmark when there is room, the P mark when there is not.
5
+ //
6
+ // Keyboard (WAI-ARIA menubar): Left / Right move between menus, Down or Enter
7
+ // opens one, Up / Down move inside it, Esc closes and returns to the title.
8
+ import { h, clear, announce } from "./dom.js";
9
+ import { api } from "./api.js";
10
+ import { state, set, TABS, currentProject, liveRun, simple } from "./state.js";
11
+ import { shortProject } from "./format.js";
12
+
13
+ let openIdx = -1;
14
+
15
+ export function renderMenubar(el, ctx) {
16
+ const project = currentProject() || state.overview?.projects?.[0]?.key || "";
17
+ const scanned = state.overview?.projects?.some((p) => p.key === project);
18
+ const run = liveRun();
19
+ const inDoc = state.sel.kind === "all" || state.sel.kind === "project";
20
+ const report = (a) => ({
21
+ label: `Download ${a} report`, disabled: !scanned,
22
+ run: () => api.download(`/api/report?${new URLSearchParams({ project, audience: a })}`).catch((e) => announce(e.message)),
23
+ });
24
+
25
+ const easy = simple();
26
+ const all = [
27
+ { id: "app", label: "Penthera", logo: true, items: [
28
+ { label: "About Penthera…", run: () => ctx.about() },
29
+ { label: "Set up…", run: () => ctx.setup() },
30
+ { label: "Settings…", run: () => set({ right: "settings" }) },
31
+ "-",
32
+ { label: "Simple mode", check: easy, run: () => ctx.setUx("simple") },
33
+ { label: "Advanced mode", check: !easy, run: () => ctx.setUx("advanced") },
34
+ "-",
35
+ { label: "Light theme", check: ctx.theme.get() === "light", run: () => ctx.setTheme("light") },
36
+ { label: "Dark theme", check: ctx.theme.get() === "dark", run: () => ctx.setTheme("dark") },
37
+ ] },
38
+ { id: "check", label: "Check", simple: true, items: [
39
+ { label: "Check my app…", key: "n", run: () => ctx.newScan("scan") },
40
+ { label: "Fix problems for me…", run: () => ctx.newScan("loop") },
41
+ "-",
42
+ { label: "Show the running check", disabled: !run, run: () => set({ sel: { kind: "run", key: run.id, run } }) },
43
+ { label: "Stop it", disabled: !run, run: () => api.post(`/api/runs/${run.id}/stop`).then(() => announce("Stopping after the current step")) },
44
+ ] },
45
+ { id: "file", label: "File", items: [
46
+ { label: "New scan…", key: "n", run: () => ctx.newScan() },
47
+ "-",
48
+ { label: "All projects", run: () => set({ sel: { kind: "all", key: null } }) },
49
+ { label: "Close the detail", key: "Esc", disabled: state.right === "none", run: () => set({ right: "none", finding: null }) },
50
+ ] },
51
+ { id: "scan", label: "Scan", items: [
52
+ { label: "Scan a site or folder…", run: () => ctx.newScan("scan") },
53
+ { label: "Check dependencies…", run: () => ctx.newScan("deps") },
54
+ { label: "Audit AI tooling…", run: () => ctx.newScan("agents") },
55
+ ] },
56
+ { id: "fix", label: "Fix", items: [
57
+ { label: "Run the fix loop…", run: () => ctx.newScan("loop") },
58
+ { label: "Show the running run", disabled: !run, run: () => set({ sel: { kind: "run", key: run.id, run } }) },
59
+ { label: "Stop the run", disabled: !run, run: () => api.post(`/api/runs/${run.id}/stop`).then(() => announce("Stopping after the current step")) },
60
+ ] },
61
+ { id: "evidence", label: "Evidence", items: [
62
+ { label: `Proof for ${shortProject(project)}`, disabled: !project, key: "5", run: () => ctx.openTab("proof", project) },
63
+ "-",
64
+ report("auditor"), report("owner"), report("developer"),
65
+ ] },
66
+ { id: "view", label: "View", simple: true, items: [
67
+ { label: "Projects window", key: "[", check: ctx.panels.left, run: ctx.toggleLeft },
68
+ { label: "Inspector window", key: "]", check: ctx.panels.right, run: ctx.toggleRight },
69
+ { label: "Search findings", key: "/", disabled: !inDoc, run: () => { ctx.openTab("findings"); queueMicrotask(() => document.getElementById("q")?.focus()); } },
70
+ "-",
71
+ ...(easy ? [] : TABS.map((t) => ({ label: t.label, key: t.key, check: inDoc && state.tab === t.id, disabled: !inDoc, run: () => ctx.openTab(t.id) }))),
72
+ ] },
73
+ ];
74
+ // Simple mode keeps three menus: Penthera, Check, View. Advanced has everything but Check.
75
+ const menus = all.filter((m) => m.logo || (easy ? m.simple : m.id !== "check"));
76
+
77
+ const titles = [];
78
+ const bar = h("ul", { class: "menus", role: "menubar", "aria-label": "Menu bar" });
79
+ menus.forEach((m, i) => {
80
+ const pop = h("ul", { class: "menu", role: "menu", "aria-label": m.label, hidden: i !== openIdx });
81
+ const title = h("button", {
82
+ type: "button", class: ["menu-title", m.logo ? "logo" : null], role: "menuitem", "aria-haspopup": "menu", "aria-expanded": String(i === openIdx),
83
+ tabindex: i === 0 ? "0" : "-1", "aria-label": m.logo ? "Penthera menu" : null,
84
+ }, m.logo ? [h("span", { class: "logotype", "aria-hidden": "true" }), h("span", { class: "mark", "aria-hidden": "true" })] : m.label);
85
+ titles.push(title);
86
+ for (const it of m.items) {
87
+ if (it === "-") { pop.append(h("li", { role: "separator", class: "sep" })); continue; }
88
+ const kids = [h("span", { class: "check", "aria-hidden": "true" }, it.check ? "✓" : ""), h("span", { class: "grow" }, it.label), it.key ? h("kbd", {}, it.key) : null];
89
+ const role = it.check != null ? "menuitemcheckbox" : "menuitem";
90
+ const item = h("button", { type: "button", class: "menu-item", role, tabindex: "-1", disabled: it.disabled || null, "aria-checked": it.check != null ? String(it.check) : null }, kids);
91
+ if (it.disabled) item.setAttribute("aria-disabled", "true");
92
+ item.addEventListener("click", () => { close(); it.run?.(); });
93
+ pop.append(h("li", { role: "none" }, item));
94
+ }
95
+ title.addEventListener("click", () => (openIdx === i ? close() : open(i)));
96
+ title.addEventListener("pointerenter", () => { if (openIdx !== -1 && openIdx !== i) open(i, false); });
97
+ title.addEventListener("keydown", (e) => {
98
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
99
+ e.preventDefault();
100
+ const n = (i + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length;
101
+ if (openIdx !== -1) open(n); else { titles[n].focus(); roving(n); }
102
+ } else if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
103
+ e.preventDefault();
104
+ open(i);
105
+ }
106
+ });
107
+ pop.addEventListener("keydown", (e) => {
108
+ const items = [...pop.querySelectorAll(".menu-item:not([disabled])")];
109
+ const at = items.indexOf(document.activeElement);
110
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
111
+ e.preventDefault();
112
+ items[(at + (e.key === "ArrowDown" ? 1 : -1) + items.length) % items.length]?.focus();
113
+ } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
114
+ e.preventDefault();
115
+ open((i + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length);
116
+ } else if (e.key === "Escape" || e.key === "Tab") {
117
+ if (e.key === "Escape") e.preventDefault();
118
+ close();
119
+ titles[i].focus();
120
+ }
121
+ });
122
+ bar.append(h("li", { role: "none", class: "menu-wrap" }, title, pop));
123
+ });
124
+
125
+ function roving(n) { titles.forEach((t, j) => t.setAttribute("tabindex", j === n ? "0" : "-1")); }
126
+ function open(i, focusFirst = true) {
127
+ openIdx = i;
128
+ bar.querySelectorAll(".menu").forEach((p, j) => { p.hidden = j !== i; });
129
+ titles.forEach((t, j) => t.setAttribute("aria-expanded", String(j === i)));
130
+ roving(i);
131
+ titles[i].focus();
132
+ if (focusFirst) bar.querySelectorAll(".menu")[i].querySelector(".menu-item:not([disabled])")?.focus();
133
+ }
134
+ function close() {
135
+ openIdx = -1;
136
+ bar.querySelectorAll(".menu").forEach((p) => { p.hidden = true; });
137
+ titles.forEach((t) => t.setAttribute("aria-expanded", "false"));
138
+ }
139
+ menubarClose = close;
140
+
141
+ // Status, right-aligned: the live run, then egress.
142
+ const offline = state.meta?.settings?.offline !== false;
143
+ const egress = h("button", { type: "button", class: "status", role: "switch", "aria-checked": String(offline), title: offline ? "Scans started here contact only their target. Click to go online." : "Scans may also contact recon sources. Click to go offline." },
144
+ h("span", { class: ["lamp", offline ? null : "on"], "aria-hidden": "true" }), offline ? "Offline" : "Online");
145
+ egress.addEventListener("click", async () => {
146
+ const s = await api.post("/api/settings", { offline: !offline }).catch(() => null);
147
+ if (!s) return;
148
+ state.meta.settings = s;
149
+ set({ meta: state.meta });
150
+ announce(`Offline ${s.offline ? "on" : "off"}`);
151
+ });
152
+ const waiting = state.pending;
153
+ const runStatus = run
154
+ ? h("button", { type: "button", class: ["status", waiting ? "waiting" : null], onclick: () => set({ sel: { kind: "run", key: run.id, run } }) },
155
+ h("span", { class: "lamp live", "aria-hidden": "true" }),
156
+ waiting ? `Fix ${waiting.index} of ${waiting.total} waiting for you` : `${({ scan: "Scan", deps: "Dependencies", agents: "AI tooling", loop: "Fix loop" })[run.kind] || run.kind} running`)
157
+ : null;
158
+
159
+ clear(el).append(bar, h("span", { class: "grow" }),
160
+ h("div", { class: "statuses", role: "status", "aria-live": "polite" }, runStatus, egress,
161
+ state.meta?.demo ? h("span", { class: "status dim" }, "demo") : null));
162
+ }
163
+
164
+ let menubarClose = () => {};
165
+ /** Close any open menu (a click elsewhere, or Esc from the page). Returns true if one was open. */
166
+ export function closeMenus() {
167
+ if (openIdx === -1) return false;
168
+ menubarClose();
169
+ return true;
170
+ }
@@ -0,0 +1,104 @@
1
+ // First-run setup: three screens in one window. Welcome, choose Simple or
2
+ // Advanced, then the project and a first check. Back is always there, the
3
+ // fraction says where you are, and closing it early keeps Simple mode.
4
+ import { h, announce } from "./dom.js";
5
+ import { api } from "./api.js";
6
+ import { state, set, folderFor } from "./state.js";
7
+ import { shortProject } from "./format.js";
8
+ import { SETUP } from "./copy.js";
9
+ import { openWindow } from "./dialog.js";
10
+
11
+ const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
12
+
13
+ export function setup(ctx) {
14
+ const answers = { ux: state.ux || "simple", folder: folderFor(state.sel.kind === "project" ? state.sel.key : null), url: "" };
15
+ const body = h("div", { class: "setup" });
16
+ const progress = h("span", { class: "progress" });
17
+ const win = openWindow({ title: "Set up Penthera", body, wide: true });
18
+ win.el.querySelector(".win-bar").append(h("span", { class: "grow" }), progress);
19
+ win.el.addEventListener("close", () => ctx.finishSetup(answers.ux), { once: true });
20
+ let step = 0;
21
+
22
+ const nav = (...right) => h("div", { class: "actions" },
23
+ step > 0 ? h("button", { type: "button", class: "btn", onclick: () => go(step - 1) }, "◂ Back") : null,
24
+ h("span", { class: "grow" }), ...right);
25
+
26
+ function go(n) {
27
+ step = n;
28
+ progress.textContent = `${n + 1} / ${SETUP.steps}`;
29
+ body.replaceChildren(...[welcome, mode, project][n]());
30
+ body.querySelector("[data-first]")?.focus();
31
+ }
32
+
33
+ function welcome() {
34
+ const w = SETUP.welcome;
35
+ return [
36
+ h("span", { class: "logotype xl", role: "img", "aria-label": "Penthera" }),
37
+ h("h2", {}, w.title),
38
+ ...w.body.map((p) => h("p", { class: "prose" }, p)),
39
+ h("p", { class: "gate prose" }, w.gate),
40
+ nav(h("button", { type: "button", class: "btn primary", "data-first": "", onclick: () => go(1) }, "Start ▸")),
41
+ ];
42
+ }
43
+
44
+ function mode() {
45
+ const m = SETUP.mode;
46
+ const opt = (id, o) => {
47
+ const input = h("input", { type: "radio", name: "ux", value: id, checked: answers.ux === id, "data-first": answers.ux === id ? "" : null });
48
+ input.addEventListener("change", () => { answers.ux = id; });
49
+ return h("label", { class: "choice big" }, input, h("span", {}, h("span", { class: "t1" }, o.title), h("span", { class: "note prose" }, o.body)));
50
+ };
51
+ return [
52
+ h("h2", {}, m.title),
53
+ h("fieldset", {}, h("legend", { class: "sr" }, m.title), h("div", { class: "choices" }, opt("simple", m.simple), opt("advanced", m.advanced))),
54
+ h("p", { class: "muted prose" }, m.note),
55
+ nav(h("button", { type: "button", class: "btn primary", onclick: () => { ctx.setUx(answers.ux); go(2); } }, "Next ▸")),
56
+ ];
57
+ }
58
+
59
+ function project() {
60
+ const p = SETUP.project;
61
+ const folders = state.meta?.projects || [];
62
+ const folder = h("select", { id: "su-folder", "data-first": "" }, folders.map((f) => h("option", { value: f, selected: f === answers.folder }, shortProject(f))));
63
+ folder.addEventListener("change", () => { answers.folder = folder.value; });
64
+ const url = h("input", { type: "text", id: "su-url", autocomplete: "off", spellcheck: "false", placeholder: "http://localhost:3000", value: answers.url });
65
+ const confirm = h("input", { type: "text", id: "su-confirm", autocomplete: "off", spellcheck: "false" });
66
+ const confirmQ = h("label", { for: "su-confirm", class: "prose" });
67
+ const confirmRow = h("div", { class: "field confirm", hidden: true }, confirmQ, confirm);
68
+ const err = h("p", { class: "err", role: "alert", hidden: true });
69
+ url.addEventListener("input", () => {
70
+ answers.url = url.value;
71
+ let host = null;
72
+ try { host = url.value.trim() ? new URL(/^[a-z]+:\/\//i.test(url.value) ? url.value : `http://${url.value}`).hostname : null; } catch { /* not a URL yet */ }
73
+ const remote = Boolean(host && !LOCAL.test(host));
74
+ confirmRow.hidden = !remote;
75
+ if (remote) confirmQ.textContent = `${host} is not on this computer. Type its name to confirm it is yours or you may test it:`;
76
+ });
77
+ const check = h("button", { type: "button", class: "btn primary" }, `${p.check} ▸`);
78
+ check.addEventListener("click", async () => {
79
+ err.hidden = true;
80
+ check.disabled = true;
81
+ try {
82
+ const r = await api.post("/api/runs", { kind: "scan", repo: folder.value, url: url.value, confirm: confirm.value, maxIterations: 5 });
83
+ win.close();
84
+ await ctx.refresh();
85
+ set({ sel: { kind: "run", key: r.id, run: r } });
86
+ announce("First check started");
87
+ } catch (e) {
88
+ err.textContent = e.message;
89
+ err.hidden = false;
90
+ check.disabled = false;
91
+ }
92
+ });
93
+ return [
94
+ h("h2", {}, p.title),
95
+ h("div", { class: "form" },
96
+ h("div", { class: "field" }, h("label", { for: "su-folder" }, p.folder), folders.length ? folder : h("p", { class: "muted" }, "No folder given."), h("p", { class: "muted prose" }, p.folderNote)),
97
+ h("div", { class: "field" }, h("label", { for: "su-url" }, p.url), url, h("p", { class: "muted prose" }, p.urlNote)),
98
+ confirmRow, err),
99
+ nav(h("button", { type: "button", class: "btn", onclick: win.close }, p.skip), check),
100
+ ];
101
+ }
102
+
103
+ go(0);
104
+ }
@@ -0,0 +1,186 @@
1
+ // The project's tabs besides Findings: Dependencies, AI tooling, History and
2
+ // Proof. These were the right-hand cards; they now live with the project they
3
+ // describe, so the inspector on the right only ever shows one thing.
4
+ import { h, empty, sevBadge, announce } from "./dom.js";
5
+ import { api } from "./api.js";
6
+ import { state, set } from "./state.js";
7
+ import { bars, SHADE, trendChart } from "./charts.js";
8
+ import { ago, stamp, shortProject, plural, pct } from "./format.js";
9
+ import { EMPTY } from "./copy.js";
10
+
11
+ const section = (title, aside, ...body) => h("section", { class: "section" },
12
+ h("h2", {}, h("span", {}, title), aside ? h("span", {}, aside) : null), ...body);
13
+ const label = (text) => h("p", { class: "label" }, text);
14
+ const failed = (e) => h("p", { class: "bad", role: "alert" }, e.message);
15
+
16
+ // ── Dependencies ────────────────────────────────────────────────────────
17
+
18
+ const CAP_SEV = { "install-scripts": "high", obfuscated: "high", "ai-config-write": "critical", "ai-cli-invoke": "critical", "token-harvest": "critical" };
19
+
20
+ export async function depsTab(el) {
21
+ let d;
22
+ try { d = await api.get("/api/deps"); } catch (e) { el.append(failed(e)); return; }
23
+ const l = d.latest;
24
+ if (!l) { el.append(section("Dependencies", "", empty(EMPTY.deps))); return; }
25
+ const diff = l.diff;
26
+ const gained = diff ? diff.upgraded.reduce((n, u) => n + u.gained.length, 0) + diff.added.reduce((n, a) => n + a.capabilities.length, 0) : 0;
27
+ const chip = (id, plus) => h("span", { class: `chip ${CAP_SEV[id] ? "inv" : ""}` }, `${plus ? "+" : ""}${id}`);
28
+ el.append(
29
+ section("Dependencies", `${l.ecosystem} · ${l.total} packages · ${ago(l.ts)}`,
30
+ h("dl", { class: "kv" },
31
+ h("dt", {}, "packages"), h("dd", {}, `${l.total} (${l.direct} direct) · ${l.lockfile}`),
32
+ h("dt", {}, "diff"), h("dd", {}, diff ? `+${diff.added.length} added · ~${diff.upgraded.length} upgraded · -${diff.removed.length} removed vs ${diff.base}` : "no base compared"),
33
+ h("dt", {}, "gained"), h("dd", {}, `${gained} capabilities`),
34
+ h("dt", {}, "typosquats"), h("dd", { class: l.typosquats.length ? "bad" : null }, l.typosquats.length ? l.typosquats.map((t) => `${t.name} ~ ${t.similarTo}`).join(", ") : "none"))),
35
+ diff ? section("What changed", `vs ${diff.base}`,
36
+ h("ul", { class: "mini" },
37
+ diff.added.map((a) => h("li", { class: "stacked" }, h("span", {}, `+ ${a.name}@${a.version}`), h("span", { class: "chips" }, a.capabilities.map((c) => chip(c, true))))),
38
+ diff.upgraded.filter((u) => u.gained.length).map((u) => h("li", { class: "stacked" }, h("span", {}, `${u.name} ${u.from} → ${u.to}`), h("span", { class: "chips" }, u.gained.map((c) => chip(c, true))))))) : null,
39
+ section("Capabilities in the tree", "",
40
+ bars(Object.entries(l.capabilityCounts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([id, n]) => ({ label: id, value: n, ch: SHADE[CAP_SEV[id] || "low"] })), { width: 28 })),
41
+ d.installs.length ? section("Install firewall", plural(d.installs.length, "decision"),
42
+ h("ul", { class: "mini" }, d.installs.map((i) => {
43
+ const deny = i.changes.some((c) => c.deny.length) || i.action === "refused";
44
+ return h("li", {}, h("span", { class: "chip" }, deny ? "Refused" : i.clean ? "Clean" : "Approved"), h("span", { class: "grow" }, i.specs.join(" ")), h("span", { class: "muted" }, ago(i.ts)));
45
+ }))) : null,
46
+ );
47
+ }
48
+
49
+ // ── AI tooling ──────────────────────────────────────────────────────────
50
+
51
+ export async function aiTab(el) {
52
+ let d;
53
+ try { d = await api.get("/api/ai"); } catch (e) { el.append(failed(e)); return; }
54
+ const l = d.latest;
55
+ if (!l) { el.append(section("AI tooling", "", empty(EMPTY.ai))); return; }
56
+ const lock = l.lock || {};
57
+ const drift = (lock.changed || 0) + (lock.added || 0) + (lock.removed || 0);
58
+ el.append(
59
+ section("AI tooling", ago(l.ts),
60
+ h("div", { class: "head" },
61
+ h("span", { class: `grade lg grade-${l.grade}` }, l.grade),
62
+ h("div", {}, h("div", { class: "t1" }, `score ${l.score}/100`), h("div", { class: "muted" }, `grade history ${d.history.map((x) => x.grade).join(" → ")}`))),
63
+ h("dl", { class: "kv" },
64
+ h("dt", {}, "penthera.lock"), h("dd", { class: drift ? "bad" : null }, lock.status === "missing" ? "not pinned" : drift ? `${lock.changed || 0} changed · ${lock.added || 0} new · ${lock.removed || 0} removed` : "no drift"),
65
+ h("dt", {}, "inventory"), h("dd", {}, `${l.inventory.skills || 0} skills · ${l.inventory.mcpServers || 0} MCP servers · ${l.inventory.hooks || 0} hooks`),
66
+ h("dt", {}, "hidden content"), h("dd", { class: l.hidden.length ? "bad" : null }, l.hidden.length ? plural(l.hidden.length, "hit") : "none"))),
67
+ l.hidden.length ? section("Hidden content", plural(l.hidden.length, "hit"),
68
+ h("ul", { class: "rows" }, l.hidden.map((x) => h("li", {}, h("div", { class: "row static" }, sevBadge(x.severity), h("span", { class: "t" }, x.title), h("span", { class: "tag" }, ""), x.location ? h("span", { class: "where" }, x.location) : null))))) : null,
69
+ );
70
+ }
71
+
72
+ // ── History: trend, runs, commits, memory ───────────────────────────────
73
+
74
+ export async function historyTab(el, project) {
75
+ const p = project ? state.overview?.projects?.find((x) => x.key === project) : null;
76
+ const points = p ? p.trend.map((t) => ({ ts: t.ts, total: t.total, severe: (t.critical || 0) + (t.high || 0) })) : [];
77
+ if (points.length) el.append(section("Open findings per scan", plural(points.length, "scan"), trendChart(points, { width: 640, height: 160 })));
78
+
79
+ const runs = [
80
+ ...state.runs.live.filter((r) => !project || r.repo === project || r.url === project).map((r) => ({ live: true, kind: r.kind, at: r.startedAt, status: r.status, run: r })),
81
+ ...state.runs.past.filter((l) => !project || l.target.repo === project || l.target.url === project).map((l) => ({ live: false, kind: "loop", at: l.startedAt, past: l })),
82
+ ];
83
+ el.append(section("Runs", plural(runs.length, "run"),
84
+ runs.length ? h("ul", { class: "rows" }, runs.map((r) => h("li", {}, h("button", {
85
+ type: "button", class: "row",
86
+ onclick: () => set(r.live ? { sel: { kind: "run", key: r.run.id, run: r.run } } : { sel: { kind: "past", key: r.at, past: r.past } }),
87
+ },
88
+ h("span", { class: "tag" }, r.live ? r.status : stamp(r.at).slice(5)),
89
+ h("span", { class: "t" }, `${({ scan: "Scan", deps: "Dependency check", agents: "AI tooling audit", loop: "Fix loop" })[r.kind] || r.kind}${project ? "" : ` · ${shortProject(r.run?.repo || r.past?.target?.repo)}`}`),
90
+ h("span", { class: "tag" }, ago(r.at)),
91
+ r.past ? h("span", { class: "where" }, `${r.past.totals.verified} verified · ${r.past.totals.failed} failed · findings ${r.past.findings.initial ?? "?"} → ${r.past.findings.final ?? "?"} · ${r.past.branch}`) : null)))) : empty(EMPTY.history)));
92
+
93
+ const past = state.runs.past.filter((l) => !project || l.target.repo === project || l.target.url === project);
94
+ const fixes = past.flatMap((l) => l.iterations.flatMap((it) => it.fixes.filter((f) => f.commit).map((f) => ({ ...f, branch: l.branch }))));
95
+ let env = { fixBranches: [] };
96
+ try { env = await api.get(`/api/env?project=${encodeURIComponent(project || "")}`); } catch { /* shown as none */ }
97
+ el.append(section("Commits the fix loop made", plural(fixes.length, "fix", "fixes"),
98
+ fixes.length ? h("ul", { class: "mini" }, fixes.map((f) => h("li", {},
99
+ h("span", {}, "✓"), h("span", { class: "grow" }, f.title, h("span", { class: "muted" }, ` · ${f.branch}`)),
100
+ h("code", { class: "muted", title: `git show ${f.commit}` }, f.commit.slice(0, 7))))) : empty(EMPTY.changes),
101
+ env.fixBranches.length ? h("div", {}, label("Local fix branches"), h("ul", { class: "mini" }, env.fixBranches.map((b) => h("li", {}, h("code", { class: "grow" }, b))))) : null,
102
+ h("p", { class: "muted prose" }, "Nothing is pushed. Review with git log --stat main..<branch>, then merge or push it yourself.")));
103
+
104
+ let mem = { projects: [] };
105
+ try { mem = await api.get("/api/memory"); } catch { /* shown as empty */ }
106
+ const mine = mem.projects.filter((m) => project && (m.label === project || m.label.startsWith(`${project} `)));
107
+ const list = mine.length ? mine : mem.projects;
108
+ if (!list.length) { el.append(section("Memory", "", empty(EMPTY.memory))); return; }
109
+ const recipes = new Map();
110
+ for (const m of list) for (const r of m.recipes) {
111
+ const c = recipes.get(r.recipe) || { verified: 0, failed: 0 };
112
+ c.verified += r.verified;
113
+ c.failed += r.failed;
114
+ recipes.set(r.recipe, c);
115
+ }
116
+ const broken = list.filter((m) => m.chain !== "ok");
117
+ const ttf = list.map((m) => m.medianTimeToFixDays).filter((x) => x != null);
118
+ el.append(section("Memory", broken.length ? `${broken.length} broken` : "chain ok",
119
+ h("dl", { class: "kv" },
120
+ h("dt", {}, "chain"), h("dd", { class: broken.length ? "bad" : null }, broken.length ? `[!] broken in ${plural(broken.length, "project")} :: ignored there` : "✓ sha-256 hash chain"),
121
+ h("dt", {}, "events"), h("dd", {}, String(list.reduce((n, m) => n + m.events, 0))),
122
+ h("dt", {}, "fixes"), h("dd", {}, `${list.reduce((n, m) => n + m.verifiedFixes, 0)} verified · ${list.reduce((n, m) => n + m.failedFixes, 0)} failed`),
123
+ h("dt", {}, "time to fix"), h("dd", {}, ttf.length ? `median ${Math.min(...ttf)}-${Math.max(...ttf)} days` : "-")),
124
+ label("Recipe success"),
125
+ bars([...recipes].map(([recipe, r]) => ({ label: `${recipe} ${pct(r.verified / Math.max(1, r.verified + r.failed))}`, value: r.verified })), { width: 24 })));
126
+ }
127
+
128
+ // ── Proof: what was touched, and evidence an auditor can check ──────────
129
+
130
+ export async function proofTab(el, project) {
131
+ const target = project || state.overview?.projects?.[0]?.key || "";
132
+ let env; let ev;
133
+ try {
134
+ [env, ev] = await Promise.all([api.get(`/api/env?project=${encodeURIComponent(target)}`), api.get("/api/evidence")]);
135
+ } catch (e) { el.append(failed(e)); return; }
136
+ const scanned = state.overview?.projects?.some((p) => p.key === target);
137
+
138
+ el.append(section("What the last scan touched", shortProject(target),
139
+ h("dl", { class: "kv" },
140
+ h("dt", {}, "folder"), h("dd", {}, env.folder || (target.startsWith("/") ? target : "a URL target")),
141
+ h("dt", {}, "branch"), h("dd", {}, env.branch ? `${env.branch}${env.head ? ` @ ${env.head}` : ""}` : "-"),
142
+ h("dt", {}, "dashboard"), h("dd", {}, `${location.host}, no network`)),
143
+ env.hosts.length ? h("ul", { class: "mini" },
144
+ env.hosts.map((x) => h("li", {}, h("span", { class: "chip" }, x.thirdParty ? "3rd party" : "target"), h("span", { class: "grow" }, x.host), h("span", { class: "muted" }, plural(x.requests, "request")))),
145
+ env.blocked.map((x) => h("li", {}, h("span", { class: "chip inv" }, "blocked"), h("span", { class: "grow" }, x.host), h("span", { class: "muted" }, "offline mode")))) : empty("No hosts recorded for the last scan."),
146
+ ));
147
+
148
+ const msg = h("p", { class: "muted prose", "aria-live": "polite" });
149
+ const out = h("div");
150
+ const create = h("button", { type: "button", class: "btn primary", disabled: !scanned }, "Create bundle");
151
+ create.addEventListener("click", async () => {
152
+ create.disabled = true;
153
+ msg.textContent = "Writing the bundle and hashing every file…";
154
+ try {
155
+ const r = await api.post("/api/evidence/create", { project: target });
156
+ const v = await api.post("/api/evidence/verify", { id: r.id });
157
+ msg.textContent = `${r.path} :: ${v.ok ? "verified" : "verification failed"}`;
158
+ announce(msg.textContent);
159
+ set({ tab: "proof" });
160
+ } catch (e) { msg.textContent = e.message; create.disabled = false; }
161
+ });
162
+ el.append(section("Evidence bundle", plural(ev.bundles.length, "bundle"),
163
+ h("p", { class: "prose" }, "A bundle holds the latest scan, its inputs and a hash of every file, so an auditor can check nothing changed after the fact."),
164
+ h("div", { class: "actions start" }, create,
165
+ // A download through the API carries the session header; a plain link could not.
166
+ ...["auditor", "owner", "developer"].map((a) => h("button", {
167
+ type: "button", class: "btn", disabled: !scanned,
168
+ onclick: () => api.download(`/api/report?${new URLSearchParams({ project: target, audience: a })}`).catch((e) => { msg.textContent = e.message; }),
169
+ }, `${a} report`))),
170
+ msg,
171
+ ev.bundles.length ? h("ul", { class: "mini" }, ev.bundles.map((b) => {
172
+ const res = h("span", { class: "muted" }, b.signed ? "signed" : "unsigned");
173
+ const btn = h("button", { type: "button", class: "btn small" }, "Verify");
174
+ btn.addEventListener("click", async () => {
175
+ btn.disabled = true;
176
+ res.textContent = "hashing…";
177
+ const r = await api.post("/api/evidence/verify", { id: b.id }).catch((e) => ({ ok: false, problems: [e.message], files: [] }));
178
+ res.textContent = r.ok ? "✓ intact" : `[!] ${plural(r.problems.length, "problem")}`;
179
+ res.className = r.ok ? "t1" : "bad";
180
+ out.replaceChildren(...(r.ok ? [] : [h("pre", { class: "block" }, r.problems.map((p) => `- ${p}`).join("\n"))]));
181
+ btn.disabled = false;
182
+ });
183
+ return h("li", {}, h("span", { class: "grow", title: b.target || "" }, `${b.path} · ${ago(b.generatedAt)}`), res, btn);
184
+ })) : empty(EMPTY.evidence),
185
+ out));
186
+ }