penthera 1.6.0 → 1.7.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 +67 -0
- package/README.md +158 -301
- package/bin/scan.js +2 -2
- package/docs/METHODOLOGY.md +236 -182
- package/docs/commands/README.md +2 -1
- package/docs/commands/init.md +114 -0
- package/docs/commands/scan.md +1 -1
- package/docs/compare/github.md +1 -1
- package/docs/dashboard.md +95 -152
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/internal-audit.md +1 -1
- package/docs/plan.md +3 -2
- package/docs/references.bib +43 -33
- package/docs/references.md +92 -51
- package/lib/whitebox/frameworks.js +36 -0
- package/package.json +1 -1
- package/skills/penthera/SKILL.md +19 -5
- package/src/cli/deps.js +1 -1
- package/src/cli/detect.js +125 -0
- package/src/cli/onboarding.js +428 -6
- package/src/cli/prompt.js +42 -0
- package/src/cli.js +18 -5
- package/src/ui/api/data.js +30 -0
- package/src/ui/api/demo.js +13 -0
- package/src/ui/api/routes.js +1 -0
- package/src/ui/static/app.css +286 -560
- package/src/ui/static/index.html +8 -39
- package/src/ui/static/js/app.js +105 -107
- package/src/ui/static/js/center.js +388 -0
- package/src/ui/static/js/copy.js +36 -62
- package/src/ui/static/js/dom.js +0 -38
- package/src/ui/static/js/left.js +83 -0
- package/src/ui/static/js/right.js +270 -0
- package/src/ui/static/js/state.js +35 -0
- package/src/ui/static/js/views/ai.js +0 -68
- package/src/ui/static/js/views/deps.js +0 -96
- package/src/ui/static/js/views/evidence.js +0 -115
- package/src/ui/static/js/views/findings.js +0 -165
- package/src/ui/static/js/views/history.js +0 -92
- package/src/ui/static/js/views/overview.js +0 -184
- package/src/ui/static/js/views/run.js +0 -349
- package/src/ui/static/js/views/settings.js +0 -67
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `penthera init` finds in a project before it asks anything. Read-only:
|
|
3
|
+
* files under the project, `git` plumbing, and TCP connects to 127.0.0.1 / ::1.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { createConnection } from "node:net";
|
|
9
|
+
import { detectProjectFramework } from "../../lib/whitebox/frameworks.js";
|
|
10
|
+
import { HOOK_MARKER } from "../../lib/hook/index.js";
|
|
11
|
+
|
|
12
|
+
export const DEV_PORTS = [3000, 5173, 8000, 8080];
|
|
13
|
+
export const NPM_LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
|
|
14
|
+
export const PY_LOCKFILES = ["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock", "requirements.txt"];
|
|
15
|
+
export const AGENT_CONFIG = [".mcp.json", ".claude", ".cursor", "CLAUDE.md", "AGENTS.md", "skills"];
|
|
16
|
+
|
|
17
|
+
function git(cwd, args) {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The git toplevel for `cwd`, or null outside a repo (or without git). */
|
|
22
|
+
export function gitRoot(cwd) {
|
|
23
|
+
try { return git(cwd, ["rev-parse", "--show-toplevel"]); } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when something accepts a TCP connection on host:port within `timeout` ms. */
|
|
27
|
+
function listening(host, port, timeout) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const sock = createConnection({ host, port });
|
|
30
|
+
const done = (ok) => { sock.destroy(); resolve(ok); };
|
|
31
|
+
sock.setTimeout(timeout, () => done(false));
|
|
32
|
+
sock.once("connect", () => done(true));
|
|
33
|
+
sock.once("error", () => done(false));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first dev-server port that answers on localhost. Vite and others bind
|
|
39
|
+
* ::1 only, so both loopback addresses are tried. Never contacts another host.
|
|
40
|
+
* @returns {Promise<string|null>} e.g. "http://localhost:3000"
|
|
41
|
+
*/
|
|
42
|
+
export async function detectDevServer(ports = DEV_PORTS, timeout = 300) {
|
|
43
|
+
const hits = await Promise.all(ports.map(async (port) =>
|
|
44
|
+
(await listening("127.0.0.1", port, timeout)) || (await listening("::1", port, timeout)) ? port : null));
|
|
45
|
+
const port = hits.find((p) => p != null);
|
|
46
|
+
return port == null ? null : `http://localhost:${port}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Ports to probe: PENTHERA_INIT_PORTS (comma list, "" for none) overrides DEV_PORTS. */
|
|
50
|
+
export function portsFromEnv(env = process.env) {
|
|
51
|
+
if (env.PENTHERA_INIT_PORTS == null) return DEV_PORTS;
|
|
52
|
+
return env.PENTHERA_INIT_PORTS.split(",").map((p) => Number(p.trim())).filter((p) => Number.isInteger(p) && p > 0 && p < 65536);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hookInstalled(root) {
|
|
56
|
+
try {
|
|
57
|
+
const path = join(git(root, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]), "pre-commit");
|
|
58
|
+
return existsSync(path) && readFileSync(path, "utf-8").includes(HOOK_MARKER);
|
|
59
|
+
} catch { return false; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Workflow files under .github/workflows that already use the Penthera action or CLI. */
|
|
63
|
+
function pentheraWorkflows(root) {
|
|
64
|
+
const dir = join(root, ".github", "workflows");
|
|
65
|
+
if (!existsSync(dir)) return [];
|
|
66
|
+
return readdirSync(dir)
|
|
67
|
+
.filter((f) => /\.ya?ml$/i.test(f))
|
|
68
|
+
.filter((f) => {
|
|
69
|
+
try { return /danoszz\/penthera|\bpenthera\b/.test(readFileSync(join(dir, f), "utf-8")); } catch { return false; }
|
|
70
|
+
})
|
|
71
|
+
.map((f) => `.github/workflows/${f}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function reportsIgnored(root, isGit) {
|
|
75
|
+
if (isGit) {
|
|
76
|
+
try { git(root, ["check-ignore", "-q", "reports/penthera.json"]); return true; } catch { return false; }
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return readFileSync(join(root, ".gitignore"), "utf-8").split(/\r?\n/).some((l) => /^\/?reports\/?$/.test(l.trim()));
|
|
80
|
+
} catch { return false; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasCode(root) {
|
|
84
|
+
try {
|
|
85
|
+
return readdirSync(root).some((f) => {
|
|
86
|
+
if (f === "node_modules" || f.startsWith(".")) return false;
|
|
87
|
+
if (/\.(js|ts|mjs|cjs|jsx|tsx|py|go|rb|php|java|rs)$/.test(f)) return true;
|
|
88
|
+
return ["src", "app", "pages", "api", "lib", "server"].includes(f) && statSync(join(root, f)).isDirectory();
|
|
89
|
+
}) || existsSync(join(root, "package.json"));
|
|
90
|
+
} catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Everything the wizard shows under "Found".
|
|
95
|
+
* @param {string} cwd
|
|
96
|
+
* @param {{ ports?: number[] }} [opts]
|
|
97
|
+
*/
|
|
98
|
+
export async function detectProject(cwd, opts = {}) {
|
|
99
|
+
const top = gitRoot(cwd);
|
|
100
|
+
const root = top || cwd;
|
|
101
|
+
const isGit = !!top;
|
|
102
|
+
let clean = null;
|
|
103
|
+
let branch = null;
|
|
104
|
+
if (isGit) {
|
|
105
|
+
try { clean = git(root, ["status", "--porcelain"]) === ""; } catch { clean = null; }
|
|
106
|
+
try { branch = git(root, ["symbolic-ref", "-q", "--short", "HEAD"]) || null; } catch { branch = null; }
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
root,
|
|
110
|
+
git: isGit,
|
|
111
|
+
clean,
|
|
112
|
+
branch,
|
|
113
|
+
framework: detectProjectFramework(root),
|
|
114
|
+
code: hasCode(root),
|
|
115
|
+
lockfiles: {
|
|
116
|
+
npm: NPM_LOCKFILES.filter((f) => existsSync(join(root, f))),
|
|
117
|
+
python: PY_LOCKFILES.filter((f) => existsSync(join(root, f))),
|
|
118
|
+
},
|
|
119
|
+
agentConfig: AGENT_CONFIG.filter((f) => existsSync(join(root, f))),
|
|
120
|
+
devServer: await detectDevServer(opts.ports ?? DEV_PORTS),
|
|
121
|
+
hook: isGit && hookInstalled(root),
|
|
122
|
+
workflows: pentheraWorkflows(root),
|
|
123
|
+
reportsIgnored: reportsIgnored(root, isGit),
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/cli/onboarding.js
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PostHog-style interactive onboarding, simple defaults, advanced hidden.
|
|
3
|
+
*
|
|
4
|
+
* `penthera` (no arguments, in a terminal) and `penthera init` run the setup
|
|
5
|
+
* wizard: detect what the project has, ask what to check, run it offline, show
|
|
6
|
+
* one summary, then offer the hook, the GitHub workflow, .gitignore, the fix
|
|
7
|
+
* loop and the dashboard, each a yes/no. Nothing is committed or pushed, and
|
|
8
|
+
* nothing is written outside the project. `penthera-scan` keeps the URL-first
|
|
9
|
+
* wizard (runScanWizard).
|
|
3
10
|
*/
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
|
-
import { resolve } from "node:path";
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
12
|
+
import { resolve, join, relative, sep } from "node:path";
|
|
6
13
|
import { readFileSync } from "node:fs";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { parseArgs } from "node:util";
|
|
17
|
+
import { initPrompt, closePrompt, ask, choose, confirm, checklist, normalizeUrlInput } from "./prompt.js";
|
|
18
|
+
import { banner, logo, box, bold, dim, cyan, green, yellow, red, hr, brand } from "./ansi.js";
|
|
9
19
|
import { executeScan, writeScanReports } from "./run-scan.js";
|
|
20
|
+
import { detectProject, portsFromEnv } from "./detect.js";
|
|
21
|
+
import { mdInline } from "../report/md-escape.js";
|
|
22
|
+
import { mergeResults } from "./merge-results.js";
|
|
23
|
+
import { resolveLicensePolicy } from "./deps.js";
|
|
10
24
|
import { isPrivateHost } from "../utils/url.js";
|
|
11
|
-
import { printError } from "../reporter.js";
|
|
25
|
+
import { printError, writeJsonReport } from "../reporter.js";
|
|
26
|
+
import { writeMarkdownReport } from "../report/markdown.js";
|
|
27
|
+
import { buildActionPlan } from "../../lib/remediation/index.js";
|
|
28
|
+
import { gradeFindings } from "../../lib/agents/index.js";
|
|
29
|
+
import { installHook } from "../../lib/hook/index.js";
|
|
12
30
|
|
|
13
31
|
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
|
|
14
32
|
|
|
@@ -70,7 +88,8 @@ function printScanFailure(url, err) {
|
|
|
70
88
|
printError(err.message);
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
|
|
91
|
+
/** `penthera-scan`: the URL-first wizard (asks for a URL, scans it, offers a deeper scan). */
|
|
92
|
+
export async function runScanWizard() {
|
|
74
93
|
await initPrompt();
|
|
75
94
|
|
|
76
95
|
process.stdout.write(banner());
|
|
@@ -214,3 +233,406 @@ export async function runOnboarding() {
|
|
|
214
233
|
process.exit(2);
|
|
215
234
|
}
|
|
216
235
|
}
|
|
236
|
+
|
|
237
|
+
// ── `penthera init`: the setup wizard ─────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The release the generated workflow pins, by commit SHA. Update after each
|
|
241
|
+
* release is tagged: git rev-parse vX.Y.Z^{commit}
|
|
242
|
+
*/
|
|
243
|
+
export const ACTION_PIN = Object.freeze({ tag: "v1.6.1", sha: "525895575f4a9bebe911707a43804a934eac0c4a" });
|
|
244
|
+
|
|
245
|
+
export const WORKFLOW_PATH = ".github/workflows/penthera.yml";
|
|
246
|
+
const BIN = fileURLToPath(new URL("../../bin/penthera.js", import.meta.url));
|
|
247
|
+
const WORKFLOW_EXAMPLE = new URL("../../docs/examples/pr-dependency-review.yml", import.meta.url);
|
|
248
|
+
|
|
249
|
+
const INIT_HELP = `
|
|
250
|
+
${bold("penthera init")} — set up Penthera in this project in one command
|
|
251
|
+
|
|
252
|
+
${dim("Usage")}
|
|
253
|
+
$ penthera In a terminal with no arguments: the same wizard
|
|
254
|
+
$ penthera init Detect, ask what to check, run it, offer setup steps
|
|
255
|
+
$ penthera init --yes Accept every safe default, ask nothing
|
|
256
|
+
$ penthera init --dry-run Show what would run and be written; change nothing
|
|
257
|
+
|
|
258
|
+
${dim("Options")}
|
|
259
|
+
-y, --yes Non-interactive. Runs the code, dependency and AI tooling checks
|
|
260
|
+
that apply, offline. Never scans a URL, never runs the fix loop,
|
|
261
|
+
never opens the dashboard
|
|
262
|
+
--dry-run Print the checks and file writes instead of doing them
|
|
263
|
+
-h, --help Show this help
|
|
264
|
+
|
|
265
|
+
Reports go to reports/penthera-<date>/. The hook goes in .git/hooks, the workflow
|
|
266
|
+
in ${WORKFLOW_PATH}, and nothing is committed or pushed.
|
|
267
|
+
`;
|
|
268
|
+
|
|
269
|
+
const AREAS = {
|
|
270
|
+
code: { label: "Code", cmd: (c) => `penthera --repo .${c.offline ? " --offline" : ""}` },
|
|
271
|
+
deps: { label: "Dependencies", cmd: (c) => `penthera deps${c.offline ? "" : " --online"}` },
|
|
272
|
+
agents: { label: "AI tooling", cmd: (c) => `penthera agents${c.home ? " --home" : ""}` },
|
|
273
|
+
app: { label: "Running app", cmd: (c) => `penthera ${c.url}${c.offline ? " --offline" : ""}` },
|
|
274
|
+
};
|
|
275
|
+
const EFFORT_RANK = { quick: 0, moderate: 1, significant: 2 };
|
|
276
|
+
|
|
277
|
+
const noop = () => {};
|
|
278
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
279
|
+
const out = (s = "") => process.stdout.write(s + "\n");
|
|
280
|
+
|
|
281
|
+
/** Local calendar date, YYYY-MM-DD: one report folder per day. */
|
|
282
|
+
function today(now = new Date()) {
|
|
283
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
284
|
+
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Refuse any write that would land outside the project root. */
|
|
288
|
+
function inside(root, path) {
|
|
289
|
+
const full = resolve(root, path);
|
|
290
|
+
if (full !== root && !full.startsWith(root + sep)) throw new Error(`Refusing to write outside the project: ${full}`);
|
|
291
|
+
return full;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function countLine(findings) {
|
|
295
|
+
const c = countFindings(findings);
|
|
296
|
+
const parts = ["critical", "high", "medium", "low"].filter((s) => c[s]).map((s) => `${c[s]} ${s}`);
|
|
297
|
+
return parts.length ? parts.join(" · ") : "no issues";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Word-wrap `text` to the terminal (max 80 columns), each line prefixed by `indent`. */
|
|
301
|
+
function wrap(text, indent) {
|
|
302
|
+
const width = Math.min(process.stdout.columns || 80, 80) - indent.length;
|
|
303
|
+
const lines = [];
|
|
304
|
+
let line = "";
|
|
305
|
+
for (const word of text.split(/\s+/)) {
|
|
306
|
+
if (line && line.length + 1 + word.length > width) { lines.push(line); line = word; }
|
|
307
|
+
else line = line ? `${line} ${word}` : word;
|
|
308
|
+
}
|
|
309
|
+
if (line) lines.push(line);
|
|
310
|
+
return lines.map((l) => indent + l).join("\n");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function gradeColor(g) {
|
|
314
|
+
return g === "A" ? green(g) : g === "B" || g === "C" ? yellow(g) : red(g);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The generated workflow: the PR dependency review example, pinned to ACTION_PIN. */
|
|
318
|
+
export function workflowYaml(pin = ACTION_PIN) {
|
|
319
|
+
const example = readFileSync(WORKFLOW_EXAMPLE, "utf-8");
|
|
320
|
+
const body = example.slice(example.indexOf("\nname:") + 1);
|
|
321
|
+
const pinned = body.replace(/danoszz\/penthera@[0-9a-f]{40} # .*$/m, `danoszz/penthera@${pin.sha} # ${pin.tag}`);
|
|
322
|
+
if (pinned === body) throw new Error("docs/examples/pr-dependency-review.yml no longer has a pinned `uses: danoszz/penthera@<sha>` line");
|
|
323
|
+
return [
|
|
324
|
+
"# Added by `penthera init`. Review it, then commit it yourself.",
|
|
325
|
+
"#",
|
|
326
|
+
"# Comments a dependency capability diff on every PR that changes the lockfile",
|
|
327
|
+
"# (new packages, or packages that gained network, shell, install-script or",
|
|
328
|
+
"# dynamic-code capabilities) and uploads SARIF to code scanning. Penthera runs",
|
|
329
|
+
"# offline from the action's own checkout. The action is pinned to the",
|
|
330
|
+
`# ${pin.tag} release commit. Do not switch this to \`pull_request_target\`: that`,
|
|
331
|
+
"# would run with write access next to untrusted code.",
|
|
332
|
+
"",
|
|
333
|
+
pinned,
|
|
334
|
+
].join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** A new-file unified diff, for showing the workflow before it is written. */
|
|
338
|
+
export function newFileDiff(path, content) {
|
|
339
|
+
const lines = content.replace(/\n$/, "").split("\n");
|
|
340
|
+
return [dim("--- /dev/null"), dim(`+++ b/${path}`), dim(`@@ -0,0 +1,${lines.length} @@`), ...lines.map((l) => green(`+${l}`))].join("\n");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The checklist offered after detection, with defaults from what was found. */
|
|
344
|
+
export function checkItems(found) {
|
|
345
|
+
const items = [
|
|
346
|
+
{ id: "code", label: "Code: secrets and risky API routes in ./", hint: "penthera --repo .", checked: found.code || found.git },
|
|
347
|
+
{ id: "deps", label: "Dependencies: what your npm and Python packages can do", hint: "penthera deps", checked: found.lockfiles.npm.length + found.lockfiles.python.length > 0 },
|
|
348
|
+
{ id: "agents", label: "AI tooling: skills, MCP servers, hooks, agent instructions", hint: "penthera agents", checked: found.agentConfig.length > 0 },
|
|
349
|
+
];
|
|
350
|
+
if (found.devServer) items.push({ id: "app", label: `The running app at ${found.devServer}`, hint: "only if it is yours", checked: false });
|
|
351
|
+
items.push({ id: "offline", label: "Offline mode: nothing leaves this machine", hint: "recommended", checked: true });
|
|
352
|
+
return items;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function printFound(f, cwd) {
|
|
356
|
+
const yes = (s) => `${green("✓")} ${s}`;
|
|
357
|
+
const no = (s) => `${dim("·")} ${dim(s)}`;
|
|
358
|
+
const rel = relative(cwd, f.root) || ".";
|
|
359
|
+
out(` ${bold("Found")} ${dim(rel === "." ? "in this folder" : `in ${rel}`)}`);
|
|
360
|
+
out(` ${f.git ? yes(`git repository${f.branch ? ` (branch ${f.branch})` : ""}`) : no("not a git repository (hook and workflow steps are skipped)")}`);
|
|
361
|
+
out(` ${f.framework ? yes(`framework: ${f.framework}`) : no("no web framework detected")}`);
|
|
362
|
+
const locks = [...f.lockfiles.npm, ...f.lockfiles.python];
|
|
363
|
+
out(` ${locks.length ? yes(`lockfiles: ${locks.join(", ")}`) : no("no npm or Python lockfile")}`);
|
|
364
|
+
out(` ${f.agentConfig.length ? yes(`agent config: ${f.agentConfig.join(", ")}`) : no("no agent config (.mcp.json, .claude/, .cursor/, CLAUDE.md, AGENTS.md, skills/)")}`);
|
|
365
|
+
out(` ${f.devServer ? yes(`dev server: ${f.devServer}`) : no("no dev server on localhost:3000, 5173, 8000 or 8080")}`);
|
|
366
|
+
if (f.git) out(` ${f.hook ? yes("Penthera pre-commit hook installed") : no("no Penthera pre-commit hook")}`);
|
|
367
|
+
out(` ${f.workflows.length ? yes(`Penthera workflow: ${f.workflows.join(", ")}`) : no("no Penthera GitHub workflow")}`);
|
|
368
|
+
out();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Run one check and return its merged result (findings + actionPlan). */
|
|
372
|
+
async function runCheck(id, ctx) {
|
|
373
|
+
const { root, offline, version } = ctx;
|
|
374
|
+
if (id === "code") return (await executeScan({ repo: root, offline, json: true, version, onPhase: noop })).merged;
|
|
375
|
+
if (id === "app") return (await executeScan({ url: ctx.url, offline, json: true, version, onPhase: noop })).merged;
|
|
376
|
+
let result;
|
|
377
|
+
if (id === "deps") {
|
|
378
|
+
const { scanDeps } = await import("../../lib/deps/index.js");
|
|
379
|
+
result = await scanDeps(root, { licensePolicy: await resolveLicensePolicy(root), online: !offline, onPhase: noop });
|
|
380
|
+
} else {
|
|
381
|
+
const { scanAgents } = await import("../../lib/agents/index.js");
|
|
382
|
+
result = await scanAgents(root, { home: ctx.home, onPhase: noop });
|
|
383
|
+
}
|
|
384
|
+
const merged = mergeResults([result]);
|
|
385
|
+
merged.actionPlan = buildActionPlan(merged.findings);
|
|
386
|
+
return merged;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The three actions to do first, across every area. */
|
|
390
|
+
export function fixFirst(results, limit = 3) {
|
|
391
|
+
const SEV = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
392
|
+
return results
|
|
393
|
+
.flatMap((r) => (r.merged?.actionPlan || []).map((a) => ({ ...a, area: r.label })))
|
|
394
|
+
.sort((a, b) => (SEV[a.severity] ?? 5) - (SEV[b.severity] ?? 5) || (EFFORT_RANK[a.effort] ?? 3) - (EFFORT_RANK[b.effort] ?? 3))
|
|
395
|
+
.slice(0, limit)
|
|
396
|
+
.map((a) => ({ action: a.action, area: a.area, severity: a.severity, why: (a.why || "").split(/(?<=\.)\s/)[0], findings: a.findings.length }));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function summaryMarkdown(results, top, ctx) {
|
|
400
|
+
const L = [`# Penthera summary, ${ctx.date}`, "", `Penthera ${ctx.version}, ${ctx.offline ? "offline" : "online"}.`, "", "| Area | Grade | Findings | Report |", "| --- | --- | --- | --- |"];
|
|
401
|
+
for (const r of results) {
|
|
402
|
+
L.push(r.error
|
|
403
|
+
? `| ${r.label} | n/a | could not run: ${mdInline(r.error)} | |`
|
|
404
|
+
: `| ${mdInline(r.label)} | ${r.grade} | ${countLine(r.merged.findings)} | [${r.id}.md](${r.id}.md) |`);
|
|
405
|
+
}
|
|
406
|
+
if (top.length) {
|
|
407
|
+
L.push("", "## Fix first", "");
|
|
408
|
+
top.forEach((a, i) => L.push(`${i + 1}. **${mdInline(a.action)}** (${mdInline(a.area)}, ${a.severity}). ${mdInline(a.why)}`));
|
|
409
|
+
}
|
|
410
|
+
L.push("", "## Rerun", "", ...results.map((r) => `- ${r.label}: \`${r.command}\``), "");
|
|
411
|
+
return L.join("\n");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* `penthera init [--yes] [--dry-run]`.
|
|
416
|
+
* @returns {Promise<number>} exit code: 0 done, 2 bad usage
|
|
417
|
+
*/
|
|
418
|
+
export async function runInit(argv, { version = pkg.version } = {}) {
|
|
419
|
+
let opts;
|
|
420
|
+
try {
|
|
421
|
+
opts = parseArgs({
|
|
422
|
+
args: argv,
|
|
423
|
+
allowPositionals: false,
|
|
424
|
+
options: {
|
|
425
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
426
|
+
"dry-run": { type: "boolean", default: false },
|
|
427
|
+
help: { type: "boolean", short: "h", default: false },
|
|
428
|
+
},
|
|
429
|
+
}).values;
|
|
430
|
+
} catch (e) {
|
|
431
|
+
printError(e.message);
|
|
432
|
+
return 2;
|
|
433
|
+
}
|
|
434
|
+
if (opts.help) { console.log(INIT_HELP); return 0; }
|
|
435
|
+
|
|
436
|
+
const auto = opts.yes;
|
|
437
|
+
const dry = opts["dry-run"];
|
|
438
|
+
const cwd = process.cwd();
|
|
439
|
+
if (!auto) await initPrompt();
|
|
440
|
+
const yesNo = (q, def) => (auto ? Promise.resolve(def) : confirm(` ${cyan(">")} ${q}`, def));
|
|
441
|
+
|
|
442
|
+
out(logo("set up Penthera in this project: detect, check, set up"));
|
|
443
|
+
if (dry) out(` ${yellow("Dry run")} ${dim("nothing is scanned, written or started.")}\n`);
|
|
444
|
+
if (auto) out(` ${dim("--yes: accepting the safe defaults. No URL scan, no fix loop, no dashboard.")}\n`);
|
|
445
|
+
|
|
446
|
+
// ── 1. Detect ──────────────────────────────────────────────────────────
|
|
447
|
+
const found = await detectProject(cwd, { ports: portsFromEnv() });
|
|
448
|
+
const root = found.root;
|
|
449
|
+
printFound(found, cwd);
|
|
450
|
+
|
|
451
|
+
// ── 2. What to check ───────────────────────────────────────────────────
|
|
452
|
+
const items = checkItems(found);
|
|
453
|
+
let picked;
|
|
454
|
+
if (auto) {
|
|
455
|
+
picked = new Set(items.filter((i) => i.checked && i.id !== "app").map((i) => i.id));
|
|
456
|
+
} else {
|
|
457
|
+
picked = await checklist(bold("What should Penthera check?"), items);
|
|
458
|
+
out();
|
|
459
|
+
}
|
|
460
|
+
const ctx = { root, version, offline: picked.has("offline"), home: false, url: null, date: today() };
|
|
461
|
+
if (picked.has("agents") && !auto) {
|
|
462
|
+
ctx.home = await yesNo("Also check ~/.claude and ~/.cursor (your user-level agent config)?", false);
|
|
463
|
+
}
|
|
464
|
+
if (picked.has("app")) {
|
|
465
|
+
out(` ${yellow("Note")} ${dim("The app check sends safe requests (no attack payloads) to")} ${cyan(found.devServer)}`);
|
|
466
|
+
const owned = await yesNo(`Is ${found.devServer} your app (you own it or may test it)?`, false);
|
|
467
|
+
if (owned) ctx.url = found.devServer;
|
|
468
|
+
else { picked.delete("app"); out(` ${dim("Skipping the app check.")}`); }
|
|
469
|
+
}
|
|
470
|
+
const checks = ["code", "deps", "agents", "app"].filter((id) => picked.has(id));
|
|
471
|
+
const dir = inside(root, join("reports", `penthera-${ctx.date}`));
|
|
472
|
+
const relDir = relative(cwd, dir) || ".";
|
|
473
|
+
|
|
474
|
+
// ── 3. Run ─────────────────────────────────────────────────────────────
|
|
475
|
+
const results = [];
|
|
476
|
+
if (!checks.length) {
|
|
477
|
+
out(` ${dim("Nothing selected to check.")}`);
|
|
478
|
+
} else if (dry) {
|
|
479
|
+
out(` ${bold("Would run")} ${dim(ctx.offline ? "(offline)" : "(online)")}`);
|
|
480
|
+
for (const id of checks) out(` ${AREAS[id].label.padEnd(13)} ${cyan(AREAS[id].cmd(ctx))} ${dim(`→ ${join(relDir, `${id}.json`)}, ${id}.md`)}`);
|
|
481
|
+
out(` ${"Summary".padEnd(13)} ${dim(`→ ${join(relDir, "summary.md")}, summary.json`)}`);
|
|
482
|
+
} else {
|
|
483
|
+
out(` ${bold(`Running ${plural(checks.length, "check")}`)} ${dim(ctx.offline ? "(offline)" : "(online)")}`);
|
|
484
|
+
mkdirSync(dir, { recursive: true });
|
|
485
|
+
for (const id of checks) {
|
|
486
|
+
const label = AREAS[id].label;
|
|
487
|
+
const r = { id, label, command: AREAS[id].cmd(ctx) };
|
|
488
|
+
if (process.stdout.isTTY) process.stdout.write(` ${dim("…")} ${label.padEnd(13)} ${dim(r.command)}`);
|
|
489
|
+
const t0 = Date.now();
|
|
490
|
+
try {
|
|
491
|
+
r.merged = await runCheck(id, ctx);
|
|
492
|
+
Object.assign(r, gradeFindings(r.merged.findings));
|
|
493
|
+
writeJsonReport(r.merged, join(dir, `${id}.json`));
|
|
494
|
+
writeMarkdownReport(r.merged, join(dir, `${id}.md`), { version });
|
|
495
|
+
} catch (e) {
|
|
496
|
+
r.error = e.message.split("\n")[0];
|
|
497
|
+
}
|
|
498
|
+
const secs = dim(`${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
499
|
+
if (process.stdout.isTTY) process.stdout.write("\r\x1b[2K");
|
|
500
|
+
out(r.error
|
|
501
|
+
? ` ${yellow("!")} ${label.padEnd(13)} ${dim("could not run:")} ${r.error}`
|
|
502
|
+
: ` ${green("✓")} ${label.padEnd(13)} ${gradeColor(r.grade)} ${countLine(r.merged.findings)} ${secs}`);
|
|
503
|
+
results.push(r);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const top = fixFirst(results);
|
|
507
|
+
writeFileSync(join(dir, "summary.md"), summaryMarkdown(results, top, ctx));
|
|
508
|
+
writeFileSync(join(dir, "summary.json"), JSON.stringify({
|
|
509
|
+
generatedAt: new Date().toISOString(),
|
|
510
|
+
version,
|
|
511
|
+
offline: ctx.offline,
|
|
512
|
+
checks: results.map((r) => ({
|
|
513
|
+
id: r.id, label: r.label, command: r.command,
|
|
514
|
+
...(r.error ? { error: r.error } : { grade: r.grade, score: r.score, counts: countFindings(r.merged.findings), report: { json: `${r.id}.json`, markdown: `${r.id}.md` } }),
|
|
515
|
+
})),
|
|
516
|
+
fixFirst: top,
|
|
517
|
+
}, null, 2) + "\n");
|
|
518
|
+
|
|
519
|
+
out(`\n ${hr()}`);
|
|
520
|
+
out(` ${bold("Summary")}`);
|
|
521
|
+
for (const r of results) out(` ${r.label.padEnd(13)} ${r.error ? dim("n/a") : `${gradeColor(r.grade)} ${countLine(r.merged.findings)}`}`);
|
|
522
|
+
if (top.length) {
|
|
523
|
+
out(`\n ${bold("Fix first")}`);
|
|
524
|
+
top.forEach((a, i) => {
|
|
525
|
+
out(` ${i + 1}. ${a.action} ${dim(`(${a.area}, ${a.severity})`)}`);
|
|
526
|
+
if (a.why) out(dim(wrap(a.why, " ")));
|
|
527
|
+
});
|
|
528
|
+
} else if (results.some((r) => !r.error)) {
|
|
529
|
+
out(`\n ${green("Nothing to fix first.")}`);
|
|
530
|
+
}
|
|
531
|
+
out(`\n ${bold("Report")} ${cyan(join(relDir, "summary.md"))} ${dim(`(+ JSON and Markdown per area in ${relDir}/)`)}`);
|
|
532
|
+
out(` ${hr()}\n`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── 4. Set up ──────────────────────────────────────────────────────────
|
|
536
|
+
out(` ${bold("Set up")} ${dim(dry ? "(dry run: answers are shown, nothing is written)" : "nothing is committed or pushed")}`);
|
|
537
|
+
const did = (s) => out(` ${dry ? yellow("would") : green("✓")} ${s}`);
|
|
538
|
+
const skip = (s) => out(` ${dim("·")} ${dim(s)}`);
|
|
539
|
+
|
|
540
|
+
// Pre-commit hook
|
|
541
|
+
if (!found.git) skip("Pre-commit hook: not a git repository");
|
|
542
|
+
else if (found.hook) skip("Pre-commit hook: already installed");
|
|
543
|
+
else if (await yesNo("Install the pre-commit hook? It blocks staged secrets, risky agent config and lockfile changes", true)) {
|
|
544
|
+
if (dry) did("install .git/hooks/pre-commit (penthera hook install)");
|
|
545
|
+
else {
|
|
546
|
+
try {
|
|
547
|
+
const r = installHook(root);
|
|
548
|
+
did(`Installed ${relative(cwd, r.path) || r.path}${r.chained ? dim(" (your existing hook runs first)") : ""}`);
|
|
549
|
+
} catch (e) {
|
|
550
|
+
out(` ${yellow("!")} Hook not installed: ${e.message}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// GitHub workflow
|
|
556
|
+
const wfFull = inside(root, WORKFLOW_PATH);
|
|
557
|
+
if (!found.git) skip("GitHub workflow: not a git repository");
|
|
558
|
+
else if (found.workflows.length) skip(`GitHub workflow: already present (${found.workflows.join(", ")})`);
|
|
559
|
+
else if (existsSync(wfFull)) skip(`GitHub workflow: ${WORKFLOW_PATH} exists, left as is`);
|
|
560
|
+
else if (!found.lockfiles.npm.length) skip("GitHub workflow: the PR dependency review reads package-lock.json, none here");
|
|
561
|
+
else {
|
|
562
|
+
const yaml = workflowYaml();
|
|
563
|
+
out(`\n ${dim(`The PR dependency review workflow, pinned to the ${ACTION_PIN.tag} release commit:`)}\n`);
|
|
564
|
+
out(newFileDiff(WORKFLOW_PATH, yaml).split("\n").map((l) => ` ${l}`).join("\n"));
|
|
565
|
+
out();
|
|
566
|
+
if (await yesNo(`Write ${WORKFLOW_PATH}? It is not committed`, true)) {
|
|
567
|
+
if (!dry) {
|
|
568
|
+
mkdirSync(join(root, ".github", "workflows"), { recursive: true });
|
|
569
|
+
writeFileSync(wfFull, yaml);
|
|
570
|
+
}
|
|
571
|
+
did(`${dry ? "write " : "Wrote "}${WORKFLOW_PATH}${dry ? "" : dim(" (review and commit it yourself)")}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// .gitignore
|
|
576
|
+
if (found.reportsIgnored) skip(".gitignore: reports/ is already ignored");
|
|
577
|
+
else if (await yesNo("Add reports/ to .gitignore so scan output is never committed?", true)) {
|
|
578
|
+
if (!dry) {
|
|
579
|
+
const gi = inside(root, ".gitignore");
|
|
580
|
+
const prev = existsSync(gi) ? readFileSync(gi, "utf-8") : "";
|
|
581
|
+
appendFileSync(gi, `${prev && !prev.endsWith("\n") ? "\n" : ""}${prev ? "\n" : ""}# Penthera scan reports\nreports/\n`);
|
|
582
|
+
}
|
|
583
|
+
did(`${dry ? "add" : "Added"} reports/ to .gitignore`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Fix loop (never in --yes)
|
|
587
|
+
let loop = false;
|
|
588
|
+
if (auto) skip("Fix loop: not run with --yes (penthera agent --loop)");
|
|
589
|
+
else if (!found.git) skip("Fix loop: needs a git repository");
|
|
590
|
+
else if (!picked.has("code")) skip("Fix loop: the code check was not selected");
|
|
591
|
+
else loop = await yesNo("Run the fix loop now on a local branch? It shows each diff and asks before applying", false);
|
|
592
|
+
if (loop) {
|
|
593
|
+
if (dry) did("run penthera agent --loop (local branch penthera/fix-<date>, nothing pushed)");
|
|
594
|
+
else {
|
|
595
|
+
// The only uncommitted changes are ours (.gitignore, the workflow, reports/)
|
|
596
|
+
// when the tree was clean; the loop never patches changed files.
|
|
597
|
+
const args = [BIN, "agent", "--loop", "--repo", root, ...(found.clean && found.branch ? ["--allow-dirty"] : [])];
|
|
598
|
+
out();
|
|
599
|
+
closePrompt();
|
|
600
|
+
// Ctrl-C belongs to the child while it runs.
|
|
601
|
+
process.on("SIGINT", noop);
|
|
602
|
+
spawnSync(process.execPath, args, { stdio: "inherit" });
|
|
603
|
+
process.off("SIGINT", noop);
|
|
604
|
+
await initPrompt();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Dashboard (never in --yes: it runs until Ctrl-C)
|
|
609
|
+
let dashboard = false;
|
|
610
|
+
if (auto) skip("Dashboard: not opened with --yes (penthera ui --open)");
|
|
611
|
+
else dashboard = await yesNo("Open the dashboard (penthera ui --open)?", true);
|
|
612
|
+
|
|
613
|
+
// ── 5. What next ───────────────────────────────────────────────────────
|
|
614
|
+
out(`\n ${bold("What next")}`);
|
|
615
|
+
const next = [
|
|
616
|
+
["penthera", "this wizard again"],
|
|
617
|
+
...checks.map((id) => [AREAS[id].cmd(ctx), AREAS[id].label.toLowerCase()]),
|
|
618
|
+
...(found.git ? [["penthera hook run", "check what is staged now"], ["penthera agent --loop", "fix findings on a local branch"]] : []),
|
|
619
|
+
["penthera ui --open", "the dashboard"],
|
|
620
|
+
["penthera --help", "every command"],
|
|
621
|
+
];
|
|
622
|
+
const w = Math.max(...next.map(([c]) => c.length));
|
|
623
|
+
for (const [c, why] of next) out(` ${cyan(c.padEnd(w))} ${dim(why)}`);
|
|
624
|
+
out();
|
|
625
|
+
|
|
626
|
+
closePrompt();
|
|
627
|
+
if (dashboard) {
|
|
628
|
+
if (dry) did("start penthera ui --open (127.0.0.1 only)");
|
|
629
|
+
else {
|
|
630
|
+
process.on("SIGINT", noop);
|
|
631
|
+
// The dashboard reads top-level report JSON, so point it at this run's folder.
|
|
632
|
+
const reports = results.some((r) => !r.error) ? ["--reports", dir] : [];
|
|
633
|
+
spawnSync(process.execPath, [BIN, "ui", "--open", "--project", root, ...reports], { stdio: "inherit" });
|
|
634
|
+
process.off("SIGINT", noop);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return 0;
|
|
638
|
+
}
|
package/src/cli/prompt.js
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
* Shared terminal prompt helpers (TTY + piped stdin).
|
|
3
3
|
*/
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
|
+
import { dim } from "./ansi.js";
|
|
5
6
|
|
|
6
7
|
let _pipedLines = null;
|
|
7
8
|
let _rl = null;
|
|
8
9
|
|
|
10
|
+
/** Open the prompt. Safe to call again after closePrompt(): piped answers already read are kept. */
|
|
9
11
|
export async function initPrompt() {
|
|
12
|
+
if (_pipedLines) return;
|
|
10
13
|
if (process.stdin.isTTY) {
|
|
11
14
|
_rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
12
15
|
} else {
|
|
@@ -18,6 +21,7 @@ export async function initPrompt() {
|
|
|
18
21
|
|
|
19
22
|
export function closePrompt() {
|
|
20
23
|
if (_rl) _rl.close();
|
|
24
|
+
_rl = null;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function ask(question) {
|
|
@@ -62,3 +66,41 @@ export async function confirm(question, defaultYes = true) {
|
|
|
62
66
|
if (!answer) return defaultYes;
|
|
63
67
|
return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
|
|
64
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a checklist answer: "" keeps the defaults, numbers ("2", "1,3", "2 4")
|
|
72
|
+
* toggle those items. Returns null when the answer is not a list of valid numbers.
|
|
73
|
+
*/
|
|
74
|
+
export function parseToggles(answer, count) {
|
|
75
|
+
const a = (answer || "").trim();
|
|
76
|
+
if (!a) return [];
|
|
77
|
+
const parts = a.split(/[\s,]+/).filter(Boolean);
|
|
78
|
+
const nums = parts.map((p) => Number(p));
|
|
79
|
+
if (nums.some((n) => !Number.isInteger(n) || n < 1 || n > count)) return null;
|
|
80
|
+
return [...new Set(nums.map((n) => n - 1))];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Multi-select list with defaults. Enter accepts; numbers toggle items.
|
|
85
|
+
* @param {string} question
|
|
86
|
+
* @param {{ id: string, label: string, hint?: string, checked: boolean }[]} items
|
|
87
|
+
* @returns {Promise<Set<string>>} the ids that end up checked
|
|
88
|
+
*/
|
|
89
|
+
export async function checklist(question, items, { indent = " " } = {}) {
|
|
90
|
+
const checked = items.map((i) => !!i.checked);
|
|
91
|
+
process.stdout.write(`${indent}${question}\n`);
|
|
92
|
+
const pad = Math.max(...items.map((i) => i.label.length));
|
|
93
|
+
items.forEach((item, i) => {
|
|
94
|
+
const hint = item.hint ? ` ${dim(item.hint)}` : "";
|
|
95
|
+
process.stdout.write(`${indent} ${i + 1}. [${checked[i] ? "x" : " "}] ${item.hint ? item.label.padEnd(pad) : item.label}${hint}\n`);
|
|
96
|
+
});
|
|
97
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
98
|
+
const toggles = parseToggles(await ask(`${indent}Enter to accept, or numbers to toggle (e.g. 2,4): `), items.length);
|
|
99
|
+
if (toggles) {
|
|
100
|
+
for (const i of toggles) checked[i] = !checked[i];
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(`${indent}Type item numbers from 1 to ${items.length}, separated by commas, or just press Enter.\n`);
|
|
104
|
+
}
|
|
105
|
+
return new Set(items.filter((_, i) => checked[i]).map((i) => i.id));
|
|
106
|
+
}
|