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/agent/loop.js
CHANGED
|
@@ -14,22 +14,35 @@
|
|
|
14
14
|
* Nothing is pushed, fetched or opened as a pull request.
|
|
15
15
|
* - Everything runs in one egress context limited to the URL target, always
|
|
16
16
|
* offline. Loopback is added only with --fix-model, for Ollama.
|
|
17
|
-
* - A dirty tree or a detached HEAD is refused unless allowDirty is set.
|
|
17
|
+
* - A dirty tree or a detached HEAD is refused unless allowDirty is set. A fix
|
|
18
|
+
* that touches a file with uncommitted changes (checked right before writing),
|
|
19
|
+
* an untracked file or a git-ignored path is never applied.
|
|
20
|
+
* - A revert puts back the bytes each file had before the fix, and only while
|
|
21
|
+
* the file still holds exactly what the fix wrote; a commit includes only the
|
|
22
|
+
* fix's files, and only while they still hold what the fix wrote. Anything
|
|
23
|
+
* edited meanwhile is left alone and the fix is reported as a conflict.
|
|
18
24
|
* - Every diff is approved by `ask` unless --auto low and memory shows the
|
|
19
25
|
* recipe verified at least 3 times on this framework with no failures.
|
|
20
26
|
* - When `signal` aborts, the fix in flight is verified and committed or
|
|
21
27
|
* reverted before the loop returns; the branch holds only verified commits.
|
|
28
|
+
* - The judge stays as it was: no fix may edit tests, test config or
|
|
29
|
+
* Penthera's own files (applyDiff refuses with PROTECTED), and a fix is not
|
|
30
|
+
* verified if any of them changed since the run began.
|
|
31
|
+
* - A finding that failed once in a run is given up (event gave_up, with the
|
|
32
|
+
* reason) and not retried in that run. A give-up never counts against a
|
|
33
|
+
* recipe in memory.
|
|
22
34
|
*/
|
|
23
35
|
import { randomUUID, createHash } from "node:crypto";
|
|
24
|
-
import {
|
|
36
|
+
import { readFileSync } from "node:fs";
|
|
25
37
|
import { join, resolve } from "node:path";
|
|
26
38
|
import { scanRepo as defaultScanRepo } from "../scan-repo.js";
|
|
27
39
|
import { scanUrl as defaultScanUrl } from "../scan-url.js";
|
|
28
40
|
import { createEgressContext, runWithEgress, request } from "../utils/http.js";
|
|
29
41
|
import {
|
|
30
|
-
planFixes, probeRepo, applyDiff, redactPlan, findingKey, createModelFixer,
|
|
42
|
+
planFixes, probeRepo, applyDiff, restoreFiles, redactPlan, findingKey, createModelFixer, parsePatch,
|
|
31
43
|
} from "../../lib/fix/index.js";
|
|
32
44
|
import { assertLocalModelUrl } from "../../lib/fix/model.js";
|
|
45
|
+
import { protectedReason } from "../../lib/fix/apply.js";
|
|
33
46
|
import { detectFramework } from "../../lib/fix/util.js";
|
|
34
47
|
import {
|
|
35
48
|
openMemory, projectIdFor, buildViews, rankFindings, isSuppressed, withRepoSuppressions,
|
|
@@ -101,6 +114,25 @@ async function timed(list, step, fn) {
|
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Parse `git status --porcelain=v1 -z` into [{ xy, path }]. Paths are taken
|
|
119
|
+
* verbatim (no C-quoting in -z mode), made relative to `prefix` when the repo
|
|
120
|
+
* is a subdirectory of the work tree; a rename lists both of its paths.
|
|
121
|
+
*/
|
|
122
|
+
export function parseStatusZ(out, prefix = "") {
|
|
123
|
+
const parts = String(out || "").split("\0");
|
|
124
|
+
const entries = [];
|
|
125
|
+
const rel = (p) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p);
|
|
126
|
+
for (let i = 0; i < parts.length; i++) {
|
|
127
|
+
const e = parts[i];
|
|
128
|
+
if (e.length < 4) continue;
|
|
129
|
+
const xy = e.slice(0, 2);
|
|
130
|
+
entries.push({ xy, path: rel(e.slice(3)) });
|
|
131
|
+
if (/[RC]/.test(xy) && i + 1 < parts.length) entries.push({ xy, path: rel(parts[++i]) });
|
|
132
|
+
}
|
|
133
|
+
return entries;
|
|
134
|
+
}
|
|
135
|
+
|
|
104
136
|
function clean(obj) {
|
|
105
137
|
return JSON.parse(JSON.stringify(obj)); // drops undefined so memory's canonical JSON accepts it
|
|
106
138
|
}
|
|
@@ -149,12 +181,9 @@ export async function runLoop(opts = {}) {
|
|
|
149
181
|
if (!base && !opts.allowDirty) {
|
|
150
182
|
throw new LoopError("HEAD is detached. Check out a branch first (the fix branch starts from it), or pass --allow-dirty.", "DETACHED");
|
|
151
183
|
}
|
|
152
|
-
const prefix = git.run("rev-parse", "--show-prefix").
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
.map((l) => l.slice(3).replace(/^"|"$/g, "").split(" -> ").pop())
|
|
156
|
-
.map((p) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p)),
|
|
157
|
-
);
|
|
184
|
+
const prefix = git.run("rev-parse", "--show-prefix").replace(/\n$/, "");
|
|
185
|
+
const statusNow = () => parseStatusZ(git.run("status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."), prefix);
|
|
186
|
+
const dirtyFiles = new Set(statusNow().map((e) => e.path));
|
|
158
187
|
if (dirtyFiles.size && !opts.allowDirty) {
|
|
159
188
|
throw new LoopError(`${repo} has uncommitted changes (${[...dirtyFiles].slice(0, 5).join(", ")}${dirtyFiles.size > 5 ? ", ..." : ""}). Commit or stash them first, or pass --allow-dirty.`, "DIRTY");
|
|
160
189
|
}
|
|
@@ -210,8 +239,9 @@ export async function runLoop(opts = {}) {
|
|
|
210
239
|
},
|
|
211
240
|
stopReason: null,
|
|
212
241
|
error: null,
|
|
242
|
+
gaveUp: [],
|
|
213
243
|
findings: { initial: null, final: null },
|
|
214
|
-
totals: { iterations: 0, planned: 0, applied: 0, verified: 0, failed: 0, declined: 0, skipped: 0, commits: [] },
|
|
244
|
+
totals: { iterations: 0, planned: 0, applied: 0, verified: 0, failed: 0, conflicts: 0, declined: 0, skipped: 0, commits: [] },
|
|
215
245
|
memory: { projectId, chain: chainOk ? "ok" : "broken", eventsAppended: 0 },
|
|
216
246
|
iterations: [],
|
|
217
247
|
};
|
|
@@ -227,20 +257,70 @@ export async function runLoop(opts = {}) {
|
|
|
227
257
|
loopRun.branch.fix = branch;
|
|
228
258
|
log(` on ${branch} (from ${base || baseSha.slice(0, 12)})`);
|
|
229
259
|
|
|
230
|
-
|
|
260
|
+
/**
|
|
261
|
+
* Why a plan must not be applied right now, or null. Run immediately before
|
|
262
|
+
* writing: files with uncommitted changes (including ones saved while the
|
|
263
|
+
* prompt was open), untracked files and git-ignored paths are all off limits,
|
|
264
|
+
* since the loop could neither revert nor commit them cleanly.
|
|
265
|
+
*/
|
|
266
|
+
const guardFiles = (files) => {
|
|
267
|
+
const status = statusNow();
|
|
268
|
+
const set = new Set(files);
|
|
269
|
+
const untracked = status.filter((e) => e.xy === "??" && set.has(e.path)).map((e) => e.path);
|
|
270
|
+
const dirty = status.filter((e) => e.xy !== "??" && set.has(e.path)).map((e) => e.path);
|
|
271
|
+
if (dirty.length) return `touches files with uncommitted changes (${dirty.join(", ")}); fix them by hand`;
|
|
272
|
+
if (untracked.length) return `touches untracked files (${untracked.join(", ")}); commit them first or fix them by hand`;
|
|
273
|
+
// One file per call: the exit code is the answer, so no quoted path output to parse.
|
|
274
|
+
const ignored = files.filter((f) => {
|
|
275
|
+
try {
|
|
276
|
+
git.run("check-ignore", "-q", "--", `./${f}`);
|
|
277
|
+
return true;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (e?.status === 1) return false; // not ignored
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
if (ignored.length) return `touches git-ignored files (${ignored.join(", ")}), which git can neither commit nor restore; fix them by hand`;
|
|
284
|
+
return null;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// The fix in flight ({ files: applyDiff's result, staged }), so a second Ctrl-C can put the files back.
|
|
231
288
|
let pending = null;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
289
|
+
/** Restore every file of a fix from its saved bytes; never throws, reports what it could not do. */
|
|
290
|
+
const revert = (fix) => {
|
|
291
|
+
const r = restoreFiles(repo, fix.files);
|
|
292
|
+
if (fix.staged && r.restored.length) {
|
|
293
|
+
try {
|
|
294
|
+
git.run("reset", "-q", "--", ...r.restored);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
r.errors.push({ path: r.restored.join(", "), message: `could not unstage: ${e.message}` });
|
|
297
|
+
}
|
|
238
298
|
}
|
|
299
|
+
return r;
|
|
239
300
|
};
|
|
301
|
+
const revertProblems = (r) => [
|
|
302
|
+
...(r.conflicts.length ? [`left as they are because they changed after the fix was written: ${r.conflicts.join(", ")}`] : []),
|
|
303
|
+
...r.errors.map((e) => `could not restore ${e.path}: ${e.message}`),
|
|
304
|
+
].join("; ");
|
|
305
|
+
const currentSha = (file) => {
|
|
306
|
+
try {
|
|
307
|
+
return sha256(readFileSync(join(repo, file)));
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
// Protected files (the judge) that differ from the base commit, with their hashes; must not change during the run.
|
|
313
|
+
const judgeState = () => [...new Set([
|
|
314
|
+
...git.run("diff", "--name-only", "-z", "--relative", baseSha, "--", ".").split("\0"),
|
|
315
|
+
...statusNow().filter((e) => e.xy === "??").map((e) => e.path),
|
|
316
|
+
])].filter((f) => f && protectedReason(f)).sort().map((f) => `${f} ${currentSha(f)}`).join("\n");
|
|
317
|
+
const judgeAtStart = judgeState();
|
|
318
|
+
|
|
240
319
|
if (opts.handle) {
|
|
241
320
|
opts.handle.revertPending = () => {
|
|
242
|
-
|
|
321
|
+
const r = pending ? revert(pending) : null;
|
|
243
322
|
pending = null;
|
|
323
|
+
return r;
|
|
244
324
|
};
|
|
245
325
|
}
|
|
246
326
|
|
|
@@ -278,15 +358,28 @@ export async function runLoop(opts = {}) {
|
|
|
278
358
|
return { ranked: chainOk ? rankFindings(kept, views, { framework: stack }) : kept, suppressed };
|
|
279
359
|
}
|
|
280
360
|
|
|
281
|
-
let heldBack = 0; // plans left out of the last plan() because they were declined or
|
|
361
|
+
let heldBack = 0; // plans and findings left out of the last plan() because they were declined or given up
|
|
282
362
|
const tried = new Set(); // plan keys declined or failed in this run: never proposed again
|
|
363
|
+
const gaveUp = new Set(); // finding keys the loop could not fix in this run: never retried
|
|
364
|
+
/** The safe exit: record that the loop could not fix these findings, and why. Never a recipe failure. */
|
|
365
|
+
const giveUp = (findings, reason, source) => {
|
|
366
|
+
for (const f of findings) {
|
|
367
|
+
if (gaveUp.has(findingKey(f))) continue;
|
|
368
|
+
gaveUp.add(findingKey(f));
|
|
369
|
+
loopRun.gaveUp.push({ fid: findingId(f), title: f.title, reason });
|
|
370
|
+
remember("gave_up", { id: findingId(f), reason: String(reason).slice(0, 1_000), source: source || null, loop: loopRun.id });
|
|
371
|
+
}
|
|
372
|
+
};
|
|
283
373
|
const planKey = (p) => (p.source === "recipe" ? `recipe:${p.recipe}` : `model:${p.findings.map(findingKey).sort().join("|")}`);
|
|
284
374
|
|
|
285
375
|
async function plan(ranked) {
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
const
|
|
289
|
-
|
|
376
|
+
const open = ranked.filter((f) => !gaveUp.has(findingKey(f)));
|
|
377
|
+
const byKey = new Map(open.map((f) => [findingKey(f), f]));
|
|
378
|
+
const { plans, unmatched, refused } = await planFixes(repo, open, { model });
|
|
379
|
+
for (const r of refused) giveUp([r.finding], r.reason, "local-model");
|
|
380
|
+
if (model) giveUp(actionable(unmatched), "no recipe matched and the local model gave no usable fix", "local-model");
|
|
381
|
+
const order = new Map(open.map((f, i) => [findingKey(f), i]));
|
|
382
|
+
heldBack = plans.filter((p) => tried.has(planKey(p))).length + ranked.filter((f) => gaveUp.has(findingKey(f))).length;
|
|
290
383
|
return plans
|
|
291
384
|
.filter((p) => !tried.has(planKey(p)))
|
|
292
385
|
.map((p) => {
|
|
@@ -389,27 +482,29 @@ export async function runLoop(opts = {}) {
|
|
|
389
482
|
if (answer !== "y" && answer !== "auto") { entry.decision = "skipped"; loopRun.totals.skipped++; continue; }
|
|
390
483
|
entry.decision = answer === "auto" ? "auto" : "approved";
|
|
391
484
|
|
|
392
|
-
|
|
393
|
-
if (touchesDirty.length) {
|
|
394
|
-
entry.reason = `touches files with uncommitted changes (${touchesDirty.join(", ")}); fix them by hand`;
|
|
395
|
-
tried.add(planKey(p));
|
|
396
|
-
log(` not applied: ${entry.reason}`);
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// ── Patch ──
|
|
485
|
+
// ── Patch: re-check the files right before writing (the prompt may have been open a while) ──
|
|
401
486
|
let applied;
|
|
402
487
|
try {
|
|
403
|
-
applied = await timed(entry.steps, "patch", async () =>
|
|
488
|
+
applied = await timed(entry.steps, "patch", async () => {
|
|
489
|
+
const targets = [...new Set([...p.files, ...parsePatch(p.diff).map((f) => f.newPath).filter(Boolean)])];
|
|
490
|
+
const refused = guardFiles(targets);
|
|
491
|
+
if (refused) return { refused };
|
|
492
|
+
return applyDiff(repo, p.diff, { allowDirty: Boolean(opts.allowDirty) });
|
|
493
|
+
});
|
|
404
494
|
} catch (e) {
|
|
405
|
-
|
|
495
|
+
if (e?.name === "GitPolicyError") throw e;
|
|
496
|
+
applied = { refused: `did not apply: ${e.message}` };
|
|
497
|
+
}
|
|
498
|
+
if (applied.refused) {
|
|
499
|
+
entry.reason = applied.refused;
|
|
406
500
|
tried.add(planKey(p));
|
|
407
|
-
|
|
501
|
+
giveUp(originals, entry.reason, p.source);
|
|
502
|
+
log(` not applied: ${entry.reason}`);
|
|
408
503
|
continue;
|
|
409
504
|
}
|
|
410
505
|
const files = applied.files.map((f) => f.path);
|
|
411
506
|
const created = applied.files.filter((f) => f.created).map((f) => f.path);
|
|
412
|
-
pending = { files,
|
|
507
|
+
pending = { files: applied.files, staged: false };
|
|
413
508
|
loopRun.totals.applied++;
|
|
414
509
|
appliedHere++;
|
|
415
510
|
const primary = originals[0];
|
|
@@ -429,13 +524,17 @@ export async function runLoop(opts = {}) {
|
|
|
429
524
|
const targetedRemaining = p.findings.filter((f) => staticNow.has(findingKey(f))).length;
|
|
430
525
|
const liveRemaining = p.findings.filter((f) => liveNow.has(findingKey(f))).length;
|
|
431
526
|
const introduced = actionable(after.all).filter((f) => !beforeKeys.has(findingKey(f)));
|
|
527
|
+
// The judge must be the one the run began with, before and after the tests run.
|
|
528
|
+
let judgeOk = judgeState() === judgeAtStart;
|
|
432
529
|
let tests = null;
|
|
433
|
-
if (opts.runTests && hasTestScript(repo)) {
|
|
530
|
+
if (judgeOk && opts.runTests && hasTestScript(repo)) {
|
|
434
531
|
const r = await testsFn(repo);
|
|
435
532
|
tests = { ok: r.ok, exitCode: r.exitCode, durationMs: r.durationMs };
|
|
436
533
|
if (!r.ok) entry.testOutput = r.output?.slice(-2_000);
|
|
437
534
|
}
|
|
535
|
+
judgeOk &&= judgeState() === judgeAtStart;
|
|
438
536
|
const reasons = [];
|
|
537
|
+
if (!judgeOk) reasons.push("tests, test config or Penthera's own files changed during the run, so the fix was not verified");
|
|
439
538
|
if (targetedRemaining) reasons.push(`${targetedRemaining} targeted finding(s) still reported`);
|
|
440
539
|
if (introduced.length) reasons.push(`new finding(s): ${introduced.slice(0, 3).map((f) => f.title).join("; ")}`);
|
|
441
540
|
if (tests && !tests.ok) reasons.push(`project tests failed (exit ${tests.exitCode})`);
|
|
@@ -443,11 +542,24 @@ export async function runLoop(opts = {}) {
|
|
|
443
542
|
});
|
|
444
543
|
entry.verify = verdict.summary;
|
|
445
544
|
|
|
446
|
-
|
|
545
|
+
// Files edited by someone else since the fix was written: neither commit nor revert them.
|
|
546
|
+
const changed = applied.files.filter((f) => currentSha(f.path) !== f.sha256).map((f) => f.path);
|
|
547
|
+
if (verdict.ok && changed.length) {
|
|
548
|
+
pending = null;
|
|
549
|
+
entry.status = "conflict";
|
|
550
|
+
entry.reason = `verified, but ${changed.join(", ")} changed while it was being verified; left as they are and nothing was committed`;
|
|
551
|
+
tried.add(planKey(p));
|
|
552
|
+
loopRun.totals.conflicts++;
|
|
553
|
+
log(` not committed: ${entry.reason}`);
|
|
554
|
+
} else if (verdict.ok) {
|
|
447
555
|
const [subject, msg] = commitMessage(p, entry, entry.decision);
|
|
448
556
|
await timed(entry.steps, "commit", async () => {
|
|
449
|
-
|
|
450
|
-
|
|
557
|
+
if (created.length) {
|
|
558
|
+
pending.staged = true;
|
|
559
|
+
git.run("add", "--", ...created);
|
|
560
|
+
}
|
|
561
|
+
// --only: the fix's files as they are on disk, nothing else that happens to be staged.
|
|
562
|
+
git.run("commit", "-q", "--only", "-m", subject, "-m", msg, "--", ...files);
|
|
451
563
|
});
|
|
452
564
|
pending = null;
|
|
453
565
|
entry.commit = git.run("rev-parse", "HEAD").trim();
|
|
@@ -459,14 +571,20 @@ export async function runLoop(opts = {}) {
|
|
|
459
571
|
const live = entry.verify.liveRemaining ? `; ${entry.verify.liveRemaining} still served by the running app until it restarts` : "";
|
|
460
572
|
log(` verified and committed ${entry.commit.slice(0, 10)}${live}`);
|
|
461
573
|
} else {
|
|
462
|
-
await timed(entry.steps, "revert", async () => revert(
|
|
574
|
+
const r = await timed(entry.steps, "revert", async () => revert(pending));
|
|
463
575
|
pending = null;
|
|
464
|
-
entry.status = "failed";
|
|
576
|
+
entry.status = r.conflicts.length ? "conflict" : "failed";
|
|
465
577
|
entry.reason = verdict.reasons.join("; ");
|
|
578
|
+
const problems = revertProblems(r);
|
|
579
|
+
if (problems) entry.reason += `; revert: ${problems}`;
|
|
466
580
|
tried.add(planKey(p));
|
|
467
581
|
loopRun.totals.failed++;
|
|
468
|
-
|
|
469
|
-
|
|
582
|
+
if (r.conflicts.length) loopRun.totals.conflicts++;
|
|
583
|
+
await timed(entry.steps, "learn", async () => {
|
|
584
|
+
remember("fix_failed", { ...learnData, reason: entry.reason });
|
|
585
|
+
giveUp(originals, entry.reason, p.source);
|
|
586
|
+
});
|
|
587
|
+
log(` failed verification, ${problems ? "not fully reverted" : "reverted"}: ${entry.reason}`);
|
|
470
588
|
}
|
|
471
589
|
if (stopped()) { stopReason = "interrupted"; break; }
|
|
472
590
|
}
|
|
@@ -488,12 +606,13 @@ export async function runLoop(opts = {}) {
|
|
|
488
606
|
try {
|
|
489
607
|
await runWithEgress(ctx, body);
|
|
490
608
|
} catch (e) {
|
|
609
|
+
let problems = "";
|
|
491
610
|
if (pending) {
|
|
492
|
-
|
|
611
|
+
problems = revertProblems(revert(pending));
|
|
493
612
|
pending = null;
|
|
494
613
|
}
|
|
495
614
|
stopReason = "error";
|
|
496
|
-
loopRun.error = e.message;
|
|
615
|
+
loopRun.error = problems ? `${e.message}; revert: ${problems}` : e.message;
|
|
497
616
|
}
|
|
498
617
|
|
|
499
618
|
loopRun.stopReason = stopReason;
|
package/src/cli/agent.js
CHANGED
|
@@ -14,7 +14,7 @@ import { existsSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
|
14
14
|
import { createInterface } from "node:readline";
|
|
15
15
|
import { runLoop, LoopError } from "../agent/loop.js";
|
|
16
16
|
import { normalizeBaseUrl } from "../utils/url.js";
|
|
17
|
-
import { printPlan } from "./fix.js";
|
|
17
|
+
import { printPlan, visible } from "./fix.js";
|
|
18
18
|
import { bold, dim, green, red, yellow } from "./ansi.js";
|
|
19
19
|
|
|
20
20
|
const HELP = `
|
|
@@ -136,7 +136,10 @@ export async function runAgent(argv) {
|
|
|
136
136
|
controller.abort();
|
|
137
137
|
return;
|
|
138
138
|
}
|
|
139
|
-
|
|
139
|
+
let r = null;
|
|
140
|
+
try { r = handle.revertPending?.(); } catch (e) { process.stdout.write(`\n ${red("Revert failed")} ${visible(e.message)}\n`); }
|
|
141
|
+
for (const f of r?.conflicts || []) process.stdout.write(`\n ${yellow("Left as is")} ${visible(f)}: it changed after the fix was written\n`);
|
|
142
|
+
for (const e of r?.errors || []) process.stdout.write(`\n ${red("Not restored")} ${visible(e.path)}: ${visible(e.message)}\n`);
|
|
140
143
|
process.stdout.write(`\n ${red("Stopped.")} The fix branch holds only verified commits.\n`);
|
|
141
144
|
process.exit(130);
|
|
142
145
|
};
|
|
@@ -151,7 +154,7 @@ export async function runAgent(argv) {
|
|
|
151
154
|
run = await runLoop({
|
|
152
155
|
repo, url, maxIterations, auto: opts.auto || "off", runTests: opts["run-tests"],
|
|
153
156
|
fixModel: opts["fix-model"] || null, allowDirty: opts["allow-dirty"],
|
|
154
|
-
ask: asker.ask, log: (l) => process.stdout.write(`${l}\n`), signal: controller.signal, handle,
|
|
157
|
+
ask: asker.ask, log: (l) => process.stdout.write(`${String(l).split("\n").map((x) => visible(x)).join("\n")}\n`), signal: controller.signal, handle,
|
|
155
158
|
});
|
|
156
159
|
} catch (e) {
|
|
157
160
|
asker.close();
|
|
@@ -170,9 +173,12 @@ export async function runAgent(argv) {
|
|
|
170
173
|
|
|
171
174
|
const t = run.totals;
|
|
172
175
|
process.stdout.write(`\n ${bold("Done")}: ${run.stopReason} after ${t.iterations} iteration${t.iterations === 1 ? "" : "s"} (${(run.durationMs / 1000).toFixed(1)}s)\n`);
|
|
173
|
-
process.stdout.write(` ${green(`${t.verified} verified`)}, ${t.failed ? red(`${t.failed} failed and reverted`) : "0 failed"}, ${t.declined} declined, ${t.skipped} skipped\n`);
|
|
176
|
+
process.stdout.write(` ${green(`${t.verified} verified`)}, ${t.failed ? red(`${t.failed} failed and reverted`) : "0 failed"}, ${t.conflicts ? `${yellow(`${t.conflicts} left for you (files changed meanwhile)`)}, ` : ""}${t.declined} declined, ${t.skipped} skipped\n`);
|
|
174
177
|
process.stdout.write(` findings: ${run.findings.initial ?? "?"} → ${run.findings.final ?? "?"} (non-info)\n`);
|
|
175
|
-
if (run.
|
|
178
|
+
if (run.gaveUp.length) {
|
|
179
|
+
process.stdout.write(` ${yellow(`gave up on ${run.gaveUp.length}`)} (not retried this run; fix by hand): ${run.gaveUp.map((g) => `${visible(g.title)} (${visible(g.reason)})`).join("; ")}\n`);
|
|
180
|
+
}
|
|
181
|
+
if (run.error) process.stdout.write(` ${red("Error")} ${visible(run.error)}\n`);
|
|
176
182
|
process.stdout.write(` branch ${bold(run.branch.fix)}: review with git log --stat ${run.branch.base || run.branch.baseSha.slice(0, 12)}..${run.branch.fix}\n`);
|
|
177
183
|
if (opts.output) process.stdout.write(` loopRun written to ${opts.output}\n`);
|
|
178
184
|
process.stdout.write(` Nothing was pushed. Merge or push the branch yourself when you are happy with it.\n\n`);
|
package/src/cli/deps.js
CHANGED
|
@@ -11,8 +11,6 @@ import { writeMarkdownReport, markdownPathFromJson } from "../report/markdown.js
|
|
|
11
11
|
import { prepareReportOptions, customReport, writeEvidence } from "./report-outputs.js";
|
|
12
12
|
import { compareWithBaseline } from "./baseline.js";
|
|
13
13
|
|
|
14
|
-
export const DEPS_FLAGS = ["diff", "base", "online", "pr-comment", "prod", "license-policy", "venv"];
|
|
15
|
-
|
|
16
14
|
/**
|
|
17
15
|
* Scan-command flags that mean nothing for a dependency scan. Rejected rather
|
|
18
16
|
* than ignored: a flag that silently does nothing reads as a check that ran.
|
package/src/cli/detect.js
CHANGED
|
@@ -4,23 +4,27 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { execFileSync } from "node:child_process";
|
|
8
7
|
import { createConnection } from "node:net";
|
|
9
8
|
import { detectProjectFramework } from "../../lib/whitebox/frameworks.js";
|
|
10
9
|
import { HOOK_MARKER } from "../../lib/hook/index.js";
|
|
10
|
+
import { git as runGit, gitToplevel } from "../../lib/git.js";
|
|
11
11
|
|
|
12
12
|
export const DEV_PORTS = [3000, 5173, 8000, 8080];
|
|
13
13
|
export const NPM_LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
|
|
14
14
|
export const PY_LOCKFILES = ["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock", "requirements.txt"];
|
|
15
15
|
export const AGENT_CONFIG = [".mcp.json", ".claude", ".cursor", "CLAUDE.md", "AGENTS.md", "skills"];
|
|
16
16
|
|
|
17
|
-
function git(cwd, args) {
|
|
18
|
-
return
|
|
17
|
+
function git(cwd, args, opts) {
|
|
18
|
+
return runGit(cwd, args, { stdio: ["ignore", "pipe", "ignore"], ...opts }).trim();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The git toplevel for `cwd`, or null outside a repo (or without git). Git
|
|
23
|
+
* prints forward slashes on Windows (D:/a/app); resolve() makes it a native
|
|
24
|
+
* path so prefix checks against resolve()d paths hold.
|
|
25
|
+
*/
|
|
22
26
|
export function gitRoot(cwd) {
|
|
23
|
-
|
|
27
|
+
return gitToplevel(cwd);
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/** True when something accepts a TCP connection on host:port within `timeout` ms. */
|
|
@@ -28,7 +32,10 @@ function listening(host, port, timeout) {
|
|
|
28
32
|
return new Promise((resolve) => {
|
|
29
33
|
const sock = createConnection({ host, port });
|
|
30
34
|
const done = (ok) => { sock.destroy(); resolve(ok); };
|
|
31
|
-
|
|
35
|
+
// On a starved machine the timer and a connect that already completed can
|
|
36
|
+
// be ready in the same loop turn, and timers run first. setImmediate runs
|
|
37
|
+
// after pending I/O, so a connection made within the deadline still wins.
|
|
38
|
+
sock.setTimeout(timeout, () => setImmediate(() => done(false)));
|
|
32
39
|
sock.once("connect", () => done(true));
|
|
33
40
|
sock.once("error", () => done(false));
|
|
34
41
|
});
|
|
@@ -54,7 +61,7 @@ export function portsFromEnv(env = process.env) {
|
|
|
54
61
|
|
|
55
62
|
function hookInstalled(root) {
|
|
56
63
|
try {
|
|
57
|
-
const path = join(git(root, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]), "pre-commit");
|
|
64
|
+
const path = join(git(root, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"], { raw: true }), "pre-commit");
|
|
58
65
|
return existsSync(path) && readFileSync(path, "utf-8").includes(HOOK_MARKER);
|
|
59
66
|
} catch { return false; }
|
|
60
67
|
}
|
package/src/cli/fix.js
CHANGED
|
@@ -57,8 +57,24 @@ async function gatherFindings(repo, input) {
|
|
|
57
57
|
return { findings, fresh: true };
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const NAMED = { "\0": "\\0", "\b": "\\b", "\t": "\\t", "\n": "\\n", "\v": "\\v", "\f": "\\f", "\r": "\\r", "\x1b": "\\e" };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `s` with every control character (C0 except tab, DEL, C1) and every bidi
|
|
64
|
+
* override shown as an escape such as \r or \e, so text from a scanned repo or
|
|
65
|
+
* a model cannot move the cursor, erase a line or reorder what the reviewer sees.
|
|
66
|
+
*/
|
|
67
|
+
export function visible(s, { keepTabs = true } = {}) {
|
|
68
|
+
return String(s ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, (c) => {
|
|
69
|
+
if (c === "\t" && keepTabs) return c;
|
|
70
|
+
const code = c.charCodeAt(0);
|
|
71
|
+
return NAMED[c] || (code <= 0xff ? `\\x${code.toString(16).padStart(2, "0")}` : `\\u{${code.toString(16)}}`);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
export function colorDiff(diff) {
|
|
61
|
-
return diff.split("\n").map((
|
|
76
|
+
return String(diff).split("\n").map((raw) => {
|
|
77
|
+
const l = visible(raw);
|
|
62
78
|
if (/^(---|\+\+\+) /.test(l)) return bold(l);
|
|
63
79
|
if (l.startsWith("@@")) return cyan(l);
|
|
64
80
|
if (l.startsWith("+")) return green(l);
|
|
@@ -68,10 +84,11 @@ export function colorDiff(diff) {
|
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
export function printPlan(plan) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
for (const
|
|
87
|
+
const v = (s) => visible(s, { keepTabs: false });
|
|
88
|
+
const conf = plan.confidence === "needs-human-review" ? yellow(v(plan.confidence)) : v(plan.confidence);
|
|
89
|
+
process.stdout.write(`\n ${bold(v(plan.id))} ${v(plan.title)}${plan.framework ? dim(` (${v(plan.framework)})`) : ""} confidence: ${conf} source: ${v(plan.source)}\n`);
|
|
90
|
+
for (const f of plan.findings) process.stdout.write(` ${dim("fixes")} [${v(f.severity || "?")}] ${v(f.title)}${f.url ? dim(` ${v(f.url)}`) : ""}\n`);
|
|
91
|
+
for (const a of plan.ownerActions) process.stdout.write(` ${yellow("owner action")} ${v(a)}\n`);
|
|
75
92
|
process.stdout.write("\n" + colorDiff(plan.diff).replace(/^/gm, " ").trimEnd() + "\n");
|
|
76
93
|
}
|
|
77
94
|
|
|
@@ -133,20 +150,21 @@ export async function runFix(argv) {
|
|
|
133
150
|
process.stderr.write(` ${red("Error")} Could not read findings: ${e.message}\n`);
|
|
134
151
|
process.exit(2);
|
|
135
152
|
}
|
|
136
|
-
const { plans, unmatched } = await planFixes(repo, gathered.findings, { model });
|
|
153
|
+
const { plans, unmatched, refused } = await planFixes(repo, gathered.findings, { model });
|
|
137
154
|
|
|
138
155
|
if (!opts.apply) {
|
|
139
156
|
if (opts.json) {
|
|
140
|
-
console.log(JSON.stringify({ repo, plans: plans.map(redactPlan), unmatched: unmatched.map((f) => ({ title: f.title, category: f.category, severity: f.severity, url: f.url || null })) }, null, 2));
|
|
157
|
+
console.log(JSON.stringify({ repo, plans: plans.map(redactPlan), unmatched: unmatched.map((f) => ({ title: f.title, category: f.category, severity: f.severity, url: f.url || null })), refused: refused.map((r) => ({ title: r.finding.title, reason: r.reason })) }, null, 2));
|
|
141
158
|
process.exit(0);
|
|
142
159
|
}
|
|
143
160
|
process.stdout.write(`\n ${bold("penthera fix")} ${dim(repo)}\n`);
|
|
144
161
|
process.stdout.write(` ${plans.length} fix${plans.length === 1 ? "" : "es"} planned for ${gathered.findings.length} finding${gathered.findings.length === 1 ? "" : "s"}${gathered.fresh ? " (fresh repo scan)" : ` (from ${opts.input})`}\n`);
|
|
145
162
|
for (const p of plans) printPlan(redactPlan(p));
|
|
163
|
+
for (const r of refused) process.stdout.write(`\n ${yellow("Refused")} the model's fix for ${visible(r.finding.title)}: ${visible(r.reason)}\n`);
|
|
146
164
|
const manual = unmatched.filter((f) => f.severity !== "info");
|
|
147
165
|
if (manual.length) {
|
|
148
166
|
process.stdout.write(`\n ${dim(`${manual.length} finding${manual.length === 1 ? "" : "s"} without a recipe; fix by hand (see the report's action plan)${model ? "" : " or try --fix-model <name>"}:`)}\n`);
|
|
149
|
-
for (const f of manual.slice(0, 20)) process.stdout.write(` [${f.severity}] ${f.title}\n`);
|
|
167
|
+
for (const f of manual.slice(0, 20)) process.stdout.write(` [${visible(f.severity)}] ${visible(f.title)}\n`);
|
|
150
168
|
}
|
|
151
169
|
if (plans.length) process.stdout.write(`\n Apply one: penthera fix --apply <id>${opts.repo ? ` --repo ${opts.repo}` : ""}${opts.input ? ` -o ${opts.input}` : ""}\n\n`);
|
|
152
170
|
process.exit(0);
|
package/src/cli/hook.js
CHANGED
|
@@ -31,7 +31,8 @@ const HELP = `
|
|
|
31
31
|
|
|
32
32
|
${dim("Policy")} ${dim("(penthera.policy.json)")}
|
|
33
33
|
{ "hook": { "blockOn": "high", "ignore": ["tests/fixtures/**"],
|
|
34
|
-
"checks": { "secrets": true, "agents": true, "deps": true, "licenses": true }
|
|
34
|
+
"checks": { "secrets": true, "agents": true, "deps": true, "licenses": true },
|
|
35
|
+
"largeFiles": "block" } } ${dim("// staged files over 16 MB: block or warn")}
|
|
35
36
|
|
|
36
37
|
${dim("Other hook managers")}
|
|
37
38
|
pre-commit: repo: https://github.com/danoszz/penthera, hooks: [{ id: penthera }]
|
package/src/cli/onboarding.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* wizard (runScanWizard).
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
12
|
-
import { resolve, join, relative, sep } from "node:path";
|
|
12
|
+
import { resolve, join, relative, isAbsolute, sep } from "node:path";
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -284,10 +284,16 @@ function today(now = new Date()) {
|
|
|
284
284
|
return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}`;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/**
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Refuse any write that would land outside the project root. Both sides are
|
|
289
|
+
* resolved first, so a root in another spelling (git prints D:/a/app on
|
|
290
|
+
* Windows) still compares equal. `p` is the path module (path.win32 in tests).
|
|
291
|
+
*/
|
|
292
|
+
export function inside(root, path, p = { resolve, relative, isAbsolute, sep }) {
|
|
293
|
+
const base = p.resolve(root);
|
|
294
|
+
const full = p.resolve(base, path);
|
|
295
|
+
const rel = p.relative(base, full);
|
|
296
|
+
if (rel === ".." || rel.startsWith(`..${p.sep}`) || p.isAbsolute(rel)) throw new Error(`Refusing to write outside the project: ${full}`);
|
|
291
297
|
return full;
|
|
292
298
|
}
|
|
293
299
|
|
|
@@ -608,7 +614,9 @@ export async function runInit(argv, { version = pkg.version } = {}) {
|
|
|
608
614
|
// Dashboard (never in --yes: it runs until Ctrl-C)
|
|
609
615
|
let dashboard = false;
|
|
610
616
|
if (auto) skip("Dashboard: not opened with --yes (penthera ui --open)");
|
|
611
|
-
|
|
617
|
+
// Default yes only on a terminal: piped answers that run out must not start
|
|
618
|
+
// something that runs until Ctrl-C (and opens a browser).
|
|
619
|
+
else dashboard = await yesNo("Open the dashboard (penthera ui --open)?", process.stdin.isTTY === true);
|
|
612
620
|
|
|
613
621
|
// ── 5. What next ───────────────────────────────────────────────────────
|
|
614
622
|
out(`\n ${bold("What next")}`);
|