penthera 1.5.0 → 1.6.1
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 +179 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
2
|
+
|
|
3
|
+
export function ago(iso, now = Date.now()) {
|
|
4
|
+
const t = Date.parse(iso);
|
|
5
|
+
if (Number.isNaN(t)) return "-";
|
|
6
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
7
|
+
if (s < 60) return `${s}s ago`;
|
|
8
|
+
const m = Math.round(s / 60);
|
|
9
|
+
if (m < 60) return `${m}m ago`;
|
|
10
|
+
const h = Math.round(m / 60);
|
|
11
|
+
if (h < 48) return `${h}h ago`;
|
|
12
|
+
return `${Math.round(h / 24)}d ago`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stamp(iso) {
|
|
16
|
+
const t = Date.parse(iso);
|
|
17
|
+
if (Number.isNaN(t)) return "-";
|
|
18
|
+
const d = new Date(t);
|
|
19
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
20
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function duration(ms) {
|
|
24
|
+
if (ms == null || !Number.isFinite(ms)) return "-";
|
|
25
|
+
if (ms < 1000) return `${ms}ms`;
|
|
26
|
+
const s = ms / 1000;
|
|
27
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
28
|
+
const m = Math.floor(s / 60);
|
|
29
|
+
return `${m}m${String(Math.round(s % 60)).padStart(2, "0")}s`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const pct = (x) => (x == null ? "-" : `${Math.round(x * 100)}%`);
|
|
33
|
+
|
|
34
|
+
/** Basename-ish short label for a project key (path or origin). */
|
|
35
|
+
export function shortProject(key) {
|
|
36
|
+
if (!key) return "-";
|
|
37
|
+
if (/^https?:\/\//.test(key)) return key.replace(/^https?:\/\//, "");
|
|
38
|
+
const parts = key.split(/[\\/]/).filter(Boolean);
|
|
39
|
+
return parts.slice(-2).join("/") || key;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const plural = (n, word, many = `${word}s`) => `${n} ${n === 1 ? word : many}`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Set the theme before first paint: the saved choice, else the OS preference.
|
|
2
|
+
// A classic script (not a module) so it runs before the stylesheet applies.
|
|
3
|
+
(function () {
|
|
4
|
+
var t = null;
|
|
5
|
+
try { t = localStorage.getItem("penthera-ui-theme"); } catch (e) { /* storage off */ }
|
|
6
|
+
if (t !== "light" && t !== "dark") {
|
|
7
|
+
t = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
|
8
|
+
}
|
|
9
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
10
|
+
})();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// "Hacker typer" output: lines arrive whole from the server and are typed out
|
|
2
|
+
// a few characters per frame. The speed scales with the backlog, so the
|
|
3
|
+
// screen never falls more than a moment behind the real run. With
|
|
4
|
+
// prefers-reduced-motion, lines appear at once.
|
|
5
|
+
//
|
|
6
|
+
// Screen readers: each line holds its full text in a visually hidden span;
|
|
7
|
+
// the typed copy is aria-hidden, so nothing is read out letter by letter.
|
|
8
|
+
import { h, prefersReducedMotion } from "./dom.js";
|
|
9
|
+
|
|
10
|
+
export function createTyper(container) {
|
|
11
|
+
const cursor = h("span", { class: "cursor", "aria-hidden": "true" });
|
|
12
|
+
const queue = [];
|
|
13
|
+
let current = null;
|
|
14
|
+
let raf = 0;
|
|
15
|
+
let stopped = false;
|
|
16
|
+
|
|
17
|
+
function backlog() {
|
|
18
|
+
return queue.reduce((n, q) => n + q.text.length, current ? current.text.length - current.pos : 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function nearBottom() {
|
|
22
|
+
return container.scrollHeight - container.scrollTop - container.clientHeight < 60;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function startLine(item) {
|
|
26
|
+
const typed = h("span", { "aria-hidden": "true" });
|
|
27
|
+
const line = h("div", { class: ["ln", item.cls] }, h("span", { class: "sr" }, item.text), typed);
|
|
28
|
+
container.append(line);
|
|
29
|
+
line.append(cursor);
|
|
30
|
+
return { ...item, typed, pos: 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tick() {
|
|
34
|
+
raf = 0;
|
|
35
|
+
if (stopped) return;
|
|
36
|
+
const stick = nearBottom();
|
|
37
|
+
let budget = 3 + Math.floor(backlog() / 30);
|
|
38
|
+
while (budget > 0 && (current || queue.length)) {
|
|
39
|
+
if (!current) current = startLine(queue.shift());
|
|
40
|
+
const take = Math.min(budget, current.text.length - current.pos);
|
|
41
|
+
current.pos += take;
|
|
42
|
+
budget -= take;
|
|
43
|
+
current.typed.textContent = current.text.slice(0, current.pos);
|
|
44
|
+
if (current.pos >= current.text.length) current = null;
|
|
45
|
+
if (budget <= 0) break;
|
|
46
|
+
budget--; // a line break costs a character
|
|
47
|
+
}
|
|
48
|
+
if (stick) container.scrollTop = container.scrollHeight;
|
|
49
|
+
if (current || queue.length) raf = requestAnimationFrame(tick);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
/** Add a line. cls: cmd | dim | ok | bad | hl */
|
|
54
|
+
push(text, cls = null) {
|
|
55
|
+
const item = { text: String(text), cls };
|
|
56
|
+
if (prefersReducedMotion()) {
|
|
57
|
+
const stick = nearBottom();
|
|
58
|
+
const typed = h("span", { "aria-hidden": "true" }, item.text);
|
|
59
|
+
const line = h("div", { class: ["ln", cls] }, h("span", { class: "sr" }, item.text), typed);
|
|
60
|
+
container.append(line);
|
|
61
|
+
line.append(cursor);
|
|
62
|
+
if (stick) container.scrollTop = container.scrollHeight;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
queue.push(item);
|
|
66
|
+
if (!raf) raf = requestAnimationFrame(tick);
|
|
67
|
+
},
|
|
68
|
+
/** Finish typing everything now (e.g. before showing an approval). */
|
|
69
|
+
flush() {
|
|
70
|
+
while (current || queue.length) {
|
|
71
|
+
if (!current) current = startLine(queue.shift());
|
|
72
|
+
current.typed.textContent = current.text;
|
|
73
|
+
current = null;
|
|
74
|
+
}
|
|
75
|
+
container.scrollTop = container.scrollHeight;
|
|
76
|
+
},
|
|
77
|
+
idle: () => !current && !queue.length,
|
|
78
|
+
stop() {
|
|
79
|
+
stopped = true;
|
|
80
|
+
if (raf) cancelAnimationFrame(raf);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Type a short string into an element once (the header prompt). */
|
|
86
|
+
const typing = new WeakMap(); // element -> timer, so a new line cancels the one still typing
|
|
87
|
+
|
|
88
|
+
export function typeInto(el, text, { cps = 40 } = {}) {
|
|
89
|
+
clearTimeout(typing.get(el));
|
|
90
|
+
if (prefersReducedMotion()) { el.textContent = text; return; }
|
|
91
|
+
let i = 0;
|
|
92
|
+
const step = () => {
|
|
93
|
+
i++;
|
|
94
|
+
el.textContent = text.slice(0, i);
|
|
95
|
+
if (i < text.length) typing.set(el, setTimeout(step, 1000 / cps));
|
|
96
|
+
};
|
|
97
|
+
el.textContent = "";
|
|
98
|
+
step();
|
|
99
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { h, put, panel, empty, sevBadge } from "../dom.js";
|
|
2
|
+
import { bigGrade, sparkline, severityBars } from "../charts.js";
|
|
3
|
+
import { ago } from "../format.js";
|
|
4
|
+
import { SCREENS, EMPTY } from "../copy.js";
|
|
5
|
+
|
|
6
|
+
const GRADE_SCORE = { A: 95, B: 80, C: 65, D: 50, F: 20 };
|
|
7
|
+
|
|
8
|
+
export async function render(root, { api }) {
|
|
9
|
+
const d = await api.get("/api/ai");
|
|
10
|
+
put(root,
|
|
11
|
+
h("h1", {}, SCREENS.ai.title),
|
|
12
|
+
h("p", { class: "lede" }, SCREENS.ai.lede),
|
|
13
|
+
);
|
|
14
|
+
const l = d.latest;
|
|
15
|
+
if (!l) {
|
|
16
|
+
put(root, h("div", { class: "notice" }, empty(EMPTY.ai)));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const grid = h("div", { class: "grid" });
|
|
20
|
+
put(root, grid);
|
|
21
|
+
|
|
22
|
+
const lock = l.lock || {};
|
|
23
|
+
const drift = (lock.changed || 0) + (lock.added || 0) + (lock.removed || 0);
|
|
24
|
+
put(grid, panel("Agent config grade", { class: "span-4" },
|
|
25
|
+
h("div", { class: "filters" },
|
|
26
|
+
bigGrade(l.grade),
|
|
27
|
+
h("div", {},
|
|
28
|
+
h("div", { class: "big" }, h("span", { class: `grade-${l.grade}` }, `Grade ${l.grade}`)),
|
|
29
|
+
h("div", { class: "muted" }, `score ${l.score}/100`),
|
|
30
|
+
h("div", { class: "faint" }, `${l.home ? "repo + home config" : "repo config"} :: ${ago(l.ts)}`),
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
h("p", { class: "label" }, "Grade over time"),
|
|
34
|
+
sparkline(d.history.map((x) => x.score ?? GRADE_SCORE[x.grade]), { width: 260, height: 32, label: "agent config score", zero: false }),
|
|
35
|
+
h("p", { class: "panel-note" }, d.history.map((x) => x.grade).join(" → ")),
|
|
36
|
+
));
|
|
37
|
+
|
|
38
|
+
const inv = l.inventory || {};
|
|
39
|
+
put(grid, panel("Inventory", { class: "span-4" },
|
|
40
|
+
h("dl", { class: "kv" }, ...[["skills", inv.skills], ["MCP servers", inv.mcpServers], ["hooks", inv.hooks], ["permissions", inv.permissions], ["plugins", inv.plugins], ["marketplaces", inv.marketplaces], ["instruction files", inv.instructionFiles]]
|
|
41
|
+
.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, String(v ?? 0))])),
|
|
42
|
+
));
|
|
43
|
+
|
|
44
|
+
put(grid, panel("penthera.lock drift", { class: "span-4" },
|
|
45
|
+
h("div", { class: "big" }, h("span", { class: drift ? "t1" : "ok" }, drift ? `${drift} drifted` : lock.status === "missing" ? "not pinned" : "no drift")),
|
|
46
|
+
h("dl", { class: "kv" },
|
|
47
|
+
h("dt", {}, "status"), h("dd", {}, lock.status || "-"),
|
|
48
|
+
h("dt", {}, "changed"), h("dd", { class: lock.changed ? "bad" : null }, String(lock.changed || 0)),
|
|
49
|
+
h("dt", {}, "new"), h("dd", { class: lock.added ? "bad" : null }, String(lock.added || 0)),
|
|
50
|
+
h("dt", {}, "removed"), h("dd", {}, String(lock.removed || 0)),
|
|
51
|
+
h("dt", {}, "pinned"), h("dd", {}, String(lock.entries || 0)),
|
|
52
|
+
),
|
|
53
|
+
h("p", { class: "panel-note" }, "A pinned skill or server that changes after review is how a rug pull works. Diff it, then re-pin with penthera agents --pin."),
|
|
54
|
+
));
|
|
55
|
+
|
|
56
|
+
put(grid, panel(`Hidden content (${l.hidden.length})`, { class: "span-7" },
|
|
57
|
+
l.hidden.length ? h("ul", { class: "list" }, l.hidden.map((x) => h("li", {},
|
|
58
|
+
sevBadge(x.severity),
|
|
59
|
+
h("div", { class: "grow" }, h("div", {}, x.title), h("div", { class: "faint" }, `${x.location || ""}${x.rule ? ` [${x.rule}]` : ""}`), x.description ? h("pre", { class: "block" }, x.description) : null),
|
|
60
|
+
))) : empty("No hidden instructions, invisible characters or smuggled binaries found."),
|
|
61
|
+
));
|
|
62
|
+
|
|
63
|
+
put(grid, panel("Other findings", { class: "span-5" },
|
|
64
|
+
severityBars(l.counts, { width: 14 }),
|
|
65
|
+
h("p", { class: "label" }, "Config findings"),
|
|
66
|
+
h("ul", { class: "list" }, l.findings.slice(0, 12).map((x) => h("li", {}, sevBadge(x.severity), h("span", { class: "grow" }, x.title)))),
|
|
67
|
+
));
|
|
68
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { h, put, panel, empty, sevBadge } from "../dom.js";
|
|
2
|
+
import { bars, sparkline, severityBars } from "../charts.js";
|
|
3
|
+
import { ago, plural } from "../format.js";
|
|
4
|
+
import { SCREENS, EMPTY } from "../copy.js";
|
|
5
|
+
import { SHADE } from "../charts.js";
|
|
6
|
+
|
|
7
|
+
const CAP_SEV = {
|
|
8
|
+
"install-scripts": "high", shell: "medium", network: "medium", "fs-write": "medium", env: "low", "env-bulk": "medium",
|
|
9
|
+
eval: "medium", "dynamic-require": "low", native: "low", obfuscated: "high", "ai-config-write": "critical", "ai-cli-invoke": "critical", "token-harvest": "critical",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const capChip = (id, gained = false) => h("span", { class: `chip ${CAP_SEV[id] === "critical" || CAP_SEV[id] === "high" ? "inv" : ""}` }, `${gained ? "+" : ""}${id}`);
|
|
13
|
+
|
|
14
|
+
export async function render(root, { api }) {
|
|
15
|
+
const d = await api.get("/api/deps");
|
|
16
|
+
put(root,
|
|
17
|
+
h("h1", {}, SCREENS.deps.title),
|
|
18
|
+
h("p", { class: "lede" }, SCREENS.deps.lede),
|
|
19
|
+
);
|
|
20
|
+
const grid = h("div", { class: "grid" });
|
|
21
|
+
put(root, grid);
|
|
22
|
+
const l = d.latest;
|
|
23
|
+
|
|
24
|
+
if (!l) {
|
|
25
|
+
put(grid, panel("Latest deps scan", { class: "span-12" }, empty(EMPTY.deps)));
|
|
26
|
+
} else {
|
|
27
|
+
const caps = Object.entries(l.capabilityCounts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]);
|
|
28
|
+
const diff = l.diff;
|
|
29
|
+
root.querySelector(".lede").after(h("div", { class: "strip" },
|
|
30
|
+
tile("Packages", String(l.total), `${l.scanned} scanned, ${l.direct} direct`),
|
|
31
|
+
tile("Added", diff ? String(diff.added.length) : "-", diff ? `vs ${diff.base}` : "no base to compare"),
|
|
32
|
+
tile("Gained capabilities", diff ? String(diff.upgraded.reduce((n, u) => n + u.gained.length, 0) + diff.added.reduce((n, a) => n + a.capabilities.length, 0)) : "-", "new things code can do"),
|
|
33
|
+
tile("Typosquats", String(l.typosquats.length), l.typosquats.length ? "names one edit from a popular package" : "none found"),
|
|
34
|
+
));
|
|
35
|
+
|
|
36
|
+
put(grid, panel("Capabilities in the tree", { class: "span-5" },
|
|
37
|
+
bars(caps.map(([id, n]) => ({ label: id, value: n, cls: "t1", ch: SHADE[CAP_SEV[id] || "info"] })), { width: 18 }),
|
|
38
|
+
h("p", { class: "panel-note" }, `${l.ecosystem} :: ${l.lockfile} :: ${l.network} :: ${ago(l.ts)}`),
|
|
39
|
+
));
|
|
40
|
+
|
|
41
|
+
put(grid, panel("Capability diff", { class: "span-7" },
|
|
42
|
+
diff ? h("div", {},
|
|
43
|
+
h("div", { class: "filters" },
|
|
44
|
+
h("span", { class: "chip" }, `+${diff.added.length} added`),
|
|
45
|
+
h("span", { class: "chip" }, `~${diff.upgraded.length} upgraded`),
|
|
46
|
+
h("span", { class: "chip" }, `-${diff.removed.length} removed`),
|
|
47
|
+
h("span", { class: "faint" }, `base ${diff.base}`),
|
|
48
|
+
),
|
|
49
|
+
h("div", { class: "table-wrap" }, h("table", {},
|
|
50
|
+
h("thead", {}, h("tr", {}, h("th", {}, "Package"), h("th", {}, "Change"), h("th", {}, "Can now"))),
|
|
51
|
+
h("tbody", {},
|
|
52
|
+
diff.added.map((a) => h("tr", {}, h("td", {}, a.name), h("td", { class: "t1" }, `+ ${a.version}`), h("td", {}, a.capabilities.length ? a.capabilities.map((c) => capChip(c, true)) : h("span", { class: "faint" }, "nothing flagged")))),
|
|
53
|
+
diff.upgraded.filter((u) => u.gained.length || u.lost.length).map((u) => h("tr", {}, h("td", {}, u.name), h("td", { class: "t1" }, `${u.from} → ${u.to}`), h("td", {}, u.gained.map((c) => capChip(c, true)), u.lost.map((c) => h("span", { class: "chip" }, `-${c}`))))),
|
|
54
|
+
diff.removed.map((r) => h("tr", {}, h("td", { class: "faint" }, r.name), h("td", { class: "faint" }, `- ${r.version}`), h("td", {}))),
|
|
55
|
+
),
|
|
56
|
+
)),
|
|
57
|
+
h("p", { class: "panel-note" }, `${plural(diff.upgraded.filter((u) => !u.gained.length && !u.lost.length).length, "upgrade")} with no capability change not shown.`),
|
|
58
|
+
) : empty("No diff in the latest report. Run penthera deps --base <ref> to compare."),
|
|
59
|
+
));
|
|
60
|
+
|
|
61
|
+
put(grid, panel("Deps findings", { class: "span-7" },
|
|
62
|
+
l.findings.length ? h("ul", { class: "list" }, l.findings.map((f) => h("li", {}, sevBadge(f.severity), h("span", { class: "grow" }, f.title), h("span", { class: "faint trunc hide-narrow" }, f.location || "")))) : empty("No findings."),
|
|
63
|
+
l.typosquats.length ? h("p", { class: "panel-note" }, l.typosquats.map((t) => `${t.name} looks like ${t.similarTo}`).join(" :: ")) : null,
|
|
64
|
+
));
|
|
65
|
+
|
|
66
|
+
put(grid, panel("Severity and history", { class: "span-5" },
|
|
67
|
+
severityBars(l.counts, { width: 16 }),
|
|
68
|
+
h("p", { class: "label" }, "Package count over time"),
|
|
69
|
+
sparkline(d.history.map((x) => x.total), { width: 300, height: 36, label: "package count", zero: false }),
|
|
70
|
+
));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
put(grid, panel("Install firewall decisions", { class: "span-12" },
|
|
74
|
+
d.installs.length ? h("div", { class: "table-wrap" }, h("table", {},
|
|
75
|
+
h("thead", {}, h("tr", {}, ...["When", "Install", "Decision", "Flags", "Denied by policy", "Needed approval"].map((t) => h("th", {}, t)))),
|
|
76
|
+
h("tbody", {}, d.installs.map((i) => {
|
|
77
|
+
const flags = [...new Set(i.changes.flatMap((c) => c.flags))];
|
|
78
|
+
const deny = [...new Set(i.changes.flatMap((c) => c.deny))];
|
|
79
|
+
const appr = [...new Set(i.changes.flatMap((c) => c.approval))];
|
|
80
|
+
const verdict = i.action === "refused" || deny.length ? ["REFUSED", "bad"] : i.clean ? ["CLEAN", "ok"] : ["APPROVED", "t1"];
|
|
81
|
+
return h("tr", {},
|
|
82
|
+
h("td", { class: "muted" }, ago(i.ts)),
|
|
83
|
+
h("td", {}, i.specs.join(" "), h("span", { class: "faint" }, ` (${plural(i.changes.length, "package")})`)),
|
|
84
|
+
h("td", { class: verdict[1] }, `[${verdict[0]}]`),
|
|
85
|
+
h("td", {}, flags.length ? flags.map((f) => capChip(f)) : h("span", { class: "faint" }, "-")),
|
|
86
|
+
h("td", { class: "bad" }, deny.join(", ") || h("span", { class: "faint" }, "-")),
|
|
87
|
+
h("td", { class: "t1" }, appr.join(", ") || h("span", { class: "faint" }, "-")),
|
|
88
|
+
);
|
|
89
|
+
})),
|
|
90
|
+
)) : empty("No penthera install --json reports yet."),
|
|
91
|
+
));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tile(label, value, sub) {
|
|
95
|
+
return h("div", { class: "cell" }, h("span", { class: "label" }, label), h("div", { class: "big" }, value), h("div", { class: "sub" }, sub));
|
|
96
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { h, put, panel, empty, clear, announce, fig } from "../dom.js";
|
|
2
|
+
import { ago, stamp, shortProject } from "../format.js";
|
|
3
|
+
import { SCREENS, EMPTY } from "../copy.js";
|
|
4
|
+
|
|
5
|
+
export async function render(root, { api, params }) {
|
|
6
|
+
const [{ bundles }, o] = await Promise.all([api.get("/api/evidence"), api.get("/api/overview")]);
|
|
7
|
+
put(root, h("h1", {}, SCREENS.evidence.title), h("p", { class: "lede" }, SCREENS.evidence.lede));
|
|
8
|
+
|
|
9
|
+
const out = h("div");
|
|
10
|
+
const keys = o.projects.map((p) => p.key);
|
|
11
|
+
const projSel = h("select", { id: "ev-project", "aria-label": "Project" }, keys.map((k) => h("option", { value: k, selected: k === params.get("project") }, shortProject(k))));
|
|
12
|
+
const create = h("button", { type: "button", class: "btn primary", disabled: !keys.length }, "Create evidence bundle ▸");
|
|
13
|
+
const downloads = h("p", { class: "filters" });
|
|
14
|
+
const createMsg = h("p", { class: "muted", "aria-live": "polite" });
|
|
15
|
+
|
|
16
|
+
const paintDownloads = () => {
|
|
17
|
+
const p = projSel.value;
|
|
18
|
+
clear(downloads).append(
|
|
19
|
+
h("span", { class: "muted" }, "Download the latest report as Markdown ::"),
|
|
20
|
+
...["auditor", "owner", "developer"].map((a) => h("a", { class: "btn", href: `/api/report?${new URLSearchParams({ project: p, audience: a })}`, download: "" }, `${a}.md`)),
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
projSel.addEventListener("change", paintDownloads);
|
|
24
|
+
if (keys.length) paintDownloads();
|
|
25
|
+
|
|
26
|
+
create.addEventListener("click", async () => {
|
|
27
|
+
create.disabled = true;
|
|
28
|
+
createMsg.textContent = "Writing the bundle and hashing every file…";
|
|
29
|
+
try {
|
|
30
|
+
const r = await api.post("/api/evidence/create", { project: projSel.value });
|
|
31
|
+
const v = await api.post("/api/evidence/verify", { id: r.id });
|
|
32
|
+
createMsg.textContent = `Written to ${r.path} :: ${v.ok ? "verified" : "verification failed"}.`;
|
|
33
|
+
announce(createMsg.textContent);
|
|
34
|
+
await refresh();
|
|
35
|
+
clear(out).append(report({ path: r.path }, v));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
createMsg.textContent = e.message;
|
|
38
|
+
} finally {
|
|
39
|
+
create.disabled = false;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const tableSlot = h("div");
|
|
44
|
+
async function refresh() {
|
|
45
|
+
const { bundles: list } = await api.get("/api/evidence");
|
|
46
|
+
clear(tableSlot).append(bundleTable(list));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bundleTable(list) {
|
|
50
|
+
if (!list.length) return empty(EMPTY.evidence);
|
|
51
|
+
const rows = list.map((b) => {
|
|
52
|
+
const result = h("td", { class: "muted", "aria-live": "polite" }, "not checked");
|
|
53
|
+
const btn = h("button", { type: "button", class: "btn" }, "Verify");
|
|
54
|
+
btn.addEventListener("click", async () => {
|
|
55
|
+
btn.disabled = true;
|
|
56
|
+
result.textContent = "hashing…";
|
|
57
|
+
result.className = "muted";
|
|
58
|
+
try {
|
|
59
|
+
const r = await api.post("/api/evidence/verify", { id: b.id });
|
|
60
|
+
result.textContent = r.ok ? "[VERIFIED]" : `[FAILED] ${r.problems.length} problem${r.problems.length === 1 ? "" : "s"}`;
|
|
61
|
+
result.className = r.ok ? "ok" : "bad";
|
|
62
|
+
clear(out).append(report(b, r));
|
|
63
|
+
announce(`${b.path}: ${r.ok ? "verified" : "verification failed"}`);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
result.textContent = e.message;
|
|
66
|
+
result.className = "bad";
|
|
67
|
+
} finally {
|
|
68
|
+
btn.disabled = false;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return h("tr", {},
|
|
72
|
+
h("td", { class: "t1" }, b.path),
|
|
73
|
+
h("td", { class: "trunc hide-narrow", title: b.target || "" }, shortProject(b.target)),
|
|
74
|
+
h("td", { class: "muted hide-narrow" }, ago(b.generatedAt)),
|
|
75
|
+
h("td", { class: "num" }, String(b.artifacts)),
|
|
76
|
+
h("td", {}, b.signed ? h("span", { class: "chip on" }, "signed") : h("span", { class: "chip" }, "unsigned")),
|
|
77
|
+
h("td", {}, btn),
|
|
78
|
+
result,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
return h("div", { class: "table-wrap" }, h("table", {},
|
|
82
|
+
h("thead", {}, h("tr", {}, ...["Bundle", "Target", "Written", "Files", "Signature", "", "Result"].map((t, i) => h("th", { class: [i === 3 ? "num" : null, i === 1 || i === 2 ? "hide-narrow" : null] }, t)))),
|
|
83
|
+
h("tbody", {}, rows),
|
|
84
|
+
));
|
|
85
|
+
}
|
|
86
|
+
put(tableSlot, bundleTable(bundles));
|
|
87
|
+
|
|
88
|
+
put(root, h("div", { class: "grid" },
|
|
89
|
+
panel("For your auditor", { class: "span-12" },
|
|
90
|
+
keys.length ? h("div", {},
|
|
91
|
+
h("p", { class: "t1" }, "One folder they can check without trusting you: the JSON, Markdown and SARIF reports, the auditor, owner and developer reports, and manifest.json with a SHA-256 for each file."),
|
|
92
|
+
h("div", { class: "filters" }, h("label", { class: "field" }, h("span", {}, "Project"), projSel), create),
|
|
93
|
+
createMsg,
|
|
94
|
+
downloads,
|
|
95
|
+
h("p", { class: "muted" }, "To sign it, write the bundle from the CLI with --evidence <dir> --sign-key ~/.ssh/id_ed25519. They verify with penthera evidence verify <dir>."),
|
|
96
|
+
) : empty("Scan a project first: a bundle is made from its latest report."),
|
|
97
|
+
),
|
|
98
|
+
panel("Bundles in the reports folder", { class: "span-12" }, tableSlot, fig("fig 1 :: verify recomputes every hash and checks the SSH signature", `${bundles.length} bundles`)),
|
|
99
|
+
h("div", { class: "span-12" }, out),
|
|
100
|
+
));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function report(b, r) {
|
|
104
|
+
return panel(`Verification :: ${b.path}`, {},
|
|
105
|
+
h("p", { class: r.ok ? "ok" : "bad" }, r.ok ? "[OK] Every file matches the manifest." : "[!] NOT VERIFIED"),
|
|
106
|
+
h("ul", { class: "list" }, r.files.map((f) => h("li", {},
|
|
107
|
+
h("span", { class: f.ok ? "ok" : "bad" }, f.ok ? "ok " : "FAIL"),
|
|
108
|
+
h("span", { class: "grow t1" }, f.path),
|
|
109
|
+
f.ok ? null : h("span", { class: "bad" }, f.reason),
|
|
110
|
+
))),
|
|
111
|
+
r.signature ? h("p", {}, h("span", { class: r.signature.ok ? (r.signature.status === "valid" ? "ok" : "t1") : "bad" }, `signature ${r.signature.status} :: `), r.signature.detail) : null,
|
|
112
|
+
r.problems.length ? h("pre", { class: "block" }, r.problems.map((p) => `- ${p}`).join("\n")) : null,
|
|
113
|
+
fig(`checked ${stamp(new Date().toISOString())}`, "sha-256"),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { h, put, panel, clear, empty, sevBadge, diffBlock, announce } from "../dom.js";
|
|
2
|
+
import { SEVERITIES, ago, shortProject } from "../format.js";
|
|
3
|
+
import { SCREENS, EMPTY } from "../copy.js";
|
|
4
|
+
|
|
5
|
+
export async function render(root, { api, params, go }) {
|
|
6
|
+
const { findings } = await api.get("/api/findings");
|
|
7
|
+
const projects = [...new Set(findings.map((f) => f.project))].sort();
|
|
8
|
+
const state = {
|
|
9
|
+
q: params.get("q") || "",
|
|
10
|
+
sev: new Set(SEVERITIES.filter((x) => x !== "info")),
|
|
11
|
+
project: params.get("project") || "",
|
|
12
|
+
suppressed: false,
|
|
13
|
+
selected: params.get("id") ? `${params.get("project")}|${params.get("id")}` : null,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const search = h("input", { type: "search", class: "search", placeholder: "filter (press /)", "aria-label": "Filter findings", value: state.q });
|
|
17
|
+
const projectSel = h("select", { "aria-label": "Project" },
|
|
18
|
+
h("option", { value: "" }, `All projects (${projects.length})`),
|
|
19
|
+
projects.map((p) => h("option", { value: p, selected: p === state.project }, shortProject(p))),
|
|
20
|
+
);
|
|
21
|
+
const toggles = SEVERITIES.map((sev) => h("button", {
|
|
22
|
+
type: "button", class: `toggle sev-${sev}`, "aria-pressed": String(state.sev.has(sev)),
|
|
23
|
+
onclick: (e) => {
|
|
24
|
+
if (state.sev.has(sev)) state.sev.delete(sev); else state.sev.add(sev);
|
|
25
|
+
e.currentTarget.setAttribute("aria-pressed", String(state.sev.has(sev)));
|
|
26
|
+
draw();
|
|
27
|
+
},
|
|
28
|
+
}, sev));
|
|
29
|
+
const supp = h("input", { type: "checkbox", id: "f-supp" });
|
|
30
|
+
const count = h("span", { class: "muted", "aria-live": "polite" });
|
|
31
|
+
const tbody = h("tbody");
|
|
32
|
+
const detail = h("div", { class: "detail" });
|
|
33
|
+
|
|
34
|
+
put(root,
|
|
35
|
+
h("h1", {}, SCREENS.findings.title),
|
|
36
|
+
h("p", { class: "lede" }, SCREENS.findings.lede),
|
|
37
|
+
h("div", { class: "filters", role: "search" }, search, projectSel, h("div", { class: "filters", role: "group", "aria-label": "Severity" }, toggles),
|
|
38
|
+
h("label", { class: "check", for: "f-supp" }, supp, "suppressed"), count),
|
|
39
|
+
h("div", { class: "split" },
|
|
40
|
+
panel("Findings", {}, h("div", { class: "table-wrap" }, h("table", {},
|
|
41
|
+
h("thead", {}, h("tr", {}, h("th", {}, "Sev"), h("th", {}, "Finding"), h("th", { class: "hide-narrow" }, "Project"), h("th", { class: "hide-narrow" }, "Category"))),
|
|
42
|
+
tbody,
|
|
43
|
+
))),
|
|
44
|
+
detail,
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
let visible = [];
|
|
49
|
+
const keyOf = (f) => `${f.project}|${f.id}`;
|
|
50
|
+
|
|
51
|
+
function draw() {
|
|
52
|
+
const q = state.q.trim().toLowerCase();
|
|
53
|
+
visible = findings.filter((f) => state.sev.has(f.severity)
|
|
54
|
+
&& (!state.project || f.project === state.project)
|
|
55
|
+
&& (state.suppressed || !f.suppressed)
|
|
56
|
+
&& (!q || `${f.title} ${f.category} ${f.location} ${f.source}`.toLowerCase().includes(q)));
|
|
57
|
+
clear(tbody);
|
|
58
|
+
let group = null;
|
|
59
|
+
for (const f of visible) {
|
|
60
|
+
if (f.severity !== group) {
|
|
61
|
+
group = f.severity;
|
|
62
|
+
const n = visible.filter((x) => x.severity === group).length;
|
|
63
|
+
put(tbody, h("tr", { class: "group" }, h("td", { colspan: "4" }, `${group} :: ${n}`)));
|
|
64
|
+
}
|
|
65
|
+
const tr = h("tr", { class: keyOf(f) === state.selected ? "sel" : null, dataset: { key: keyOf(f) } },
|
|
66
|
+
h("td", {}, sevBadge(f.severity)),
|
|
67
|
+
h("td", {},
|
|
68
|
+
h("button", { type: "button", class: "row-btn", onclick: () => select(f) }, f.title),
|
|
69
|
+
h("span", { class: "faint" }, f.location ? `${f.location}` : ""),
|
|
70
|
+
f.suppressed ? h("span", { class: "chip" }, " suppressed") : null),
|
|
71
|
+
h("td", { class: "hide-narrow muted trunc", title: f.project }, shortProject(f.project)),
|
|
72
|
+
h("td", { class: "hide-narrow muted" }, f.category || "-"),
|
|
73
|
+
);
|
|
74
|
+
put(tbody, tr);
|
|
75
|
+
}
|
|
76
|
+
if (!visible.length) {
|
|
77
|
+
const hidden = [state.q.trim() ? "the text filter" : null, state.project ? "the project filter" : null, state.sev.size < SEVERITIES.length ? "the severity toggles" : null].filter(Boolean).join(", ");
|
|
78
|
+
put(tbody, h("tr", {}, h("td", { colspan: "4" }, empty(findings.length ? EMPTY.findingsFiltered(hidden || "the filters") : EMPTY.findingsNone))));
|
|
79
|
+
}
|
|
80
|
+
count.textContent = `${visible.length} of ${findings.length}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let detailSeq = 0;
|
|
84
|
+
async function select(f, { focus = false } = {}) {
|
|
85
|
+
state.selected = keyOf(f);
|
|
86
|
+
for (const tr of tbody.querySelectorAll("tr")) tr.classList.toggle("sel", tr.dataset.key === state.selected);
|
|
87
|
+
const seq = ++detailSeq;
|
|
88
|
+
clear(detail).append(panel("Detail", {}, h("p", { class: "muted" }, "Reading evidence…")));
|
|
89
|
+
let d;
|
|
90
|
+
try {
|
|
91
|
+
d = await api.get(`/api/findings/${f.id}?project=${encodeURIComponent(f.project)}`);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
if (seq === detailSeq) clear(detail).append(panel("Detail", {}, h("p", { class: "error" }, e.message)));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (seq !== detailSeq) return;
|
|
97
|
+
clear(detail).append(renderDetail(d, f, go));
|
|
98
|
+
announce(`Showing ${f.severity} finding: ${f.title}`);
|
|
99
|
+
if (focus) detail.querySelector("h2")?.focus();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
search.addEventListener("input", () => { state.q = search.value; draw(); });
|
|
103
|
+
projectSel.addEventListener("change", () => { state.project = projectSel.value; draw(); });
|
|
104
|
+
supp.addEventListener("change", () => { state.suppressed = supp.checked; draw(); });
|
|
105
|
+
|
|
106
|
+
const onKey = (e) => {
|
|
107
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
108
|
+
const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement?.tagName || "");
|
|
109
|
+
if (e.key === "/" && !typing) { e.preventDefault(); search.focus(); return; }
|
|
110
|
+
if (e.key === "Escape" && document.activeElement === search) { search.blur(); return; }
|
|
111
|
+
if (typing || !visible.length) return;
|
|
112
|
+
if (e.key === "j" || e.key === "k") {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
const i = visible.findIndex((f) => keyOf(f) === state.selected);
|
|
115
|
+
const next = visible[Math.max(0, Math.min(visible.length - 1, i < 0 ? 0 : i + (e.key === "j" ? 1 : -1)))];
|
|
116
|
+
select(next);
|
|
117
|
+
tbody.querySelector(`tr.sel button`)?.focus();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener("keydown", onKey);
|
|
121
|
+
|
|
122
|
+
draw();
|
|
123
|
+
const first = state.selected ? visible.find((f) => keyOf(f) === state.selected) : visible[0];
|
|
124
|
+
if (first) select(first);
|
|
125
|
+
else put(detail, panel("Detail", {}, empty("Select a finding.")));
|
|
126
|
+
|
|
127
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderDetail(d, f, go) {
|
|
131
|
+
const section = (label, ...body) => h("section", {}, h("div", { class: "label" }, label), ...body);
|
|
132
|
+
const repo = f.project.startsWith("/") ? f.project : null;
|
|
133
|
+
const x = d.explain || {};
|
|
134
|
+
return panel("Detail", {},
|
|
135
|
+
h("div", {}, sevBadge(d.severity), " ", h("span", { class: "chip" }, d.confidence || "unrated"), " ", d.category ? h("span", { class: "chip" }, d.category) : null),
|
|
136
|
+
h("h2", { tabindex: "-1" }, d.title),
|
|
137
|
+
h("dl", { class: "kv" },
|
|
138
|
+
h("dt", {}, "where"), h("dd", {}, d.location || "-"),
|
|
139
|
+
h("dt", {}, "project"), h("dd", {}, f.project),
|
|
140
|
+
h("dt", {}, "source"), h("dd", {}, d.source || "-"),
|
|
141
|
+
h("dt", {}, "seen"), h("dd", {}, ago(f.seen)),
|
|
142
|
+
h("dt", {}, "id"), h("dd", { class: "faint" }, d.id),
|
|
143
|
+
),
|
|
144
|
+
h("div", { class: "plain", "aria-label": "In plain words" },
|
|
145
|
+
h("p", {}, h("span", { class: "strong" }, "In plain words :: "), (x.plainSeverity || d.severity) === "note" ? "a note, not a problem." : `a ${x.plainSeverity || d.severity} problem${d.category ? ` with ${d.category.replace(/-/g, " ")}` : ""}.`),
|
|
146
|
+
x.confidenceMeaning ? h("p", {}, h("span", { class: "strong" }, `How sure (${x.confidence}) :: `), x.confidenceMeaning) : null,
|
|
147
|
+
x.fixText ? h("p", {}, h("span", { class: "strong" }, "What to do :: "), x.fixText) : null,
|
|
148
|
+
x.owasp?.length ? h("p", { class: "muted" }, `Maps to ${x.owasp.join(" · ")}`) : null,
|
|
149
|
+
),
|
|
150
|
+
d.description ? section("What we saw", h("p", {}, d.description)) : null,
|
|
151
|
+
d.evidence ? section("Evidence", h("pre", { class: "block" }, d.evidence)) : null,
|
|
152
|
+
d.request ? section("Request", h("pre", { class: "block" }, d.request)) : null,
|
|
153
|
+
d.response ? section("Response", h("pre", { class: "block" }, d.response)) : null,
|
|
154
|
+
d.remediation ? section("How to fix", h("p", {}, d.remediation)) : null,
|
|
155
|
+
d.suppression ? section("Suppressed", h("p", { class: "muted" }, `${d.suppression.reason} (${d.suppression.source})`)) : null,
|
|
156
|
+
d.sources?.length ? section("Sources", h("ul", { class: "list" }, d.sources.map((s) => h("li", {}, h("span", { class: "faint" }, "↗"), h("a", { href: s.url, target: "_blank", rel: "noopener noreferrer" }, s.cite))))) : null,
|
|
157
|
+
d.fix
|
|
158
|
+
? section("Fix recipe",
|
|
159
|
+
h("p", {}, h("span", { class: "ok" }, d.fix.recipe), ` :: ${d.fix.title} `, h("span", { class: "chip" }, `confidence ${d.fix.confidence}`)),
|
|
160
|
+
h("p", { class: "faint" }, `files: ${d.fix.files.join(", ")}`),
|
|
161
|
+
diffBlock(d.fix.diff),
|
|
162
|
+
repo ? h("p", {}, h("button", { type: "button", class: "btn", onclick: () => go("run", { kind: "loop", repo }) }, "Fix it with the agent ▸")) : null)
|
|
163
|
+
: section("Fix recipe", h("p", { class: "muted" }, "No recipe matches this finding. Fix it by hand, or run the loop with a local model (--fix-model) from the CLI.")),
|
|
164
|
+
);
|
|
165
|
+
}
|