penthera 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
@@ -8,7 +8,7 @@
8
8
  * findings and bundles are addressed by ids this module hands out.
9
9
  */
10
10
  import { readdirSync, readFileSync, lstatSync, realpathSync, existsSync, statSync, mkdirSync, openSync, writeSync, closeSync, constants as FS } from "node:fs";
11
- import { join, resolve, relative, sep, isAbsolute, dirname } from "node:path";
11
+ import { join, resolve, relative, sep, isAbsolute, dirname, basename } from "node:path";
12
12
  import { createHash } from "node:crypto";
13
13
  import { openMemory, buildViews, buildHistory } from "../../../lib/memory/index.js";
14
14
  import { referencesFor } from "../../../lib/references.js";
@@ -79,9 +79,27 @@ export function assertNoSymlinks(root, target) {
79
79
  }
80
80
 
81
81
  /**
82
- * Write a new file in `dir` (which must be `root` or below it): exclusive
83
- * create, never through a symlink, never over an existing file. A name is a
84
- * single path segment.
82
+ * Throws unless `dir` still resolves to itself: `dir` is a real path checked
83
+ * at startup, and a folder swapped for a symlink since then would send the
84
+ * write somewhere else.
85
+ */
86
+ export function assertResolvesTo(dir, { missingOk = false } = {}) {
87
+ let real;
88
+ try {
89
+ real = realpathSync(dir);
90
+ } catch (e) {
91
+ // Not created yet: its parent must resolve to itself, so mkdir lands here.
92
+ if (missingOk && e.code === "ENOENT") return assertResolvesTo(dirname(dir));
93
+ throw new Error(`Refusing to write into ${dir}: ${e.code || e.message}`);
94
+ }
95
+ if (real !== dir) throw new Error(`Refusing to write into ${dir}: it now resolves to ${real}`);
96
+ }
97
+
98
+ /**
99
+ * Write a new file in `dir` (which must be `root` or below it, as a real
100
+ * path): exclusive create, never through a symlink, never over an existing
101
+ * file, and only while `dir` still resolves to itself. A name is a single
102
+ * path segment.
85
103
  */
86
104
  export function writeNewFile(root, dir, name, content) {
87
105
  if (typeof name !== "string" || !name || name.includes("/") || name.includes("\\") || name.includes("\0") || name === "." || name === "..") {
@@ -89,8 +107,10 @@ export function writeNewFile(root, dir, name, content) {
89
107
  }
90
108
  const target = join(dir, name);
91
109
  assertNoSymlinks(root, target);
110
+ assertResolvesTo(root, { missingOk: true });
92
111
  mkdirSync(dir, { recursive: true });
93
112
  assertNoSymlinks(root, target); // again, after mkdir
113
+ assertResolvesTo(dir);
94
114
  const fd = openSync(target, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW || 0), 0o644);
95
115
  try {
96
116
  const buf = Buffer.from(content, "utf-8");
@@ -104,9 +124,16 @@ export function writeNewFile(root, dir, name, content) {
104
124
 
105
125
  const idFor = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
106
126
 
127
+ /** Real path of `p`; for a missing folder, its parent's real path plus its name. */
128
+ function realOrPlanned(p) {
129
+ const abs = resolve(p);
130
+ try { return realpathSync(abs); } catch { /* missing */ }
131
+ try { return join(realpathSync(dirname(abs)), basename(abs)); } catch { return abs; }
132
+ }
133
+
107
134
  export function createDataSource({ projects, reportsDir, home, memoryRoot, settings, version = "dev" }) {
108
135
  const projectRoots = realRoots(projects);
109
- const reportsReal = realRoots([reportsDir])[0];
136
+ const reportsReal = realOrPlanned(reportsDir);
110
137
  const roots = realRoots([...projects, reportsDir, home]);
111
138
  let cache = null;
112
139
 
@@ -257,7 +284,13 @@ export function createDataSource({ projects, reportsDir, home, memoryRoot, setti
257
284
  const dir = join(reportsReal, "evidence", `${slug(project)}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}`);
258
285
  if (dirname(dirname(dir)) !== reportsReal) return null;
259
286
  assertNoSymlinks(reportsReal, dir); // reports/evidence must not be a planted symlink
260
- writeEvidence(latest.data, { evidence: dir }, { version, mdOpts: { version }, argv: ["ui", "evidence", "create"] });
287
+ assertResolvesTo(reportsReal, { missingOk: true });
288
+ mkdirSync(dirname(dir), { recursive: true });
289
+ assertNoSymlinks(reportsReal, dir);
290
+ assertResolvesTo(dirname(dir));
291
+ // The bundle is built in a private temp folder under reports/evidence and
292
+ // renamed into place only while that folder still resolves to itself.
293
+ writeEvidence(latest.data, { evidence: dir, evidenceParent: dirname(dir) }, { version, mdOpts: { version }, argv: ["ui", "evidence", "create"] });
261
294
  cache = null;
262
295
  return { id: idFor(dir), path: relative(reportsReal, dir) };
263
296
  },
@@ -1,40 +1,56 @@
1
1
  /**
2
2
  * JSON API for the dashboard. Every route here runs after the gate in
3
- * ../security.js (Host, session cookie, Origin), so handlers only validate
3
+ * ../security.js (Host, Origin, bearer session), so handlers only validate
4
4
  * their own input.
5
5
  */
6
6
  import { RunError } from "./runs.js";
7
7
 
8
8
  const MAX_BODY = 64 * 1024;
9
+ const MAX_STREAMS = 16;
9
10
  const ID = /^[0-9a-f]{16}$/;
10
11
 
11
- export function sendJson(res, status, body) {
12
+ export function sendJson(res, status, body, headers = {}) {
12
13
  const buf = Buffer.from(JSON.stringify(body));
13
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": buf.length });
14
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": buf.length, ...headers });
14
15
  res.end(buf);
15
16
  }
16
17
 
18
+ /** A 413 that the client can read: the rest of the body is not read, and the connection closes after the answer. */
19
+ function tooLarge() {
20
+ const e = new RunError("Request body too large.", 413);
21
+ e.close = true;
22
+ return e;
23
+ }
24
+
17
25
  export function readJson(req) {
18
26
  return new Promise((resolve, reject) => {
19
27
  const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
20
28
  if (type !== "application/json") { reject(new RunError("Send JSON (Content-Type: application/json).", 415)); return; }
29
+ if (Number(req.headers["content-length"]) > MAX_BODY) { reject(tooLarge()); return; }
21
30
  let size = 0;
31
+ let over = false;
22
32
  const chunks = [];
23
33
  req.on("data", (c) => {
34
+ if (over) return;
24
35
  size += c.length;
25
- if (size > MAX_BODY) { reject(new RunError("Request body too large.", 413)); req.destroy(); return; }
36
+ if (size > MAX_BODY) { over = true; chunks.length = 0; reject(tooLarge()); return; }
26
37
  chunks.push(c);
27
38
  });
28
39
  req.on("end", () => {
40
+ if (over) return;
29
41
  if (!size) { resolve({}); return; }
30
42
  try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8"))); } catch { reject(new RunError("Body is not valid JSON.")); }
31
43
  });
32
- req.on("error", reject);
44
+ // A client that hangs up (or an idle socket the server times out) is not an internal error.
45
+ req.on("error", () => reject(new RunError("The request body did not arrive.", 400)));
33
46
  });
34
47
  }
35
48
 
36
- function stream(req, res, runs, id) {
49
+ export function stream(req, res, runs, id, onEnd = () => {}) {
37
50
  const last = Number(req.headers["last-event-id"]) || 0;
51
+ // The stream is long-lived by design: no idle timeout on its socket. The
52
+ // keep-alive comment below keeps proxies and the browser from giving up.
53
+ req.socket?.setTimeout?.(0);
38
54
  res.writeHead(200, {
39
55
  "Content-Type": "text/event-stream; charset=utf-8",
40
56
  "Cache-Control": "no-store",
@@ -43,18 +59,32 @@ function stream(req, res, runs, id) {
43
59
  });
44
60
  res.write(": penthera\n\n");
45
61
  const beat = setInterval(() => res.write(": keep-alive\n\n"), 15_000);
46
- const end = () => { clearInterval(beat); unsubscribe?.(); if (!res.writableEnded) res.end(); };
47
- const unsubscribe = runs.subscribe(id, last, (ev) => {
62
+ // subscribe() replays history synchronously and calls fn(null) at once for a
63
+ // run that already finished, so end() can run before subscribe() returns.
64
+ let unsubscribe = null;
65
+ let ended = false;
66
+ const end = () => {
67
+ if (ended) return;
68
+ ended = true;
69
+ clearInterval(beat);
70
+ unsubscribe?.();
71
+ onEnd();
72
+ if (!res.writableEnded) res.end();
73
+ };
74
+ unsubscribe = runs.subscribe(id, last, (ev) => {
75
+ if (ended) return;
48
76
  if (!ev) { res.write("event: end\ndata: {}\n\n"); end(); return; }
49
77
  res.write(`id: ${ev.seq}\nevent: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
50
78
  });
79
+ if (ended) unsubscribe?.();
51
80
  req.on("close", end);
52
81
  }
53
82
 
54
83
  /**
55
84
  * @returns {(req, res, path: string, query: URLSearchParams) => Promise<boolean>} true when handled
56
85
  */
57
- export function createApi({ source, runs, version, settings }) {
86
+ export function createApi({ source, runs, version, settings, maxStreams = MAX_STREAMS }) {
87
+ let streams = 0;
58
88
  const routes = [
59
89
  ["GET", /^\/api\/meta$/, () => ({ version, demo: source.demo, ...source.meta(), settings: settings.get() })],
60
90
  ["GET", /^\/api\/overview$/, () => source.overview()],
@@ -110,7 +140,10 @@ export function createApi({ source, runs, version, settings }) {
110
140
  const sse = path.match(/^\/api\/runs\/([0-9a-f]{16})\/stream$/);
111
141
  if (sse && req.method === "GET") {
112
142
  if (!runs.get(sse[1])) { sendJson(res, 404, { error: "No such run." }); return true; }
113
- stream(req, res, runs, sse[1]);
143
+ if (streams >= maxStreams) { sendJson(res, 429, { error: "Too many open run streams. Close a dashboard tab." }, { "Retry-After": "5" }); return true; }
144
+ streams++;
145
+ let counted = true;
146
+ stream(req, res, runs, sse[1], () => { if (counted) { counted = false; streams--; } });
114
147
  return true;
115
148
  }
116
149
  const matching = routes.filter(([, re]) => re.test(path));
@@ -127,7 +160,7 @@ export function createApi({ source, runs, version, settings }) {
127
160
  sendJson(res, 200, out);
128
161
  } catch (e) {
129
162
  const status = e instanceof RunError ? e.status : 500;
130
- sendJson(res, status, { error: status === 500 ? "Internal error. Check the terminal running penthera ui." : e.message });
163
+ sendJson(res, status, { error: status === 500 ? "Internal error. Check the terminal running penthera ui." : e.message }, e.close ? { Connection: "close" } : {});
131
164
  if (status === 500) process.stderr.write(` penthera ui: ${e.stack || e.message}\n`);
132
165
  }
133
166
  return true;
@@ -13,11 +13,13 @@
13
13
  * Output is an append-only event list per run, streamed over SSE. Events are
14
14
  * plain data; the frontend renders them as text.
15
15
  */
16
- import { randomBytes } from "node:crypto";
16
+ import { randomBytes, createHash } from "node:crypto";
17
17
  import { normalizeBaseUrl, urlToFilename } from "../../utils/url.js";
18
18
  import { confineDir } from "./data.js";
19
19
 
20
20
  const MAX_EVENTS = 5000;
21
+ const MAX_FINISHED = 20; // finished runs kept for replay; older ones are dropped
22
+ export const MAX_DIFF = 60000;
21
23
  const LOCAL_HOST = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
22
24
  const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
23
25
  const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
@@ -31,6 +33,8 @@ export class RunError extends Error {
31
33
 
32
34
  export const cleanLine = (s) => String(s ?? "").replace(ANSI, "").replace(CONTROL, "").slice(0, 4000);
33
35
 
36
+ export const diffSha256 = (s) => createHash("sha256").update(String(s), "utf-8").digest("hex");
37
+
34
38
  export function isLocalHost(hostname) {
35
39
  return LOCAL_HOST.test(hostname);
36
40
  }
@@ -91,7 +95,9 @@ export function createRunManager(deps) {
91
95
  let active = null;
92
96
 
93
97
  function emit(run, type, data = {}) {
94
- const ev = { seq: run.events.length + 1, type, ts: new Date().toISOString(), data };
98
+ // Ids keep counting after the buffer drops old events, so a reconnect
99
+ // with Last-Event-ID replays exactly what it missed.
100
+ const ev = { seq: ++run.nextSeq, type, ts: new Date().toISOString(), data };
95
101
  run.events.push(ev);
96
102
  if (run.events.length > MAX_EVENTS) run.events.splice(0, run.events.length - MAX_EVENTS);
97
103
  for (const fn of run.listeners) fn(ev);
@@ -126,17 +132,21 @@ export function createRunManager(deps) {
126
132
  ask: (q) => new Promise((resolveAnswer) => {
127
133
  const approvalId = randomBytes(8).toString("hex");
128
134
  const p = q.plan || {};
135
+ // The page shows at most MAX_DIFF characters. It says so (truncated,
136
+ // fullLength), and an approval names the SHA-256 of what it showed.
137
+ const fullDiff = String(p.diff || "");
138
+ const diff = fullDiff.slice(0, MAX_DIFF);
129
139
  const request = {
130
140
  approvalId, iteration: q.iteration, index: q.index, total: q.total, memory: q.memory || null,
131
141
  plan: {
132
142
  title: cleanLine(p.title), recipe: cleanLine(p.recipe), framework: p.framework ? cleanLine(p.framework) : null,
133
143
  confidence: cleanLine(p.confidence), source: cleanLine(p.source),
134
- files: (p.files || []).map(cleanLine), diff: String(p.diff || "").slice(0, 60000),
144
+ files: (p.files || []).map(cleanLine), diff, truncated: diff.length < fullDiff.length, fullLength: fullDiff.length,
135
145
  findings: (p.findings || []).map((f) => ({ title: cleanLine(f.title), severity: cleanLine(f.severity) })),
136
146
  ownerActions: (p.ownerActions || []).map(cleanLine), redacted: Boolean(p.redacted),
137
147
  },
138
148
  };
139
- run.pending = { request, resolve: resolveAnswer };
149
+ run.pending = { request, resolve: resolveAnswer, diffSha256: diffSha256(diff), truncated: diff.length < fullDiff.length };
140
150
  emit(run, "approval", request);
141
151
  }),
142
152
  });
@@ -185,9 +195,11 @@ export function createRunManager(deps) {
185
195
  start(body) {
186
196
  if (active && active.status === "running") throw new RunError("A run is already in progress. Stop it first.", 409);
187
197
  const req = validateRunRequest(body, deps.projectRoots, deps.confineDir);
198
+ const finished = [...runs.values()].filter((r) => r.status !== "running");
199
+ for (const old of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED + 1))) runs.delete(old.id);
188
200
  const run = {
189
201
  id: randomBytes(8).toString("hex"), kind: req.kind, repo: req.repo, url: req.url, status: "running",
190
- startedAt: new Date().toISOString(), events: [], listeners: new Set(), pending: null, summary: null,
202
+ startedAt: new Date().toISOString(), events: [], nextSeq: 0, listeners: new Set(), pending: null, summary: null,
191
203
  controller: new AbortController(),
192
204
  };
193
205
  runs.set(run.id, run);
@@ -205,13 +217,26 @@ export function createRunManager(deps) {
205
217
  return publicRun(run);
206
218
  },
207
219
 
208
- decide(id, { approvalId, answer } = {}) {
220
+ /**
221
+ * An approval must name the SHA-256 of the diff the page showed
222
+ * (`diffSha256`), and for a diff cut at MAX_DIFF characters also
223
+ * `acknowledgeTruncated: true`. Skip and stop need neither.
224
+ */
225
+ decide(id, { approvalId, answer, diffSha256: shown, acknowledgeTruncated } = {}) {
209
226
  const run = runs.get(id);
210
227
  if (!run) throw new RunError("No such run.", 404);
211
228
  if (!run.pending || run.pending.request.approvalId !== approvalId) throw new RunError("Nothing is waiting for that decision.", 409);
212
229
  const map = { approve: "y", y: "y", skip: "n", n: "n", later: "skip", stop: "quit", quit: "quit" };
213
- const a = map[answer];
230
+ const a = typeof answer === "string" && Object.hasOwn(map, answer) ? map[answer] : null;
214
231
  if (!a) throw new RunError("answer must be approve, skip or stop.");
232
+ if (a === "y") {
233
+ if (typeof shown !== "string" || shown.toLowerCase() !== run.pending.diffSha256) {
234
+ throw new RunError("This approval does not match the diff on offer. Reload the run and review the diff again.", 409);
235
+ }
236
+ if (run.pending.truncated && acknowledgeTruncated !== true) {
237
+ throw new RunError(`The diff is ${run.pending.request.plan.fullLength} characters and only the first ${MAX_DIFF} were shown. Acknowledge that to apply it, or skip it and review the change in the repo.`, 409);
238
+ }
239
+ }
215
240
  const { resolve: done } = run.pending;
216
241
  run.pending = null;
217
242
  emit(run, "decision", { approvalId, answer: a });
@@ -5,8 +5,13 @@
5
5
  *
6
6
  * 1. Host check Host must be 127.0.0.1:<port> or localhost:<port>. Blocks
7
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.
8
+ * 2. Session The one-time token travels in the URL fragment (never
9
+ * sent over the network), the page swaps it for a session
10
+ * id through POST /api/session and keeps that in
11
+ * sessionStorage. Every other /api request needs it as
12
+ * `Authorization: Bearer`. No cookie: cookies are not bound
13
+ * to a port, so every other server on 127.0.0.1 would get
14
+ * one. The id lives in this origin's sessionStorage only.
10
15
  * 3. Origin State-changing requests need an Origin equal to the
11
16
  * server's own and a JSON body (no simple cross-site POSTs).
12
17
  *
@@ -14,8 +19,6 @@
14
19
  */
15
20
  import { randomBytes, timingSafeEqual, createHash } from "node:crypto";
16
21
 
17
- export const COOKIE_NAME = "penthera_ui";
18
-
19
22
  export const CSP = [
20
23
  "default-src 'self'",
21
24
  "script-src 'self'",
@@ -70,14 +73,11 @@ export function hostAllowed(hostHeader, port) {
70
73
  return typeof hostHeader === "string" && allowedHosts(port).has(hostHeader.toLowerCase());
71
74
  }
72
75
 
73
- export function readCookie(header, name) {
76
+ /** The token of an `Authorization: Bearer <token>` header, or null. */
77
+ export function readBearer(header) {
74
78
  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;
79
+ const m = /^Bearer ([A-Za-z0-9_-]{16,128})$/.exec(header.trim());
80
+ return m ? m[1] : null;
81
81
  }
82
82
 
83
83
  /**
@@ -100,7 +100,7 @@ export function isStateChanging(method) {
100
100
 
101
101
  /**
102
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
103
+ * browser never hits it (it always sends its session), so anything that does is
104
104
  * another page or process guessing. Unauthenticated state-changing requests
105
105
  * have no legitimate source at all and get a much smaller budget.
106
106
  */
@@ -120,11 +120,11 @@ export function createAuthLimiter({ windowMs = 60_000, maxReads = 120, maxWrites
120
120
  }
121
121
 
122
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.
123
+ * Session state: one bootstrap token (single use, printed in the URL fragment)
124
+ * and the session id. The id is minted when the link is redeemed, handed to
125
+ * the page once in the POST /api/session response, and never kept: the server
126
+ * holds only its SHA-256, so nothing readable from this process (or a heap
127
+ * dump) is a working session.
128
128
  */
129
129
  export function createSession({ bootstrap = randomToken(), mint = () => randomBytes(32).toString("base64url") } = {}) {
130
130
  let used = false;
@@ -141,13 +141,10 @@ export function createSession({ bootstrap = randomToken(), mint = () => randomBy
141
141
  return sid;
142
142
  },
143
143
  get redeemed() { return used; },
144
- check(cookieHeader) {
145
- const v = readCookie(cookieHeader, COOKIE_NAME);
144
+ /** True for an `Authorization: Bearer <session id>` header that matches. */
145
+ check(authorizationHeader) {
146
+ const v = readBearer(authorizationHeader);
146
147
  return digest != null && typeof v === "string" && safeEqual(hash(v), digest);
147
148
  },
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
149
  };
153
150
  }
package/src/ui/server.js CHANGED
@@ -7,14 +7,15 @@
7
7
  * request goes through the gate in ./security.js first.
8
8
  */
9
9
  import { createServer } from "node:http";
10
- import { readdirSync, readFileSync } from "node:fs";
10
+ import { readdirSync, readFileSync, lstatSync } from "node:fs";
11
11
  import { join, relative, extname, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { homedir } from "node:os";
14
14
  import {
15
15
  SECURITY_HEADERS, createSession, createAuthLimiter, hostAllowed, originAllowed, isStateChanging,
16
16
  } from "./security.js";
17
- import { createApi, sendJson } from "./api/routes.js";
17
+ import { createApi, sendJson, readJson } from "./api/routes.js";
18
+ import { RunError } from "./api/runs.js";
18
19
  import { createRunManager } from "./api/runs.js";
19
20
  import { createDataSource } from "./api/data.js";
20
21
  import { memoryRoot as defaultMemoryRoot } from "../../lib/memory/index.js";
@@ -28,6 +29,7 @@ const TYPES = {
28
29
  ".svg": "image/svg+xml",
29
30
  ".png": "image/png",
30
31
  ".ico": "image/x-icon",
32
+ ".woff2": "font/woff2",
31
33
  ".txt": "text/plain; charset=utf-8",
32
34
  };
33
35
 
@@ -70,16 +72,26 @@ export function createSettings(initial = {}) {
70
72
  * @param {object} opts
71
73
  * @param {number} [opts.port=0]
72
74
  * @param {string[]} [opts.projects] folders the dashboard may read and run in (default: cwd)
73
- * @param {string} [opts.reportsDir] report JSON folder (default: <first project>/reports)
75
+ * @param {string} [opts.reportsDir] report JSON folder (default: <first project>/reports, which
76
+ * must not be a symlink; an explicit folder may be one)
74
77
  * @param {boolean} [opts.demo] seed realistic fake data, no disk reads
75
78
  * @param {string} [opts.version]
76
79
  * @param {object} [opts.source] data source override (tests)
77
80
  * @param {object} [opts.runDeps] run manager deps override (tests)
81
+ * @param {{ request?: number, idle?: number }} [opts.timeouts] ms; the event stream is exempt from idle
78
82
  * @returns {Promise<{ server, port, url, origin, session, close }>}
79
83
  */
80
84
  export async function startUiServer(opts = {}) {
81
85
  const projects = (opts.projects?.length ? opts.projects : [process.cwd()]).map((p) => resolve(p));
82
86
  const reportsDir = resolve(opts.reportsDir || join(projects[0], "reports"));
87
+ // A cloned repo can ship `reports -> ../elsewhere`. The default folder is
88
+ // taken only when it is a real folder (or missing); --reports is the user's
89
+ // own choice and may point anywhere.
90
+ if (!opts.reportsDir && !opts.demo && !opts.source) {
91
+ let st = null;
92
+ try { st = lstatSync(reportsDir); } catch { /* missing: created on first write */ }
93
+ if (st?.isSymbolicLink()) throw new Error(`${reportsDir} is a symlink. Pass --reports <dir> to use it on purpose.`);
94
+ }
83
95
  const home = process.env.PENTHERA_HOME ? resolve(process.env.PENTHERA_HOME) : join(homedir(), ".penthera");
84
96
  const memoryRoot = opts.memoryRoot || defaultMemoryRoot();
85
97
  const settings = createSettings({ offline: true, memoryRoot, reportsDir });
@@ -130,43 +142,56 @@ export async function startUiServer(opts = {}) {
130
142
  const url = new URL(req.url, `http://127.0.0.1:${port}`);
131
143
  if (req.method === "OPTIONS") return plain(res, 405, "Method not allowed.", { Allow: "GET, POST" });
132
144
 
133
- // 2. Session: the one-time URL token becomes an HttpOnly cookie.
134
- const token = url.searchParams.get("token");
135
- if (token != null && req.method === "GET" && url.pathname === "/") {
136
- const sid = session.redeem(token);
137
- if (!sid) { const wait = limiter.hit(req.method); if (wait) return tooMany(res, wait); }
138
- if (!sid) return plain(res, 403, "This link was already used or is wrong. Restart `penthera ui` for a new one.");
139
- res.writeHead(303, { "Set-Cookie": session.cookie(sid), Location: "/" });
140
- return res.end();
141
- }
142
- if (!session.check(req.headers.cookie)) {
143
- const wait = limiter.hit(req.method);
144
- if (wait) return tooMany(res, wait);
145
- return url.pathname.startsWith("/api/")
146
- ? sendJson(res, 401, { error: "Not signed in. Open the one-time URL printed by penthera ui." })
147
- : plain(res, 401, "Open the one-time URL printed by `penthera ui` in the terminal.");
145
+ // Static files: the page and its scripts hold no data, so they need no
146
+ // session (there is no cookie to carry one). Everything under /api does.
147
+ if (!url.pathname.startsWith("/api/")) {
148
+ if (req.method !== "GET" && req.method !== "HEAD") return plain(res, 405, "Method not allowed.", { Allow: "GET" });
149
+ const file = files.get(url.pathname);
150
+ if (!file) return plain(res, 404, "Not found.");
151
+ res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length });
152
+ return res.end(req.method === "HEAD" ? undefined : file.body);
148
153
  }
149
154
 
150
- // 3. Origin on anything that changes state.
155
+ // 3. Origin on anything that changes state, the sign-in included.
151
156
  if (isStateChanging(req.method) && !originAllowed(req, port)) {
157
+ const wait = session.check(req.headers.authorization) ? 0 : limiter.hit(req.method);
158
+ if (wait) return tooMany(res, wait);
152
159
  return sendJson(res, 403, { error: "Cross-origin request refused." });
153
160
  }
154
161
 
155
- if (await api(req, res, url.pathname, url.searchParams)) return;
162
+ // 2. Session: the page posts the one-time token from the URL fragment
163
+ // and gets the session id back, once.
164
+ if (url.pathname === "/api/session" && req.method === "POST") {
165
+ let body;
166
+ try { body = await readJson(req); } catch (e) { return sendJson(res, e instanceof RunError ? e.status : 400, { error: e.message }, e.close ? { Connection: "close" } : undefined); }
167
+ const sid = session.redeem(typeof body?.token === "string" ? body.token : "");
168
+ if (!sid) {
169
+ const wait = limiter.hit(req.method);
170
+ if (wait) return tooMany(res, wait);
171
+ return sendJson(res, 403, { error: "This link was already used or is wrong. Restart penthera ui for a new one." });
172
+ }
173
+ return sendJson(res, 200, { session: sid });
174
+ }
175
+ if (!session.check(req.headers.authorization)) {
176
+ const wait = limiter.hit(req.method);
177
+ if (wait) return tooMany(res, wait);
178
+ return sendJson(res, 401, { error: "Not signed in. Open the one-time URL printed by penthera ui." });
179
+ }
156
180
 
157
- if (req.method !== "GET" && req.method !== "HEAD") return plain(res, 405, "Method not allowed.", { Allow: "GET" });
158
- const file = files.get(url.pathname);
159
- if (!file) return plain(res, 404, "Not found.");
160
- res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length });
161
- res.end(req.method === "HEAD" ? undefined : file.body);
181
+ if (await api(req, res, url.pathname, url.searchParams)) return;
182
+ return sendJson(res, 404, { error: "Not found." });
162
183
  } catch (e) {
163
184
  process.stderr.write(` penthera ui: ${e.stack || e.message}\n`);
164
185
  if (!res.headersSent) plain(res, 500, "Internal error.");
165
186
  else res.end();
166
187
  }
167
188
  });
189
+ // Bounded request reads and idle sockets; the run event stream turns the
190
+ // idle timeout off for its own socket (routes.js) and keeps itself alive.
168
191
  server.headersTimeout = 20_000;
169
- server.requestTimeout = 0; // SSE streams stay open
192
+ server.requestTimeout = opts.timeouts?.request ?? 30_000;
193
+ server.timeout = opts.timeouts?.idle ?? 120_000;
194
+ if (server.requestTimeout) server.headersTimeout = Math.min(server.headersTimeout, server.requestTimeout);
170
195
 
171
196
  await new Promise((resolveListen, reject) => {
172
197
  server.once("error", reject);
@@ -178,7 +203,7 @@ export async function startUiServer(opts = {}) {
178
203
  server,
179
204
  port,
180
205
  origin,
181
- url: `${origin}/?token=${session.bootstrap}`,
206
+ url: `${origin}/#t=${session.bootstrap}`,
182
207
  session,
183
208
  runs,
184
209
  close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),