penthera 1.4.0 → 1.6.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 +227 -0
- package/README.md +148 -575
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +11 -2
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +101 -0
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +314 -30
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +29 -3
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +54 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +345 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +27 -7
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +31 -8
- package/package.json +8 -3
- package/skills/penthera/SKILL.md +55 -8
- package/src/cli/deps.js +81 -6
- package/src/cli/evidence.js +14 -4
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +81 -11
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live runs started from the dashboard: `agent --loop` and plain scans.
|
|
3
|
+
*
|
|
4
|
+
* A run goes through the same functions as the CLI (runLoop, executeScan),
|
|
5
|
+
* with the same rules: the loop is always offline and limited to its URL
|
|
6
|
+
* target, every diff waits for a decision, nothing is pushed. On top of that:
|
|
7
|
+
*
|
|
8
|
+
* - The repo must be inside a project folder the dashboard was started with.
|
|
9
|
+
* - A URL that is not on this machine needs its hostname typed back as
|
|
10
|
+
* confirmation (the CLI's authorization gate, made explicit).
|
|
11
|
+
* - One run at a time.
|
|
12
|
+
*
|
|
13
|
+
* Output is an append-only event list per run, streamed over SSE. Events are
|
|
14
|
+
* plain data; the frontend renders them as text.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { normalizeBaseUrl, urlToFilename } from "../../utils/url.js";
|
|
18
|
+
import { confineDir } from "./data.js";
|
|
19
|
+
|
|
20
|
+
const MAX_EVENTS = 5000;
|
|
21
|
+
const LOCAL_HOST = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
|
|
22
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
23
|
+
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
24
|
+
|
|
25
|
+
export class RunError extends Error {
|
|
26
|
+
constructor(message, status = 400) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const cleanLine = (s) => String(s ?? "").replace(ANSI, "").replace(CONTROL, "").slice(0, 4000);
|
|
33
|
+
|
|
34
|
+
export function isLocalHost(hostname) {
|
|
35
|
+
return LOCAL_HOST.test(hostname);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate a run request. Returns { kind, repo, url } or throws RunError.
|
|
40
|
+
* @param {object} body { kind, repo, url, confirm, maxIterations, runTests }
|
|
41
|
+
* @param {string[]} projectRoots
|
|
42
|
+
*/
|
|
43
|
+
export function validateRunRequest(body, projectRoots, confine = confineDir) {
|
|
44
|
+
if (!body || typeof body !== "object") throw new RunError("Expected a JSON body.");
|
|
45
|
+
const kind = ["scan", "loop", "deps", "agents"].includes(body.kind) ? body.kind : null;
|
|
46
|
+
if (!kind) throw new RunError('kind must be "scan", "deps", "agents" or "loop".');
|
|
47
|
+
|
|
48
|
+
let url = null;
|
|
49
|
+
if (body.url != null && String(body.url).trim()) {
|
|
50
|
+
const raw = String(body.url).trim();
|
|
51
|
+
let parsed;
|
|
52
|
+
try { parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`); } catch { throw new RunError(`Not a URL: ${raw.slice(0, 200)}`); }
|
|
53
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new RunError("Only http and https URLs can be scanned.");
|
|
54
|
+
if (parsed.username || parsed.password) throw new RunError("Leave credentials out of the URL.");
|
|
55
|
+
url = normalizeBaseUrl(parsed.href);
|
|
56
|
+
if (!isLocalHost(parsed.hostname)) {
|
|
57
|
+
const confirm = typeof body.confirm === "string" ? body.confirm.trim().toLowerCase() : "";
|
|
58
|
+
if (confirm !== parsed.hostname.toLowerCase()) {
|
|
59
|
+
throw new RunError(`${parsed.hostname} is not on this machine. Type the hostname to confirm you own it or have written permission to test it.`, 428);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let repo = null;
|
|
65
|
+
if (body.repo != null && String(body.repo).trim()) {
|
|
66
|
+
try { repo = confine(String(body.repo), projectRoots); } catch (e) { throw new RunError(e.message); }
|
|
67
|
+
}
|
|
68
|
+
if (kind === "loop" && !repo) throw new RunError("The agent loop fixes a repo: enter a project folder.");
|
|
69
|
+
if ((kind === "deps" || kind === "agents") && !repo) throw new RunError(`${kind === "deps" ? "The dependency check reads package-lock.json" : "The AI tooling audit reads agent config"} in a project folder: enter one.`);
|
|
70
|
+
if ((kind === "deps" || kind === "agents") && url) throw new RunError("This check reads files only; leave the URL empty.");
|
|
71
|
+
if (kind === "scan" && !repo && !url) throw new RunError("Enter a project folder, a URL, or both.");
|
|
72
|
+
|
|
73
|
+
const maxIterations = body.maxIterations == null ? 5 : Number(body.maxIterations);
|
|
74
|
+
if (!Number.isInteger(maxIterations) || maxIterations < 1 || maxIterations > 50) throw new RunError("Max iterations must be a whole number from 1 to 50.");
|
|
75
|
+
return { kind, repo, url, maxIterations, runTests: body.runTests === true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The run manager.
|
|
80
|
+
* @param {object} deps
|
|
81
|
+
* @param {Function} deps.runLoop src/agent/loop.js runLoop (injectable for tests and --demo)
|
|
82
|
+
* @param {Function} deps.executeScan src/cli/run-scan.js executeScan
|
|
83
|
+
* @param {Function} deps.writeReport (merged, base) => file name
|
|
84
|
+
* @param {Function} deps.saveLoopRun (loopRun) => file name
|
|
85
|
+
* @param {() => { offline: boolean }} deps.settings
|
|
86
|
+
* @param {string[]} deps.projectRoots
|
|
87
|
+
* @param {Function} [deps.confineDir] folder check override (--demo has no real folders)
|
|
88
|
+
*/
|
|
89
|
+
export function createRunManager(deps) {
|
|
90
|
+
const runs = new Map();
|
|
91
|
+
let active = null;
|
|
92
|
+
|
|
93
|
+
function emit(run, type, data = {}) {
|
|
94
|
+
const ev = { seq: run.events.length + 1, type, ts: new Date().toISOString(), data };
|
|
95
|
+
run.events.push(ev);
|
|
96
|
+
if (run.events.length > MAX_EVENTS) run.events.splice(0, run.events.length - MAX_EVENTS);
|
|
97
|
+
for (const fn of run.listeners) fn(ev);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function publicRun(run) {
|
|
101
|
+
return {
|
|
102
|
+
id: run.id, kind: run.kind, status: run.status, repo: run.repo, url: run.url,
|
|
103
|
+
startedAt: run.startedAt, pending: run.pending ? run.pending.request : null, summary: run.summary,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function execute(run, req) {
|
|
108
|
+
const log = (line) => {
|
|
109
|
+
const text = cleanLine(line);
|
|
110
|
+
emit(run, "log", { text });
|
|
111
|
+
const m = text.match(/^\s*iteration (\d+)\/(\d+)/);
|
|
112
|
+
if (m) emit(run, "iteration", { n: Number(m[1]), of: Number(m[2]) });
|
|
113
|
+
const s = text.match(/^\s*scan: (\d+) findings \((\d+) suppressed\), (\d+) fixable/);
|
|
114
|
+
if (s) emit(run, "scanned", { findings: Number(s[1]), suppressed: Number(s[2]), fixable: Number(s[3]) });
|
|
115
|
+
if (/verified and committed/.test(text)) emit(run, "fix", { status: "verified", text });
|
|
116
|
+
else if (/failed verification, reverted/.test(text)) emit(run, "fix", { status: "failed", text });
|
|
117
|
+
else if (/^\s*not applied:/.test(text)) emit(run, "fix", { status: "not-applied", text });
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (req.kind === "loop") {
|
|
121
|
+
emit(run, "log", { text: `penthera agent --loop ${req.repo}${req.url ? ` + ${req.url}` : ""}` });
|
|
122
|
+
emit(run, "log", { text: `local only: egress limited to ${req.url ? new URL(req.url).host : "no hosts (repo scan only)"}; nothing is pushed` });
|
|
123
|
+
const loopRun = await deps.runLoop({
|
|
124
|
+
repo: req.repo, url: req.url, maxIterations: req.maxIterations, auto: "off", runTests: req.runTests,
|
|
125
|
+
log, signal: run.controller.signal,
|
|
126
|
+
ask: (q) => new Promise((resolveAnswer) => {
|
|
127
|
+
const approvalId = randomBytes(8).toString("hex");
|
|
128
|
+
const p = q.plan || {};
|
|
129
|
+
const request = {
|
|
130
|
+
approvalId, iteration: q.iteration, index: q.index, total: q.total, memory: q.memory || null,
|
|
131
|
+
plan: {
|
|
132
|
+
title: cleanLine(p.title), recipe: cleanLine(p.recipe), framework: p.framework ? cleanLine(p.framework) : null,
|
|
133
|
+
confidence: cleanLine(p.confidence), source: cleanLine(p.source),
|
|
134
|
+
files: (p.files || []).map(cleanLine), diff: String(p.diff || "").slice(0, 60000),
|
|
135
|
+
findings: (p.findings || []).map((f) => ({ title: cleanLine(f.title), severity: cleanLine(f.severity) })),
|
|
136
|
+
ownerActions: (p.ownerActions || []).map(cleanLine), redacted: Boolean(p.redacted),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
run.pending = { request, resolve: resolveAnswer };
|
|
140
|
+
emit(run, "approval", request);
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
run.summary = {
|
|
144
|
+
stopReason: loopRun.stopReason, error: loopRun.error, totals: loopRun.totals, findings: loopRun.findings,
|
|
145
|
+
branch: loopRun.branch?.fix || null, durationMs: loopRun.durationMs,
|
|
146
|
+
iterations: loopRun.iterations.map((it) => ({
|
|
147
|
+
n: it.n, durationMs: it.durationMs, findings: it.findings,
|
|
148
|
+
fixes: it.fixes.map((f) => ({ title: f.title, recipe: f.recipe, decision: f.decision, status: f.status, commit: f.commit })),
|
|
149
|
+
})),
|
|
150
|
+
};
|
|
151
|
+
const file = deps.saveLoopRun?.(loopRun);
|
|
152
|
+
if (file) emit(run, "log", { text: `loopRun written to ${file}` });
|
|
153
|
+
emit(run, "log", { text: `branch ${run.summary.branch}: nothing was pushed. Merge it yourself when you are happy with it.` });
|
|
154
|
+
} else if (req.kind === "deps" || req.kind === "agents") {
|
|
155
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-");
|
|
156
|
+
emit(run, "log", { text: req.kind === "deps" ? `penthera deps ${req.repo} (offline)` : `penthera agents ${req.repo} (offline, read-only)` });
|
|
157
|
+
const result = req.kind === "deps"
|
|
158
|
+
? await deps.scanDeps(req.repo, { onPhase: (m) => log(m) })
|
|
159
|
+
: await deps.scanAgents(req.repo, { onPhase: (m) => log(m) });
|
|
160
|
+
const c = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
161
|
+
for (const f of result.findings || []) if (c[f.severity] != null) c[f.severity]++;
|
|
162
|
+
const file = deps.writeReport(result, `${req.kind}_${stamp}.json`);
|
|
163
|
+
run.summary = { counts: c, total: (result.findings || []).length, file, grade: result.agents?.grade || null };
|
|
164
|
+
log(`${req.kind === "deps" ? "deps" : "agents"} done: ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.low} low${result.agents ? `, grade ${result.agents.grade}` : ""}`);
|
|
165
|
+
if (file) log(`report written to ${file}`);
|
|
166
|
+
} else {
|
|
167
|
+
const offline = deps.settings().offline;
|
|
168
|
+
emit(run, "log", { text: `penthera${req.url ? ` ${req.url}` : ""}${req.repo ? ` --repo ${req.repo}` : ""} --profile standard${offline ? " --offline" : ""}` });
|
|
169
|
+
const r = await deps.executeScan({ url: req.url, repo: req.repo, profile: "standard", offline, json: true, onPhase: (m) => log(m) });
|
|
170
|
+
const c = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
171
|
+
for (const f of r.merged.findings) if (c[f.severity] != null) c[f.severity]++;
|
|
172
|
+
const base = `${req.url ? urlToFilename(req.url) : "repo"}_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.json`;
|
|
173
|
+
const file = deps.writeReport(r.merged, base);
|
|
174
|
+
run.summary = { counts: c, total: r.merged.findings.length, file };
|
|
175
|
+
log(`scan done: ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.low} low, ${c.info} info`);
|
|
176
|
+
if (file) log(`report written to ${file}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
list: () => [...runs.values()].map(publicRun).reverse(),
|
|
182
|
+
get: (id) => runs.get(id) || null,
|
|
183
|
+
publicRun,
|
|
184
|
+
|
|
185
|
+
start(body) {
|
|
186
|
+
if (active && active.status === "running") throw new RunError("A run is already in progress. Stop it first.", 409);
|
|
187
|
+
const req = validateRunRequest(body, deps.projectRoots, deps.confineDir);
|
|
188
|
+
const run = {
|
|
189
|
+
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,
|
|
191
|
+
controller: new AbortController(),
|
|
192
|
+
};
|
|
193
|
+
runs.set(run.id, run);
|
|
194
|
+
active = run;
|
|
195
|
+
emit(run, "status", { status: "running" });
|
|
196
|
+
execute(run, req)
|
|
197
|
+
.then(() => { run.status = run.controller.signal.aborted ? "stopped" : "done"; })
|
|
198
|
+
.catch((e) => { run.status = "error"; emit(run, "log", { text: `error: ${cleanLine(e.message)}` }); })
|
|
199
|
+
.finally(() => {
|
|
200
|
+
if (run.pending) { run.pending.resolve("quit"); run.pending = null; }
|
|
201
|
+
emit(run, "status", { status: run.status, summary: run.summary });
|
|
202
|
+
for (const fn of run.listeners) fn(null);
|
|
203
|
+
run.listeners.clear();
|
|
204
|
+
});
|
|
205
|
+
return publicRun(run);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
decide(id, { approvalId, answer } = {}) {
|
|
209
|
+
const run = runs.get(id);
|
|
210
|
+
if (!run) throw new RunError("No such run.", 404);
|
|
211
|
+
if (!run.pending || run.pending.request.approvalId !== approvalId) throw new RunError("Nothing is waiting for that decision.", 409);
|
|
212
|
+
const map = { approve: "y", y: "y", skip: "n", n: "n", later: "skip", stop: "quit", quit: "quit" };
|
|
213
|
+
const a = map[answer];
|
|
214
|
+
if (!a) throw new RunError("answer must be approve, skip or stop.");
|
|
215
|
+
const { resolve: done } = run.pending;
|
|
216
|
+
run.pending = null;
|
|
217
|
+
emit(run, "decision", { approvalId, answer: a });
|
|
218
|
+
if (a === "quit") run.controller.abort();
|
|
219
|
+
done(a);
|
|
220
|
+
return publicRun(run);
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
stop(id) {
|
|
224
|
+
const run = runs.get(id);
|
|
225
|
+
if (!run) throw new RunError("No such run.", 404);
|
|
226
|
+
if (run.status !== "running") return publicRun(run);
|
|
227
|
+
emit(run, "log", { text: "stopping after the current step" });
|
|
228
|
+
run.controller.abort();
|
|
229
|
+
if (run.pending) { const { resolve: done } = run.pending; run.pending = null; done("quit"); }
|
|
230
|
+
return publicRun(run);
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/** Subscribe: replays events after `afterSeq`, then live ones. `fn(null)` means the run ended. */
|
|
234
|
+
subscribe(id, afterSeq, fn) {
|
|
235
|
+
const run = runs.get(id);
|
|
236
|
+
if (!run) return null;
|
|
237
|
+
for (const ev of run.events) if (ev.seq > afterSeq) fn(ev);
|
|
238
|
+
if (run.status !== "running") { fn(null); return () => {}; }
|
|
239
|
+
run.listeners.add(fn);
|
|
240
|
+
return () => run.listeners.delete(fn);
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|