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.
Files changed (104) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +128 -663
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/dashboard.md +244 -0
  20. package/docs/evidence.md +8 -4
  21. package/docs/examples/github-actions-deps.yml +4 -4
  22. package/docs/examples/github-actions-scan.yml +4 -4
  23. package/docs/index.md +9 -0
  24. package/docs/internal-audit.md +83 -0
  25. package/docs/marketing.md +151 -0
  26. package/docs/plan.md +4 -3
  27. package/docs/privacy.md +38 -0
  28. package/docs/references.bib +10 -0
  29. package/docs/references.md +1 -0
  30. package/docs/research/socket-positioning-notes.md +183 -0
  31. package/docs/use-cases/README.md +14 -0
  32. package/docs/use-cases/agencies.md +71 -0
  33. package/docs/use-cases/ai-coding-teams.md +81 -0
  34. package/docs/use-cases/eu-sme-compliance.md +68 -0
  35. package/docs/use-cases/open-source-maintainers.md +75 -0
  36. package/docs/use-cases/solo-founder.md +65 -0
  37. package/lib/blackbox/email-dns.js +5 -0
  38. package/lib/compliance/questionnaire.js +4 -2
  39. package/lib/deps/capabilities.js +49 -12
  40. package/lib/deps/index.js +290 -30
  41. package/lib/deps/lockfile.js +25 -2
  42. package/lib/deps/popular-pypi.json +1 -0
  43. package/lib/deps/python/capabilities.js +447 -0
  44. package/lib/deps/python/index.js +234 -0
  45. package/lib/deps/python/lockfiles.js +359 -0
  46. package/lib/deps/python/source.js +396 -0
  47. package/lib/deps/python/toml.js +219 -0
  48. package/lib/deps/report.js +37 -6
  49. package/lib/deps/source.js +149 -24
  50. package/lib/deps/typosquat.js +24 -13
  51. package/lib/fix/apply.js +83 -19
  52. package/lib/hook/index.js +41 -8
  53. package/lib/install/index.js +59 -18
  54. package/lib/install/policy.js +17 -5
  55. package/lib/memory/repo-suppress.js +11 -3
  56. package/lib/references.json +77 -0
  57. package/lib/remediation/index.js +9 -0
  58. package/lib/retirejs.js +69 -17
  59. package/lib/rules/regex-guard.js +368 -0
  60. package/lib/templates.js +13 -6
  61. package/lib/tls.js +7 -0
  62. package/lib/whitebox/frameworks.js +8 -5
  63. package/lib/whitebox/secrets.js +20 -7
  64. package/package.json +7 -3
  65. package/skills/penthera/SKILL.md +23 -5
  66. package/src/cli/deps.js +60 -5
  67. package/src/cli/evidence.js +14 -4
  68. package/src/cli/install.js +14 -5
  69. package/src/cli/report-outputs.js +25 -2
  70. package/src/cli/ui.js +89 -0
  71. package/src/cli.js +19 -7
  72. package/src/report/audience.js +10 -10
  73. package/src/report/evidence.js +33 -14
  74. package/src/report/footer.js +11 -0
  75. package/src/report/markdown.js +6 -3
  76. package/src/report/md-escape.js +16 -0
  77. package/src/reporter.js +1 -0
  78. package/src/scan-url.js +5 -1
  79. package/src/ui/api/data.js +309 -0
  80. package/src/ui/api/demo.js +464 -0
  81. package/src/ui/api/routes.js +134 -0
  82. package/src/ui/api/runs.js +243 -0
  83. package/src/ui/api/summarize.js +451 -0
  84. package/src/ui/security.js +153 -0
  85. package/src/ui/server.js +186 -0
  86. package/src/ui/static/app.css +723 -0
  87. package/src/ui/static/favicon.svg +1 -0
  88. package/src/ui/static/index.html +57 -0
  89. package/src/ui/static/js/api.js +29 -0
  90. package/src/ui/static/js/app.js +138 -0
  91. package/src/ui/static/js/charts.js +207 -0
  92. package/src/ui/static/js/copy.js +79 -0
  93. package/src/ui/static/js/dom.js +120 -0
  94. package/src/ui/static/js/format.js +42 -0
  95. package/src/ui/static/js/theme.js +10 -0
  96. package/src/ui/static/js/typer.js +99 -0
  97. package/src/ui/static/js/views/ai.js +68 -0
  98. package/src/ui/static/js/views/deps.js +96 -0
  99. package/src/ui/static/js/views/evidence.js +115 -0
  100. package/src/ui/static/js/views/findings.js +165 -0
  101. package/src/ui/static/js/views/history.js +92 -0
  102. package/src/ui/static/js/views/overview.js +184 -0
  103. package/src/ui/static/js/views/run.js +349 -0
  104. package/src/ui/static/js/views/settings.js +67 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Pure summaries for the dashboard: report JSON (and memory views) in, small
3
+ * plain objects out. Shared by the real data source and the --demo one, so
4
+ * the demo exercises the same code paths as a real project.
5
+ *
6
+ * Report content is untrusted data (finding titles come from scanned sites
7
+ * and repos). Nothing here interprets it; the frontend only ever renders it
8
+ * as text.
9
+ */
10
+ import { findingId } from "../../../lib/memory/views.js";
11
+ import { gradeFindings } from "../../../lib/agents/index.js";
12
+ import { buildReportModel, CONFIDENCE_LABELS } from "../../report/model.js";
13
+
14
+ export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
15
+ const SEV_RANK = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
16
+ const DAY = 86_400_000;
17
+
18
+ export const HIDDEN_CONTENT_RULES = new Set([
19
+ "bidi-controls", "curl-pipe-interpreter", "curl-pipe-shell", "html-comment-instructions",
20
+ "js-eval-remote", "powershell-iex", "py-exec-remote", "scanner-directed",
21
+ "shell-command-substitution", "shell-eval", "shell-process-substitution", "unicode-tags",
22
+ "variation-selectors", "whitespace-horizontal", "whitespace-vertical", "zero-width",
23
+ "embedded-binary", "unknown-binary", "symlink-escape", "file-too-large",
24
+ ]);
25
+
26
+ const str = (x, max = 2000) => (typeof x === "string" ? x.slice(0, max) : x == null ? null : String(x).slice(0, max));
27
+ const time = (x) => { const t = Date.parse(x); return Number.isNaN(t) ? null : t; };
28
+
29
+ export function counts(findings) {
30
+ const c = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
31
+ for (const f of findings || []) if (c[f?.severity] != null) c[f.severity]++;
32
+ return c;
33
+ }
34
+
35
+ export const actionable = (c) => c.critical + c.high + c.medium + c.low;
36
+
37
+ export function median(xs) {
38
+ if (!xs.length) return null;
39
+ const s = [...xs].sort((a, b) => a - b);
40
+ const m = Math.floor(s.length / 2);
41
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
42
+ }
43
+
44
+ /** What kind of Penthera output a parsed JSON file is, or null. */
45
+ export function classify(obj) {
46
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
47
+ if (obj.loopRun && typeof obj.loopRun === "object") return "loop";
48
+ if (obj.mode === "deps" && obj.deps) return "deps";
49
+ if (obj.mode === "agents" && obj.agents) return "agents";
50
+ if (Array.isArray(obj.changes) && Array.isArray(obj.specs) && obj.npm) return "install";
51
+ if (Array.isArray(obj.findings) && obj.target) return "scan";
52
+ return null;
53
+ }
54
+
55
+ export function projectKey(target) {
56
+ const t = str(target, 500) || "unknown";
57
+ try {
58
+ const u = new URL(t);
59
+ if (u.protocol === "http:" || u.protocol === "https:") return u.origin;
60
+ } catch { /* a path */ }
61
+ return t;
62
+ }
63
+
64
+ /** One finding as a table row. */
65
+ export function findingRow(f, report) {
66
+ const loc = f.file ? (Number.isInteger(f.line) ? `${f.file}:${f.line}` : f.file) : f.url;
67
+ return {
68
+ id: `${findingId(f)}`,
69
+ project: projectKey(report.target),
70
+ severity: SEV_RANK[f.severity] != null ? f.severity : "info",
71
+ title: str(f.title, 300) || "(untitled)",
72
+ category: str(f.category, 80),
73
+ source: str(f.source, 80),
74
+ confidence: str(f.confidence, 40),
75
+ location: str(loc, 500),
76
+ seen: report.timestamp || null,
77
+ };
78
+ }
79
+
80
+ /** Latest scan report per project, plus the full series per project (oldest first). */
81
+ export function groupScans(scans) {
82
+ const byProject = new Map();
83
+ for (const r of scans) {
84
+ const k = projectKey(r.data.target);
85
+ if (!byProject.has(k)) byProject.set(k, []);
86
+ byProject.get(k).push(r);
87
+ }
88
+ for (const list of byProject.values()) list.sort((a, b) => (time(a.data.timestamp) ?? a.mtime) - (time(b.data.timestamp) ?? b.mtime));
89
+ return byProject;
90
+ }
91
+
92
+ /** Findings that disappeared between consecutive reports, with how long they lived. */
93
+ export function fixedBetween(series) {
94
+ const firstSeen = new Map();
95
+ const fixed = new Map();
96
+ for (const r of series) {
97
+ const ts = time(r.data.timestamp) ?? r.mtime;
98
+ const ids = new Set((r.data.findings || []).filter((f) => f.severity !== "info").map(findingId));
99
+ for (const id of ids) {
100
+ if (!firstSeen.has(id)) firstSeen.set(id, ts);
101
+ fixed.delete(id); // came back: open again
102
+ }
103
+ for (const [id, seen] of firstSeen) if (!ids.has(id) && !fixed.has(id)) fixed.set(id, ts - seen);
104
+ }
105
+ return [...fixed.values()];
106
+ }
107
+
108
+ export function postureGrade(c) {
109
+ const list = [];
110
+ for (const s of SEVERITIES) for (let i = 0; i < c[s]; i++) list.push({ severity: s });
111
+ return gradeFindings(list);
112
+ }
113
+
114
+ /**
115
+ * The overview: posture per project, severity mix, trend, open vs fixed,
116
+ * time to fix, egress hosts, last scans.
117
+ * @param {{ scans: object[], memory: object[] }} input scans: [{ file, mtime, data }]
118
+ */
119
+ export function buildOverview({ scans = [], memory = [], runs = [] }) {
120
+ const groups = groupScans(scans);
121
+ const projects = [];
122
+ const mix = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
123
+ const ttf = [];
124
+ let open = 0;
125
+ let fixed = 0;
126
+
127
+ for (const [key, series] of groups) {
128
+ const latest = series[series.length - 1];
129
+ const c = counts(latest.data.findings);
130
+ for (const s of SEVERITIES) mix[s] += c[s];
131
+ const gone = fixedBetween(series);
132
+ ttf.push(...gone);
133
+ open += actionable(c);
134
+ fixed += gone.length;
135
+ const mem = memory.find((m) => matchesProject(m.label, key));
136
+ const trend = series.length > 1 || !mem?.trend?.length
137
+ ? series.slice(-12).map((r) => ({ ts: r.data.timestamp, ...counts(r.data.findings) }))
138
+ : mem.trend;
139
+ projects.push({
140
+ key,
141
+ mode: str(latest.data.mode, 20) || (latest.data.modes || []).join("+") || null,
142
+ lastScan: latest.data.timestamp || null,
143
+ counts: c,
144
+ open: actionable(c),
145
+ fixed: gone.length,
146
+ ...postureGrade(c),
147
+ trend: trend.map((t) => ({ ts: t.ts, total: actionable(t), critical: t.critical, high: t.high })),
148
+ memory: mem ? { events: mem.events, chain: mem.chain, medianTimeToFixDays: mem.medianTimeToFixDays } : null,
149
+ });
150
+ }
151
+ projects.sort((a, b) => b.counts.critical - a.counts.critical || b.counts.high - a.counts.high || a.key.localeCompare(b.key));
152
+
153
+ for (const m of memory) {
154
+ if (m.medianTimeToFixMs != null) ttf.push(m.medianTimeToFixMs);
155
+ fixed = Math.max(fixed, m.verifiedFixes || 0);
156
+ }
157
+
158
+ const egress = new Map();
159
+ const blocked = new Map();
160
+ for (const [, series] of groups) {
161
+ const e = series[series.length - 1].data.egress;
162
+ for (const h of e?.hosts || []) bumpHost(egress, h, e.thirdPartyHosts);
163
+ for (const h of e?.blocked || []) blocked.set(h.host, (blocked.get(h.host) || 0) + (h.requests || 0));
164
+ }
165
+
166
+ const last = [...scans]
167
+ .sort((a, b) => (time(b.data.timestamp) ?? b.mtime) - (time(a.data.timestamp) ?? a.mtime))
168
+ .slice(0, 8)
169
+ .map((r) => ({
170
+ target: projectKey(r.data.target), mode: str(r.data.mode, 20), ts: r.data.timestamp || null,
171
+ durationMs: Number.isFinite(r.data.duration) ? r.data.duration : null, counts: counts(r.data.findings), file: r.name,
172
+ }));
173
+
174
+ // "This week" columns: fix first, new since the previous scan, fixed since the previous scan.
175
+ const fixFirst = [];
176
+ const newest = [];
177
+ const fixedLately = [];
178
+ for (const [key] of groups) {
179
+ const hst = buildScanHistory(scans, key);
180
+ if (!hst) continue;
181
+ const latest = groups.get(key)[groups.get(key).length - 1];
182
+ for (const f of latest.data.findings || []) if (f.severity === "critical" || f.severity === "high") fixFirst.push(findingRow(f, latest.data));
183
+ if (hst.scans.length > 1) { newest.push(...hst.new); fixedLately.push(...hst.fixed); }
184
+ }
185
+ const bySev = (xs) => xs.sort((x, y) => SEV_RANK[x.severity] - SEV_RANK[y.severity] || x.title.localeCompare(y.title));
186
+ const columns = { fixFirst: bySev(fixFirst), newest: bySev(newest), fixed: bySev(fixedLately) };
187
+
188
+ const ttfDays = ttf.map((ms) => ms / DAY);
189
+ return {
190
+ columns,
191
+ projects,
192
+ mix,
193
+ open,
194
+ fixed,
195
+ timeToFix: {
196
+ medianDays: ttfDays.length ? Math.round(median(ttfDays) * 10) / 10 : null,
197
+ buckets: bucketDays(ttfDays),
198
+ samples: ttfDays.length,
199
+ },
200
+ egress: {
201
+ hosts: [...egress.values()].sort((a, b) => b.requests - a.requests).slice(0, 12),
202
+ blocked: [...blocked].map(([host, requests]) => ({ host, requests })).sort((a, b) => b.requests - a.requests).slice(0, 8),
203
+ },
204
+ lastScans: last,
205
+ loops: runs.slice(0, 5).map(loopSummary),
206
+ };
207
+ }
208
+
209
+ function bumpHost(map, h, thirdParty = []) {
210
+ const host = str(h.host, 255);
211
+ if (!host) return;
212
+ const cur = map.get(host) || { host, requests: 0, thirdParty: (thirdParty || []).some((t) => t.host === host) };
213
+ cur.requests += Number.isFinite(h.requests) ? h.requests : 0;
214
+ map.set(host, cur);
215
+ }
216
+
217
+ function bucketDays(days) {
218
+ const b = [
219
+ { label: "< 1d", n: 0 }, { label: "1-7d", n: 0 }, { label: "7-30d", n: 0 }, { label: "> 30d", n: 0 },
220
+ ];
221
+ for (const d of days) b[d < 1 ? 0 : d < 7 ? 1 : d < 30 ? 2 : 3].n++;
222
+ return b;
223
+ }
224
+
225
+ function matchesProject(label, key) {
226
+ if (typeof label !== "string") return false;
227
+ return label === key || label.startsWith(`${key} (`);
228
+ }
229
+
230
+ export function loopSummary(run) {
231
+ const l = run.loopRun || run;
232
+ return {
233
+ id: str(l.id, 64),
234
+ startedAt: l.startedAt || null,
235
+ durationMs: Number.isFinite(l.durationMs) ? l.durationMs : null,
236
+ target: { repo: str(l.target?.repo, 500), url: str(l.target?.url, 500) },
237
+ branch: str(l.branch?.fix, 200),
238
+ stopReason: str(l.stopReason, 60),
239
+ findings: { initial: l.findings?.initial ?? null, final: l.findings?.final ?? null },
240
+ totals: {
241
+ iterations: l.totals?.iterations ?? 0, verified: l.totals?.verified ?? 0, failed: l.totals?.failed ?? 0,
242
+ declined: l.totals?.declined ?? 0, skipped: l.totals?.skipped ?? 0,
243
+ },
244
+ iterations: (l.iterations || []).slice(0, 50).map((it) => ({
245
+ n: it.n,
246
+ durationMs: it.durationMs ?? null,
247
+ findings: it.findings || null,
248
+ fixes: (it.fixes || []).slice(0, 50).map((f) => ({
249
+ title: str(f.title, 200), recipe: str(f.recipe, 80), decision: str(f.decision, 20), status: str(f.status, 20),
250
+ commit: str(f.commit, 64), files: (f.files || []).slice(0, 20).map((x) => str(x, 300)),
251
+ })),
252
+ })),
253
+ };
254
+ }
255
+
256
+ /** Rows for the findings table: every finding of the latest report per project. */
257
+ export function buildFindings(scans) {
258
+ const rows = [];
259
+ for (const [, series] of groupScans(scans)) {
260
+ const latest = series[series.length - 1];
261
+ for (const f of latest.data.findings || []) rows.push(findingRow(f, latest.data));
262
+ for (const f of latest.data.suppressed || []) rows.push({ ...findingRow(f, latest.data), suppressed: true });
263
+ }
264
+ return rows.sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.project.localeCompare(b.project) || a.title.localeCompare(b.title));
265
+ }
266
+
267
+ /** The finding and the report it came from, by row id + project. */
268
+ export function locateFinding(scans, id, project) {
269
+ for (const [key, series] of groupScans(scans)) {
270
+ if (project && key !== project) continue;
271
+ const latest = series[series.length - 1];
272
+ const all = [...(latest.data.findings || []), ...(latest.data.suppressed || [])];
273
+ const f = all.find((x) => findingId(x) === id);
274
+ if (f) return { finding: f, report: latest };
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /** The evidence part of a finding, every field as text. */
280
+ export function findingDetail(f) {
281
+ const pick = (k) => (f[k] == null ? null : typeof f[k] === "string" ? f[k].slice(0, 8000) : JSON.stringify(f[k], null, 2).slice(0, 8000));
282
+ return {
283
+ title: str(f.title, 300),
284
+ severity: f.severity,
285
+ category: str(f.category, 80),
286
+ source: str(f.source, 80),
287
+ confidence: str(f.confidence, 40),
288
+ description: pick("description"),
289
+ evidence: pick("evidence") || pick("detail") || pick("proof"),
290
+ request: pick("request"),
291
+ response: pick("response"),
292
+ remediation: pick("remediation") || pick("fix") || pick("recommendation"),
293
+ location: f.file ? (Number.isInteger(f.line) ? `${f.file}:${f.line}` : f.file) : str(f.url, 500),
294
+ owasp: Array.isArray(f.owasp) ? f.owasp.slice(0, 10).map((x) => str(x, 40)) : [],
295
+ suppression: f.suppression ? { reason: str(f.suppression.reason, 300), source: str(f.suppression.source, 40) } : null,
296
+ };
297
+ }
298
+
299
+ export function buildDeps({ deps = [], installs = [] }) {
300
+ const latest = deps[deps.length - 1]?.data || null;
301
+ const d = latest?.deps;
302
+ return {
303
+ latest: latest ? {
304
+ target: str(latest.target, 500),
305
+ ts: latest.timestamp || null,
306
+ lockfile: str(d.lockfile, 300),
307
+ ecosystem: str(d.ecosystem, 20) || "npm",
308
+ network: str(d.network, 80),
309
+ total: d.total ?? 0,
310
+ scanned: d.scanned ?? 0,
311
+ direct: (d.direct || []).length,
312
+ capabilityCounts: d.capabilityCounts || {},
313
+ typosquats: (d.typosquats || []).slice(0, 20).map((t) => ({ name: str(t.name, 214), similarTo: str(t.similarTo || t.target || t.popular, 214) })),
314
+ counts: counts(latest.findings),
315
+ findings: (latest.findings || []).slice(0, 60).map((f) => ({ severity: f.severity, title: str(f.title, 300), location: str(f.url || f.file, 300) })),
316
+ diff: d.diff ? {
317
+ base: str(d.diff.base, 200),
318
+ added: (d.diff.added || []).slice(0, 100).map((a) => ({ name: str(a.name, 214), version: str(a.version, 64), capabilities: a.capabilities || [], installScripts: Object.keys(a.installScripts || {}) })),
319
+ upgraded: (d.diff.upgraded || []).slice(0, 100).map((u) => ({ name: str(u.name, 214), from: str(u.from, 64), to: str(u.to, 64), gained: u.gained || [], lost: u.lost || [] })),
320
+ removed: (d.diff.removed || []).slice(0, 100).map((r) => ({ name: str(r.name, 214), version: str(r.version, 64) })),
321
+ } : null,
322
+ } : null,
323
+ history: deps.slice(-12).map((r) => ({ ts: r.data.timestamp, total: r.data.deps?.total ?? 0, findings: actionable(counts(r.data.findings)) })),
324
+ installs: installs.slice(-20).reverse().map((r) => ({
325
+ file: r.name,
326
+ ts: r.data.timestamp || new Date(r.mtime).toISOString(),
327
+ specs: (r.data.specs || []).slice(0, 20).map((s) => str(s, 214)),
328
+ clean: Boolean(r.data.clean),
329
+ action: str(r.data.action, 40),
330
+ changes: (r.data.changes || []).slice(0, 50).map((c) => ({
331
+ name: str(c.name, 214), version: str(c.version, 64), flags: c.flags || [], deny: c.deny || [], approval: c.approval || [], allowedBy: c.allowedBy ? true : false,
332
+ })),
333
+ })),
334
+ };
335
+ }
336
+
337
+ export function buildAi(agents = []) {
338
+ const latest = agents[agents.length - 1]?.data || null;
339
+ if (!latest) return { latest: null, history: [] };
340
+ const a = latest.agents;
341
+ const findings = latest.findings || [];
342
+ const rule = (f) => String(f.templateId || "").replace(/^agents\//, "");
343
+ return {
344
+ latest: {
345
+ target: str(latest.target, 500),
346
+ ts: latest.timestamp || null,
347
+ grade: str(a.grade, 1),
348
+ score: a.score ?? null,
349
+ home: Boolean(a.home),
350
+ inventory: a.inventory || {},
351
+ lock: a.lock || null,
352
+ counts: counts(findings),
353
+ hidden: findings.filter((f) => HIDDEN_CONTENT_RULES.has(rule(f))).slice(0, 50).map((f) => ({ severity: f.severity, rule: rule(f), title: str(f.title, 300), location: str(f.url, 300), description: str(f.description, 1000) })),
354
+ findings: findings.filter((f) => !HIDDEN_CONTENT_RULES.has(rule(f))).slice(0, 80).map((f) => ({ severity: f.severity, rule: rule(f), title: str(f.title, 300), location: str(f.url, 300) })),
355
+ },
356
+ history: agents.slice(-12).map((r) => ({ ts: r.data.timestamp, grade: str(r.data.agents?.grade, 1), score: r.data.agents?.score ?? null })),
357
+ };
358
+ }
359
+
360
+ /** A memory project's stats from its verified events (views + history). */
361
+ export function memoryStats({ id, label, chain, views, history }) {
362
+ return {
363
+ id: id.slice(0, 16),
364
+ label: str(label, 500) || id.slice(0, 16),
365
+ chain: chain.ok ? "ok" : "broken",
366
+ brokenAt: chain.ok ? null : chain.brokenAt,
367
+ events: chain.count,
368
+ scans: views?.scans ?? 0,
369
+ medianTimeToFixDays: views?.medianTimeToFixDays ?? null,
370
+ medianTimeToFixMs: views?.medianTimeToFixDays == null ? null : views.medianTimeToFixDays * DAY,
371
+ verifiedFixes: (views?.recipeSuccess || []).reduce((n, r) => n + r.verified, 0),
372
+ failedFixes: (views?.recipeSuccess || []).reduce((n, r) => n + r.failed, 0),
373
+ suppressions: (views?.suppressions || []).length,
374
+ recipes: (views?.recipeSuccess || []).slice(0, 20).map((r) => ({ framework: r.framework, recipe: r.recipe, verified: r.verified, failed: r.failed, rate: r.rate })),
375
+ trend: history?.trend || [],
376
+ };
377
+ }
378
+
379
+ /**
380
+ * "Understand one finding": the report model's plain-language fields for a
381
+ * single finding (plain severity, what the confidence label means, OWASP
382
+ * mapping, the recipe that would fix it, the action-plan fix text).
383
+ */
384
+ export function explainFinding(f, report) {
385
+ const m = buildReportModel({ target: report?.target, timestamp: report?.timestamp, findings: [f] });
386
+ const x = m.findings[0];
387
+ const conf = CONFIDENCE_LABELS.find((c) => c.label === x.confidence);
388
+ return {
389
+ plainSeverity: x.plainSeverity,
390
+ confidence: x.confidence,
391
+ confidenceMeaning: conf ? conf.meaning : null,
392
+ owasp: x.owasp,
393
+ recipe: x.recipe,
394
+ fixText: x.fixText,
395
+ };
396
+ }
397
+
398
+ const scanMeta = (r, i) => ({
399
+ i, ts: r.data.timestamp || new Date(r.mtime).toISOString(), file: r.name,
400
+ counts: counts(r.data.findings), open: actionable(counts(r.data.findings)),
401
+ });
402
+
403
+ /**
404
+ * "Compare over time": every scan of one project, and what changed between
405
+ * two of them (default: the previous scan and the latest).
406
+ */
407
+ export function buildScanHistory(scans, project, a = null, b = null) {
408
+ const series = groupScans(scans).get(project);
409
+ if (!series) return null;
410
+ const list = series.map(scanMeta);
411
+ const bi = Number.isInteger(b) && b >= 0 && b < series.length ? b : series.length - 1;
412
+ const ai = Number.isInteger(a) && a >= 0 && a < series.length ? a : Math.max(0, bi - 1);
413
+ const rows = (r) => new Map((r.data.findings || []).filter((f) => f.severity !== "info").map((f) => [findingId(f), findingRow(f, r.data)]));
414
+ const A = rows(series[ai]);
415
+ const B = rows(series[bi]);
416
+ const sort = (xs) => xs.sort((x, y) => SEV_RANK[x.severity] - SEV_RANK[y.severity] || x.title.localeCompare(y.title));
417
+ // Finding lifetimes across every scan of the project: first seen, last seen, open or fixed.
418
+ const life = new Map();
419
+ series.forEach((r, i) => {
420
+ for (const f of (r.data.findings || []).filter((x) => x.severity !== "info")) {
421
+ const id = findingId(f);
422
+ if (!life.has(id)) life.set(id, { ...findingRow(f, r.data), from: i, to: i });
423
+ life.get(id).to = i;
424
+ }
425
+ });
426
+ const last = series.length - 1;
427
+ const lifetimes = sort([...life.values()].map((x) => ({ ...x, open: x.to === last })))
428
+ .sort((x, y) => Number(y.open) - Number(x.open) || SEV_RANK[x.severity] - SEV_RANK[y.severity] || x.from - y.from)
429
+ .slice(0, 40);
430
+ return {
431
+ project,
432
+ scans: list,
433
+ lifetimes,
434
+ a: list[ai],
435
+ b: list[bi],
436
+ new: sort([...B].filter(([id]) => !A.has(id)).map(([, r]) => r)),
437
+ fixed: sort([...A].filter(([id]) => !B.has(id)).map(([, r]) => r)),
438
+ still: sort([...B].filter(([id]) => A.has(id)).map(([, r]) => r)),
439
+ };
440
+ }
441
+
442
+ /** The latest scan report for a project, or null. */
443
+ export function latestScan(scans, project) {
444
+ const series = groupScans(scans).get(project);
445
+ return series ? series[series.length - 1] : null;
446
+ }
447
+
448
+ /** A filesystem-safe slug for a project key. */
449
+ export function slug(key) {
450
+ return String(key).replace(/^https?:\/\//, "").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase().slice(-48) || "project";
451
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The dashboard's request gate. `penthera ui` is a local web server, so it is
3
+ * an attack surface: any page the user has open in the same browser can send
4
+ * requests to 127.0.0.1. Every request passes, in order:
5
+ *
6
+ * 1. Host check Host must be 127.0.0.1:<port> or localhost:<port>. Blocks
7
+ * DNS rebinding (attacker.com resolving to 127.0.0.1).
8
+ * 2. Session The one-time URL token is swapped for an HttpOnly,
9
+ * SameSite=Strict cookie; every other request needs it.
10
+ * 3. Origin State-changing requests need an Origin equal to the
11
+ * server's own and a JSON body (no simple cross-site POSTs).
12
+ *
13
+ * No CORS headers are ever sent. Every response carries a strict CSP.
14
+ */
15
+ import { randomBytes, timingSafeEqual, createHash } from "node:crypto";
16
+
17
+ export const COOKIE_NAME = "penthera_ui";
18
+
19
+ export const CSP = [
20
+ "default-src 'self'",
21
+ "script-src 'self'",
22
+ "style-src 'self'",
23
+ "img-src 'self'",
24
+ "font-src 'self'",
25
+ "connect-src 'self'",
26
+ "object-src 'none'",
27
+ "base-uri 'none'",
28
+ "form-action 'self'",
29
+ "frame-ancestors 'none'",
30
+ "require-trusted-types-for 'script'",
31
+ ].join("; ");
32
+
33
+ export const SECURITY_HEADERS = Object.freeze({
34
+ "Content-Security-Policy": CSP,
35
+ "X-Content-Type-Options": "nosniff",
36
+ "X-Frame-Options": "DENY",
37
+ "Referrer-Policy": "no-referrer",
38
+ "Cross-Origin-Opener-Policy": "same-origin",
39
+ "Cross-Origin-Resource-Policy": "same-origin",
40
+ "Cross-Origin-Embedder-Policy": "require-corp",
41
+ "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
42
+ "X-Permitted-Cross-Domain-Policies": "none",
43
+ "Cache-Control": "no-store",
44
+ });
45
+
46
+ const STATE_CHANGING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
47
+
48
+ export function randomToken() {
49
+ return randomBytes(32).toString("base64url");
50
+ }
51
+
52
+ /** Constant-time string compare (false on any length mismatch or non-string). */
53
+ export function safeEqual(a, b) {
54
+ if (typeof a !== "string" || typeof b !== "string") return false;
55
+ const x = Buffer.from(a);
56
+ const y = Buffer.from(b);
57
+ return x.length === y.length && timingSafeEqual(x, y);
58
+ }
59
+
60
+ export function allowedHosts(port) {
61
+ return new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
62
+ }
63
+
64
+ export function allowedOrigins(port) {
65
+ return new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
66
+ }
67
+
68
+ /** Host header check. Exact match only: no suffixes, no default ports. */
69
+ export function hostAllowed(hostHeader, port) {
70
+ return typeof hostHeader === "string" && allowedHosts(port).has(hostHeader.toLowerCase());
71
+ }
72
+
73
+ export function readCookie(header, name) {
74
+ if (typeof header !== "string") return null;
75
+ for (const part of header.split(";")) {
76
+ const i = part.indexOf("=");
77
+ if (i < 0) continue;
78
+ if (part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Origin check for state-changing requests. Browsers always send Origin on a
85
+ * cross-site or same-origin POST from fetch, so a missing Origin is refused.
86
+ * Sec-Fetch-Site, when present, must say same-origin.
87
+ */
88
+ export function originAllowed(req, port) {
89
+ if (!STATE_CHANGING.has(req.method)) return true;
90
+ const origin = req.headers.origin;
91
+ if (typeof origin !== "string" || !allowedOrigins(port).has(origin.toLowerCase())) return false;
92
+ const site = req.headers["sec-fetch-site"];
93
+ if (site != null && site !== "same-origin") return false;
94
+ return true;
95
+ }
96
+
97
+ export function isStateChanging(method) {
98
+ return STATE_CHANGING.has(method);
99
+ }
100
+
101
+ /**
102
+ * Fixed-window limiter for requests that fail the session check. A signed-in
103
+ * browser never hits it (it always has the cookie), so anything that does is
104
+ * another page or process guessing. Unauthenticated state-changing requests
105
+ * have no legitimate source at all and get a much smaller budget.
106
+ */
107
+ export function createAuthLimiter({ windowMs = 60_000, maxReads = 120, maxWrites = 5, now = () => Date.now() } = {}) {
108
+ let start = now();
109
+ let reads = 0;
110
+ let writes = 0;
111
+ return {
112
+ /** Count one unauthenticated request; returns seconds to wait, or 0 when allowed. */
113
+ hit(method) {
114
+ const t = now();
115
+ if (t - start >= windowMs) { start = t; reads = 0; writes = 0; }
116
+ const over = isStateChanging(method) ? ++writes > maxWrites : ++reads > maxReads;
117
+ return over ? Math.max(1, Math.ceil((start + windowMs - t) / 1000)) : 0;
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Session state: one bootstrap token (single use, printed in the URL) and the
124
+ * session cookie. The cookie value is minted when the link is redeemed, handed
125
+ * to the browser once in Set-Cookie, and never kept: the server holds only its
126
+ * SHA-256, so nothing readable from this process (or a heap dump) is a
127
+ * working session.
128
+ */
129
+ export function createSession({ bootstrap = randomToken(), mint = () => randomBytes(32).toString("base64url") } = {}) {
130
+ let used = false;
131
+ let digest = null;
132
+ const hash = (v) => createHash("sha256").update(String(v)).digest("base64url");
133
+ return {
134
+ bootstrap,
135
+ /** Swap the URL token for a fresh session id. Returns null when wrong or already used. */
136
+ redeem(token) {
137
+ if (used || !safeEqual(token, bootstrap)) return null;
138
+ used = true;
139
+ const sid = mint();
140
+ digest = hash(sid);
141
+ return sid;
142
+ },
143
+ get redeemed() { return used; },
144
+ check(cookieHeader) {
145
+ const v = readCookie(cookieHeader, COOKIE_NAME);
146
+ return digest != null && typeof v === "string" && safeEqual(hash(v), digest);
147
+ },
148
+ /** The Set-Cookie header for a session id that redeem() just returned. */
149
+ cookie(sid) {
150
+ return `${COOKIE_NAME}=${sid}; HttpOnly; SameSite=Strict; Path=/`;
151
+ },
152
+ };
153
+ }