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,186 @@
1
+ /**
2
+ * `penthera ui`: the local dashboard server.
3
+ *
4
+ * node:http only, bound to 127.0.0.1, random port by default. Static files
5
+ * come from an allowlist built at startup (no path from a request ever
6
+ * reaches the filesystem), the API is JSON plus SSE for live runs, and every
7
+ * request goes through the gate in ./security.js first.
8
+ */
9
+ import { createServer } from "node:http";
10
+ import { readdirSync, readFileSync } from "node:fs";
11
+ import { join, relative, extname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { homedir } from "node:os";
14
+ import {
15
+ SECURITY_HEADERS, createSession, createAuthLimiter, hostAllowed, originAllowed, isStateChanging,
16
+ } from "./security.js";
17
+ import { createApi, sendJson } from "./api/routes.js";
18
+ import { createRunManager } from "./api/runs.js";
19
+ import { createDataSource } from "./api/data.js";
20
+ import { memoryRoot as defaultMemoryRoot } from "../../lib/memory/index.js";
21
+
22
+ export const STATIC_DIR = fileURLToPath(new URL("./static/", import.meta.url));
23
+
24
+ const TYPES = {
25
+ ".html": "text/html; charset=utf-8",
26
+ ".css": "text/css; charset=utf-8",
27
+ ".js": "text/javascript; charset=utf-8",
28
+ ".svg": "image/svg+xml",
29
+ ".png": "image/png",
30
+ ".ico": "image/x-icon",
31
+ ".txt": "text/plain; charset=utf-8",
32
+ };
33
+
34
+ /** Map of URL path -> { body, type } for every file under the static dir, read once. */
35
+ export function loadStatic(dir = STATIC_DIR) {
36
+ const files = new Map();
37
+ const walk = (d) => {
38
+ for (const e of readdirSync(d, { withFileTypes: true })) {
39
+ const p = join(d, e.name);
40
+ if (e.isDirectory()) walk(p);
41
+ else if (e.isFile() && TYPES[extname(e.name)]) {
42
+ files.set(`/${relative(dir, p).split("\\").join("/")}`, { body: readFileSync(p), type: TYPES[extname(e.name)] });
43
+ }
44
+ }
45
+ };
46
+ walk(dir);
47
+ files.set("/", files.get("/index.html"));
48
+ return files;
49
+ }
50
+
51
+ function plain(res, status, text, headers = {}) {
52
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8", ...headers });
53
+ res.end(`${text}\n`);
54
+ }
55
+
56
+ /** The Settings screen's state. Offline is on by default in the dashboard. */
57
+ export function createSettings(initial = {}) {
58
+ const state = { offline: initial.offline !== false, memoryRoot: initial.memoryRoot, reportsDir: initial.reportsDir };
59
+ return {
60
+ get: () => ({ ...state }),
61
+ update(body) {
62
+ if (body && typeof body.offline === "boolean") state.offline = body.offline;
63
+ return { ...state };
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Start the dashboard.
70
+ * @param {object} opts
71
+ * @param {number} [opts.port=0]
72
+ * @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)
74
+ * @param {boolean} [opts.demo] seed realistic fake data, no disk reads
75
+ * @param {string} [opts.version]
76
+ * @param {object} [opts.source] data source override (tests)
77
+ * @param {object} [opts.runDeps] run manager deps override (tests)
78
+ * @returns {Promise<{ server, port, url, origin, session, close }>}
79
+ */
80
+ export async function startUiServer(opts = {}) {
81
+ const projects = (opts.projects?.length ? opts.projects : [process.cwd()]).map((p) => resolve(p));
82
+ const reportsDir = resolve(opts.reportsDir || join(projects[0], "reports"));
83
+ const home = process.env.PENTHERA_HOME ? resolve(process.env.PENTHERA_HOME) : join(homedir(), ".penthera");
84
+ const memoryRoot = opts.memoryRoot || defaultMemoryRoot();
85
+ const settings = createSettings({ offline: true, memoryRoot, reportsDir });
86
+
87
+ let source = opts.source;
88
+ let runDeps = opts.runDeps;
89
+ if (!source && opts.demo) {
90
+ const demo = await import("./api/demo.js");
91
+ source = demo.createDemoSource({ settings, version: opts.version });
92
+ runDeps = runDeps || demo.demoRunDeps();
93
+ }
94
+ if (!source) source = createDataSource({ projects, reportsDir, home, memoryRoot, settings, version: opts.version });
95
+ if (!runDeps) {
96
+ const [{ runLoop }, { executeScan }, { scanDeps }, { scanAgents }] = await Promise.all([
97
+ import("../agent/loop.js"), import("../cli/run-scan.js"),
98
+ import("../../lib/deps/index.js"), import("../../lib/agents/index.js"),
99
+ ]);
100
+ runDeps = {
101
+ runLoop,
102
+ executeScan,
103
+ scanDeps,
104
+ scanAgents,
105
+ saveLoopRun: (l) => source.saveLoopRun?.(l),
106
+ writeReport: (result, name) => source.writeReport(name, result),
107
+ };
108
+ }
109
+ const runs = createRunManager({ ...runDeps, settings: settings.get, projectRoots: source.projectRoots || projects });
110
+ const api = createApi({ source, runs, version: opts.version || "dev", settings });
111
+ const files = loadStatic(opts.staticDir);
112
+ const session = createSession();
113
+ const limiter = createAuthLimiter(opts.limits);
114
+ let port = 0;
115
+ const tooMany = (res, wait) => {
116
+ res.writeHead(429, { "Content-Type": "text/plain; charset=utf-8", "Retry-After": String(wait) });
117
+ res.end("Too many requests without a session. Rate limit exceeded.\n");
118
+ };
119
+
120
+ const server = createServer(async (req, res) => {
121
+ for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
122
+ try {
123
+ // 1. Host: blocks DNS rebinding. Before anything else, including the token.
124
+ if (!hostAllowed(req.headers.host, port)) return plain(res, 421, "Host not allowed. Open the dashboard at http://127.0.0.1 with the URL penthera ui printed.");
125
+
126
+ // No dot segments, encoded slashes or dots, backslashes or NULs: the URL
127
+ // parser would normalise some of them, and none has a legitimate use here.
128
+ const rawPath = String(req.url || "/").split("?")[0];
129
+ if (!rawPath.startsWith("/") || /(^|\/)\.\.?(\/|$)|%2e|%2f|%5c|%00|\\|\/\//i.test(rawPath)) return plain(res, 400, "Bad path.");
130
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
131
+ if (req.method === "OPTIONS") return plain(res, 405, "Method not allowed.", { Allow: "GET, POST" });
132
+
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.");
148
+ }
149
+
150
+ // 3. Origin on anything that changes state.
151
+ if (isStateChanging(req.method) && !originAllowed(req, port)) {
152
+ return sendJson(res, 403, { error: "Cross-origin request refused." });
153
+ }
154
+
155
+ if (await api(req, res, url.pathname, url.searchParams)) return;
156
+
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);
162
+ } catch (e) {
163
+ process.stderr.write(` penthera ui: ${e.stack || e.message}\n`);
164
+ if (!res.headersSent) plain(res, 500, "Internal error.");
165
+ else res.end();
166
+ }
167
+ });
168
+ server.headersTimeout = 20_000;
169
+ server.requestTimeout = 0; // SSE streams stay open
170
+
171
+ await new Promise((resolveListen, reject) => {
172
+ server.once("error", reject);
173
+ server.listen(opts.port ?? 0, "127.0.0.1", () => { server.off("error", reject); resolveListen(); });
174
+ });
175
+ port = server.address().port;
176
+ const origin = `http://127.0.0.1:${port}`;
177
+ return {
178
+ server,
179
+ port,
180
+ origin,
181
+ url: `${origin}/?token=${session.bootstrap}`,
182
+ session,
183
+ runs,
184
+ close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }),
185
+ };
186
+ }