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.
Files changed (95) hide show
  1. package/CHANGELOG.md +269 -3
  2. package/README.md +489 -286
  3. package/SECURITY.md +28 -6
  4. package/docs/METHODOLOGY.md +321 -0
  5. package/docs/compliance-nis2.md +166 -0
  6. package/docs/evidence.md +158 -0
  7. package/docs/examples/github-actions-deps.yml +105 -0
  8. package/docs/examples/github-actions-scan.yml +55 -0
  9. package/docs/examples/pr-dependency-review.yml +56 -0
  10. package/docs/funding/due-diligence-checklist.md +79 -0
  11. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  12. package/docs/funding/partner-one-pager.md +84 -0
  13. package/docs/funding/sponsor-tiers.md +81 -0
  14. package/docs/index.md +15 -3
  15. package/docs/local-loop.md +276 -0
  16. package/docs/references.bib +539 -0
  17. package/docs/references.md +77 -0
  18. package/docs/release-plan.md +228 -0
  19. package/lib/adaptive-probes.js +8 -5
  20. package/lib/agents/discover.js +384 -0
  21. package/lib/agents/hidden-content.js +387 -0
  22. package/lib/agents/index.js +286 -0
  23. package/lib/agents/lock.js +115 -0
  24. package/lib/agents/owasp.js +83 -0
  25. package/lib/agents/rules.js +376 -0
  26. package/lib/crawler.js +5 -15
  27. package/lib/data/retire-jsrepository.json.gz +0 -0
  28. package/lib/data/retire-jsrepository.meta.json +7 -0
  29. package/lib/deps/capabilities.js +523 -0
  30. package/lib/deps/diff.js +84 -0
  31. package/lib/deps/index.js +416 -0
  32. package/lib/deps/lockfile.js +106 -0
  33. package/lib/deps/online.js +132 -0
  34. package/lib/deps/popular-packages.json +1 -0
  35. package/lib/deps/report.js +173 -0
  36. package/lib/deps/source.js +171 -0
  37. package/lib/deps/typosquat.js +113 -0
  38. package/lib/fix/apply.js +95 -0
  39. package/lib/fix/diff.js +199 -0
  40. package/lib/fix/index.js +129 -0
  41. package/lib/fix/model.js +108 -0
  42. package/lib/fix/recipes/cookies.js +130 -0
  43. package/lib/fix/recipes/cors.js +116 -0
  44. package/lib/fix/recipes/headers.js +250 -0
  45. package/lib/fix/recipes/rate-limit.js +211 -0
  46. package/lib/fix/recipes/secrets.js +110 -0
  47. package/lib/fix/util.js +231 -0
  48. package/lib/fuzzer.js +14 -8
  49. package/lib/injections.js +19 -15
  50. package/lib/install/index.js +271 -0
  51. package/lib/install/policy.js +117 -0
  52. package/lib/memory/history.js +52 -0
  53. package/lib/memory/index.js +111 -0
  54. package/lib/memory/repo-suppress.js +64 -0
  55. package/lib/memory/store.js +325 -0
  56. package/lib/memory/views.js +198 -0
  57. package/lib/params.js +4 -12
  58. package/lib/provenance.js +2 -0
  59. package/lib/recon.js +12 -20
  60. package/lib/references.js +31 -0
  61. package/lib/references.json +374 -0
  62. package/lib/remediation/index.js +28 -0
  63. package/lib/retirejs.js +50 -22
  64. package/lib/templates.js +7 -10
  65. package/lib/whitebox/frameworks.js +1 -1
  66. package/lib/whitebox/secrets.js +49 -19
  67. package/package.json +19 -9
  68. package/skills/penthera/SKILL.md +104 -23
  69. package/skills/penthera/references/output-and-ci.md +12 -50
  70. package/skills/penthera/references/troubleshooting.md +1 -1
  71. package/skills/penthera/scripts/preflight.sh +4 -4
  72. package/src/agent/git.js +87 -0
  73. package/src/agent/loop.js +519 -0
  74. package/src/agent/tests.js +48 -0
  75. package/src/cli/agent.js +183 -0
  76. package/src/cli/deps.js +84 -0
  77. package/src/cli/evidence.js +67 -0
  78. package/src/cli/fix.js +191 -0
  79. package/src/cli/install.js +225 -0
  80. package/src/cli/memory.js +146 -0
  81. package/src/cli/merge-results.js +2 -0
  82. package/src/cli/report-outputs.js +72 -0
  83. package/src/cli/run-scan.js +2 -0
  84. package/src/cli.js +152 -6
  85. package/src/report/audience.js +270 -0
  86. package/src/report/evidence.js +253 -0
  87. package/src/report/markdown.js +65 -2
  88. package/src/report/model.js +212 -0
  89. package/src/report/template.js +175 -0
  90. package/src/report/timeline.js +145 -0
  91. package/src/reporter.js +109 -5
  92. package/src/scan-url.js +31 -7
  93. package/src/utils/http.js +252 -11
  94. package/src/utils/url.js +23 -0
  95. package/scripts/package-skill.sh +0 -16
@@ -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 fetch(`${baseUrl}${path}`, {
189
+ const res = await request(`${baseUrl}${path}`, {
184
190
  method,
185
191
  headers,
186
192
  body,
187
- signal: controller.signal,
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, skip
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
- * WARNING: These probes send attack payloads to the target.
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
- async function safeFetch(url, opts = {}) {
26
- const controller = new AbortController();
27
- const timeout = setTimeout(() => controller.abort(), opts.timeout || INJECT_TIMEOUT);
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
+ }