penthera 1.1.1 → 1.4.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 +269 -3
- package/README.md +489 -286
- package/SECURITY.md +28 -6
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-nis2.md +166 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +55 -0
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +15 -3
- package/docs/local-loop.md +276 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +49 -19
- package/package.json +19 -9
- package/skills/penthera/SKILL.md +104 -23
- package/skills/penthera/references/output-and-ci.md +12 -50
- package/skills/penthera/references/troubleshooting.md +1 -1
- package/skills/penthera/scripts/preflight.sh +4 -4
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +152 -6
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
- package/scripts/package-skill.sh +0 -16
package/lib/fix/util.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for fix recipes: framework detection, file discovery, and the
|
|
3
|
+
* small, anchor-based text edits recipes are built from. No AST: every edit
|
|
4
|
+
* keys off a line the recipe can recognise exactly, and gives up otherwise.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { detectFramework as detectSourceFramework } from "../whitebox/frameworks.js";
|
|
9
|
+
import { createPatch } from "./diff.js";
|
|
10
|
+
|
|
11
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor", "reports", ".security-results"]);
|
|
12
|
+
const CODE_EXT = /\.(js|ts|mjs|cjs|jsx|tsx)$/;
|
|
13
|
+
const MAX_FILES = 2000;
|
|
14
|
+
|
|
15
|
+
export const ENTRY_CANDIDATES = [
|
|
16
|
+
"server.js", "server.ts", "server.mjs", "app.js", "app.ts", "app.mjs",
|
|
17
|
+
"index.js", "index.ts", "index.mjs", "main.js", "main.ts",
|
|
18
|
+
"src/server.js", "src/server.ts", "src/app.js", "src/app.ts",
|
|
19
|
+
"src/index.js", "src/index.ts", "src/main.js", "src/main.ts",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function readText(repoPath, rel) {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(join(repoPath, rel), "utf-8");
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readPackageJson(repoPath) {
|
|
31
|
+
const text = readText(repoPath, "package.json");
|
|
32
|
+
if (!text) return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function hasDep(repoPath, name) {
|
|
41
|
+
const pkg = readPackageJson(repoPath);
|
|
42
|
+
return Boolean(pkg && (pkg.dependencies?.[name] || pkg.devDependencies?.[name]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The web framework a repo uses: "next" | "hono" | "fastify" | "express" | null.
|
|
47
|
+
* package.json decides; source imports (via the white-box route scanner's
|
|
48
|
+
* detector) are the fallback for repos without one.
|
|
49
|
+
*/
|
|
50
|
+
export function detectFramework(repoPath) {
|
|
51
|
+
for (const fw of ["next", "hono", "fastify", "express"]) if (hasDep(repoPath, fw)) return fw;
|
|
52
|
+
if (["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"].some((f) => existsSync(join(repoPath, f)))) return "next";
|
|
53
|
+
for (const rel of ENTRY_CANDIDATES) {
|
|
54
|
+
const source = readText(repoPath, rel);
|
|
55
|
+
if (!source) continue;
|
|
56
|
+
const fw = detectSourceFramework(source);
|
|
57
|
+
if (fw !== "node") return fw;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Repo-relative paths of JS/TS source files, skipping dependencies and build output. */
|
|
63
|
+
export function walkCodeFiles(repoPath) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const walk = (dir) => {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dir);
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const entry of entries.sort()) {
|
|
73
|
+
if (out.length >= MAX_FILES) return;
|
|
74
|
+
if (SKIP.has(entry)) continue;
|
|
75
|
+
const full = join(dir, entry);
|
|
76
|
+
let st;
|
|
77
|
+
try {
|
|
78
|
+
st = statSync(full);
|
|
79
|
+
} catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (st.isDirectory()) walk(full);
|
|
83
|
+
else if (CODE_EXT.test(entry) && !/\.(test|spec)\.|\.d\.ts$|\.min\.js$/.test(entry)) out.push(relative(repoPath, full));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
walk(repoPath);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** First entry-candidate file whose source matches `anchor`. */
|
|
91
|
+
export function findEntryFile(repoPath, anchor) {
|
|
92
|
+
const candidates = [...ENTRY_CANDIDATES];
|
|
93
|
+
const main = readPackageJson(repoPath)?.main;
|
|
94
|
+
if (typeof main === "string") candidates.unshift(main.replace(/^\.\//, ""));
|
|
95
|
+
for (const rel of candidates) {
|
|
96
|
+
const source = readText(repoPath, rel);
|
|
97
|
+
if (source && anchor.test(source)) return { file: rel, source };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const isTypeScript = (file) => /\.tsx?$/.test(file);
|
|
103
|
+
|
|
104
|
+
export function moduleStyle(source) {
|
|
105
|
+
if (/^\s*import\s[^;]*?from\s+['"]|^\s*import\s+['"]|^\s*export\s/m.test(source)) return "esm";
|
|
106
|
+
if (/\brequire\s*\(/.test(source)) return "cjs";
|
|
107
|
+
return "esm";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Index of the line after the last top-level import/require statement. */
|
|
111
|
+
function importBlockEnd(lines) {
|
|
112
|
+
let end = 0;
|
|
113
|
+
let open = false;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const l = lines[i];
|
|
116
|
+
if (open) {
|
|
117
|
+
if (/\bfrom\s+['"][^'"]+['"];?\s*$/.test(l)) { open = false; end = i + 1; }
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (/^import\s.*\bfrom\s+['"][^'"]+['"];?\s*$/.test(l) || /^import\s+['"][^'"]+['"];?\s*$/.test(l)) end = i + 1;
|
|
121
|
+
else if (/^import\s+(?:type\s+)?\{[^}]*$/.test(l)) open = true;
|
|
122
|
+
else if (/^(?:const|let|var)\s+[\w{}\s,:]+=\s*require\(\s*['"][^'"]+['"]\s*\)[\w.]*;?\s*$/.test(l)) end = i + 1;
|
|
123
|
+
else if (i === 0 && /^#!/.test(l)) end = 1;
|
|
124
|
+
else if (/^['"]use (strict|client|server)['"];?\s*$/.test(l)) end = Math.max(end, i + 1);
|
|
125
|
+
}
|
|
126
|
+
return end;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Insert `block` (lines) after the import block, separated by a blank line. */
|
|
130
|
+
export function insertAfterImports(source, block) {
|
|
131
|
+
const lines = source.split("\n");
|
|
132
|
+
const at = importBlockEnd(lines);
|
|
133
|
+
const insert = [...block];
|
|
134
|
+
if (at > 0 && lines[at - 1].trim() !== "") insert.unshift("");
|
|
135
|
+
if (lines[at] !== undefined && lines[at].trim() !== "") insert.push("");
|
|
136
|
+
lines.splice(at, 0, ...insert);
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Add an import (ESM or CommonJS, matching the file) unless the module is already imported. */
|
|
141
|
+
export function addImport(source, { name, module, named = false }) {
|
|
142
|
+
const escaped = module.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
143
|
+
if (new RegExp(`from\\s+['"]${escaped}['"]|require\\(\\s*['"]${escaped}['"]\\s*\\)`).test(source)) return source;
|
|
144
|
+
const line = moduleStyle(source) === "cjs"
|
|
145
|
+
? `const ${named ? `{ ${name} }` : name} = require("${module}");`
|
|
146
|
+
: `import ${named ? `{ ${name} }` : name} from "${module}";`;
|
|
147
|
+
const lines = source.split("\n");
|
|
148
|
+
const at = importBlockEnd(lines);
|
|
149
|
+
lines.splice(at, 0, line);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Insert lines right after line `index`, matching that line's indentation. */
|
|
154
|
+
export function insertAfterLine(source, index, block) {
|
|
155
|
+
const lines = source.split("\n");
|
|
156
|
+
const indent = lines[index].match(/^\s*/)[0];
|
|
157
|
+
lines.splice(index + 1, 0, ...block.map((l) => (l ? indent + l : l)));
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Index of the first line matching re, or -1. */
|
|
162
|
+
export function findLine(source, re) {
|
|
163
|
+
return source.split("\n").findIndex((l) => re.test(l));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Split a call's arguments starting at the "(" at `open`, on one line only.
|
|
168
|
+
* Returns { args: [{ text, start, end }], close } with absolute offsets, or
|
|
169
|
+
* null if the call does not close before the end of the line.
|
|
170
|
+
*/
|
|
171
|
+
export function splitCallArgs(text, open) {
|
|
172
|
+
const args = [];
|
|
173
|
+
let depth = 0;
|
|
174
|
+
let quote = null;
|
|
175
|
+
let argStart = open + 1;
|
|
176
|
+
for (let i = open; i < text.length; i++) {
|
|
177
|
+
const ch = text[i];
|
|
178
|
+
if (ch === "\n" && !quote) return null;
|
|
179
|
+
if (quote) {
|
|
180
|
+
if (ch === "\\") i++;
|
|
181
|
+
else if (ch === quote) quote = null;
|
|
182
|
+
else if (ch === "\n") return null;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
186
|
+
else if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
187
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
188
|
+
depth--;
|
|
189
|
+
if (depth === 0) {
|
|
190
|
+
if (text.slice(argStart, i).trim()) args.push({ text: text.slice(argStart, i).trim(), start: argStart, end: i });
|
|
191
|
+
return { args, close: i };
|
|
192
|
+
}
|
|
193
|
+
} else if (ch === "," && depth === 1) {
|
|
194
|
+
args.push({ text: text.slice(argStart, i).trim(), start: argStart, end: i });
|
|
195
|
+
argStart = i + 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Add `NAME=value` to .env.example (creating it) unless NAME is already listed. */
|
|
202
|
+
export function envExampleChange(repoPath, name, value = "", comment = null) {
|
|
203
|
+
const before = readText(repoPath, ".env.example");
|
|
204
|
+
if (before != null && new RegExp(`^\\s*${name}\\s*=`, "m").test(before)) return null;
|
|
205
|
+
const add = [...(comment ? [`# ${comment}`] : []), `${name}=${value}`].join("\n") + "\n";
|
|
206
|
+
const after = before == null ? add : before + (before.endsWith("\n") || before === "" ? "" : "\n") + add;
|
|
207
|
+
return { file: ".env.example", before, after };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Turn [{ file, before, after }] into { diff, files }, dropping no-op changes. */
|
|
211
|
+
export function toDiff(changes) {
|
|
212
|
+
const parts = [];
|
|
213
|
+
const files = [];
|
|
214
|
+
for (const c of changes.filter(Boolean)) {
|
|
215
|
+
const patch = createPatch(c.file, c.before, c.after);
|
|
216
|
+
if (!patch) continue;
|
|
217
|
+
parts.push(patch);
|
|
218
|
+
files.push(c.file);
|
|
219
|
+
}
|
|
220
|
+
if (parts.length === 0) return null;
|
|
221
|
+
return { diff: parts.join(""), files };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** "sessionSecret" / "api-key" -> "SESSION_SECRET" / "API_KEY". */
|
|
225
|
+
export function toEnvName(key) {
|
|
226
|
+
return String(key)
|
|
227
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
228
|
+
.replace(/[^A-Za-z0-9]+/g, "_")
|
|
229
|
+
.replace(/^_+|_+$/g, "")
|
|
230
|
+
.toUpperCase();
|
|
231
|
+
}
|
package/lib/fuzzer.js
CHANGED
|
@@ -8,8 +8,17 @@
|
|
|
8
8
|
* - Schemathesis, property-based testing from API schemas
|
|
9
9
|
* - RESTler, stateful fuzzing with dependency inference
|
|
10
10
|
* - fast-check, shrinkable random generators
|
|
11
|
+
*
|
|
12
|
+
* What this file is, for anyone reviewing the package:
|
|
13
|
+
* The values below are edge-case test inputs (type confusion, oversized
|
|
14
|
+
* strings, well-known injection probes). Penthera never executes them. They
|
|
15
|
+
* are sent as JSON request bodies to the user's own target, and only when
|
|
16
|
+
* the user opts in with --fuzz (or the deep profile), to see whether the API
|
|
17
|
+
* validates its input. Nothing runs on the machine running Penthera.
|
|
11
18
|
*/
|
|
12
19
|
|
|
20
|
+
import { request } from "../src/utils/http.js";
|
|
21
|
+
|
|
13
22
|
// ── Payload Generators ────────────────────────────────────────────────────
|
|
14
23
|
|
|
15
24
|
/** Generate fuzz values for a given field name (heuristic-based) */
|
|
@@ -176,15 +185,14 @@ export async function fuzzEndpoint(baseUrl, path, validBody, opts = {}) {
|
|
|
176
185
|
continue; // can't serialize (e.g., circular)
|
|
177
186
|
}
|
|
178
187
|
|
|
179
|
-
const controller = new AbortController();
|
|
180
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
181
|
-
|
|
182
188
|
try {
|
|
183
|
-
const res = await
|
|
189
|
+
const res = await request(`${baseUrl}${path}`, {
|
|
184
190
|
method,
|
|
185
191
|
headers,
|
|
186
192
|
body,
|
|
187
|
-
|
|
193
|
+
timeout,
|
|
194
|
+
probe: "api-fuzzing",
|
|
195
|
+
redirect: "follow",
|
|
188
196
|
});
|
|
189
197
|
|
|
190
198
|
const text = await res.text().catch(() => "");
|
|
@@ -223,9 +231,7 @@ export async function fuzzEndpoint(baseUrl, path, validBody, opts = {}) {
|
|
|
223
231
|
findings.push(result);
|
|
224
232
|
}
|
|
225
233
|
} catch {
|
|
226
|
-
// timeout or network error
|
|
227
|
-
} finally {
|
|
228
|
-
clearTimeout(timer);
|
|
234
|
+
// timeout or network error: recorded in result.errors by the HTTP client
|
|
229
235
|
}
|
|
230
236
|
}
|
|
231
237
|
|
package/lib/injections.js
CHANGED
|
@@ -14,28 +14,26 @@
|
|
|
14
14
|
* - Nuclei, pattern-based detection
|
|
15
15
|
* - Burp Suite, time-based blind SQLi
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* What this file is, for anyone reviewing the package:
|
|
18
|
+
* The strings below are test inputs, the same well-known probes found in
|
|
19
|
+
* OWASP testing guides and scanners like sqlmap, Burp and Nuclei. Penthera
|
|
20
|
+
* never executes them. They are sent as request parameters to the one target
|
|
21
|
+
* URL the user names, and only when the user opts in with --deep (or the
|
|
22
|
+
* deep profile). Detection happens by reading that target's responses.
|
|
23
|
+
* Nothing here runs on, or reaches out from, the machine running Penthera;
|
|
24
|
+
* the HTTP client in src/utils/http.js refuses other hosts under --offline.
|
|
25
|
+
*
|
|
18
26
|
* Only use against targets you have permission to test.
|
|
19
27
|
*/
|
|
20
28
|
|
|
21
29
|
import { redirectsToHost } from "../src/utils/url.js";
|
|
30
|
+
import { fetchText } from "../src/utils/http.js";
|
|
22
31
|
|
|
23
32
|
const INJECT_TIMEOUT = 10_000;
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const start = Date.now();
|
|
29
|
-
try {
|
|
30
|
-
const res = await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
|
|
31
|
-
const elapsed = Date.now() - start;
|
|
32
|
-
const body = await res.text().catch(() => "");
|
|
33
|
-
return { status: res.status, body, headers: Object.fromEntries(res.headers.entries()), elapsed, ok: true };
|
|
34
|
-
} catch {
|
|
35
|
-
return { status: 0, body: "", headers: {}, elapsed: Date.now() - start, ok: false };
|
|
36
|
-
} finally {
|
|
37
|
-
clearTimeout(timeout);
|
|
38
|
-
}
|
|
34
|
+
/** A fetch bound to one probe name, so failures are attributed in result.errors. */
|
|
35
|
+
function clientFor(probe) {
|
|
36
|
+
return (url, opts = {}) => fetchText(url, { timeout: INJECT_TIMEOUT, probe, ...opts });
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
// ── Timing-based detection helpers ───────────────────────────────────────
|
|
@@ -125,6 +123,7 @@ const SQLI_PAYLOADS = [
|
|
|
125
123
|
* @returns {object[]} Findings
|
|
126
124
|
*/
|
|
127
125
|
export async function probeSqli(baseUrl, path, opts = {}) {
|
|
126
|
+
const safeFetch = clientFor("sqli");
|
|
128
127
|
const findings = [];
|
|
129
128
|
const method = opts.method || "GET";
|
|
130
129
|
|
|
@@ -253,6 +252,7 @@ export function sstiEvaluated(body, payload, expr, product) {
|
|
|
253
252
|
* Test an endpoint for SSTI.
|
|
254
253
|
*/
|
|
255
254
|
export async function probeSsti(baseUrl, path, opts = {}) {
|
|
255
|
+
const safeFetch = clientFor("ssti");
|
|
256
256
|
const findings = [];
|
|
257
257
|
const { probes, expr, product } = buildSstiProbes();
|
|
258
258
|
|
|
@@ -316,6 +316,7 @@ const SSRF_TARGETS = [
|
|
|
316
316
|
* Test an endpoint for SSRF via URL parameters.
|
|
317
317
|
*/
|
|
318
318
|
export async function probeSsrf(baseUrl, path, opts = {}) {
|
|
319
|
+
const safeFetch = clientFor("ssrf");
|
|
319
320
|
const findings = [];
|
|
320
321
|
|
|
321
322
|
// Identify URL-like parameters in the path or common parameter names
|
|
@@ -373,6 +374,7 @@ const REDIRECT_PAYLOADS = [
|
|
|
373
374
|
* Test an endpoint for open redirect.
|
|
374
375
|
*/
|
|
375
376
|
export async function probeOpenRedirect(baseUrl, path, opts = {}) {
|
|
377
|
+
const safeFetch = clientFor("open-redirect");
|
|
376
378
|
const findings = [];
|
|
377
379
|
const redirectParams = ["redirect", "redirect_uri", "return", "return_to", "next", "url", "callback", "redir", "destination", "go"];
|
|
378
380
|
|
|
@@ -421,6 +423,7 @@ const CMDI_PAYLOADS = [
|
|
|
421
423
|
* Test an endpoint for OS command injection.
|
|
422
424
|
*/
|
|
423
425
|
export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
426
|
+
const safeFetch = clientFor("cmdi");
|
|
424
427
|
const findings = [];
|
|
425
428
|
const method = opts.method || "POST";
|
|
426
429
|
|
|
@@ -512,6 +515,7 @@ export function isHtmlResponse(res) {
|
|
|
512
515
|
* 5. Verify the payload is reflected unescaped
|
|
513
516
|
*/
|
|
514
517
|
export async function probeXss(baseUrl, path, opts = {}) {
|
|
518
|
+
const safeFetch = clientFor("xss");
|
|
515
519
|
const findings = [];
|
|
516
520
|
const CANARY = makeCanary();
|
|
517
521
|
const inputParams = ["q", "search", "query", "name", "input", "text", "value", "msg", "error", "redirect", "url", "id"];
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera install`: an install firewall on top of the `penthera deps` engine.
|
|
3
|
+
*
|
|
4
|
+
* 1. Resolve. `npm install --package-lock-only --ignore-scripts [pkg...]` runs
|
|
5
|
+
* in a temporary copy of package.json / package-lock.json / .npmrc. Not in
|
|
6
|
+
* place: npm rewrites node_modules/.package-lock.json even in lock-only
|
|
7
|
+
* mode, and an abort must leave the project byte for byte as it was.
|
|
8
|
+
* 2. Fetch. `npm cache add` puts each new tarball in npm's cache; nothing is
|
|
9
|
+
* unpacked into node_modules and no package code runs. The deps engine
|
|
10
|
+
* reads tarballs back from the cache only after verifying them against
|
|
11
|
+
* the proposed lockfile's integrity (lib/deps/source.js).
|
|
12
|
+
* 3. Diff. lib/deps scans the proposed tree against the current lockfile:
|
|
13
|
+
* new packages, capabilities gained, install scripts, typosquats.
|
|
14
|
+
* 4. Decide. lib/install/policy.js turns that into deny / needs-approval.
|
|
15
|
+
* 5. Apply (only when allowed). The vetted package.json and lockfile are
|
|
16
|
+
* written into the project and `npm install` installs exactly that tree,
|
|
17
|
+
* with `--ignore-scripts` unless the new install scripts were approved.
|
|
18
|
+
*
|
|
19
|
+
* Penthera makes no network calls here. npm talks to its configured registry
|
|
20
|
+
* (resolve and fetch); that is the one expected egress.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync, copyFileSync, rmSync, mkdirSync } from "node:fs";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { scanDeps } from "../deps/index.js";
|
|
28
|
+
import { parseLockfile } from "../deps/lockfile.js";
|
|
29
|
+
import { sourceFromNpmCache, npmCacheDir } from "../deps/source.js";
|
|
30
|
+
import { evaluatePolicy, normalizePolicy } from "./policy.js";
|
|
31
|
+
|
|
32
|
+
const run = promisify(execFile);
|
|
33
|
+
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
34
|
+
const LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
|
|
35
|
+
const COPY = ["package.json", ...LOCKFILES, ".npmrc"];
|
|
36
|
+
const QUIET = ["--no-audit", "--no-fund", "--no-update-notifier"];
|
|
37
|
+
const LOCAL_SPEC = /^(file:|link:|portal:|\.{1,2}\/|\/)/;
|
|
38
|
+
|
|
39
|
+
async function npm(args, opts) {
|
|
40
|
+
try {
|
|
41
|
+
return await run(NPM, args, { maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32", ...opts });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const msg = String(e.stderr || e.message).trim().split("\n").filter((l) => !/^npm (verbose|silly|timing)/.test(l)).slice(-8).join("\n");
|
|
44
|
+
throw new Error(`npm ${args[0]} failed:\n${msg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** npm's version and registry, as npm sees them from the project. */
|
|
49
|
+
export async function npmInfo(root, env = process.env) {
|
|
50
|
+
const [version, registry] = await Promise.all([
|
|
51
|
+
npm(["--version"], { cwd: root, env }).then((r) => r.stdout.trim()),
|
|
52
|
+
npm(["config", "get", "registry"], { cwd: root, env }).then((r) => r.stdout.trim(), () => "the configured npm registry"),
|
|
53
|
+
]);
|
|
54
|
+
const major = parseInt(version, 10) || 0;
|
|
55
|
+
// npm 12 blocks dependency install scripts unless package.json allowScripts lists them.
|
|
56
|
+
return { version, major, registry, allowScriptsPolicy: major >= 12 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const key = (p) => `${p.path}@${p.version}#${p.integrity || ""}`;
|
|
60
|
+
const scriptsOf = (s) => (s && Object.keys(s).length ? s : null);
|
|
61
|
+
|
|
62
|
+
/** The rule ids a change raises (see lib/install/policy.js). */
|
|
63
|
+
function flagsFor(caps, { newScripts, typosquat, unscanned }) {
|
|
64
|
+
const flags = [...(caps || [])].filter((id) => id !== "install-scripts");
|
|
65
|
+
if (newScripts) flags.unshift("install-scripts");
|
|
66
|
+
if (typosquat) flags.push("typosquat");
|
|
67
|
+
if (unscanned) flags.push("unscanned");
|
|
68
|
+
return flags;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Turn the deps diff into one change per new or upgraded package.
|
|
73
|
+
* `packages` is result.deps.packages, for the capabilities of an upgrade whose
|
|
74
|
+
* old version could not be read.
|
|
75
|
+
*/
|
|
76
|
+
export function changesFromDiff(diff, typosquats = [], packages = []) {
|
|
77
|
+
const typo = new Map(typosquats.map((t) => [t.name, t]));
|
|
78
|
+
const capsOf = (name, version) => {
|
|
79
|
+
const c = packages.find((p) => p.name === name && p.version === version)?.capabilities;
|
|
80
|
+
return c ? Object.keys(c) : null;
|
|
81
|
+
};
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const a of diff.added) {
|
|
84
|
+
const scripts = scriptsOf(a.installScripts);
|
|
85
|
+
out.push({
|
|
86
|
+
change: "added",
|
|
87
|
+
name: a.name,
|
|
88
|
+
version: a.version,
|
|
89
|
+
capabilities: a.capabilities || [],
|
|
90
|
+
installScripts: scripts,
|
|
91
|
+
typosquatOf: typo.get(a.name)?.target || null,
|
|
92
|
+
flags: flagsFor(a.capabilities, { newScripts: !!scripts, typosquat: typo.has(a.name), unscanned: a.capabilities === null }),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const u of diff.upgraded) {
|
|
96
|
+
const scripts = scriptsOf(u.installScripts);
|
|
97
|
+
const before = scriptsOf(u.previousInstallScripts);
|
|
98
|
+
// A hook that is new, or whose command changed, is new code at install time.
|
|
99
|
+
const newScripts = !!scripts && JSON.stringify(scripts) !== JSON.stringify(before);
|
|
100
|
+
// Old version unreadable: judge the new version on everything it can do.
|
|
101
|
+
const gained = u.gained ?? (u.unavailable === "old" ? capsOf(u.name, u.to) : null);
|
|
102
|
+
out.push({
|
|
103
|
+
change: "upgraded",
|
|
104
|
+
name: u.name,
|
|
105
|
+
version: u.to,
|
|
106
|
+
from: u.from,
|
|
107
|
+
capabilities: gained || [],
|
|
108
|
+
installScripts: scripts,
|
|
109
|
+
previousInstallScripts: before,
|
|
110
|
+
compared: u.unavailable == null,
|
|
111
|
+
typosquatOf: null,
|
|
112
|
+
flags: flagsFor(gained, { newScripts, typosquat: false, unscanned: u.unavailable === "new" }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readIfExists(p) {
|
|
119
|
+
return existsSync(p) ? readFileSync(p) : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function checkProject(root, pkgJson) {
|
|
123
|
+
if (pkgJson.workspaces) throw new Error("npm workspaces are not supported by `penthera install` yet. Run `npm install --package-lock-only --ignore-scripts` and review with `penthera deps --base HEAD`.");
|
|
124
|
+
const local = Object.entries({ ...pkgJson.dependencies, ...pkgJson.devDependencies, ...pkgJson.optionalDependencies })
|
|
125
|
+
.filter(([, spec]) => LOCAL_SPEC.test(String(spec)));
|
|
126
|
+
if (local.length) throw new Error(`Local path dependencies (${local.map(([n]) => n).join(", ")}) cannot be resolved from a temporary copy. Run \`npm install --package-lock-only --ignore-scripts\` and review with \`penthera deps --base HEAD\`.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Work out what `npm install [specs]` would change, without running any
|
|
131
|
+
* package code and without writing to the project.
|
|
132
|
+
* @param {string} root project folder (has package.json)
|
|
133
|
+
* @param {string[]} specs packages as npm accepts them (name, name@range, ...)
|
|
134
|
+
* @param {{ policy?: object, saveDev?: boolean, saveExact?: boolean, offline?: boolean,
|
|
135
|
+
* env?: object, onPhase?: Function, info?: object }} opts
|
|
136
|
+
*/
|
|
137
|
+
export async function planInstall(root, specs, opts = {}) {
|
|
138
|
+
root = resolve(root);
|
|
139
|
+
const env = opts.env || process.env;
|
|
140
|
+
const onPhase = opts.onPhase || (() => {});
|
|
141
|
+
const pkgPath = join(root, "package.json");
|
|
142
|
+
if (!existsSync(pkgPath)) throw new Error(`No package.json in ${root}`);
|
|
143
|
+
checkProject(root, JSON.parse(readFileSync(pkgPath, "utf-8")));
|
|
144
|
+
const bad = specs.filter((s) => s.startsWith("-") || LOCAL_SPEC.test(s));
|
|
145
|
+
if (bad.length) throw new Error(`Unsupported package spec(s): ${bad.join(", ")}. Pass registry names (name, name@version, name@range).`);
|
|
146
|
+
|
|
147
|
+
const info = opts.info || await npmInfo(root, env);
|
|
148
|
+
const original = Object.fromEntries(COPY.map((f) => [f, readIfExists(join(root, f))]));
|
|
149
|
+
const lockName = LOCKFILES.find((f) => original[f]) || "package-lock.json";
|
|
150
|
+
|
|
151
|
+
const tmp = mkdtempSync(join(tmpdir(), "penthera-install-"));
|
|
152
|
+
try {
|
|
153
|
+
const work = join(tmp, "project");
|
|
154
|
+
mkdirSync(work);
|
|
155
|
+
for (const f of COPY) if (original[f]) copyFileSync(join(root, f), join(work, f));
|
|
156
|
+
|
|
157
|
+
const flags = [...(opts.saveDev ? ["--save-dev"] : []), ...(opts.saveExact ? ["--save-exact"] : []), ...(opts.offline ? ["--offline"] : [])];
|
|
158
|
+
onPhase(`Resolving in a temporary copy: npm install --package-lock-only --ignore-scripts${specs.length ? ` ${specs.join(" ")}` : ""}`);
|
|
159
|
+
await npm(["install", "--package-lock-only", "--ignore-scripts", ...QUIET, ...flags, ...specs], { cwd: work, env });
|
|
160
|
+
|
|
161
|
+
const proposedLockPath = join(work, lockName);
|
|
162
|
+
if (!existsSync(proposedLockPath)) throw new Error("npm did not write a lockfile");
|
|
163
|
+
const proposedLock = JSON.parse(readFileSync(proposedLockPath, "utf-8"));
|
|
164
|
+
|
|
165
|
+
// Base: the project's current lockfile, or an empty tree for a first install.
|
|
166
|
+
let baseLockPath = original[lockName] ? join(root, lockName) : null;
|
|
167
|
+
if (!baseLockPath) {
|
|
168
|
+
mkdirSync(join(tmp, "base"));
|
|
169
|
+
baseLockPath = join(tmp, "base", "package-lock.json");
|
|
170
|
+
writeFileSync(baseLockPath, JSON.stringify({ lockfileVersion: 3, packages: { "": {} } }));
|
|
171
|
+
}
|
|
172
|
+
const oldKeys = new Set(parseLockfile(JSON.parse(readFileSync(baseLockPath, "utf-8"))).map(key));
|
|
173
|
+
const fresh = parseLockfile(proposedLock).filter((p) => !p.link && p.version && !oldKeys.has(key(p)));
|
|
174
|
+
|
|
175
|
+
// New tarballs go into a private npm cache (npm cache add): nothing is
|
|
176
|
+
// unpacked or run. Private because npm will not reliably say where its own
|
|
177
|
+
// cache is (`npm config get cache` refuses some paths), and because the
|
|
178
|
+
// scan must not depend on what else that cache holds.
|
|
179
|
+
const userCache = npmCacheDir(env);
|
|
180
|
+
const fetchCache = join(tmp, "cache");
|
|
181
|
+
const toFetch = fresh.filter((p) => !p.integrity || !sourceFromNpmCache(p.integrity, userCache));
|
|
182
|
+
if (toFetch.length) {
|
|
183
|
+
onPhase(`Fetching ${toFetch.length} tarball(s) with npm cache add (nothing is unpacked or run)`);
|
|
184
|
+
const fetchSpecs = [...new Set(toFetch.map((p) => (p.resolved && !/^https?:/.test(p.resolved) ? p.resolved : `${p.name}@${p.version}`)))];
|
|
185
|
+
await npm(["cache", "add", "--cache", fetchCache, ...(opts.offline ? ["--offline"] : []), ...fetchSpecs], { cwd: tmp, env });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onPhase("Comparing capabilities with the current tree (offline, integrity-verified)");
|
|
189
|
+
const result = await scanDeps(work, { diff: baseLockPath, treeRoot: root, cacheDir: [fetchCache, userCache] });
|
|
190
|
+
const changes = changesFromDiff(result.deps.diff, result.deps.typosquats, result.deps.packages);
|
|
191
|
+
const verdict = evaluatePolicy(changes, opts.policy || normalizePolicy({}));
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
root,
|
|
195
|
+
specs,
|
|
196
|
+
npm: info,
|
|
197
|
+
lockName,
|
|
198
|
+
original,
|
|
199
|
+
proposed: {
|
|
200
|
+
"package.json": readFileSync(join(work, "package.json")),
|
|
201
|
+
[lockName]: readFileSync(proposedLockPath),
|
|
202
|
+
},
|
|
203
|
+
changes,
|
|
204
|
+
removed: result.deps.diff.removed,
|
|
205
|
+
verdict,
|
|
206
|
+
findings: result.findings,
|
|
207
|
+
typosquats: result.deps.typosquats,
|
|
208
|
+
evidence: result.deps.diff.evidence,
|
|
209
|
+
};
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Packages whose new or changed install scripts would run. */
|
|
216
|
+
export const scriptChanges = (plan) => plan.verdict.items.filter((i) => i.flags.includes("install-scripts"));
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pin approved packages in package.json allowScripts (npm >= 12 runs a
|
|
220
|
+
* dependency's install scripts only when listed there). Returns new bytes.
|
|
221
|
+
*/
|
|
222
|
+
export function withAllowScripts(pkgJsonBuf, items) {
|
|
223
|
+
const text = pkgJsonBuf.toString("utf-8");
|
|
224
|
+
const pkg = JSON.parse(text);
|
|
225
|
+
const allow = { ...(pkg.allowScripts || {}) };
|
|
226
|
+
for (const i of items) {
|
|
227
|
+
if (allow[i.name] === false) continue; // an explicit denial in the project wins
|
|
228
|
+
allow[`${i.name}@${i.version}`] = true;
|
|
229
|
+
}
|
|
230
|
+
pkg.allowScripts = Object.fromEntries(Object.entries(allow).sort((a, b) => a[0].localeCompare(b[0])));
|
|
231
|
+
const indent = text.match(/^\{\r?\n([ \t]+)"/)?.[1] || " ";
|
|
232
|
+
return Buffer.from(JSON.stringify(pkg, null, indent) + (text.endsWith("\n") ? "\n" : ""));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function restore(root, original) {
|
|
236
|
+
for (const [f, buf] of Object.entries(original)) {
|
|
237
|
+
const p = join(root, f);
|
|
238
|
+
if (buf) writeFileSync(p, buf);
|
|
239
|
+
else rmSync(p, { force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Install the vetted tree. Writes the proposed package.json and lockfile,
|
|
245
|
+
* then runs `npm install` with no package arguments, so npm installs exactly
|
|
246
|
+
* the lockfile that was reviewed. Restores both files if npm fails.
|
|
247
|
+
* @param {{ runScripts: boolean, env?: object, stdio?: string, offline?: boolean }} opts
|
|
248
|
+
*/
|
|
249
|
+
export async function applyInstall(plan, opts) {
|
|
250
|
+
const { root, original } = plan;
|
|
251
|
+
const env = opts.env || process.env;
|
|
252
|
+
let pkgJson = plan.proposed["package.json"];
|
|
253
|
+
const approved = scriptChanges(plan);
|
|
254
|
+
if (opts.runScripts && plan.npm.allowScriptsPolicy && approved.length) pkgJson = withAllowScripts(pkgJson, approved);
|
|
255
|
+
|
|
256
|
+
writeFileSync(join(root, "package.json"), pkgJson);
|
|
257
|
+
writeFileSync(join(root, plan.lockName), plan.proposed[plan.lockName]);
|
|
258
|
+
const args = ["install", ...QUIET, ...(opts.runScripts ? [] : ["--ignore-scripts"]), ...(opts.offline ? ["--offline"] : [])];
|
|
259
|
+
try {
|
|
260
|
+
await npm(args, { cwd: root, env });
|
|
261
|
+
} catch (e) {
|
|
262
|
+
restore(root, original);
|
|
263
|
+
throw new Error(`${e.message}\npackage.json and ${plan.lockName} were restored; node_modules may be partly updated (run \`npm ci\` to reset it).`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// npm should have installed the reviewed lockfile as is.
|
|
267
|
+
const want = new Set(parseLockfile(JSON.parse(plan.proposed[plan.lockName])).map(key));
|
|
268
|
+
const got = parseLockfile(JSON.parse(readFileSync(join(root, plan.lockName), "utf-8"))).map(key);
|
|
269
|
+
const drift = got.filter((k) => !want.has(k));
|
|
270
|
+
return { args, scriptsRan: opts.runScripts, allowScriptsAdded: pkgJson !== plan.proposed["package.json"] ? approved.map((i) => `${i.name}@${i.version}`) : [], drift };
|
|
271
|
+
}
|