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/lib/fix/apply.js CHANGED
@@ -2,12 +2,23 @@
2
2
  * applyDiff: the one place fix recipes' diffs get written to disk.
3
3
  *
4
4
  * Planning never writes. Applying is a separate, explicit step that refuses to
5
- * write outside the repo, into any .git (any case, any depth), to .gitmodules,
6
- * through a symlink anywhere in the path, or into a dirty git tree (unless the
7
- * caller passes allowDirty), and writes nothing unless every hunk applies.
5
+ * write outside the repo, into any .git (any case, any depth, any NTFS alias),
6
+ * to a protected file (tests, test config, Penthera's own files, CI, hooks,
7
+ * lockfiles, .gitmodules: see PROTECTED_FILES), through a symlink anywhere in the path, or into a dirty git
8
+ * tree (unless the caller passes allowDirty). It refuses added lines carrying
9
+ * control characters a terminal would act on, and it is all-or-nothing: every
10
+ * hunk must apply, and if a write fails the files already written are put back.
11
+ *
12
+ * Each file is written to a temp file in its directory and moved into place, so
13
+ * a hard link is replaced rather than written through, and an edit is refused
14
+ * if the file changed since it was read.
8
15
  */
9
- import { existsSync, readFileSync, mkdirSync, realpathSync, lstatSync, openSync, writeSync, closeSync, constants } from "node:fs";
10
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
+ import { createHash, randomBytes } from "node:crypto";
17
+ import {
18
+ existsSync, readFileSync, mkdirSync, realpathSync, lstatSync, fstatSync, openSync, writeSync, closeSync,
19
+ renameSync, linkSync, unlinkSync, rmdirSync, chmodSync, constants,
20
+ } from "node:fs";
21
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
22
  import { createGit } from "../../src/agent/git.js";
12
23
  import { parsePatch, applyHunks } from "./diff.js";
13
24
 
@@ -24,8 +35,10 @@ function inside(root, p) {
24
35
  return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
25
36
  }
26
37
 
27
- // Code points HFS+ ignores in names, so ".g\u200cit" is ".git" on macOS (CVE-2014-9390).
28
- const IGNORABLE = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u206a-\u206f\ufeff]/g;
38
+ export const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
39
+
40
+ // Code points HFS+ ignores in names, so ".g‌it" is ".git" on macOS (CVE-2014-9390).
41
+ const IGNORABLE = /[​-‏‪-‮⁠-⁤-]/g;
29
42
 
30
43
  /**
31
44
  * The name a path segment resolves to on case-insensitive file systems
@@ -42,18 +55,51 @@ export function isGitDirSegment(seg) {
42
55
  return c === ".git" || /^git~\d+$/.test(c);
43
56
  }
44
57
 
45
- /** Files a fix diff may never touch even though they live in the work tree. */
46
- const PROTECTED_FILES = new Set([".gitmodules"]);
58
+ const LOCKFILES = new Set([
59
+ "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "deno.lock",
60
+ "poetry.lock", "pipfile.lock", "uv.lock", "pdm.lock", "cargo.lock", "go.sum", "gemfile.lock", "composer.lock",
61
+ ]);
62
+ const TEST_CONFIG = new Set(["package.json", "pytest.ini", "tox.ini", "setup.cfg", "pyproject.toml"]);
63
+
64
+ /**
65
+ * Paths a fix diff may never touch even though they live in the work tree: the
66
+ * judge that verifies a fix (tests, test runner config, Penthera's own policy,
67
+ * suppressions, baseline and memory), CI, git hooks and lockfiles. A fix may not
68
+ * pass by editing its own checker. Each entry is [what, test(segments, basename)]
69
+ * on canonical segments, so case and Windows aliases do not slip past.
70
+ */
71
+ const PROTECTED_FILES = [
72
+ ["Windows 8.3 short names (they can alias a protected file)", (s) => s.some((d) => /~\d+(\.[^.]*)?$/.test(d))],
73
+ [".gitmodules (it can point git at other repositories)", (s, b) => b === ".gitmodules"],
74
+ ["tests", (s, b) => s.some((d) => d === "test" || d === "tests" || d === "__tests__") || /\.(test|spec)\./.test(b) || b === "conftest.py"],
75
+ ["test runner config or the test script", (s, b) => /^(vitest|jest|playwright)\./.test(b) || TEST_CONFIG.has(b)],
76
+ ["Penthera's own policy, suppressions, baseline, lock or memory", (s, b) => b.startsWith("penthera.") || s.includes(".penthera") || s.join("/").endsWith("audit/baseline.json")],
77
+ ["CI workflows", (s) => s.some((d, i) => d === ".github" && s[i + 1] === "workflows")],
78
+ ["git hooks", (s, b) => s.includes(".husky") || s.includes(".githooks") || /^(lefthook\.ya?ml|\.pre-commit-config\.ya?ml)$/.test(b)],
79
+ ["lockfiles", (s, b) => LOCKFILES.has(b)],
80
+ ];
81
+
82
+ /** What protected kind of file `file` (repo-relative) is, or null if a fix may edit it. */
83
+ export function protectedReason(file) {
84
+ const segs = String(file).split(/[\\/]/).filter((x) => x && x !== ".").map(canonicalSegment);
85
+ const hit = PROTECTED_FILES.find(([, test]) => test(segs, segs[segs.length - 1] || ""));
86
+ return hit ? hit[0] : null;
87
+ }
88
+
89
+ // C0 controls (tab aside), DEL and C1 controls: a terminal acts on them instead of showing them.
90
+ const CONTROL = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/;
47
91
 
48
92
  /**
49
- * Throws unless no component of `target` below `root` is a symlink. Every
93
+ * Throws unless no component of `target` below `root` is a symlink, and no
94
+ * component above the file itself is something other than a directory. Every
50
95
  * existing component is lstat-ed, so neither a symlinked directory nor a
51
96
  * dangling symlink at the file itself can redirect a write.
52
97
  */
53
98
  function assertNoSymlinks(root, target, file) {
54
99
  let cur = root;
55
- for (const seg of relative(root, target).split(sep)) {
56
- cur = join(cur, seg);
100
+ const segs = relative(root, target).split(sep);
101
+ for (let i = 0; i < segs.length; i++) {
102
+ cur = join(cur, segs[i]);
57
103
  let st;
58
104
  try {
59
105
  st = lstatSync(cur);
@@ -64,20 +110,26 @@ function assertNoSymlinks(root, target, file) {
64
110
  if (st.isSymbolicLink()) {
65
111
  throw new FixError(`Refusing to write ${file}: ${relative(root, cur)} is a symlink, and fix diffs never write through symlinks`, "SYMLINK");
66
112
  }
113
+ if (i < segs.length - 1 && !st.isDirectory()) {
114
+ throw new FixError(`Refusing to write ${file}: ${relative(root, cur)} is not a directory`, "CONFLICT");
115
+ }
67
116
  }
68
117
  }
69
118
 
70
119
  /** Resolve a diff path to an absolute path inside the repo, or throw. */
71
120
  function safeTarget(root, file) {
72
- if (!file || isAbsolute(file) || file.includes("\0") || /^[a-zA-Z]:/.test(file)) {
121
+ if (!file || isAbsolute(file) || CONTROL.test(file) || file.includes("\0") || /^[a-zA-Z]:/.test(file)) {
73
122
  throw new FixError(`Refusing to write ${JSON.stringify(file)}: paths in a diff must be relative to the repo`, "OUTSIDE_REPO");
74
123
  }
75
124
  const segs = file.split(/[\\/]/);
76
125
  if (segs.includes("..")) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
126
+ // NTFS alternate data streams (".git::$INDEX_ALLOCATION") name the same directory or file.
127
+ if (segs.some((s) => s.includes(":"))) throw new FixError(`Refusing to write ${JSON.stringify(file)}: path segments may not contain ":"`, "OUTSIDE_REPO");
77
128
  // Any .git at any depth, in any case: on macOS .GIT/hooks/pre-commit is .git/hooks/pre-commit.
78
129
  if (segs.some(isGitDirSegment)) throw new FixError(`Refusing to write ${file}: it is inside .git`, "OUTSIDE_REPO");
79
- if (segs.some((s) => PROTECTED_FILES.has(canonicalSegment(s)))) {
80
- throw new FixError(`Refusing to write ${file}: fix diffs never edit .gitmodules (it can point git at other repositories)`, "PROTECTED");
130
+ const why = protectedReason(file);
131
+ if (why) {
132
+ throw new FixError(`Refusing to write ${file}: fix diffs never edit ${why}, so the checks that verify a fix stay as they were. If the real fix needs this file changed, make that change by hand and review it.`, "PROTECTED");
81
133
  }
82
134
  const target = resolve(root, file);
83
135
  if (!inside(root, target) || target === root) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
@@ -89,43 +141,187 @@ function safeTarget(root, file) {
89
141
  export function assertCleanTree(repoPath) {
90
142
  let status;
91
143
  try {
92
- // The hardened runner: no fsmonitor, no hooks, no transports (src/agent/git.js).
144
+ // The hardened runner: no fsmonitor, no hooks, no filters, no transports (src/agent/git.js).
93
145
  const git = createGit(repoPath);
94
146
  git.run("rev-parse", "--is-inside-work-tree");
95
- status = git.run("status", "--porcelain", "--", ".");
147
+ status = git.run("status", "--porcelain=v1", "-z", "--", ".");
96
148
  } catch {
97
149
  throw new FixError(`${repoPath} is not a git work tree, so a fix could not be reviewed or reverted. Commit it to git first, or pass --allow-dirty.`, "NOT_GIT");
98
150
  }
99
- if (status.trim()) {
151
+ if (status.length) {
100
152
  throw new FixError(`${repoPath} has uncommitted changes. Commit or stash them first so the fix is its own reviewable change, or pass --allow-dirty.`, "DIRTY");
101
153
  }
102
154
  }
103
155
 
104
156
  const NOFOLLOW = constants.O_NOFOLLOW || 0;
105
157
 
106
- /** Write one file: create exclusively, or overwrite in place, never through a symlink. */
107
- function writeTarget(root, w) {
108
- assertNoSymlinks(root, w.target, w.path); // again, right before writing
109
- mkdirSync(dirname(w.target), { recursive: true });
110
- assertNoSymlinks(root, w.target, w.path);
111
- const flags = constants.O_WRONLY | NOFOLLOW | (w.created ? constants.O_CREAT | constants.O_EXCL : constants.O_TRUNC);
112
- const fd = openSync(w.target, flags, 0o644);
158
+ /** Current bytes of a regular file, null if it does not exist; throws on anything else. */
159
+ function readCurrent(target, file) {
160
+ let st;
113
161
  try {
114
- const buf = Buffer.from(w.after, "utf-8");
115
- let off = 0;
116
- while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
162
+ st = lstatSync(target);
163
+ } catch (e) {
164
+ if (e.code === "ENOENT") return null;
165
+ throw e;
166
+ }
167
+ if (!st.isFile()) throw new FixError(`Refusing to write ${file}: it is not a regular file`, "CONFLICT");
168
+ const fd = openSync(target, constants.O_RDONLY | NOFOLLOW);
169
+ try {
170
+ return readFileSync(fd);
117
171
  } finally {
118
172
  closeSync(fd);
119
173
  }
120
174
  }
121
175
 
176
+ function assertDirInside(root, dir, file) {
177
+ const real = realpathSync(dir);
178
+ if (!inside(root, real)) throw new FixError(`Refusing to write ${file}: its directory resolves outside the repo`, "OUTSIDE_REPO");
179
+ }
180
+
181
+ /**
182
+ * Put `data` at `target`, but only if the file currently holds `expect`
183
+ * (a Buffer), or does not exist (expect === null). The bytes go to a temp file
184
+ * in the same directory first, then are moved into place: rename for an edit
185
+ * (which replaces a hard link instead of writing through it), link for a new
186
+ * file (which fails if something appeared there meanwhile).
187
+ *
188
+ * @returns {{ madeDir: string|undefined }} the first directory mkdir created, if any
189
+ */
190
+ function replaceFile(root, file, target, data, expect) {
191
+ assertNoSymlinks(root, target, file);
192
+ const dir = dirname(target);
193
+ const madeDir = mkdirSync(dir, { recursive: true });
194
+ assertNoSymlinks(root, target, file);
195
+ assertDirInside(root, dir, file);
196
+
197
+ const current = readCurrent(target, file);
198
+ const unchanged = expect === null ? current === null : current !== null && current.equals(expect);
199
+ if (!unchanged) throw new FixError(`${file} changed on disk since the fix was planned; nothing was written to it`, "CHANGED");
200
+ const mode = current === null ? 0o644 : lstatSync(target).mode & 0o7777;
201
+
202
+ const tmp = join(dir, `.${basename(target)}.penthera-${randomBytes(6).toString("hex")}.tmp`);
203
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o600);
204
+ try {
205
+ let off = 0;
206
+ while (off < data.length) off += writeSync(fd, data, off, data.length - off);
207
+ // The temp file we wrote must be the one at that path, in a directory inside the repo.
208
+ const a = fstatSync(fd);
209
+ const b = lstatSync(tmp);
210
+ if (a.dev !== b.dev || a.ino !== b.ino) throw new FixError(`Refusing to write ${file}: its directory changed while writing`, "RACE");
211
+ } catch (e) {
212
+ closeSync(fd);
213
+ try { unlinkSync(tmp); } catch { /* best effort */ }
214
+ throw e;
215
+ }
216
+ closeSync(fd);
217
+ try {
218
+ chmodSync(tmp, mode);
219
+ assertDirInside(root, dir, file);
220
+ if (expect === null) {
221
+ try {
222
+ linkSync(tmp, target);
223
+ } catch (e) {
224
+ if (e.code === "EEXIST") throw new FixError(`${file} appeared on disk while the fix was being written; nothing was written to it`, "CHANGED");
225
+ // File systems without hard links: exclusive create, never through a symlink.
226
+ const out = openSync(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, mode);
227
+ try {
228
+ let off = 0;
229
+ while (off < data.length) off += writeSync(out, data, off, data.length - off);
230
+ } finally {
231
+ closeSync(out);
232
+ }
233
+ }
234
+ } else {
235
+ const again = readCurrent(target, file);
236
+ if (!again || !again.equals(expect)) throw new FixError(`${file} changed on disk while the fix was being written; nothing was written to it`, "CHANGED");
237
+ renameSync(tmp, target);
238
+ }
239
+ } finally {
240
+ try { unlinkSync(tmp); } catch { /* already renamed */ }
241
+ }
242
+ return { madeDir };
243
+ }
244
+
245
+ /** Remove the directories a write created (deepest first), as long as they are empty. */
246
+ function removeMadeDirs(target, madeDir) {
247
+ if (!madeDir) return;
248
+ for (let d = dirname(target); d.length >= madeDir.length; d = dirname(d)) {
249
+ try { rmdirSync(d); } catch { return; }
250
+ if (d === madeDir) return;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Put files written by applyDiff back the way they were, one by one, never
256
+ * over somebody else's edit: a file is restored (or, if the fix created it,
257
+ * removed) only while it still holds exactly what applyDiff wrote. A file that
258
+ * changed since is left alone and reported as a conflict. Every file is tried
259
+ * even if an earlier one fails.
260
+ *
261
+ * @param {string} repoPath
262
+ * @param {{ path: string, created: boolean, before: Buffer|null, sha256: string }[]} files - applyDiff's result
263
+ * @returns {{ restored: string[], conflicts: string[], errors: { path: string, message: string }[] }}
264
+ */
265
+ export function restoreFiles(repoPath, files) {
266
+ const root = realpathSync(resolve(repoPath));
267
+ const out = { restored: [], conflicts: [], errors: [] };
268
+ for (const f of files) {
269
+ try {
270
+ const target = safeTarget(root, f.path);
271
+ const current = readCurrent(target, f.path);
272
+ const original = f.created ? null : f.before;
273
+ if (current === null ? original === null : original !== null && current.equals(original)) {
274
+ out.restored.push(f.path); // already as it was
275
+ } else if (current === null || sha256(current) !== f.sha256) {
276
+ out.conflicts.push(f.path);
277
+ } else if (original === null) {
278
+ unlinkSync(target);
279
+ out.restored.push(f.path);
280
+ } else {
281
+ replaceFile(root, f.path, target, original, current);
282
+ out.restored.push(f.path);
283
+ }
284
+ } catch (e) {
285
+ if (e.code === "CHANGED") out.conflicts.push(f.path);
286
+ else out.errors.push({ path: f.path, message: e.message });
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+
292
+ /** Throws if an added line carries a control character (a trailing CR of a CRLF line is fine). */
293
+ function assertPrintable(f) {
294
+ for (const h of f.hunks) {
295
+ for (const op of h.ops) {
296
+ if (op.t !== "+") continue;
297
+ const line = op.line.replace(/\u0000$/, "").replace(/\r$/, "");
298
+ if (CONTROL.test(line)) {
299
+ throw new FixError(`Refusing to write ${f.newPath}: an added line contains a control character (${JSON.stringify(line.match(CONTROL)[0])}), which can hide code in a terminal`, "CONTROL_CHARS");
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ /** Throws on two entries for one file, or one file that is another's directory. */
306
+ function assertDistinctPaths(parsed) {
307
+ const keys = parsed.map((f) => String(f.newPath).split(/[\\/]/).map(canonicalSegment).join("/"));
308
+ for (let i = 0; i < keys.length; i++) {
309
+ for (let j = 0; j < keys.length; j++) {
310
+ if (i === j) continue;
311
+ if (keys[i] === keys[j]) throw new FixError(`Refusing the diff: it changes ${parsed[i].newPath} more than once`, "DUPLICATE");
312
+ if (keys[j].startsWith(`${keys[i]}/`)) throw new FixError(`Refusing the diff: ${parsed[i].newPath} is both a file and the directory of ${parsed[j].newPath}`, "CONFLICT");
313
+ }
314
+ }
315
+ }
316
+
122
317
  /**
123
318
  * Apply a unified diff to files under repoPath.
124
319
  *
125
320
  * @param {string} repoPath
126
321
  * @param {string} diff
127
322
  * @param {{ dryRun?: boolean, allowDirty?: boolean }} [opts]
128
- * @returns {{ applied: boolean, files: { path: string, created: boolean }[] }}
323
+ * @returns {{ applied: boolean, files: { path: string, created: boolean, before: Buffer|null, sha256: string }[] }}
324
+ * before is the file's bytes before the write; sha256 is the hash of what was written.
129
325
  */
130
326
  export function applyDiff(repoPath, diff, opts = {}) {
131
327
  const root = realpathSync(resolve(repoPath));
@@ -137,23 +333,46 @@ export function applyDiff(repoPath, diff, opts = {}) {
137
333
  for (const f of parsed) {
138
334
  if (!f.newPath) throw new FixError(`Refusing to delete ${f.oldPath}: fix diffs never delete files`, "DELETE");
139
335
  if (f.oldPath && f.oldPath !== f.newPath) throw new FixError(`Refusing to rename ${f.oldPath}: fix diffs never rename files`, "RENAME");
336
+ assertPrintable(f);
337
+ }
338
+ assertDistinctPaths(parsed);
339
+ for (const f of parsed) {
140
340
  const target = safeTarget(root, f.newPath);
141
341
  const exists = existsSync(target);
142
342
  if (exists && !lstatSync(target).isFile()) throw new FixError(`Refusing to write ${f.newPath}: it is not a regular file`, "CONFLICT");
143
343
  if (!f.oldPath && exists) throw new FixError(`${f.newPath} already exists; the plan expected to create it`, "CONFLICT");
144
344
  if (f.oldPath && !exists) throw new FixError(`${f.newPath} does not exist; the plan expected to edit it`, "CONFLICT");
145
- const before = exists ? readFileSync(target, "utf-8") : null;
345
+ const before = exists ? readCurrent(target, f.newPath) : null;
146
346
  let after;
147
347
  try {
148
- after = applyHunks(before, f.hunks);
348
+ after = Buffer.from(applyHunks(before === null ? null : before.toString("utf-8"), f.hunks), "utf-8");
149
349
  } catch (e) {
150
350
  throw new FixError(`${f.newPath}: ${e.message}`, "CONFLICT");
151
351
  }
152
- writes.push({ path: f.newPath, target, after, created: !exists });
352
+ writes.push({ path: f.newPath, target, before, after, created: !exists });
153
353
  }
154
354
 
155
355
  if (!opts.dryRun) {
156
- for (const w of writes) writeTarget(root, w);
356
+ const done = [];
357
+ try {
358
+ for (const w of writes) {
359
+ const { madeDir } = replaceFile(root, w.path, w.target, w.after, w.before);
360
+ done.push({ ...w, madeDir });
361
+ }
362
+ } catch (e) {
363
+ // All or nothing: put back what was written, newest first.
364
+ for (const w of done.reverse()) {
365
+ try {
366
+ if (w.created) unlinkSync(w.target);
367
+ else replaceFile(root, w.path, w.target, w.before, w.after);
368
+ removeMadeDirs(w.target, w.madeDir);
369
+ } catch { /* reported through the original error */ }
370
+ }
371
+ throw e;
372
+ }
157
373
  }
158
- return { applied: !opts.dryRun, files: writes.map((w) => ({ path: w.path, created: w.created })) };
374
+ return {
375
+ applied: !opts.dryRun,
376
+ files: writes.map((w) => ({ path: w.path, created: w.created, before: w.before, sha256: sha256(w.after) })),
377
+ };
159
378
  }
package/lib/fix/index.js CHANGED
@@ -14,7 +14,7 @@ import cors from "./recipes/cors.js";
14
14
  import rateLimit from "./recipes/rate-limit.js";
15
15
  import secrets from "./recipes/secrets.js";
16
16
 
17
- export { applyDiff, assertCleanTree, FixError } from "./apply.js";
17
+ export { applyDiff, assertCleanTree, restoreFiles, FixError } from "./apply.js";
18
18
  export { createPatch, parsePatch, applyHunks } from "./diff.js";
19
19
  export { createModelFixer } from "./model.js";
20
20
 
@@ -51,12 +51,14 @@ const brief = (f) => ({ title: f.title, severity: f.severity, category: f.catego
51
51
  * @param {string} repoPath
52
52
  * @param {object[]} findings
53
53
  * @param {{ model?: { plan(repoPath, finding): Promise<object|null> }, recipes?: object[] }} [opts]
54
- * @returns {Promise<{ plans: object[], unmatched: object[] }>}
54
+ * @returns {Promise<{ plans: object[], unmatched: object[], refused: { finding: object, reason: string }[] }>}
55
+ * refused: findings whose model diff edited a protected file; they are in unmatched too.
55
56
  */
56
57
  export async function planFixes(repoPath, findings, opts = {}) {
57
58
  const recipes = opts.recipes || RECIPES;
58
59
  const plans = new Map();
59
60
  const unmatched = [];
61
+ const refused = [];
60
62
  const frameworks = new Map();
61
63
  const sorted = [...(findings || [])].sort((a, b) => (SEV_ORDER[a.severity] ?? 5) - (SEV_ORDER[b.severity] ?? 5));
62
64
 
@@ -103,7 +105,8 @@ export async function planFixes(repoPath, findings, opts = {}) {
103
105
  let p = null;
104
106
  try {
105
107
  p = await opts.model.plan(repoPath, finding);
106
- } catch {
108
+ } catch (e) {
109
+ if (e?.code === "PROTECTED") refused.push({ finding, reason: e.message });
107
110
  p = null;
108
111
  }
109
112
  if (p?.diff) {
@@ -113,7 +116,7 @@ export async function planFixes(repoPath, findings, opts = {}) {
113
116
  }
114
117
  unmatched.push(finding);
115
118
  }
116
- return { plans: [...plans.values()], unmatched };
119
+ return { plans: [...plans.values()], unmatched, refused };
117
120
  }
118
121
 
119
122
  /** Mask redacted values (hardcoded secrets) so a plan is safe to print or store. */
package/lib/fix/model.js CHANGED
@@ -6,10 +6,13 @@
6
6
  * it anywhere else, redirects are refused, and the URL is checked before every
7
7
  * request. Off by default. Its output is always labelled
8
8
  * confidence "needs-human-review" and source "local-model", and a diff that
9
- * does not parse, leaves the repo, or fails to apply is dropped.
9
+ * does not parse, leaves the repo, or fails to apply is dropped. A diff that
10
+ * edits a protected file (tests, test config, Penthera's own files: see
11
+ * lib/fix/apply.js) throws FixError PROTECTED, so the attempt is reported.
10
12
  */
11
- import { existsSync, readFileSync } from "node:fs";
12
- import { join } from "node:path";
13
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
14
+ import { isAbsolute, join, relative, sep } from "node:path";
15
+ import { readJson } from "../../src/utils/http.js";
13
16
  import { applyDiff } from "./apply.js";
14
17
  import { parsePatch } from "./diff.js";
15
18
  import { detectFramework, walkCodeFiles, findEntryFile } from "./util.js";
@@ -24,7 +27,23 @@ export function assertLocalModelUrl(url) {
24
27
  return u;
25
28
  }
26
29
 
30
+ /** True when `file` (repo-relative) really lives in the repo once symlinks are resolved. */
31
+ function insideRepo(repoPath, file) {
32
+ try {
33
+ const rel = relative(realpathSync(repoPath), realpathSync(join(repoPath, file)));
34
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** The one repo file whose source goes into the prompt; never a file outside the repo. */
27
41
  function contextFile(repoPath, finding) {
42
+ const file = pickContextFile(repoPath, finding);
43
+ return file && insideRepo(repoPath, file) ? file : null;
44
+ }
45
+
46
+ function pickContextFile(repoPath, finding) {
28
47
  const files = walkCodeFiles(repoPath);
29
48
  if (finding.url && files.includes(finding.url)) return finding.url;
30
49
  const fw = detectFramework(repoPath);
@@ -84,7 +103,7 @@ export function createModelFixer({ model, fetch: fetchImpl = globalThis.fetch, t
84
103
  signal: AbortSignal.timeout(timeout),
85
104
  });
86
105
  if (!res.ok) return null;
87
- const data = await res.json();
106
+ const data = await readJson(res, { maxBytes: 4 * 1024 * 1024, timeout });
88
107
  const diff = extractDiff(data?.response);
89
108
  if (!diff) return null;
90
109
  let files;
@@ -92,7 +111,8 @@ export function createModelFixer({ model, fetch: fetchImpl = globalThis.fetch, t
92
111
  files = parsePatch(diff).map((f) => f.newPath);
93
112
  // Validates paths stay inside the repo and every hunk applies; writes nothing.
94
113
  applyDiff(repoPath, diff, { dryRun: true, allowDirty: true });
95
- } catch {
114
+ } catch (e) {
115
+ if (e?.code === "PROTECTED") throw e; // an edit to the checker is reported, never quietly dropped
96
116
  return null;
97
117
  }
98
118
  return {
package/lib/fix/util.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * keys off a line the recipe can recognise exactly, and gives up otherwise.
5
5
  */
6
6
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
7
- import { join, relative } from "node:path";
7
+ import { isAbsolute, join, normalize, relative, sep } from "node:path";
8
8
  import { detectFramework as detectSourceFramework } from "../whitebox/frameworks.js";
9
9
  import { createPatch } from "./diff.js";
10
10
 
@@ -91,7 +91,11 @@ export function walkCodeFiles(repoPath) {
91
91
  export function findEntryFile(repoPath, anchor) {
92
92
  const candidates = [...ENTRY_CANDIDATES];
93
93
  const main = readPackageJson(repoPath)?.main;
94
- if (typeof main === "string") candidates.unshift(main.replace(/^\.\//, ""));
94
+ // package.json is repo data: a "main" that points out of the repo is ignored.
95
+ if (typeof main === "string" && main && !isAbsolute(main) && !/^[a-zA-Z]:/.test(main)) {
96
+ const rel = normalize(main);
97
+ if (rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith("../")) candidates.unshift(rel.replace(/\\/g, "/"));
98
+ }
95
99
  for (const rel of candidates) {
96
100
  const source = readText(repoPath, rel);
97
101
  if (source && anchor.test(source)) return { file: rel, source };
package/lib/fuzzer.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * validates its input. Nothing runs on the machine running Penthera.
18
18
  */
19
19
 
20
- import { request } from "../src/utils/http.js";
20
+ import { request, readBody } from "../src/utils/http.js";
21
21
 
22
22
  // ── Payload Generators ────────────────────────────────────────────────────
23
23
 
@@ -140,7 +140,7 @@ export function generateFuzzedBodies(validBody, opts = {}) {
140
140
  const maxPayloads = opts.maxPayloads || 50;
141
141
  const variants = [];
142
142
 
143
- for (const [field, validValue] of Object.entries(validBody)) {
143
+ for (const field of Object.keys(validBody)) {
144
144
  const fuzzValues = fuzzField(field);
145
145
 
146
146
  // Take a sample if too many
@@ -195,7 +195,7 @@ export async function fuzzEndpoint(baseUrl, path, validBody, opts = {}) {
195
195
  redirect: "follow",
196
196
  });
197
197
 
198
- const text = await res.text().catch(() => "");
198
+ const text = await readBody(res).catch(() => "");
199
199
  const result = {
200
200
  field: variant.field,
201
201
  payload: typeof variant.payload === "object" ? JSON.stringify(variant.payload) : String(variant.payload).slice(0, 100),
package/lib/git.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The one place penthera runs git.
3
+ *
4
+ * Every call reads a repository that may not be the user's own (a scanned
5
+ * archive, a shared folder, a devcontainer), so every call runs with config
6
+ * overrides that keep that repository's config from running programs: no
7
+ * hooks, no fsmonitor, no transports, no credential helper, no signing, and
8
+ * no attributes file. The agent loop's allow-listed runner (src/agent/git.js)
9
+ * adds its own checks on top of these.
10
+ */
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import { resolve } from "node:path";
13
+
14
+ export const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
15
+
16
+ export const SAFE_CONFIG = Object.freeze([
17
+ "-c", "protocol.allow=never",
18
+ "-c", `core.hooksPath=${NULL_DEVICE}`,
19
+ "-c", "core.fsmonitor=false",
20
+ "-c", "credential.helper=",
21
+ "-c", "commit.gpgsign=false",
22
+ "-c", "tag.gpgsign=false",
23
+ "-c", "log.showSignature=false",
24
+ "-c", "gpg.program=false",
25
+ "-c", "gpg.openpgp.program=false",
26
+ "-c", "gpg.x509.program=false",
27
+ "-c", "gpg.ssh.program=false",
28
+ "-c", "gpg.ssh.defaultKeyCommand=",
29
+ "-c", `core.attributesFile=${NULL_DEVICE}`,
30
+ ]);
31
+
32
+ /** Environment for every git child: no lazy fetches, no prompts. */
33
+ export const safeEnv = (env = process.env) => ({ ...env, GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
34
+
35
+ /**
36
+ * Run git synchronously with the safe config. Returns stdout (utf-8 unless
37
+ * `encoding` says otherwise); throws like execFileSync.
38
+ * `raw: true` skips the config overrides, for the few reads that must see the
39
+ * repo's real config (where its hooks live).
40
+ * @param {string} cwd
41
+ * @param {string[]} args
42
+ * @param {import("node:child_process").ExecFileSyncOptions & { raw?: boolean }} [opts]
43
+ */
44
+ export function git(cwd, args, { raw = false, ...opts } = {}) {
45
+ return execFileSync("git", raw ? args : [...SAFE_CONFIG, ...args], {
46
+ cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, env: safeEnv(), ...opts,
47
+ });
48
+ }
49
+
50
+ /** spawnSync form of git(), for calls that feed stdin; returns the spawnSync result. */
51
+ export function gitSpawn(cwd, args, opts = {}) {
52
+ return spawnSync("git", [...SAFE_CONFIG, ...args], { cwd, maxBuffer: 1024 * 1024 * 1024, env: safeEnv(), ...opts });
53
+ }
54
+
55
+ /**
56
+ * The work tree's toplevel as a native path (git prints D:/a/app on Windows),
57
+ * or null outside a repo or without git.
58
+ */
59
+ export function gitToplevel(cwd) {
60
+ try {
61
+ return resolve(git(cwd, ["rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).trim());
62
+ } catch {
63
+ return null;
64
+ }
65
+ }