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.
Files changed (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. 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 { existsSync, unlinkSync } from "node:fs";
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").trim();
153
- const dirtyFiles = new Set(
154
- git.run("status", "--porcelain", "--untracked-files=all", "--", ".").split("\n").filter(Boolean)
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
- // The fix in flight, so a second Ctrl-C can put the files back.
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
- const revert = (files, created) => {
233
- const tracked = files.filter((f) => !created.includes(f));
234
- if (tracked.length) git.run("checkout", "--", ...tracked);
235
- for (const f of created) {
236
- const p = join(repo, f);
237
- if (existsSync(p)) unlinkSync(p);
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
- if (pending) revert(pending.files, pending.created);
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 failed
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 byKey = new Map(ranked.map((f) => [findingKey(f), f]));
287
- const { plans } = await planFixes(repo, ranked, { model });
288
- const order = new Map(ranked.map((f, i) => [findingKey(f), i]));
289
- heldBack = plans.filter((p) => tried.has(planKey(p))).length;
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
- const touchesDirty = p.files.filter((f) => dirtyFiles.has(f));
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 () => applyDiff(repo, p.diff, { allowDirty: Boolean(opts.allowDirty) }));
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
- entry.reason = `did not apply: ${e.message}`;
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
- log(` not applied: ${e.message}`);
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, created };
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
- if (verdict.ok) {
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
- git.run("add", "--", ...files);
450
- git.run("commit", "-q", "-m", subject, "-m", msg, "--", ...files);
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(files, created));
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
- await timed(entry.steps, "learn", async () => remember("fix_failed", { ...learnData, reason: entry.reason }));
469
- log(` failed verification, reverted: ${entry.reason}`);
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
- try { revert(pending.files, pending.created); } catch { /* reported below */ }
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
- try { handle.revertPending?.(); } catch { /* best effort */ }
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.error) process.stdout.write(` ${red("Error")} ${run.error}\n`);
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 execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
17
+ function git(cwd, args, opts) {
18
+ return runGit(cwd, args, { stdio: ["ignore", "pipe", "ignore"], ...opts }).trim();
19
19
  }
20
20
 
21
- /** The git toplevel for `cwd`, or null outside a repo (or without git). */
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
- try { return git(cwd, ["rev-parse", "--show-toplevel"]); } catch { return null; }
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
- sock.setTimeout(timeout, () => done(false));
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((l) => {
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 conf = plan.confidence === "needs-human-review" ? yellow(plan.confidence) : plan.confidence;
72
- process.stdout.write(`\n ${bold(plan.id)} ${plan.title}${plan.framework ? dim(` (${plan.framework})`) : ""} confidence: ${conf} source: ${plan.source}\n`);
73
- for (const f of plan.findings) process.stdout.write(` ${dim("fixes")} [${f.severity || "?"}] ${f.title}${f.url ? dim(` ${f.url}`) : ""}\n`);
74
- for (const a of plan.ownerActions) process.stdout.write(` ${yellow("owner action")} ${a}\n`);
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 }]
@@ -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
- /** 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}`);
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
- else dashboard = await yesNo("Open the dashboard (penthera ui --open)?", true);
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")}`);
@@ -71,6 +71,7 @@ export function writeEvidence(result, opts, { version, template, mdOpts, sbom, a
71
71
  },
72
72
  repo: opts.repo ? resolve(opts.repo) : null,
73
73
  signKey: opts["sign-key"] || null,
74
+ parentReal: opts.evidenceParent || null,
74
75
  });
75
76
  }
76
77