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.
- package/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
|
@@ -1,23 +1,19 @@
|
|
|
1
|
-
// The centre:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The centre window: the document you are looking at. A project (or all of
|
|
2
|
+
// them) is a header and five tabs :: Findings, Dependencies, AI tooling,
|
|
3
|
+
// History, Proof. A run is a transcript of tool output with inline Apply /
|
|
4
|
+
// Skip / Stop. Runs start from the New scan dialog, not from here.
|
|
4
5
|
import { h, clear, empty, sevBadge, diffBlock, announce } from "./dom.js";
|
|
5
6
|
import { api } from "./api.js";
|
|
6
|
-
import { state, set, projectOf,
|
|
7
|
+
import { state, set, projectOf, TABS, simple } from "./state.js";
|
|
7
8
|
import { createTyper } from "./typer.js";
|
|
8
|
-
import { stackedBar, sparkline,
|
|
9
|
+
import { stackedBar, sparkline, matrix, halftone } from "./charts.js";
|
|
9
10
|
import { ago, duration, shortProject, plural, SEVERITIES } from "./format.js";
|
|
10
|
-
import { START,
|
|
11
|
+
import { START, EMPTY, SEV_WORD, verdict } from "./copy.js";
|
|
12
|
+
import { openWindow } from "./dialog.js";
|
|
13
|
+
import { depsTab, aiTab, historyTab, proofTab } from "./panels.js";
|
|
11
14
|
|
|
12
|
-
const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
|
|
13
15
|
const SEV_RANK = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
|
|
14
16
|
|
|
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
17
|
// ── Stream state that outlives a redraw: the live run's typer and SSE ──
|
|
22
18
|
let typer = null;
|
|
23
19
|
let es = null;
|
|
@@ -25,6 +21,7 @@ let pending = null;
|
|
|
25
21
|
let visible = [];
|
|
26
22
|
|
|
27
23
|
export function stopStream() {
|
|
24
|
+
closeApproval();
|
|
28
25
|
es?.close();
|
|
29
26
|
es = null;
|
|
30
27
|
typer?.stop();
|
|
@@ -38,17 +35,18 @@ export function renderCenter(el, ctx) {
|
|
|
38
35
|
const title = state.sel.kind === "all" ? "All projects"
|
|
39
36
|
: state.sel.kind === "project" ? shortProject(state.sel.key)
|
|
40
37
|
: state.sel.kind === "run" ? `Run :: ${state.sel.run?.kind || ""}` : "Fix loop run";
|
|
41
|
-
const
|
|
42
|
-
|
|
38
|
+
const doc = state.sel.kind === "all" || state.sel.kind === "project";
|
|
39
|
+
const toolbar = h("div", { class: "win-bar" },
|
|
40
|
+
h("button", { type: "button", class: "icon", "aria-label": "Show or hide the projects window ([)", title: "Projects [", onclick: toggleLeft }, "☰"),
|
|
43
41
|
h("span", { class: "title" }, title),
|
|
44
42
|
h("span", { class: "grow" }),
|
|
45
|
-
|
|
46
|
-
h("button", { type: "button", class: "icon", "aria-label": "
|
|
43
|
+
doc && state.tab === "findings" ? search : null,
|
|
44
|
+
h("button", { type: "button", class: "icon", "aria-label": "Show or hide the inspector (])", title: "Inspector ]", onclick: toggleRight }, "◫"),
|
|
47
45
|
);
|
|
48
|
-
const stream = h("div", { class: "stream", id: "stream" });
|
|
46
|
+
const stream = h("div", { class: "stream pane-body", id: "stream" });
|
|
49
47
|
const col = h("div", { class: "col" });
|
|
50
48
|
stream.append(col);
|
|
51
|
-
clear(el).append(toolbar, stream
|
|
49
|
+
clear(el).append(toolbar, stream);
|
|
52
50
|
|
|
53
51
|
if (state.sel.kind === "run") return runView(col, ctx);
|
|
54
52
|
stopStream();
|
|
@@ -56,12 +54,6 @@ export function renderCenter(el, ctx) {
|
|
|
56
54
|
return projectView(col, ctx);
|
|
57
55
|
}
|
|
58
56
|
|
|
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
57
|
/** Re-render only the findings list (search typing must not rebuild the toolbar). */
|
|
66
58
|
export function refreshList(ctx) {
|
|
67
59
|
const list = document.getElementById("findings-list");
|
|
@@ -84,20 +76,51 @@ function projectView(col, ctx) {
|
|
|
84
76
|
const open = all ? o.open : p.open;
|
|
85
77
|
const fixed = all ? o.fixed : p.fixed;
|
|
86
78
|
const trend = all ? combinedTrend(o.projects) : p.trend.map((t) => t.total);
|
|
79
|
+
const easy = simple();
|
|
80
|
+
const when = ago(all ? o.lastScans[0]?.ts : p.lastScan);
|
|
87
81
|
col.append(h("header", { class: "head" },
|
|
88
82
|
h("span", { class: `grade lg grade-${all ? "x" : p.grade}`, title: all ? "" : `score ${p.score}/100` }, all ? "∑" : p.grade),
|
|
89
83
|
h("h1", { title: key || "" }, all ? "All projects" : shortProject(key)),
|
|
90
|
-
h("div", { class: "meta" },
|
|
84
|
+
h("div", { class: "meta" }, easy
|
|
85
|
+
? (all ? `${plural(o.projects.length, "project")} · last checked ${when}` : `last checked ${when}`)
|
|
86
|
+
: (all ? `${plural(o.projects.length, "project")} · newest scan ${when}` : `${p.mode || "scan"} · last scan ${when} · ${plural(p.trend.length, "scan")}`)),
|
|
91
87
|
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"),
|
|
88
|
+
easy ? null : stackedBar(counts, { width: 24 }),
|
|
89
|
+
easy ? null : sparkline(trend, { label: "open findings per scan" }),
|
|
90
|
+
h("div", { class: "counts" }, h("b", {}, String(open)), easy ? " to fix · " : " open · ", h("span", { class: "ok" }, String(fixed)), " fixed"),
|
|
95
91
|
),
|
|
96
92
|
));
|
|
97
93
|
|
|
98
|
-
//
|
|
94
|
+
// The verdict: one sentence, one button.
|
|
95
|
+
const mine = sorted(state.findings.filter((f) => !f.suppressed && (all || f.project === key)));
|
|
96
|
+
const v = verdict({ counts, open, top: mine[0], scanned: true, when });
|
|
97
|
+
col.append(h("section", { class: ["verdict", `v-${v.level}`], "aria-label": "Verdict" },
|
|
98
|
+
h("p", { class: "verdict-text" }, v.text),
|
|
99
|
+
h("p", { class: "verdict-sub muted" }, v.sub),
|
|
100
|
+
v.top ? h("div", { class: "actions start" },
|
|
101
|
+
h("button", { type: "button", class: "btn primary", onclick: () => ctx.openFinding(v.top) }, easy ? "Show me the first one ▸" : `Open: ${v.top.title}`),
|
|
102
|
+
easy && v.level !== "clear" ? h("button", { type: "button", class: "btn", onclick: () => ctx.newScan("loop", all ? null : key) }, "Fix problems for me…") : null) : null,
|
|
103
|
+
));
|
|
104
|
+
|
|
105
|
+
if (easy) {
|
|
106
|
+
const list = h("ul", { class: "rows simple", id: "findings-list", "aria-label": "Problems to fix" });
|
|
107
|
+
col.append(h("section", { class: "section" }, h("h2", {}, h("span", {}, "To fix"), h("span", { id: "findings-count" })), list));
|
|
108
|
+
fillFindings(list, ctx);
|
|
109
|
+
col.append(h("p", { class: "muted prose more" }, "Looking for dependencies, AI tooling, history or evidence for an auditor? Switch to Advanced in the Penthera menu."));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
col.append(tabStrip(ctx));
|
|
114
|
+
const panel = h("div", { class: "tab-panel", role: "tabpanel", id: `tab-${state.tab}`, "aria-labelledby": `tabbtn-${state.tab}` });
|
|
115
|
+
col.append(panel);
|
|
116
|
+
if (state.tab === "deps") return depsTab(panel);
|
|
117
|
+
if (state.tab === "ai") return aiTab(panel);
|
|
118
|
+
if (state.tab === "history") return historyTab(panel, all ? null : key);
|
|
119
|
+
if (state.tab === "proof") return proofTab(panel, all ? null : key);
|
|
120
|
+
|
|
121
|
+
// Since the last scan.
|
|
99
122
|
const since = h("div");
|
|
100
|
-
|
|
123
|
+
panel.append(since);
|
|
101
124
|
if (!all && p.trend.length > 1) {
|
|
102
125
|
api.get(`/api/history?project=${encodeURIComponent(key)}`).then((hst) => {
|
|
103
126
|
if (state.sel.key !== key) return;
|
|
@@ -114,41 +137,72 @@ function projectView(col, ctx) {
|
|
|
114
137
|
h("div", { class: "cols2" }, miniList(o.columns.newest, "Nothing new."), miniList(o.columns.fixed, "Nothing fixed.", true))));
|
|
115
138
|
}
|
|
116
139
|
|
|
140
|
+
const pool = state.findings.filter((f) => !f.suppressed && (all || f.project === key));
|
|
141
|
+
panel.append(h("section", { class: "section" },
|
|
142
|
+
h("h2", {}, h("span", {}, "Triage :: severity × how sure"), h("span", {}, state.filter ? h("button", { type: "button", class: "linkish", onclick: () => set({ filter: null }) }, "clear filter ×") : "pick a cell to filter")),
|
|
143
|
+
matrix(pool, state.filter, (cell) => set({ filter: cell }))));
|
|
144
|
+
|
|
117
145
|
const list = h("ul", { class: "rows", id: "findings-list", "aria-label": "Findings" });
|
|
118
|
-
|
|
146
|
+
panel.append(h("section", { class: "section" }, h("h2", {}, h("span", {}, "Findings"), h("span", { id: "findings-count" })), list));
|
|
119
147
|
fillFindings(list, ctx);
|
|
120
148
|
}
|
|
121
149
|
|
|
150
|
+
const sorted = (rows) => [...rows].sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.title.localeCompare(b.title));
|
|
151
|
+
|
|
152
|
+
/** Folder tabs: the selected one joins the page below it. Arrow keys move between them. */
|
|
153
|
+
function tabStrip(ctx) {
|
|
154
|
+
const strip = h("div", { class: "tabs", role: "tablist", "aria-label": "Project views" });
|
|
155
|
+
const btns = TABS.map((t, i) => {
|
|
156
|
+
const b = h("button", {
|
|
157
|
+
type: "button", role: "tab", class: "tab", id: `tabbtn-${t.id}`, "aria-selected": String(state.tab === t.id),
|
|
158
|
+
"aria-controls": `tab-${t.id}`, tabindex: state.tab === t.id ? "0" : "-1", title: `${t.label} ${t.key}`, onclick: () => ctx.openTab(t.id),
|
|
159
|
+
}, t.label);
|
|
160
|
+
b.addEventListener("keydown", (e) => {
|
|
161
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
const n = TABS[(i + (e.key === "ArrowRight" ? 1 : -1) + TABS.length) % TABS.length];
|
|
164
|
+
ctx.openTab(n.id);
|
|
165
|
+
queueMicrotask(() => document.getElementById(`tabbtn-${n.id}`)?.focus());
|
|
166
|
+
});
|
|
167
|
+
return b;
|
|
168
|
+
});
|
|
169
|
+
strip.append(...btns, h("span", { class: "tab-rest" }));
|
|
170
|
+
return strip;
|
|
171
|
+
}
|
|
172
|
+
|
|
122
173
|
function fillFindings(list, ctx) {
|
|
123
174
|
const all = state.sel.kind === "all";
|
|
124
175
|
const q = state.query.trim().toLowerCase();
|
|
125
|
-
const
|
|
176
|
+
const easy = simple();
|
|
177
|
+
const fl = easy ? null : state.filter;
|
|
178
|
+
const rows = sorted(state.findings
|
|
126
179
|
.filter((f) => !f.suppressed && (all || f.project === state.sel.key))
|
|
127
|
-
.filter((f) => !
|
|
128
|
-
.
|
|
180
|
+
.filter((f) => !fl || (f.severity === fl.severity && (f.confidence || "likely") === fl.confidence))
|
|
181
|
+
.filter((f) => !q || `${f.title} ${f.category} ${f.location} ${f.project}`.toLowerCase().includes(q)));
|
|
129
182
|
visible = rows;
|
|
130
183
|
clear(list);
|
|
131
184
|
let group = null;
|
|
132
185
|
for (const f of rows) {
|
|
133
186
|
const fixFirst = f.severity === "critical" || f.severity === "high";
|
|
134
|
-
const g = fixFirst ? "Fix first" : "Then";
|
|
187
|
+
const g = fixFirst ? "Fix first" : easy ? "When you have time" : "Then";
|
|
135
188
|
if (g !== group) {
|
|
136
189
|
group = g;
|
|
137
|
-
|
|
190
|
+
const n = rows.filter((x) => (x.severity === "critical" || x.severity === "high") === fixFirst).length;
|
|
191
|
+
list.append(h("li", { class: "group-h" }, `${g} :: ${n}`));
|
|
138
192
|
}
|
|
139
193
|
const cur = state.finding && state.finding.id === f.id && state.finding.project === f.project;
|
|
140
194
|
list.append(h("li", {}, h("button", {
|
|
141
195
|
type: "button", class: ["row", fixFirst ? "b" : null], "aria-current": String(Boolean(cur)), dataset: { id: f.id, project: f.project },
|
|
142
196
|
onclick: () => ctx.openFinding(f),
|
|
143
197
|
},
|
|
144
|
-
sevBadge(f.severity),
|
|
198
|
+
easy ? h("span", { class: `sev sev-${f.severity}` }, SEV_WORD[f.severity] || f.severity) : sevBadge(f.severity),
|
|
145
199
|
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)));
|
|
200
|
+
h("span", { class: "tag" }, easy ? (all ? shortProject(f.project) : "▸") : all ? shortProject(f.project) : f.category || ""),
|
|
201
|
+
!easy && f.location ? h("span", { class: "where" }, f.location) : null)));
|
|
148
202
|
}
|
|
149
203
|
if (!rows.length) list.append(h("li", {}, empty(state.findings.length && q ? `${EMPTY.filtered}` : EMPTY.findings)));
|
|
150
204
|
const count = document.getElementById("findings-count");
|
|
151
|
-
if (count) count.textContent = q ? `${rows.length} match` : plural(rows.length, "open finding");
|
|
205
|
+
if (count) count.textContent = q || fl ? `${rows.length} match` : easy ? plural(rows.length, "problem") : plural(rows.length, "open finding");
|
|
152
206
|
}
|
|
153
207
|
|
|
154
208
|
/** j / k: move the selection and open the finding in the right panel. */
|
|
@@ -175,16 +229,22 @@ function combinedTrend(projects) {
|
|
|
175
229
|
}
|
|
176
230
|
|
|
177
231
|
function startView(col, ctx) {
|
|
232
|
+
const easy = simple();
|
|
233
|
+
const steps = easy ? [] : START.steps;
|
|
178
234
|
col.append(h("div", { class: "start" },
|
|
179
|
-
|
|
180
|
-
|
|
235
|
+
h("span", { class: "logotype xl", role: "img", "aria-label": "Penthera" }),
|
|
236
|
+
halftone({ risk: 1, seed: "penthera" }),
|
|
181
237
|
h("h1", {}, START.title),
|
|
182
|
-
h("p", {}, START.intro),
|
|
183
|
-
h("div", { class: "
|
|
238
|
+
h("p", { class: "prose" }, easy ? START.introSimple : START.intro),
|
|
239
|
+
h("div", { class: "actions start" },
|
|
240
|
+
h("button", { type: "button", class: "btn primary", onclick: () => ctx.setup() }, "Set up my project…"),
|
|
241
|
+
h("button", { type: "button", class: "btn", onclick: () => ctx.newScan("scan") }, easy ? "Check my app…" : "New scan…")),
|
|
242
|
+
steps.length ? h("div", { class: "steps" }, steps.map((st, i) => h("div", { class: "step" },
|
|
184
243
|
h("span", { class: "n" }, `${i + 1}.`),
|
|
185
244
|
h("h3", {}, st.title),
|
|
186
|
-
h("button", { type: "button", class: "btn", onclick: () => ctx.
|
|
187
|
-
h("p", {}, st.body)))),
|
|
245
|
+
h("button", { type: "button", class: "btn", onclick: () => ctx.newScan(st.kind) }, `${st.title}…`),
|
|
246
|
+
h("p", {}, st.body)))) : null,
|
|
247
|
+
h("p", { class: "muted gate prose" }, START.gate),
|
|
188
248
|
h("p", { class: "muted" }, `${START.demo} `, h("code", {}, "penthera ui --demo"), "."),
|
|
189
249
|
));
|
|
190
250
|
}
|
|
@@ -193,8 +253,8 @@ function notScannedView(col, key, ctx) {
|
|
|
193
253
|
col.append(h("div", { class: "start" },
|
|
194
254
|
h("h1", {}, shortProject(key)),
|
|
195
255
|
h("p", { class: "muted" }, key),
|
|
196
|
-
h("p", {}, "Not scanned yet.
|
|
197
|
-
h("div", { class: "actions" }, h("button", { type: "button", class: "btn", onclick: () => ctx.
|
|
256
|
+
h("p", {}, "Not scanned yet. Start with a scan of this folder; it reads only what is inside it."),
|
|
257
|
+
h("div", { class: "actions start" }, h("button", { type: "button", class: "btn primary", onclick: () => ctx.newScan("scan", key) }, "New scan…")),
|
|
198
258
|
));
|
|
199
259
|
}
|
|
200
260
|
|
|
@@ -205,14 +265,23 @@ function runView(col, ctx) {
|
|
|
205
265
|
if (!run) return;
|
|
206
266
|
const status = h("span", { class: "chip" }, run.status);
|
|
207
267
|
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" });
|
|
268
|
+
const lines = h("div", { class: ["transcript", "gutter", run.status === "running" ? null : "done"], role: "log", "aria-live": "off", "aria-label": "Run output" });
|
|
209
269
|
const tail = h("div");
|
|
270
|
+
const easy = simple();
|
|
271
|
+
// Simple mode: one sentence on what is happening, the raw output folded away.
|
|
272
|
+
const said = h("p", { class: "verdict-text", "aria-live": "polite" }, run.kind === "loop" ? "Looking for problems it can fix…" : "Checking your app… this takes a minute or two.");
|
|
273
|
+
const saidSub = h("p", { class: "verdict-sub muted" }, "You can leave this page; it keeps going.");
|
|
274
|
+
const tally = { verified: 0, failed: 0 };
|
|
210
275
|
col.append(
|
|
211
276
|
h("div", { class: "run-head" },
|
|
212
|
-
h("h1", {},
|
|
277
|
+
h("h1", {}, easy ? ({ scan: "Checking your app", loop: "Fixing problems", deps: "Checking dependencies", agents: "Checking AI tooling" })[run.kind] || run.kind
|
|
278
|
+
: `${({ scan: "Scan", deps: "Dependency check", agents: "AI tooling audit", loop: "Fix loop" })[run.kind] || run.kind}`),
|
|
213
279
|
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
|
-
|
|
280
|
+
h("span", { class: "grow" }), easy ? null : status, stop),
|
|
281
|
+
// Node.append() would print a null as the text "null", so leave it out.
|
|
282
|
+
...(easy ? [h("section", { class: "verdict" }, said, saidSub)] : []),
|
|
283
|
+
tail,
|
|
284
|
+
easy ? h("details", { class: "more" }, h("summary", {}, "Show what it is doing"), lines) : lines,
|
|
216
285
|
);
|
|
217
286
|
const setStatus = (st) => {
|
|
218
287
|
status.textContent = st;
|
|
@@ -228,7 +297,6 @@ function runView(col, ctx) {
|
|
|
228
297
|
stopStream();
|
|
229
298
|
typer = createTyper(lines);
|
|
230
299
|
pending = null;
|
|
231
|
-
es = new EventSource(`/api/runs/${run.id}/stream`);
|
|
232
300
|
const scroll = () => { const st = document.getElementById("stream"); if (st) st.scrollTop = st.scrollHeight; };
|
|
233
301
|
const onEvent = (ev) => {
|
|
234
302
|
const d = ev.data;
|
|
@@ -242,31 +310,53 @@ function runView(col, ctx) {
|
|
|
242
310
|
}
|
|
243
311
|
case "approval": {
|
|
244
312
|
pending = d;
|
|
313
|
+
set({ pending: { index: d.index, total: d.total } });
|
|
245
314
|
typer.flush();
|
|
246
|
-
tail.append(
|
|
315
|
+
clear(tail).append(waitingNote(run, d, typer));
|
|
316
|
+
said.textContent = `Fix ${d.index} of ${d.total} is waiting for you to approve it.`;
|
|
247
317
|
setStatus("awaiting you");
|
|
248
318
|
scroll();
|
|
249
|
-
|
|
319
|
+
// Replayed approvals are followed at once by their decision: open the window only if this one is still waiting.
|
|
320
|
+
setTimeout(() => { if (pending === d) openApproval(run, d, typer); }, 60);
|
|
250
321
|
break;
|
|
251
322
|
}
|
|
252
323
|
case "decision": {
|
|
253
324
|
pending = null;
|
|
325
|
+
set({ pending: null });
|
|
326
|
+
closeApproval();
|
|
254
327
|
clear(tail);
|
|
255
328
|
setStatus("running");
|
|
329
|
+
said.textContent = d.answer === "y" ? "Applying the fix and checking it worked…" : "Moving on…";
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "fix": {
|
|
333
|
+
if (d.status === "verified") tally.verified++;
|
|
334
|
+
else if (d.status === "failed") tally.failed++;
|
|
335
|
+
saidSub.textContent = `${plural(tally.verified, "fix", "fixes")} applied and checked so far${tally.failed ? `, ${tally.failed} undone because it did not work` : ""}.`;
|
|
256
336
|
break;
|
|
257
337
|
}
|
|
258
338
|
case "status": {
|
|
259
339
|
if (d.status === "running") break;
|
|
340
|
+
if (state.pending) set({ pending: null });
|
|
341
|
+
closeApproval();
|
|
342
|
+
lines.classList.add("done");
|
|
260
343
|
setStatus(d.status);
|
|
261
344
|
clear(tail);
|
|
262
345
|
const sum = d.summary;
|
|
346
|
+
if (d.status === "error") said.textContent = "Something went wrong. Open “Show what it is doing” for the reason.";
|
|
347
|
+
else if (d.status === "stopped") said.textContent = "Stopped. Everything applied so far was checked; nothing was published.";
|
|
348
|
+
else if (sum?.totals) {
|
|
349
|
+
const t = sum.totals;
|
|
350
|
+
said.textContent = `Done. ${plural(t.verified, "fix", "fixes")} applied and checked${t.failed ? `, ${t.failed} undone` : ""}${t.declined ? `, ${t.declined} skipped` : ""}.`;
|
|
351
|
+
} else said.textContent = "Done. Your project is up to date.";
|
|
352
|
+
saidSub.textContent = run.kind === "loop" ? "The changes are on a separate branch on this computer. Nothing was published." : "";
|
|
263
353
|
if (sum?.totals) {
|
|
264
354
|
const t = sum.totals;
|
|
265
355
|
typer.push(`done: ${sum.stopReason} after ${plural(t.iterations, "round")} (${duration(sum.durationMs)})`, "ok");
|
|
266
356
|
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
357
|
}
|
|
268
358
|
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));
|
|
359
|
+
run.repo || run.url ? h("button", { type: "button", class: "btn primary", onclick: () => ctx.refresh().then(() => set({ sel: { kind: "project", key: run.repo || run.url } })) }, easy ? "See my project ▸" : "Open the project ▸") : null));
|
|
270
360
|
ctx.refresh();
|
|
271
361
|
announce(`Run ${d.status}`);
|
|
272
362
|
break;
|
|
@@ -274,50 +364,117 @@ function runView(col, ctx) {
|
|
|
274
364
|
default: break;
|
|
275
365
|
}
|
|
276
366
|
};
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
367
|
+
// fetch-based stream (api.js): it carries the session header, EventSource cannot.
|
|
368
|
+
es = api.stream(`/api/runs/${run.id}/stream`, (type, data) => {
|
|
369
|
+
if (type === "end") { es?.close(); es = null; return; }
|
|
370
|
+
if (type === "error") { typer?.push("error: the run's event stream closed. Reload to reconnect.", "bad"); return; }
|
|
371
|
+
if (!["log", "approval", "decision", "fix", "status"].includes(type)) return;
|
|
372
|
+
try { onEvent(JSON.parse(data)); } catch { /* malformed event */ }
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Hex SHA-256 of the diff text as shown; the server only applies the diff whose hash matches. */
|
|
377
|
+
async function sha256Hex(text) {
|
|
378
|
+
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
379
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ── The approval: a floating window, pinstriped because it waits on you ──
|
|
383
|
+
|
|
384
|
+
let approval = null; // { win, decide } while the window is open
|
|
385
|
+
let decideFn = null; // answers the waiting diff, window open or not
|
|
386
|
+
|
|
387
|
+
function closeApproval() {
|
|
388
|
+
approval?.win.close();
|
|
389
|
+
approval = null;
|
|
390
|
+
decideFn = null;
|
|
281
391
|
}
|
|
282
392
|
|
|
283
|
-
function
|
|
393
|
+
function decider(run, d, ty) {
|
|
394
|
+
let busy = false;
|
|
284
395
|
const p = d.plan;
|
|
285
|
-
const
|
|
286
|
-
|
|
396
|
+
const shownDiff = String(p.diff || "");
|
|
397
|
+
const fn = async (answer) => {
|
|
398
|
+
if (busy) return;
|
|
399
|
+
// A cut diff is never applied by accident: Apply needs the box in the window ticked first.
|
|
400
|
+
if (answer === "approve" && p.truncated && !fn.acknowledged) { openApproval(run, d, ty); return; }
|
|
401
|
+
busy = true;
|
|
402
|
+
approval?.el?.querySelectorAll("button").forEach((b) => { b.disabled = true; });
|
|
287
403
|
try {
|
|
288
|
-
|
|
404
|
+
const body = { approvalId: d.approvalId, answer };
|
|
405
|
+
if (answer === "approve") {
|
|
406
|
+
body.diffSha256 = await sha256Hex(shownDiff);
|
|
407
|
+
if (p.truncated) body.acknowledgeTruncated = fn.acknowledged === true;
|
|
408
|
+
}
|
|
409
|
+
await api.post(`/api/runs/${run.id}/decision`, body);
|
|
289
410
|
} catch (e) {
|
|
290
411
|
ty.push(`error: ${e.message}`, "bad");
|
|
291
|
-
|
|
412
|
+
approval?.el?.querySelectorAll("button").forEach((b) => { b.disabled = false; });
|
|
413
|
+
if (approval?.apply && p.truncated && !fn.acknowledged) approval.apply.disabled = true;
|
|
414
|
+
busy = false;
|
|
292
415
|
}
|
|
293
416
|
};
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
417
|
+
fn.acknowledged = false;
|
|
418
|
+
return fn;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Inline in the transcript: what is waiting, and a way back to the window. */
|
|
422
|
+
function waitingNote(run, d, ty) {
|
|
423
|
+
decideFn = decider(run, d, ty);
|
|
424
|
+
return h("div", { class: "wait-note" },
|
|
425
|
+
h("span", { class: "lamp live", "aria-hidden": "true" }),
|
|
426
|
+
h("span", { class: "grow" }, `Fix ${d.index} of ${d.total} is waiting for you: ${d.plan.title}`),
|
|
427
|
+
h("button", { type: "button", class: "btn primary", onclick: () => openApproval(run, d, ty) }, "Review the change…"));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function openApproval(run, d, ty) {
|
|
431
|
+
if (approval) return;
|
|
432
|
+
const p = d.plan;
|
|
433
|
+
const easy = simple();
|
|
434
|
+
const decide = decideFn || (decideFn = decider(run, d, ty));
|
|
435
|
+
const files = p.files.length;
|
|
436
|
+
const shownDiff = String(p.diff || "");
|
|
437
|
+
const diff = diffBlock(shownDiff);
|
|
438
|
+
const ack = p.truncated ? h("input", { type: "checkbox", id: "ap-ack", checked: decide.acknowledged || null }) : null;
|
|
439
|
+
const body = h("div", { class: "approval" },
|
|
440
|
+
h("h3", { id: "ap-h" }, easy ? `Penthera wants to change ${plural(files, "file")} to fix this:` : p.title),
|
|
441
|
+
h("ul", { class: "mini" }, p.findings.map((f) => h("li", {}, easy ? h("span", { class: `sev sev-${f.severity}` }, SEV_WORD[f.severity]) : sevBadge(f.severity), h("span", { class: "grow t1" }, f.title)))),
|
|
442
|
+
h("p", { id: "ap-d", class: easy ? "prose" : "muted" }, easy
|
|
443
|
+
? `${p.title}. It changes ${p.files.join(", ")} on a separate branch, checks the problem is gone, and undoes the change if it is not. Nothing is published.`
|
|
444
|
+
: `${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"}`),
|
|
445
|
+
easy && !p.truncated ? h("details", { class: "more" }, h("summary", {}, "Show the exact change"), diff) : diff,
|
|
446
|
+
p.truncated ? h("p", { class: "bad", role: "note" }, easy
|
|
447
|
+
? "[!] This change is too long to show in full here. Ask a developer to look at the rest in the project before you apply it."
|
|
448
|
+
: `[!] Diff cut: showing the first ${shownDiff.length.toLocaleString()} of ${Number(p.fullLength).toLocaleString()} characters. Review the rest in the repo before applying.`) : null,
|
|
449
|
+
p.truncated ? h("label", { class: "check", for: "ap-ack" }, ack, h("span", { class: "prose" }, " I have not seen the whole change and want to apply it anyway")) : null,
|
|
450
|
+
p.redacted ? h("p", { class: "muted" }, "Secret values are masked here; the real patch uses them.") : null,
|
|
309
451
|
);
|
|
310
|
-
|
|
311
|
-
|
|
452
|
+
const apply = h("button", { type: "button", class: "btn primary", disabled: Boolean(ack && !ack.checked), onclick: () => decide("approve") }, easy ? "Apply this fix" : "Apply", h("kbd", {}, "y"));
|
|
453
|
+
ack?.addEventListener("change", () => { decide.acknowledged = ack.checked; apply.disabled = !ack.checked; });
|
|
454
|
+
body.append(h("div", { class: "actions" },
|
|
455
|
+
h("button", { type: "button", class: "btn", onclick: () => decide("stop") }, easy ? "Stop fixing" : "Stop", h("kbd", {}, "q")),
|
|
456
|
+
h("span", { class: "grow" }),
|
|
457
|
+
h("button", { type: "button", class: "btn", onclick: () => decide("skip") }, easy ? "Skip this one" : "Skip", h("kbd", {}, "n")),
|
|
458
|
+
apply));
|
|
459
|
+
const win = openWindow({ title: `Fix ${d.index} of ${d.total}`, label: `Fix ${d.index} of ${d.total} waiting for you`, body, wide: true, striped: true });
|
|
460
|
+
win.el.setAttribute("aria-describedby", "ap-d");
|
|
461
|
+
win.el.addEventListener("keydown", (e) => {
|
|
462
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.target instanceof HTMLInputElement) return;
|
|
463
|
+
const a = { y: "approve", n: "skip", q: "stop" }[e.key];
|
|
464
|
+
if (a) { e.preventDefault(); decide(a); }
|
|
465
|
+
});
|
|
466
|
+
win.el.addEventListener("close", () => { approval = null; }, { once: true });
|
|
467
|
+
approval = { win, el: win.el, apply };
|
|
468
|
+
announce(`Fix ${d.index} of ${d.total} is waiting for you: ${p.title}`);
|
|
469
|
+
(ack && !ack.checked ? ack : apply).focus();
|
|
312
470
|
}
|
|
313
471
|
|
|
314
|
-
/** y / n / q while a diff waits. */
|
|
472
|
+
/** y / n / q while a diff waits and its window is closed. */
|
|
315
473
|
export function answerPending(key) {
|
|
316
|
-
if (!pending) return false;
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
btns[i].click();
|
|
474
|
+
if (!pending || !decideFn) return false;
|
|
475
|
+
const a = { y: "approve", n: "skip", q: "stop" }[key];
|
|
476
|
+
if (!a) return false;
|
|
477
|
+
decideFn(a);
|
|
321
478
|
return true;
|
|
322
479
|
}
|
|
323
480
|
|
|
@@ -342,47 +499,3 @@ function pastRunView(col, ctx) {
|
|
|
342
499
|
h("div", { class: "actions" }, h("button", { type: "button", class: "btn", onclick: () => set({ sel: { kind: "project", key: l.target.repo } }) }, "Open the project ▸")),
|
|
343
500
|
);
|
|
344
501
|
}
|
|
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
|
-
}
|