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/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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
28
|
-
|
|
38
|
+
export const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
39
|
+
|
|
40
|
+
// Code points HFS+ ignores in names, so ".git" 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
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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.
|
|
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
|
-
/**
|
|
107
|
-
function
|
|
108
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 ?
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|