penthera 1.6.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +158 -301
- package/bin/scan.js +2 -2
- package/docs/METHODOLOGY.md +236 -182
- package/docs/commands/README.md +2 -1
- package/docs/commands/init.md +114 -0
- package/docs/commands/scan.md +1 -1
- package/docs/compare/github.md +1 -1
- package/docs/dashboard.md +95 -152
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/internal-audit.md +1 -1
- package/docs/plan.md +3 -2
- package/docs/references.bib +43 -33
- package/docs/references.md +92 -51
- package/lib/whitebox/frameworks.js +36 -0
- package/package.json +1 -1
- package/skills/penthera/SKILL.md +19 -5
- package/src/cli/deps.js +1 -1
- package/src/cli/detect.js +125 -0
- package/src/cli/onboarding.js +428 -6
- package/src/cli/prompt.js +42 -0
- package/src/cli.js +18 -5
- package/src/ui/api/data.js +30 -0
- package/src/ui/api/demo.js +13 -0
- package/src/ui/api/routes.js +1 -0
- package/src/ui/static/app.css +286 -560
- package/src/ui/static/index.html +8 -39
- package/src/ui/static/js/app.js +105 -107
- package/src/ui/static/js/center.js +388 -0
- package/src/ui/static/js/copy.js +36 -62
- package/src/ui/static/js/dom.js +0 -38
- package/src/ui/static/js/left.js +83 -0
- package/src/ui/static/js/right.js +270 -0
- package/src/ui/static/js/state.js +35 -0
- package/src/ui/static/js/views/ai.js +0 -68
- package/src/ui/static/js/views/deps.js +0 -96
- package/src/ui/static/js/views/evidence.js +0 -115
- package/src/ui/static/js/views/findings.js +0 -165
- package/src/ui/static/js/views/history.js +0 -92
- package/src/ui/static/js/views/overview.js +0 -184
- package/src/ui/static/js/views/run.js +0 -349
- package/src/ui/static/js/views/settings.js +0 -67
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The right panel: stacked cards (environment, memory, dependencies, AI
|
|
2
|
+
// tooling, evidence, changes), or one finding's detail, or settings.
|
|
3
|
+
import { h, clear, empty, sevBadge, diffBlock, announce } from "./dom.js";
|
|
4
|
+
import { api } from "./api.js";
|
|
5
|
+
import { state, set } from "./state.js";
|
|
6
|
+
import { bars, SHADE } from "./charts.js";
|
|
7
|
+
import { ago, shortProject, plural, pct } from "./format.js";
|
|
8
|
+
import { EMPTY, KEYS } from "./copy.js";
|
|
9
|
+
|
|
10
|
+
let seq = 0;
|
|
11
|
+
|
|
12
|
+
export async function renderRight(el, ctx) {
|
|
13
|
+
const my = ++seq;
|
|
14
|
+
if (state.right === "finding" && state.finding) return findingView(el, ctx, my);
|
|
15
|
+
if (state.right === "settings") return settingsView(el, ctx, my);
|
|
16
|
+
return cardsView(el, ctx, my);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function card(title, aside, open, ...body) {
|
|
20
|
+
return h("details", { class: "card", open: open || null },
|
|
21
|
+
h("summary", {}, h("span", {}, title), aside ? h("span", { class: "aside" }, aside) : null),
|
|
22
|
+
h("div", { class: "card-body" }, ...body));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function head(title, ctx, extra) {
|
|
26
|
+
return h("div", { class: "detail-head" },
|
|
27
|
+
h("span", { class: "t1" }, title),
|
|
28
|
+
h("span", { class: "grow" }),
|
|
29
|
+
extra || null,
|
|
30
|
+
h("button", { type: "button", class: "icon", "aria-label": "Close (Esc)", title: "Close Esc", onclick: () => set({ right: "cards", finding: null }) }, "×"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Cards ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async function cardsView(el, ctx, my) {
|
|
36
|
+
const project = state.sel.kind === "project" ? state.sel.key : state.sel.kind === "past" ? state.sel.past?.target?.repo : state.sel.run?.repo || null;
|
|
37
|
+
const [env, mem, deps, ai, ev, settings] = await Promise.all([
|
|
38
|
+
api.get(`/api/env?project=${encodeURIComponent(project || "")}`),
|
|
39
|
+
api.get("/api/memory"), api.get("/api/deps"), api.get("/api/ai"), api.get("/api/evidence"), api.get("/api/settings"),
|
|
40
|
+
]).catch((e) => { clear(el).append(h("p", { class: "pad error" }, e.message)); return []; });
|
|
41
|
+
if (my !== seq || !env) return;
|
|
42
|
+
|
|
43
|
+
clear(el).append(h("div", { class: "cards" },
|
|
44
|
+
envCard(env, settings, project, ctx),
|
|
45
|
+
memoryCard(mem.projects),
|
|
46
|
+
depsCard(deps),
|
|
47
|
+
aiCard(ai),
|
|
48
|
+
evidenceCard(ev.bundles, project, ctx),
|
|
49
|
+
changesCard(env, project),
|
|
50
|
+
));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function envCard(env, settings, project, ctx) {
|
|
54
|
+
const sw = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(settings.offline) }, h("span", {}, "Offline"), h("span", { class: "state" }, settings.offline ? "on" : "off"));
|
|
55
|
+
sw.addEventListener("click", async () => {
|
|
56
|
+
const s = await api.post("/api/settings", { offline: sw.getAttribute("aria-checked") !== "true" });
|
|
57
|
+
sw.setAttribute("aria-checked", String(s.offline));
|
|
58
|
+
sw.lastChild.textContent = s.offline ? "on" : "off";
|
|
59
|
+
announce(`Offline ${s.offline ? "on" : "off"}`);
|
|
60
|
+
ctx.refreshMeta?.();
|
|
61
|
+
});
|
|
62
|
+
return card("Environment", settings.offline ? "offline" : "online", true,
|
|
63
|
+
h("dl", { class: "kv" },
|
|
64
|
+
h("dt", {}, "folder"), h("dd", {}, env.folder || (project ? "a URL target" : state.meta?.projects?.[0] || "-")),
|
|
65
|
+
h("dt", {}, "branch"), h("dd", {}, env.branch ? `${env.branch}${env.head ? ` @ ${env.head}` : ""}` : "-"),
|
|
66
|
+
h("dt", {}, "bound to"), h("dd", {}, `${location.host}, no network`),
|
|
67
|
+
),
|
|
68
|
+
sw,
|
|
69
|
+
h("p", { class: "muted" }, settings.offline ? "Scans started here contact only their target." : "Scans may also contact recon sources and fetch the Retire.js database."),
|
|
70
|
+
env.hosts.length ? h("div", {},
|
|
71
|
+
h("p", { class: "label" }, "Hosts the last scan contacted"),
|
|
72
|
+
h("ul", { class: "mini" }, env.hosts.map((x) => h("li", {}, h("span", { class: x.thirdParty ? "t1" : "ok" }, x.thirdParty ? "3P" : "OK"), h("span", { class: "grow" }, x.host), h("span", { class: "muted" }, String(x.requests))))),
|
|
73
|
+
env.blocked.length ? h("ul", { class: "mini" }, env.blocked.map((x) => h("li", {}, h("span", { class: "muted" }, "--"), h("span", { class: "grow muted" }, `${x.host} (blocked)`)))) : null,
|
|
74
|
+
) : null,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function memoryCard(projects) {
|
|
79
|
+
if (!projects.length) return card("Memory", "", false, empty(EMPTY.memory));
|
|
80
|
+
const recipes = new Map();
|
|
81
|
+
for (const p of projects) for (const r of p.recipes) {
|
|
82
|
+
const c = recipes.get(r.recipe) || { verified: 0, failed: 0 };
|
|
83
|
+
c.verified += r.verified;
|
|
84
|
+
c.failed += r.failed;
|
|
85
|
+
recipes.set(r.recipe, c);
|
|
86
|
+
}
|
|
87
|
+
const broken = projects.filter((p) => p.chain !== "ok");
|
|
88
|
+
const ttf = projects.map((p) => p.medianTimeToFixDays).filter((x) => x != null);
|
|
89
|
+
return card("Memory", broken.length ? `${broken.length} broken` : "chain ok", false,
|
|
90
|
+
h("dl", { class: "kv" },
|
|
91
|
+
h("dt", {}, "chain"), h("dd", { class: broken.length ? "bad" : "ok" }, broken.length ? `[!] broken in ${plural(broken.length, "project")} :: ignored there` : "[OK] sha-256 hash chain"),
|
|
92
|
+
h("dt", {}, "events"), h("dd", {}, String(projects.reduce((n, p) => n + p.events, 0))),
|
|
93
|
+
h("dt", {}, "fixes"), h("dd", {}, `${projects.reduce((n, p) => n + p.verifiedFixes, 0)} verified · ${projects.reduce((n, p) => n + p.failedFixes, 0)} failed`),
|
|
94
|
+
h("dt", {}, "time to fix"), h("dd", {}, ttf.length ? `median ${Math.min(...ttf)}-${Math.max(...ttf)} days` : "-"),
|
|
95
|
+
),
|
|
96
|
+
h("p", { class: "label" }, "Recipe success"),
|
|
97
|
+
bars([...recipes].map(([recipe, r]) => ({ label: recipe, value: r.verified })), { width: 10 }),
|
|
98
|
+
h("p", { class: "muted" }, [...recipes].map(([recipe, r]) => `${recipe} ${pct(r.verified / Math.max(1, r.verified + r.failed))}`).join(" · ")),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const CAP_SEV = { "install-scripts": "high", obfuscated: "high", "ai-config-write": "critical", "ai-cli-invoke": "critical", "token-harvest": "critical" };
|
|
103
|
+
|
|
104
|
+
function depsCard(d) {
|
|
105
|
+
const l = d.latest;
|
|
106
|
+
if (!l) return card("Dependencies", "", false, empty(EMPTY.deps));
|
|
107
|
+
const diff = l.diff;
|
|
108
|
+
const gained = diff ? diff.upgraded.reduce((n, u) => n + u.gained.length, 0) + diff.added.reduce((n, a) => n + a.capabilities.length, 0) : 0;
|
|
109
|
+
const chip = (id, plus) => h("span", { class: `chip ${CAP_SEV[id] ? "inv" : ""}` }, `${plus ? "+" : ""}${id}`);
|
|
110
|
+
return card("Dependencies", `${l.ecosystem} · ${l.total} packages`, false,
|
|
111
|
+
h("dl", { class: "kv" },
|
|
112
|
+
h("dt", {}, "packages"), h("dd", {}, `${l.total} (${l.direct} direct) · ${l.lockfile}`),
|
|
113
|
+
h("dt", {}, "diff"), h("dd", {}, diff ? `+${diff.added.length} added · ~${diff.upgraded.length} upgraded · -${diff.removed.length} removed vs ${diff.base}` : "no base compared"),
|
|
114
|
+
h("dt", {}, "gained"), h("dd", {}, `${gained} capabilities`),
|
|
115
|
+
h("dt", {}, "typosquats"), h("dd", { class: l.typosquats.length ? "bad" : null }, l.typosquats.length ? l.typosquats.map((t) => `${t.name} ~ ${t.similarTo}`).join(", ") : "none"),
|
|
116
|
+
),
|
|
117
|
+
diff ? h("ul", { class: "mini" },
|
|
118
|
+
diff.added.map((a) => h("li", { class: "stacked" }, h("span", {}, `+ ${a.name}@${a.version}`), h("span", { class: "chips" }, a.capabilities.slice(0, 4).map((c) => chip(c, true))))),
|
|
119
|
+
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.slice(0, 4).map((c) => chip(c, true)))))) : null,
|
|
120
|
+
h("p", { class: "label" }, "Capabilities in the tree"),
|
|
121
|
+
bars(Object.entries(l.capabilityCounts).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([id, n]) => ({ label: id, value: n, ch: SHADE[CAP_SEV[id] || "low"] })), { width: 10 }),
|
|
122
|
+
d.installs.length ? h("div", {},
|
|
123
|
+
h("p", { class: "label" }, "Install firewall"),
|
|
124
|
+
h("ul", { class: "mini" }, d.installs.slice(0, 5).map((i) => {
|
|
125
|
+
const deny = i.changes.some((c) => c.deny.length) || i.action === "refused";
|
|
126
|
+
return h("li", {}, h("span", { class: deny ? "bad" : i.clean ? "ok" : "t1" }, deny ? "REFUSED" : i.clean ? "CLEAN" : "APPROVED"), h("span", { class: "grow" }, i.specs.join(" ")), h("span", { class: "muted" }, ago(i.ts)));
|
|
127
|
+
}))) : null,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function aiCard(d) {
|
|
132
|
+
const l = d.latest;
|
|
133
|
+
if (!l) return card("AI tooling", "", false, empty(EMPTY.ai));
|
|
134
|
+
const lock = l.lock || {};
|
|
135
|
+
const drift = (lock.changed || 0) + (lock.added || 0) + (lock.removed || 0);
|
|
136
|
+
return card("AI tooling", `grade ${l.grade}`, false,
|
|
137
|
+
h("div", { class: "head" }, h("span", { class: `grade lg grade-${l.grade}` }, l.grade), h("div", {}, h("div", { class: "t1" }, `score ${l.score}/100`), h("div", { class: "muted" }, `${d.history.map((x) => x.grade).join(" → ")} · ${ago(l.ts)}`))),
|
|
138
|
+
h("dl", { class: "kv" },
|
|
139
|
+
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"),
|
|
140
|
+
h("dt", {}, "inventory"), h("dd", {}, `${l.inventory.skills || 0} skills · ${l.inventory.mcpServers || 0} MCP · ${l.inventory.hooks || 0} hooks`),
|
|
141
|
+
h("dt", {}, "hidden content"), h("dd", { class: l.hidden.length ? "bad" : "ok" }, l.hidden.length ? `${l.hidden.length} hits` : "none"),
|
|
142
|
+
),
|
|
143
|
+
l.hidden.length ? h("ul", { class: "mini" }, l.hidden.slice(0, 4).map((x) => h("li", {}, sevBadge(x.severity), h("span", { class: "grow" }, `${x.title} · ${x.location || ""}`)))) : null,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function evidenceCard(bundles, project, ctx) {
|
|
148
|
+
const target = project || state.overview?.projects?.[0]?.key || "";
|
|
149
|
+
const msg = h("p", { class: "muted", "aria-live": "polite" });
|
|
150
|
+
const out = h("div");
|
|
151
|
+
const create = h("button", { type: "button", class: "btn", disabled: !target || !state.overview?.projects?.some((p) => p.key === target) }, "Create bundle");
|
|
152
|
+
create.addEventListener("click", async () => {
|
|
153
|
+
create.disabled = true;
|
|
154
|
+
msg.textContent = "Writing the bundle and hashing every file…";
|
|
155
|
+
try {
|
|
156
|
+
const r = await api.post("/api/evidence/create", { project: target });
|
|
157
|
+
const v = await api.post("/api/evidence/verify", { id: r.id });
|
|
158
|
+
msg.textContent = `${r.path} :: ${v.ok ? "verified" : "verification failed"}`;
|
|
159
|
+
ctx.redrawRight();
|
|
160
|
+
} catch (e) { msg.textContent = e.message; create.disabled = false; }
|
|
161
|
+
});
|
|
162
|
+
const list = h("ul", { class: "mini" }, bundles.map((b) => {
|
|
163
|
+
const res = h("span", { class: "muted" }, b.signed ? "signed" : "unsigned");
|
|
164
|
+
const btn = h("button", { type: "button", class: "btn small" }, "Verify");
|
|
165
|
+
btn.addEventListener("click", async () => {
|
|
166
|
+
btn.disabled = true;
|
|
167
|
+
res.textContent = "hashing…";
|
|
168
|
+
const r = await api.post("/api/evidence/verify", { id: b.id }).catch((e) => ({ ok: false, problems: [e.message], files: [] }));
|
|
169
|
+
res.textContent = r.ok ? "[OK]" : `[!] ${plural(r.problems.length, "problem")}`;
|
|
170
|
+
res.className = r.ok ? "ok" : "bad";
|
|
171
|
+
clear(out).append(r.ok ? null : h("pre", { class: "block" }, r.problems.map((p) => `- ${p}`).join("\n")));
|
|
172
|
+
btn.disabled = false;
|
|
173
|
+
});
|
|
174
|
+
return h("li", {}, h("span", { class: "grow", title: b.target || "" }, `${b.path} · ${ago(b.generatedAt)}`), res, btn);
|
|
175
|
+
}));
|
|
176
|
+
return card("Evidence", plural(bundles.length, "bundle"), false,
|
|
177
|
+
target ? h("div", { class: "actions" },
|
|
178
|
+
h("span", { class: "muted grow" }, shortProject(target)),
|
|
179
|
+
create,
|
|
180
|
+
...["auditor", "owner", "developer"].map((a) => h("a", { class: "btn small", href: `/api/report?${new URLSearchParams({ project: target, audience: a })}`, download: "" }, `${a}.md`)),
|
|
181
|
+
) : null,
|
|
182
|
+
msg,
|
|
183
|
+
bundles.length ? list : empty(EMPTY.evidence),
|
|
184
|
+
out,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function changesCard(env, project) {
|
|
189
|
+
const past = state.runs.past.filter((l) => !project || l.target.repo === project || l.target.url === project);
|
|
190
|
+
const fixes = past.flatMap((l) => l.iterations.flatMap((it) => it.fixes.filter((f) => f.commit).map((f) => ({ ...f, branch: l.branch, at: l.startedAt }))));
|
|
191
|
+
return card("Changes", plural(fixes.length, "fix", "fixes"), false,
|
|
192
|
+
fixes.length ? h("ul", { class: "mini" }, fixes.slice(0, 12).map((f) => h("li", {},
|
|
193
|
+
h("span", { class: "ok" }, "OK"),
|
|
194
|
+
h("span", { class: "grow" }, f.title, h("span", { class: "muted" }, ` · ${f.branch}`)),
|
|
195
|
+
h("code", { class: "muted", title: `git show ${f.commit}` }, f.commit.slice(0, 7))))) : empty(EMPTY.changes),
|
|
196
|
+
env.fixBranches.length ? h("div", {}, h("p", { class: "label" }, "Local fix branches"), h("ul", { class: "mini" }, env.fixBranches.map((b) => h("li", {}, h("code", { class: "grow" }, b))))) : null,
|
|
197
|
+
h("p", { class: "muted" }, "Nothing is pushed. Review with git log --stat main..<branch>, then merge or push it yourself."),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── One finding ─────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async function findingView(el, ctx, my) {
|
|
204
|
+
const f = state.finding;
|
|
205
|
+
clear(el).append(head("Finding", ctx), h("p", { class: "pad muted" }, "Reading evidence…"));
|
|
206
|
+
let d;
|
|
207
|
+
try { d = await api.get(`/api/findings/${f.id}?project=${encodeURIComponent(f.project)}`); } catch (e) {
|
|
208
|
+
if (my === seq) clear(el).append(head("Finding", ctx), h("p", { class: "pad bad" }, e.message));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (my !== seq) return;
|
|
212
|
+
const x = d.explain || {};
|
|
213
|
+
const section = (label, ...body) => h("section", {}, h("p", { class: "label" }, label), ...body);
|
|
214
|
+
clear(el).append(
|
|
215
|
+
head("Finding", ctx),
|
|
216
|
+
h("div", { class: "detail" },
|
|
217
|
+
h("div", {}, sevBadge(d.severity), " ", h("span", { class: "chip" }, d.confidence || "unrated"), " ", d.category ? h("span", { class: "chip" }, d.category) : null),
|
|
218
|
+
h("h2", { tabindex: "-1" }, d.title),
|
|
219
|
+
h("dl", { class: "kv" },
|
|
220
|
+
h("dt", {}, "where"), h("dd", {}, d.location || "-"),
|
|
221
|
+
h("dt", {}, "project"), h("dd", {}, f.project),
|
|
222
|
+
h("dt", {}, "id"), h("dd", { class: "muted" }, d.id)),
|
|
223
|
+
h("div", { class: "plain", "aria-label": "In plain words" },
|
|
224
|
+
h("p", {}, h("b", {}, "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, " ")}` : ""}.`),
|
|
225
|
+
x.confidenceMeaning ? h("p", {}, h("b", {}, `How sure (${x.confidence}) :: `), x.confidenceMeaning) : null,
|
|
226
|
+
x.fixText ? h("p", {}, h("b", {}, "What to do :: "), x.fixText) : null,
|
|
227
|
+
x.owasp?.length ? h("p", { class: "muted" }, `Maps to ${x.owasp.join(" · ")}`) : null),
|
|
228
|
+
d.description ? section("What we saw", h("p", {}, d.description)) : null,
|
|
229
|
+
d.evidence ? section("Evidence", h("pre", { class: "block" }, d.evidence)) : null,
|
|
230
|
+
d.remediation ? section("How to fix", h("p", {}, d.remediation)) : null,
|
|
231
|
+
d.sources?.length ? section("Sources", h("ul", { class: "mini" }, d.sources.map((s) => h("li", {}, h("a", { href: s.url, target: "_blank", rel: "noopener noreferrer" }, s.cite))))) : null,
|
|
232
|
+
d.fix ? section("Fix recipe",
|
|
233
|
+
h("p", {}, h("span", { class: "ok" }, d.fix.recipe), ` :: ${d.fix.title}`),
|
|
234
|
+
diffBlock(d.fix.diff),
|
|
235
|
+
f.project.startsWith("/") ? h("div", { class: "actions" }, h("button", { type: "button", class: "btn primary", onclick: () => { set({ mode: "loop", sel: { kind: "project", key: f.project } }); ctx.focusBar("loop"); } }, "Fix it with the loop ▸")) : null)
|
|
236
|
+
: section("Fix recipe", h("p", { class: "muted" }, "No recipe matches. Fix it by hand, or run the loop with a local model (--fix-model) from the CLI.")),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Settings (behind the gear) ──────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
async function settingsView(el, ctx, my) {
|
|
244
|
+
const [settings, mem] = await Promise.all([api.get("/api/settings"), api.get("/api/memory")]);
|
|
245
|
+
if (my !== seq) return;
|
|
246
|
+
const radios = ["dark", "light"].map((t) => {
|
|
247
|
+
const r = h("input", { type: "radio", name: "theme", value: t, checked: ctx.theme.get() === t });
|
|
248
|
+
r.addEventListener("change", () => ctx.theme.set(t));
|
|
249
|
+
return h("label", {}, r, h("span", {}, t === "dark" ? "Dark" : "Light"));
|
|
250
|
+
});
|
|
251
|
+
clear(el).append(
|
|
252
|
+
head("Settings", ctx),
|
|
253
|
+
h("div", { class: "detail" },
|
|
254
|
+
h("section", {}, h("p", { class: "label" }, "Theme"), h("div", { class: "seg", role: "radiogroup", "aria-label": "Theme" }, radios),
|
|
255
|
+
h("p", { class: "muted" }, "Remembered in this browser. The first visit follows your system.")),
|
|
256
|
+
h("section", {}, h("p", { class: "label" }, "Where data comes from"),
|
|
257
|
+
h("dl", { class: "kv" },
|
|
258
|
+
h("dt", {}, "reports"), h("dd", {}, state.meta.reportsDir),
|
|
259
|
+
h("dt", {}, "memory"), h("dd", {}, state.meta.memoryRoot),
|
|
260
|
+
h("dt", {}, "projects"), h("dd", {}, state.meta.projects.map((p) => h("div", {}, p)))),
|
|
261
|
+
h("p", { class: "muted" }, "Only these folders and ~/.penthera are read. Add folders with penthera ui --project <dir>.")),
|
|
262
|
+
h("section", {}, h("p", { class: "label" }, "Egress for scans started here"), h("p", {}, settings.offline ? "Offline: only the target." : "Online: target plus recon sources.")),
|
|
263
|
+
h("section", {}, h("p", { class: "label" }, "Memory chains"),
|
|
264
|
+
mem.projects.length ? h("ul", { class: "mini" }, mem.projects.map((p) => h("li", {}, h("span", { class: p.chain === "ok" ? "ok" : "bad" }, p.chain === "ok" ? "[OK]" : "[!]"), h("span", { class: "grow" }, p.label), h("span", { class: "muted" }, `${p.events} events`)))) : empty(EMPTY.memory)),
|
|
265
|
+
h("section", {}, h("p", { class: "label" }, "Keys"), h("dl", { class: "kv" }, KEYS.flatMap(([k, v]) => [h("dt", {}, h("kbd", {}, k)), h("dd", {}, v)]))),
|
|
266
|
+
h("section", {}, h("p", { class: "label" }, "Session"),
|
|
267
|
+
h("p", { class: "muted" }, `127.0.0.1 only · one-time link swapped for an HttpOnly, SameSite=Strict cookie · Host and Origin checked · strict CSP · ${state.meta.demo ? "demo data" : "local reports"}`)),
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// One store for the single view. Renderers subscribe and redraw their column.
|
|
2
|
+
export const state = {
|
|
3
|
+
meta: null,
|
|
4
|
+
overview: null,
|
|
5
|
+
findings: [],
|
|
6
|
+
runs: { live: [], past: [] },
|
|
7
|
+
sel: { kind: "all", key: null }, // all | project | run | past
|
|
8
|
+
finding: null, // { id, project } open in the right panel
|
|
9
|
+
right: "cards", // cards | finding | settings
|
|
10
|
+
query: "",
|
|
11
|
+
mode: "scan",
|
|
12
|
+
activeRun: null, // the live run object being streamed
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const subs = new Set();
|
|
16
|
+
export function on(fn) { subs.add(fn); return () => subs.delete(fn); }
|
|
17
|
+
export function set(patch) {
|
|
18
|
+
Object.assign(state, patch);
|
|
19
|
+
for (const fn of subs) fn(patch);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Every project key: scanned targets first, then project folders with no scans yet. */
|
|
23
|
+
export function projectKeys() {
|
|
24
|
+
const scanned = (state.overview?.projects || []).map((p) => p.key);
|
|
25
|
+
const folders = (state.meta?.projects || []).filter((f) => !scanned.includes(f));
|
|
26
|
+
return [...scanned, ...folders];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const projectOf = (key) => (state.overview?.projects || []).find((p) => p.key === key) || null;
|
|
30
|
+
|
|
31
|
+
/** The folder a run should use: the selected project if it is a folder, else the first project folder. */
|
|
32
|
+
export function folderFor(key) {
|
|
33
|
+
if (key && key.startsWith("/")) return key;
|
|
34
|
+
return state.meta?.projects?.[0] || "";
|
|
35
|
+
}
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
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
|
-
}
|