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.
- package/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
package/src/ui/api/data.js
CHANGED
|
@@ -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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
},
|
package/src/ui/api/routes.js
CHANGED
|
@@ -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,
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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;
|
package/src/ui/api/runs.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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 });
|
package/src/ui/security.js
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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)
|
|
124
|
-
* session
|
|
125
|
-
*
|
|
126
|
-
* SHA-256, so nothing readable from this process (or a heap
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
//
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (!
|
|
139
|
-
res.writeHead(
|
|
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
|
-
|
|
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 (
|
|
158
|
-
|
|
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 =
|
|
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}
|
|
206
|
+
url: `${origin}/#t=${session.bootstrap}`,
|
|
182
207
|
session,
|
|
183
208
|
runs,
|
|
184
209
|
close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),
|