penthera 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -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
|
@@ -1,165 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { h, put, panel, clear, empty, sevBadge, fig, whatNext } from "../dom.js";
|
|
2
|
-
import { trendChart, ganttChart } from "../charts.js";
|
|
3
|
-
import { stamp, ago, shortProject } from "../format.js";
|
|
4
|
-
import { SCREENS, EMPTY } from "../copy.js";
|
|
5
|
-
|
|
6
|
-
export async function render(root, { api, params, go }) {
|
|
7
|
-
const o = await api.get("/api/overview");
|
|
8
|
-
put(root, h("h1", {}, SCREENS.history.title), h("p", { class: "lede" }, SCREENS.history.lede));
|
|
9
|
-
if (!o.projects.length) { put(root, h("div", { class: "notice" }, empty(EMPTY.history))); return; }
|
|
10
|
-
|
|
11
|
-
const keys = o.projects.map((p) => p.key);
|
|
12
|
-
// Default to the project whose last two scans differ most: that is the interesting comparison.
|
|
13
|
-
const delta = (p) => (p.trend.length > 1 ? Math.abs(p.trend[p.trend.length - 1].total - p.trend[p.trend.length - 2].total) + 0.5 : 0);
|
|
14
|
-
let project = keys.includes(params.get("project")) ? params.get("project") : [...o.projects].sort((x, y) => delta(y) - delta(x))[0].key;
|
|
15
|
-
const projSel = h("select", { "aria-label": "Project" }, keys.map((k) => h("option", { value: k, selected: k === project }, shortProject(k))));
|
|
16
|
-
const aSel = h("select", { "aria-label": "Before" });
|
|
17
|
-
const bSel = h("select", { "aria-label": "After" });
|
|
18
|
-
const body = h("div");
|
|
19
|
-
put(root,
|
|
20
|
-
h("div", { class: "filters" },
|
|
21
|
-
h("label", { class: "field" }, h("span", {}, "Project"), projSel),
|
|
22
|
-
h("label", { class: "field" }, h("span", {}, "Before"), aSel),
|
|
23
|
-
h("label", { class: "field" }, h("span", {}, "After"), bSel),
|
|
24
|
-
),
|
|
25
|
-
body,
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
let seq = 0;
|
|
29
|
-
async function load(a = null, b = null) {
|
|
30
|
-
const n = ++seq;
|
|
31
|
-
const q = new URLSearchParams({ project });
|
|
32
|
-
if (a != null) q.set("a", a);
|
|
33
|
-
if (b != null) q.set("b", b);
|
|
34
|
-
let hst;
|
|
35
|
-
try { hst = await api.get(`/api/history?${q}`); } catch (e) { clear(body).append(h("p", { class: "error" }, e.message)); return; }
|
|
36
|
-
if (n !== seq) return;
|
|
37
|
-
const opts = (sel, cur) => clear(sel).append(...hst.scans.map((sc) => h("option", { value: String(sc.i), selected: sc.i === cur.i }, `#${sc.i + 1} · ${stamp(sc.ts).slice(5)} · ${sc.open} open`)));
|
|
38
|
-
opts(aSel, hst.a);
|
|
39
|
-
opts(bSel, hst.b);
|
|
40
|
-
const one = hst.scans.length < 2;
|
|
41
|
-
clear(body).append(
|
|
42
|
-
one ? h("p", { class: "notice" }, EMPTY.history) : null,
|
|
43
|
-
h("div", { class: "strip" },
|
|
44
|
-
cell("Before", String(hst.a.open), `${stamp(hst.a.ts)} :: ${ago(hst.a.ts)}`),
|
|
45
|
-
cell("After", String(hst.b.open), `${stamp(hst.b.ts)} :: ${ago(hst.b.ts)}`),
|
|
46
|
-
cell("New", String(hst.new.length), "in after, not in before"),
|
|
47
|
-
cell("Fixed", String(hst.fixed.length), "in before, gone after", "ok"),
|
|
48
|
-
),
|
|
49
|
-
panel(`Between scan ${hst.a.i + 1} and scan ${hst.b.i + 1}`, { meta: `${stamp(hst.b.ts)}` },
|
|
50
|
-
h("div", { class: "cols" },
|
|
51
|
-
col(`New · ${hst.new.length}`, hst.new, "Nothing new."),
|
|
52
|
-
col(`Fixed · ${hst.fixed.length}`, hst.fixed, "Nothing fixed between these two scans."),
|
|
53
|
-
col(`Still open · ${hst.still.length}`, hst.still, "Nothing carried over."),
|
|
54
|
-
)),
|
|
55
|
-
h("div", { class: "grid stack" }, panel("Timeline", { class: "span-12", meta: `${hst.lifetimes.filter((l) => l.open).length} open · ${hst.lifetimes.filter((l) => !l.open).length} fixed` },
|
|
56
|
-
h("div", { class: "table-wrap" }, ganttChart(hst.lifetimes, hst.scans)),
|
|
57
|
-
h("div", { class: "legend" },
|
|
58
|
-
h("span", {}, "▨ open, up to now"), h("span", {}, "█ open and critical"), h("span", {}, "□ fixed: first seen to last seen"), h("span", {}, "┊ a scan"),
|
|
59
|
-
),
|
|
60
|
-
fig(`fig 1 :: every finding of ${shortProject(project)} across ${hst.scans.length} scans`, "from the reports folder"),
|
|
61
|
-
),
|
|
62
|
-
panel("Open findings per scan", { class: "span-12", meta: `${hst.scans.length} scans` },
|
|
63
|
-
trendChart(hst.scans.map((sc) => ({ ts: sc.ts, total: sc.open, severe: sc.counts.critical + sc.counts.high })), { width: 1100, height: 200 }),
|
|
64
|
-
fig(`fig 2 :: ${shortProject(project)}, every scan in the reports folder`, `${hst.scans.length} scans`),
|
|
65
|
-
)),
|
|
66
|
-
whatNext([
|
|
67
|
-
{ label: "Read what is still open", go: ["findings", { project }] },
|
|
68
|
-
{ label: "Fix it with the agent", why: "then compare again", go: ["run", { kind: "loop", repo: project.startsWith("/") ? project : "" }] },
|
|
69
|
-
{ label: "Create evidence for an auditor", go: ["evidence"] },
|
|
70
|
-
], go),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
projSel.addEventListener("change", () => { project = projSel.value; load(); });
|
|
75
|
-
aSel.addEventListener("change", () => load(Number(aSel.value), Number(bSel.value)));
|
|
76
|
-
bSel.addEventListener("change", () => load(Number(aSel.value), Number(bSel.value)));
|
|
77
|
-
await load();
|
|
78
|
-
|
|
79
|
-
function col(title, rows, none) {
|
|
80
|
-
return h("div", {}, h("h3", {}, title),
|
|
81
|
-
rows.length ? h("ul", { class: "rows" }, rows.map((r) => h("li", {}, h("span", { class: "code" }, r.severity.slice(0, 1).toUpperCase() + "+"), h("span", { class: ["t", r.severity === "critical" || r.severity === "high" ? "b" : null] }, r.title)))) : empty(none));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function list(title, rows, none) {
|
|
85
|
-
return panel(`${title} (${rows.length})`, {},
|
|
86
|
-
rows.length ? h("ul", { class: "list" }, rows.map((r) => h("li", {}, sevBadge(r.severity), h("span", { class: "grow t1" }, r.title)))) : empty(none));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function cell(label, value, sub, cls) {
|
|
91
|
-
return h("div", { class: "cell" }, h("span", { class: "label" }, label), h("div", { class: ["big", cls] }, value), h("div", { class: "sub" }, sub));
|
|
92
|
-
}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import { h, put, panel, empty, subwin, whatNext, fig } from "../dom.js";
|
|
2
|
-
import { severityBars, stackedBar, sparkline, trendChart, columns, wordmark } from "../charts.js";
|
|
3
|
-
import { ago, duration, shortProject, SEVERITIES, plural } from "../format.js";
|
|
4
|
-
import { SCREENS, START, NEXT, TAGLINE } from "../copy.js";
|
|
5
|
-
|
|
6
|
-
/** Merge per-project trends into one series by scan index from the end. */
|
|
7
|
-
function combinedTrend(projects) {
|
|
8
|
-
const len = Math.max(0, ...projects.map((p) => p.trend.length));
|
|
9
|
-
const out = [];
|
|
10
|
-
for (let i = 0; i < len; i++) {
|
|
11
|
-
let total = 0;
|
|
12
|
-
let severe = 0;
|
|
13
|
-
let ts = null;
|
|
14
|
-
for (const p of projects) {
|
|
15
|
-
const t = p.trend[p.trend.length - len + i] || p.trend[0];
|
|
16
|
-
if (!t) continue;
|
|
17
|
-
total += t.total;
|
|
18
|
-
severe += (t.critical || 0) + (t.high || 0);
|
|
19
|
-
if (!ts || t.ts > ts) ts = t.ts;
|
|
20
|
-
}
|
|
21
|
-
out.push({ ts, total, severe });
|
|
22
|
-
}
|
|
23
|
-
return out;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function about(meta, o) {
|
|
27
|
-
const facts = o.projects.length
|
|
28
|
-
? [["projects", String(o.projects.length)], ["open", String(o.open)], ["fixed", String(o.fixed)], ["network", "none"]]
|
|
29
|
-
: [["projects", "0"], ["network", "none"], ["telemetry", "none"]];
|
|
30
|
-
return subwin("About penthera", { class: "about" },
|
|
31
|
-
h("div", { class: "subwin-body" },
|
|
32
|
-
wordmark(),
|
|
33
|
-
h("p", { class: "sr" }, "Penthera"),
|
|
34
|
-
h("div", { class: "tagline" }, TAGLINE),
|
|
35
|
-
h("div", { class: "facts" },
|
|
36
|
-
h("span", {}, `v${meta.version}`),
|
|
37
|
-
facts.map(([k, v]) => h("span", {}, `${k} `, h("b", {}, v))),
|
|
38
|
-
h("span", {}, meta.demo ? "demo data" : "reports from this machine"),
|
|
39
|
-
),
|
|
40
|
-
));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function startHere(meta, go) {
|
|
44
|
-
const folder = meta.projects?.[0] || "";
|
|
45
|
-
return h("section", { class: "steps", "aria-labelledby": "start-h" },
|
|
46
|
-
h("div", {},
|
|
47
|
-
h("h1", { id: "start-h" }, START.welcome),
|
|
48
|
-
h("p", { class: "lede" }, START.intro),
|
|
49
|
-
h("p", { class: "muted" }, START.gate),
|
|
50
|
-
),
|
|
51
|
-
START.steps.map((st, i) => h("div", { class: "step" },
|
|
52
|
-
h("h3", {}, h("span", { class: "n" }, `Step ${i + 1}`), st.title),
|
|
53
|
-
h("p", { class: "muted" }, st.body),
|
|
54
|
-
h("button", { type: "button", class: i === 0 ? "btn primary" : "btn", onclick: () => go("run", { kind: st.kind, repo: folder }) }, `${st.action} ▸`),
|
|
55
|
-
)),
|
|
56
|
-
h("p", { class: "muted" }, `${START.demo} `, h("code", {}, "penthera ui --demo"), "."),
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function render(root, { api, go, meta }) {
|
|
61
|
-
const o = await api.get("/api/overview");
|
|
62
|
-
put(root, about(meta, o));
|
|
63
|
-
|
|
64
|
-
if (!o.projects.length) {
|
|
65
|
-
put(root, startHere(meta, go));
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const total = SEVERITIES.reduce((n, s) => n + o.mix[s], 0);
|
|
70
|
-
const severe = o.mix.critical + o.mix.high;
|
|
71
|
-
put(root,
|
|
72
|
-
h("h1", {}, SCREENS.overview.title),
|
|
73
|
-
h("p", { class: "lede" }, SCREENS.overview.lede),
|
|
74
|
-
h("div", { class: "strip" },
|
|
75
|
-
cell("Open findings", String(o.open), `${severe} critical or high`),
|
|
76
|
-
cell("Fixed", String(o.fixed), "gone in a later scan, or verified by the loop", "ok"),
|
|
77
|
-
cell("Median time to fix", o.timeToFix.medianDays == null ? "-" : String(o.timeToFix.medianDays), plural(o.timeToFix.samples, "fix measured", "fixes measured"), null, o.timeToFix.medianDays == null ? null : "days"),
|
|
78
|
-
cell("Hosts contacted", String(o.egress.hosts.length), `${o.egress.blocked.length} blocked by offline mode`),
|
|
79
|
-
),
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
const c = o.columns;
|
|
83
|
-
const rowsOf = (list, none) => (list.length
|
|
84
|
-
? h("ul", { class: "rows" }, list.slice(0, 8).map((r) => h("li", {},
|
|
85
|
-
h("span", { class: "code" }, `${r.severity.slice(0, 1).toUpperCase()}+`),
|
|
86
|
-
h("button", { type: "button", class: ["row-btn", "t", r.severity === "critical" || r.severity === "high" ? "b" : null], onclick: () => go("findings", { project: r.project, id: r.id }) },
|
|
87
|
-
r.title, h("span", { class: "where" }, ` · ${shortProject(r.project)}`)))))
|
|
88
|
-
: empty(none));
|
|
89
|
-
put(root, panel("This week", { class: "week", meta: new Date().toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }) },
|
|
90
|
-
h("div", { class: "cols" },
|
|
91
|
-
h("div", {}, h("h3", {}, `Fix first · ${c.fixFirst.length}`), rowsOf(c.fixFirst, "Nothing critical or high. Good.")),
|
|
92
|
-
h("div", {}, h("h3", {}, `New since the last scan · ${c.newest.length}`), rowsOf(c.newest, "Nothing new since the last scan.")),
|
|
93
|
-
h("div", {}, h("h3", {}, `Fixed since the last scan · ${c.fixed.length}`), rowsOf(c.fixed, "Nothing fixed since the last scan yet.")),
|
|
94
|
-
)));
|
|
95
|
-
|
|
96
|
-
const grid = h("div", { class: "grid" });
|
|
97
|
-
put(root, grid);
|
|
98
|
-
|
|
99
|
-
const rows = o.projects.map((p) => h("tr", {},
|
|
100
|
-
h("td", {}, h("span", { class: `grade grade-${p.grade}`, title: `score ${p.score}/100` }, p.grade)),
|
|
101
|
-
h("td", {},
|
|
102
|
-
h("button", { class: "row-btn trunc", type: "button", title: p.key, onclick: () => go("findings", { project: p.key }) }, shortProject(p.key)),
|
|
103
|
-
h("span", { class: "faint" }, p.mode || "")),
|
|
104
|
-
h("td", { class: "hide-narrow" }, stackedBar(p.counts, { width: 14 })),
|
|
105
|
-
h("td", {}, sparkline(p.trend.map((t) => t.total), { label: `open findings, ${shortProject(p.key)}` })),
|
|
106
|
-
h("td", { class: "num t1" }, String(p.open)),
|
|
107
|
-
h("td", { class: "num ok" }, String(p.fixed)),
|
|
108
|
-
h("td", { class: "num muted hide-narrow" }, h("button", { type: "button", class: "row-btn", onclick: () => go("history", { project: p.key }) }, ago(p.lastScan))),
|
|
109
|
-
));
|
|
110
|
-
put(grid, panel("Posture per project", { class: "span-12", meta: plural(o.projects.length, "project") },
|
|
111
|
-
h("div", { class: "table-wrap" }, h("table", {},
|
|
112
|
-
h("thead", {}, h("tr", {}, ...["Grade", "Project", "Severity", "Trend", "Open", "Fixed", "Last scan"].map((t, i) => h("th", { class: [i === 4 || i === 5 || i === 6 ? "num" : null, i === 2 || i === 6 ? "hide-narrow" : null] }, t)))),
|
|
113
|
-
h("tbody", {}, rows),
|
|
114
|
-
)),
|
|
115
|
-
fig("fig 1 :: grade = 100 − 30/critical − 15/high − 5/medium − 1/low; any critical is an F", `${o.projects.length} projects`),
|
|
116
|
-
));
|
|
117
|
-
|
|
118
|
-
put(grid, panel("Severity mix", { class: "span-5", meta: `${total} findings` },
|
|
119
|
-
stackedBar(o.mix, { width: 34 }),
|
|
120
|
-
severityBars(o.mix, { width: 18 }),
|
|
121
|
-
fig("fig 2 :: latest report per project", `${total} findings`),
|
|
122
|
-
));
|
|
123
|
-
|
|
124
|
-
put(grid, panel("Findings over time", { class: "span-7", meta: "by scan" },
|
|
125
|
-
trendChart(combinedTrend(o.projects)),
|
|
126
|
-
h("div", { class: "legend" },
|
|
127
|
-
h("span", {}, h("span", { class: "sw" }), "open (critical to low)"),
|
|
128
|
-
h("span", {}, h("span", { class: "sw sw-severe" }), "critical + high"),
|
|
129
|
-
),
|
|
130
|
-
fig("fig 3 :: summed across projects, by scan", "from reports + memory"),
|
|
131
|
-
));
|
|
132
|
-
|
|
133
|
-
put(grid, panel("Time to fix", { class: "span-4", meta: o.timeToFix.medianDays == null ? "no data" : `median ${o.timeToFix.medianDays}d` },
|
|
134
|
-
columns(o.timeToFix.buckets, { label: "Fixes by time to fix" }),
|
|
135
|
-
fig("fig 4 :: first seen to gone", `${o.timeToFix.samples} fixes`),
|
|
136
|
-
));
|
|
137
|
-
|
|
138
|
-
put(grid, panel("Hosts contacted", { class: "span-4", meta: `${o.egress.hosts.length} hosts` },
|
|
139
|
-
o.egress.hosts.length
|
|
140
|
-
? h("ul", { class: "list" }, o.egress.hosts.map((x) => h("li", {},
|
|
141
|
-
h("span", { class: x.thirdParty ? "t1" : "ok" }, x.thirdParty ? "3P" : "OK"),
|
|
142
|
-
h("span", { class: "grow trunc t1", title: x.host }, x.host),
|
|
143
|
-
h("span", { class: "num muted" }, String(x.requests)),
|
|
144
|
-
)))
|
|
145
|
-
: empty("No requests recorded."),
|
|
146
|
-
o.egress.blocked.length ? h("div", {},
|
|
147
|
-
h("p", { class: "label" }, "Blocked (offline)"),
|
|
148
|
-
h("ul", { class: "list" }, o.egress.blocked.map((x) => h("li", {}, h("span", { class: "muted" }, "--"), h("span", { class: "grow trunc" }, x.host), h("span", { class: "num muted" }, String(x.requests))))),
|
|
149
|
-
) : null,
|
|
150
|
-
fig("fig 5 :: latest scan per project", "3P = third party"),
|
|
151
|
-
));
|
|
152
|
-
|
|
153
|
-
put(grid, panel("Last scans", { class: "span-4", meta: `${o.lastScans.length} newest` },
|
|
154
|
-
h("ul", { class: "list" }, o.lastScans.map((sc) => h("li", {},
|
|
155
|
-
h("span", { class: "muted" }, ago(sc.ts).padStart(7)),
|
|
156
|
-
h("span", { class: "grow trunc t1", title: sc.target }, shortProject(sc.target)),
|
|
157
|
-
worst(sc.counts),
|
|
158
|
-
h("span", { class: "num muted hide-narrow" }, duration(sc.durationMs)),
|
|
159
|
-
))),
|
|
160
|
-
));
|
|
161
|
-
|
|
162
|
-
if (o.loops.length) {
|
|
163
|
-
put(grid, panel("Agent loop runs", { class: "span-12", meta: plural(o.loops.length, "run") },
|
|
164
|
-
h("ul", { class: "list" }, o.loops.map((l) => h("li", {},
|
|
165
|
-
h("span", { class: "muted" }, ago(l.startedAt)),
|
|
166
|
-
h("span", { class: "grow t1" }, `${shortProject(l.target.repo)} :: ${l.findings.initial ?? "?"} → ${l.findings.final ?? "?"} findings`),
|
|
167
|
-
h("span", { class: "ok" }, `${l.totals.verified} verified`),
|
|
168
|
-
l.totals.failed ? h("span", { class: "bad" }, `${l.totals.failed} reverted`) : null,
|
|
169
|
-
h("span", { class: "muted hide-narrow" }, l.branch || ""),
|
|
170
|
-
))),
|
|
171
|
-
));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
put(root, whatNext(NEXT.overview, go));
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function worst(c) {
|
|
178
|
-
const sev = SEVERITIES.find((k) => c[k] > 0);
|
|
179
|
-
return sev ? h("span", { class: `sev sev-${sev}` }, `${c[sev]} ${sev}`) : h("span", { class: "ok" }, "clean");
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function cell(label, value, sub, cls, unit) {
|
|
183
|
-
return h("div", { class: "cell" }, h("span", { class: "label" }, label), h("div", { class: ["big", cls] }, value, unit ? h("span", { class: "unit" }, unit) : null), h("div", { class: "sub" }, sub));
|
|
184
|
-
}
|