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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering for `penthera deps`: the terminal section, the Markdown section of
|
|
3
|
+
* the full report, and a short PR comment centred on the capability diff.
|
|
4
|
+
*/
|
|
5
|
+
import { CAPABILITIES, CAPABILITY_ORDER } from "./capabilities.js";
|
|
6
|
+
|
|
7
|
+
const plain = (s) => s;
|
|
8
|
+
const label = (id) => CAPABILITIES[id]?.label || id;
|
|
9
|
+
const RISKY = new Set(["install-scripts", "shell", "network", "fs-write", "env-bulk", "eval", "obfuscated", "ai-config-write", "ai-cli-invoke", "token-harvest"]);
|
|
10
|
+
|
|
11
|
+
function countsLine(counts) {
|
|
12
|
+
return CAPABILITY_ORDER.filter((id) => counts[id]).map((id) => `${label(id)} ${counts[id]}`).join(" · ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Terminal lines for result.deps. `c` holds color helpers (bold, dim, red, yellow, green, cyan).
|
|
17
|
+
*/
|
|
18
|
+
export function formatDepsTerminal(deps, c = {}, opts = {}) {
|
|
19
|
+
const k = { bold: plain, dim: plain, red: plain, yellow: plain, green: plain, cyan: plain, ...c };
|
|
20
|
+
const L = [];
|
|
21
|
+
const missing = deps.notInstalled.length;
|
|
22
|
+
L.push(` Packages ${k.bold(String(deps.total))} in ${deps.lockfile} ${k.dim(`(v${deps.lockfileVersion}${deps.prod ? ", prod only" : ""}, ${deps.network})`)}`);
|
|
23
|
+
L.push(` Scanned ${deps.scanned}${missing ? k.yellow(` · ${missing} not installed or cached`) : ""}${deps.skippedOptional ? k.dim(` · ${deps.skippedOptional} optional for other platforms`) : ""}`);
|
|
24
|
+
const cl = countsLine(deps.capabilityCounts);
|
|
25
|
+
if (cl) L.push(` Can do ${k.dim(cl)}`);
|
|
26
|
+
if (deps.typosquats.length) L.push(` Names ${k.yellow(`${deps.typosquats.length} look like popular packages`)}`);
|
|
27
|
+
|
|
28
|
+
const d = deps.diff;
|
|
29
|
+
if (d) {
|
|
30
|
+
const gained = d.upgraded.filter((u) => u.gained?.length);
|
|
31
|
+
const notable = d.added.filter((a) => (a.capabilities || []).some((id) => RISKY.has(id)));
|
|
32
|
+
L.push(` Diff vs ${k.cyan(d.base)}: ${d.added.length} added · ${d.upgraded.length} changed · ${d.removed.length} removed`);
|
|
33
|
+
L.push("");
|
|
34
|
+
if (gained.length === 0 && notable.length === 0) {
|
|
35
|
+
L.push(` ${k.green("✓")} No package gained capabilities, and no new package can reach shell, network or install-time code.`);
|
|
36
|
+
} else {
|
|
37
|
+
L.push(` ${k.bold("Gained capabilities")}`);
|
|
38
|
+
L.push("");
|
|
39
|
+
for (const u of gained) {
|
|
40
|
+
L.push(` ${k.yellow("▲")} ${k.bold(u.name)} ${u.from} → ${u.to} ${k.red("+ " + u.gained.map(label).join(", "))}`);
|
|
41
|
+
for (const id of u.gained) {
|
|
42
|
+
for (const e of (d.evidence[`${u.name}@${u.to}`]?.[id] || []).slice(0, opts.verbose ? 5 : 1)) {
|
|
43
|
+
L.push(` ${k.dim(`${e.file}:${e.line}`)} ${e.snippet}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const a of notable) {
|
|
48
|
+
const ids = a.capabilities.filter((id) => RISKY.has(id));
|
|
49
|
+
L.push(` ${k.yellow("+")} ${k.bold(a.name)} ${a.version} ${k.dim("(new)")} ${ids.map(label).join(", ")}`);
|
|
50
|
+
for (const id of ids.slice(0, opts.verbose ? ids.length : 2)) {
|
|
51
|
+
const e = d.evidence[`${a.name}@${a.version}`]?.[id]?.[0];
|
|
52
|
+
if (e) L.push(` ${k.dim(`${e.file}:${e.line}`)} ${e.snippet}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const lost = d.upgraded.filter((u) => u.lost?.length);
|
|
57
|
+
if (lost.length && opts.verbose) {
|
|
58
|
+
L.push("");
|
|
59
|
+
for (const u of lost) L.push(` ${k.green("▼")} ${u.name} ${u.from} → ${u.to} ${k.dim("- " + u.lost.map(label).join(", "))}`);
|
|
60
|
+
}
|
|
61
|
+
const unavailable = d.upgraded.filter((u) => u.unavailable);
|
|
62
|
+
if (unavailable.length) {
|
|
63
|
+
L.push("");
|
|
64
|
+
L.push(` ${k.dim(`Not compared (old version not available offline): ${unavailable.map((u) => `${u.name} ${u.from}`).join(", ")}`)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return L;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const esc = (s) => String(s ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
71
|
+
const code = (s) => "`" + String(s).replace(/`/g, "ˋ") + "`";
|
|
72
|
+
|
|
73
|
+
/** Capability diff as Markdown (used in the full report and the PR comment). */
|
|
74
|
+
export function formatDiffMarkdown(deps) {
|
|
75
|
+
const d = deps.diff;
|
|
76
|
+
const L = [];
|
|
77
|
+
if (!d) return "";
|
|
78
|
+
const gained = d.upgraded.filter((u) => u.gained?.length);
|
|
79
|
+
const added = d.added.filter((a) => (a.capabilities || []).length);
|
|
80
|
+
L.push(`Compared against \`${esc(d.base)}\`: **${d.added.length}** added · **${d.upgraded.length}** changed · **${d.removed.length}** removed.`);
|
|
81
|
+
L.push("");
|
|
82
|
+
if (gained.length === 0 && !added.some((a) => a.capabilities.some((id) => RISKY.has(id)))) {
|
|
83
|
+
L.push("No package gained capabilities, and no new package can reach shell, network or install-time code.");
|
|
84
|
+
L.push("");
|
|
85
|
+
}
|
|
86
|
+
if (gained.length) {
|
|
87
|
+
L.push("### Upgrades that gained capabilities");
|
|
88
|
+
L.push("");
|
|
89
|
+
L.push("| Package | Version | Gained | Evidence |");
|
|
90
|
+
L.push("|---|---|---|---|");
|
|
91
|
+
for (const u of gained) {
|
|
92
|
+
const ev = u.gained.map((id) => d.evidence[`${u.name}@${u.to}`]?.[id]?.[0]).filter(Boolean)[0];
|
|
93
|
+
L.push(`| **${esc(u.name)}** | ${esc(u.from)} → ${esc(u.to)} | ${u.gained.map(label).join(", ")} | ${ev ? `${code(`${ev.file}:${ev.line}`)}` : ""} |`);
|
|
94
|
+
}
|
|
95
|
+
L.push("");
|
|
96
|
+
}
|
|
97
|
+
if (added.length) {
|
|
98
|
+
L.push("### New packages");
|
|
99
|
+
L.push("");
|
|
100
|
+
L.push("| Package | Version | Can do |");
|
|
101
|
+
L.push("|---|---|---|");
|
|
102
|
+
for (const a of added) L.push(`| ${esc(a.name)} | ${esc(a.version)} | ${a.capabilities.map((id) => (RISKY.has(id) ? `**${label(id)}**` : label(id))).join(", ")} |`);
|
|
103
|
+
L.push("");
|
|
104
|
+
}
|
|
105
|
+
const unavailable = d.upgraded.filter((u) => u.unavailable);
|
|
106
|
+
if (unavailable.length) {
|
|
107
|
+
L.push(`> Not compared, old version not available offline: ${unavailable.map((u) => `${esc(u.name)} ${esc(u.from)}`).join(", ")}. Install the base tree and pass it to \`--diff\`, or use \`--online\`.`);
|
|
108
|
+
L.push("");
|
|
109
|
+
}
|
|
110
|
+
const withEvidence = [...gained.map((u) => [`${u.name}@${u.to}`, u.gained]), ...added.map((a) => [`${a.name}@${a.version}`, a.capabilities.filter((id) => RISKY.has(id))])]
|
|
111
|
+
.filter(([, ids]) => ids.length);
|
|
112
|
+
if (withEvidence.length) {
|
|
113
|
+
L.push("<details><summary>Evidence (file:line)</summary>");
|
|
114
|
+
L.push("");
|
|
115
|
+
for (const [key, ids] of withEvidence) {
|
|
116
|
+
L.push(`**${esc(key)}**`);
|
|
117
|
+
L.push("");
|
|
118
|
+
for (const id of ids) {
|
|
119
|
+
for (const e of (d.evidence[key]?.[id] || []).slice(0, 3)) L.push(`- ${label(id)}: ${code(`${e.file}:${e.line}`)} ${code(e.snippet)}`);
|
|
120
|
+
}
|
|
121
|
+
L.push("");
|
|
122
|
+
}
|
|
123
|
+
L.push("</details>");
|
|
124
|
+
L.push("");
|
|
125
|
+
}
|
|
126
|
+
return L.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Dependencies section of the full Markdown report. */
|
|
130
|
+
export function formatDepsMarkdown(deps) {
|
|
131
|
+
const L = [];
|
|
132
|
+
L.push("## Dependencies");
|
|
133
|
+
L.push("");
|
|
134
|
+
L.push(`**${deps.total}** packages in \`${esc(deps.lockfile)}\` (lockfile v${deps.lockfileVersion}${deps.prod ? ", production only" : ""}), ${deps.scanned} scanned ${deps.network}.${deps.notInstalled.length ? ` ${deps.notInstalled.length} not installed or cached.` : ""}`);
|
|
135
|
+
L.push("");
|
|
136
|
+
const rows = CAPABILITY_ORDER.filter((id) => deps.capabilityCounts[id]);
|
|
137
|
+
if (rows.length) {
|
|
138
|
+
L.push("| Capability | Packages |");
|
|
139
|
+
L.push("|---|---|");
|
|
140
|
+
for (const id of rows) L.push(`| ${label(id)} | ${deps.capabilityCounts[id]} |`);
|
|
141
|
+
L.push("");
|
|
142
|
+
}
|
|
143
|
+
if (deps.diff) {
|
|
144
|
+
L.push("### Capability diff");
|
|
145
|
+
L.push("");
|
|
146
|
+
L.push(formatDiffMarkdown(deps));
|
|
147
|
+
}
|
|
148
|
+
L.push(`_Static analysis of package code, offline. A capability is what code can do, not proof it is malicious. Typosquat names checked against ${esc(deps.popularList)}._`);
|
|
149
|
+
L.push("");
|
|
150
|
+
return L.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const MARKER = "<!-- penthera-deps -->";
|
|
154
|
+
|
|
155
|
+
/** Short PR comment: the capability diff plus the risk findings. */
|
|
156
|
+
export function formatPrComment(result, opts = {}) {
|
|
157
|
+
const deps = result.deps;
|
|
158
|
+
const L = [MARKER, "## Dependency capability diff", ""];
|
|
159
|
+
if (deps.diff) L.push(formatDiffMarkdown(deps));
|
|
160
|
+
else L.push(`No base to compare against; ${deps.total} packages scanned.\n`);
|
|
161
|
+
const risks = (result.findings || []).filter((f) => f.severity !== "info" && f.method !== "deps-capability-diff");
|
|
162
|
+
if (risks.length) {
|
|
163
|
+
L.push("### Risk signals");
|
|
164
|
+
L.push("");
|
|
165
|
+
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
166
|
+
for (const f of [...risks].sort((a, b) => (order[a.severity] ?? 4) - (order[b.severity] ?? 4))) {
|
|
167
|
+
L.push(`- **${f.severity}** ${esc(f.title)}${f.confidence === "potential" ? " _(potential)_" : ""}${f.url ? ` ${code(`${f.url}${f.line ? `:${f.line}` : ""}`)}` : ""}`);
|
|
168
|
+
}
|
|
169
|
+
L.push("");
|
|
170
|
+
}
|
|
171
|
+
L.push(`<sub>[penthera deps](https://github.com/danoszz/penthera)${opts.version ? ` v${opts.version}` : ""}: offline static analysis, no telemetry. Capabilities are what code can do, not a verdict.</sub>`);
|
|
172
|
+
return L.join("\n") + "\n";
|
|
173
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a package's files come from, offline.
|
|
3
|
+
*
|
|
4
|
+
* The capability scanner reads files through a small source interface so the
|
|
5
|
+
* same code can scan an installed folder in node_modules or a tarball from
|
|
6
|
+
* npm's local content cache. The cache is what makes `--diff` work without a
|
|
7
|
+
* network: npm stores every tarball it has downloaded under its integrity hash,
|
|
8
|
+
* and a lockfile records that hash, so an old version can be read back exactly
|
|
9
|
+
* and verified byte for byte.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, readdirSync, lstatSync, existsSync } from "node:fs";
|
|
12
|
+
import { join, relative, sep } from "node:path";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { gunzipSync } from "node:zlib";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
|
|
17
|
+
const MAX_FILES = 5000;
|
|
18
|
+
|
|
19
|
+
/** Files of an installed package folder. Skips nested node_modules and symlinks. */
|
|
20
|
+
export class DirSource {
|
|
21
|
+
constructor(dir) {
|
|
22
|
+
this.dir = dir;
|
|
23
|
+
this.kind = "installed";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
files() {
|
|
27
|
+
if (this._files) return this._files;
|
|
28
|
+
const out = [];
|
|
29
|
+
const walk = (d) => {
|
|
30
|
+
let entries;
|
|
31
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
32
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
if (out.length >= MAX_FILES) return;
|
|
35
|
+
const p = join(d, e.name);
|
|
36
|
+
if (e.isSymbolicLink()) continue;
|
|
37
|
+
if (e.isDirectory()) {
|
|
38
|
+
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
39
|
+
walk(p);
|
|
40
|
+
} else if (e.isFile()) {
|
|
41
|
+
out.push(relative(this.dir, p).split(sep).join("/"));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
walk(this.dir);
|
|
46
|
+
this._files = out;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
size(rel) {
|
|
51
|
+
try { return lstatSync(join(this.dir, rel)).size; } catch { return 0; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
read(rel) {
|
|
55
|
+
return readFileSync(join(this.dir, rel));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Files of an unpacked tarball, held in memory. */
|
|
60
|
+
export class MapSource {
|
|
61
|
+
constructor(map, kind = "npm-cache") {
|
|
62
|
+
this.map = map;
|
|
63
|
+
this.kind = kind;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
files() {
|
|
67
|
+
return [...this.map.keys()].sort().slice(0, MAX_FILES);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
size(rel) {
|
|
71
|
+
return this.map.get(rel)?.length || 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
read(rel) {
|
|
75
|
+
return this.map.get(rel);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Tarballs ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function cstr(buf, start, len) {
|
|
82
|
+
const s = buf.subarray(start, start + len);
|
|
83
|
+
const z = s.indexOf(0);
|
|
84
|
+
return (z === -1 ? s : s.subarray(0, z)).toString("utf-8");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Minimal tar reader (ustar + pax/GNU long names). Returns Map<path, Buffer>
|
|
89
|
+
* with the leading "package/" directory stripped. Only regular files are kept;
|
|
90
|
+
* paths that try to climb out ("..") are dropped.
|
|
91
|
+
*/
|
|
92
|
+
export function untar(buf) {
|
|
93
|
+
const files = new Map();
|
|
94
|
+
let off = 0;
|
|
95
|
+
let longName = null;
|
|
96
|
+
while (off + 512 <= buf.length) {
|
|
97
|
+
const header = buf.subarray(off, off + 512);
|
|
98
|
+
if (header.every((b) => b === 0)) break;
|
|
99
|
+
let name = cstr(header, 0, 100);
|
|
100
|
+
const size = parseInt(cstr(header, 124, 12).trim() || "0", 8) || 0;
|
|
101
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
102
|
+
const prefix = cstr(header, 345, 155);
|
|
103
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
104
|
+
const body = buf.subarray(off + 512, off + 512 + size);
|
|
105
|
+
off += 512 + Math.ceil(size / 512) * 512;
|
|
106
|
+
|
|
107
|
+
if (type === "x") { // pax extended header
|
|
108
|
+
const m = body.toString("utf-8").match(/\d+ path=([^\n]*)\n/);
|
|
109
|
+
if (m) longName = m[1];
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (type === "L") { // GNU long name
|
|
113
|
+
longName = cstr(body, 0, body.length);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (type === "g") continue;
|
|
117
|
+
if (longName) { name = longName; longName = null; }
|
|
118
|
+
if (type !== "0" && type !== "\0" && type !== "7") continue;
|
|
119
|
+
const parts = name.split("/").filter(Boolean);
|
|
120
|
+
parts.shift(); // top-level folder, usually "package"
|
|
121
|
+
if (parts.length === 0 || parts.includes("..")) continue;
|
|
122
|
+
files.set(parts.join("/"), Buffer.from(body));
|
|
123
|
+
}
|
|
124
|
+
return files;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Split an SRI string into { algo, hex } entries. */
|
|
128
|
+
export function parseIntegrity(sri) {
|
|
129
|
+
return String(sri || "").split(/\s+/).filter(Boolean).map((s) => {
|
|
130
|
+
const i = s.indexOf("-");
|
|
131
|
+
if (i === -1) return null;
|
|
132
|
+
const algo = s.slice(0, i);
|
|
133
|
+
const b64 = s.slice(i + 1).replace(/\?.*$/, "");
|
|
134
|
+
return { algo, hex: Buffer.from(b64, "base64").toString("hex") };
|
|
135
|
+
}).filter((x) => x && /^sha(1|256|384|512)$/.test(x.algo) && x.hex);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** True when the bytes match the SRI integrity string. */
|
|
139
|
+
export function verifyIntegrity(bytes, sri) {
|
|
140
|
+
const entries = parseIntegrity(sri);
|
|
141
|
+
if (entries.length === 0) return false;
|
|
142
|
+
return entries.some(({ algo, hex }) => createHash(algo).update(bytes).digest("hex") === hex);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** npm's cache directory: npm_config_cache, else ~/.npm. */
|
|
146
|
+
export function npmCacheDir(env = process.env) {
|
|
147
|
+
return env.npm_config_cache || env.NPM_CONFIG_CACHE || join(homedir(), ".npm");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read a package tarball from npm's content-addressed cache (cacache) by its
|
|
152
|
+
* lockfile integrity, verify it, and unpack it. Returns a MapSource or null.
|
|
153
|
+
*/
|
|
154
|
+
export function sourceFromNpmCache(integrity, cacheDir = npmCacheDir()) {
|
|
155
|
+
for (const { algo, hex } of parseIntegrity(integrity)) {
|
|
156
|
+
const p = join(cacheDir, "_cacache", "content-v2", algo, hex.slice(0, 2), hex.slice(2, 4), hex.slice(4));
|
|
157
|
+
if (!existsSync(p)) continue;
|
|
158
|
+
try {
|
|
159
|
+
const tgz = readFileSync(p);
|
|
160
|
+
if (!verifyIntegrity(tgz, integrity)) continue;
|
|
161
|
+
return new MapSource(untar(gunzipSync(tgz)), "npm-cache");
|
|
162
|
+
} catch { /* corrupt entry, try the next hash */ }
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Unpack a verified tarball buffer (used by --online). */
|
|
168
|
+
export function sourceFromTarball(tgz, integrity, kind = "registry") {
|
|
169
|
+
if (integrity && !verifyIntegrity(tgz, integrity)) return null;
|
|
170
|
+
return new MapSource(untar(gunzipSync(tgz)), kind);
|
|
171
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typosquat and slopsquat checks for npm package names (`penthera deps`).
|
|
3
|
+
*
|
|
4
|
+
* Compares each dependency name against a vendored list of popular package
|
|
5
|
+
* names (lib/deps/popular-packages.json, refreshed at release time by
|
|
6
|
+
* scripts/refresh-popular-packages.mjs; never fetched at runtime). A name that
|
|
7
|
+
* is one or two edits away from a popular package, but is not itself popular,
|
|
8
|
+
* is worth a second look: that is how `crossenv` (for cross-env) and
|
|
9
|
+
* `electorn` (for electron) shipped malware.
|
|
10
|
+
*
|
|
11
|
+
* Slopsquatting is the same attack aimed at AI assistants: registering names
|
|
12
|
+
* a model is likely to hallucinate. Offline, the useful signal is the same
|
|
13
|
+
* near-miss check, plus (with --online) a very young publish date.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
17
|
+
let _popular = null;
|
|
18
|
+
|
|
19
|
+
/** The vendored popular-name list: { source, names: string[] }. */
|
|
20
|
+
export function loadPopular() {
|
|
21
|
+
if (!_popular) {
|
|
22
|
+
const data = JSON.parse(readFileSync(new URL("./popular-packages.json", import.meta.url), "utf-8"));
|
|
23
|
+
_popular = { ...data, set: new Set(data.names), rank: new Map(data.names.map((n, i) => [n, i + 1])) };
|
|
24
|
+
}
|
|
25
|
+
return _popular;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Damerau-Levenshtein (optimal string alignment) distance, capped at max+1. */
|
|
29
|
+
export function editDistance(a, b, max = 2) {
|
|
30
|
+
if (a === b) return 0;
|
|
31
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
32
|
+
const m = a.length;
|
|
33
|
+
const n = b.length;
|
|
34
|
+
let prev2 = null;
|
|
35
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
36
|
+
for (let i = 1; i <= m; i++) {
|
|
37
|
+
const cur = [i];
|
|
38
|
+
let rowMin = i;
|
|
39
|
+
for (let k = 1; k <= n; k++) {
|
|
40
|
+
const cost = a[i - 1] === b[k - 1] ? 0 : 1;
|
|
41
|
+
let v = Math.min(prev[k] + 1, cur[k - 1] + 1, prev[k - 1] + cost);
|
|
42
|
+
if (prev2 && i > 1 && k > 1 && a[i - 1] === b[k - 2] && a[i - 2] === b[k - 1]) v = Math.min(v, prev2[k - 2] + 1);
|
|
43
|
+
cur[k] = v;
|
|
44
|
+
if (v < rowMin) rowMin = v;
|
|
45
|
+
}
|
|
46
|
+
if (rowMin > max) return max + 1;
|
|
47
|
+
prev2 = prev;
|
|
48
|
+
prev = cur;
|
|
49
|
+
}
|
|
50
|
+
return prev[n];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const JS_SUFFIX = /(?:[.-]?js|-node)$/;
|
|
54
|
+
|
|
55
|
+
/** Separator- and scope-insensitive form: "@types/lodash.merge" -> "typeslodashmerge". */
|
|
56
|
+
const squash = (s) => s.replace(/^@/, "").replace(/[-_./]/g, "");
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the popular package a name is likely imitating, or null.
|
|
60
|
+
* @returns {null | { target, distance, kind, rank }}
|
|
61
|
+
*/
|
|
62
|
+
export function checkName(name, popular = loadPopular()) {
|
|
63
|
+
if (!name || popular.set.has(name)) return null;
|
|
64
|
+
const bare = name.replace(/^@[^/]+\//, "");
|
|
65
|
+
const isScoped = bare !== name;
|
|
66
|
+
// Short names collide with each other constantly; a 1-edit window over
|
|
67
|
+
// three-letter names would flag half the registry.
|
|
68
|
+
const max = bare.length >= 12 ? 2 : bare.length >= 5 ? 1 : 0;
|
|
69
|
+
const scope = isScoped ? name.slice(0, name.indexOf("/")) : null;
|
|
70
|
+
|
|
71
|
+
let best = null;
|
|
72
|
+
for (const target of popular.names) {
|
|
73
|
+
if (target === name) return null;
|
|
74
|
+
// Nobody can publish into a scope they don't own, so siblings in one scope
|
|
75
|
+
// (@img/sharp-linux-arm vs -arm64) are the same publisher, not a squat.
|
|
76
|
+
if (scope && target.startsWith(`${scope}/`)) continue;
|
|
77
|
+
let distance;
|
|
78
|
+
let kind;
|
|
79
|
+
const tSquash = squash(target);
|
|
80
|
+
if (squash(name) === tSquash) {
|
|
81
|
+
distance = 1;
|
|
82
|
+
kind = "separator or scope swap";
|
|
83
|
+
} else if (name.replace(JS_SUFFIX, "") === target) {
|
|
84
|
+
distance = 1;
|
|
85
|
+
kind = "popular name plus a js/node suffix";
|
|
86
|
+
} else if (max > 0 && !isScoped === !target.startsWith("@")) {
|
|
87
|
+
distance = editDistance(name, target, max);
|
|
88
|
+
if (distance > max) continue;
|
|
89
|
+
kind = distance === 1 ? "one edit away" : "two edits away";
|
|
90
|
+
} else {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const rank = popular.rank.get(target);
|
|
94
|
+
if (!best || distance < best.distance || (distance === best.distance && rank < best.rank)) {
|
|
95
|
+
best = { target, distance, kind, rank };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return best;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check a list of package names. Returns hits sorted by name.
|
|
103
|
+
* @param {string[]} names
|
|
104
|
+
*/
|
|
105
|
+
export function findTyposquats(names) {
|
|
106
|
+
const popular = loadPopular();
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const name of [...new Set(names)].sort()) {
|
|
109
|
+
const hit = checkName(name, popular);
|
|
110
|
+
if (hit) out.push({ name, ...hit });
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
package/lib/fix/apply.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* applyDiff: the one place fix recipes' diffs get written to disk.
|
|
3
|
+
*
|
|
4
|
+
* Planning never writes. Applying is a separate, explicit step that refuses to
|
|
5
|
+
* write outside the repo, into .git, or into a dirty git tree (unless the caller
|
|
6
|
+
* passes allowDirty), and writes nothing unless every hunk applies.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
9
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { parsePatch, applyHunks } from "./diff.js";
|
|
12
|
+
|
|
13
|
+
export class FixError extends Error {
|
|
14
|
+
constructor(message, code) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "FixError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function inside(root, p) {
|
|
22
|
+
const rel = relative(root, p);
|
|
23
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve a diff path to an absolute path inside the repo, or throw. */
|
|
27
|
+
function safeTarget(root, file) {
|
|
28
|
+
if (!file || isAbsolute(file) || file.includes("\0")) {
|
|
29
|
+
throw new FixError(`Refusing to write ${JSON.stringify(file)}: paths in a diff must be relative to the repo`, "OUTSIDE_REPO");
|
|
30
|
+
}
|
|
31
|
+
const target = resolve(root, file);
|
|
32
|
+
if (!inside(root, target)) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
|
|
33
|
+
if (relative(root, target).split(sep)[0] === ".git") throw new FixError(`Refusing to write ${file}: it is inside .git`, "OUTSIDE_REPO");
|
|
34
|
+
// Follow symlinks: the real location of the file (or its nearest existing
|
|
35
|
+
// parent, for a new file) must still be inside the repo.
|
|
36
|
+
let probe = target;
|
|
37
|
+
while (!existsSync(probe)) probe = dirname(probe);
|
|
38
|
+
if (!inside(root, realpathSync(probe))) throw new FixError(`Refusing to write ${file}: it resolves outside the repo`, "OUTSIDE_REPO");
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Throws unless repoPath is in a git work tree with no uncommitted changes under it. */
|
|
43
|
+
export function assertCleanTree(repoPath) {
|
|
44
|
+
let status;
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: repoPath, stdio: ["ignore", "pipe", "ignore"] });
|
|
47
|
+
status = execFileSync("git", ["status", "--porcelain", "--", "."], { cwd: repoPath, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
48
|
+
} catch {
|
|
49
|
+
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");
|
|
50
|
+
}
|
|
51
|
+
if (status.trim()) {
|
|
52
|
+
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");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply a unified diff to files under repoPath.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} repoPath
|
|
60
|
+
* @param {string} diff
|
|
61
|
+
* @param {{ dryRun?: boolean, allowDirty?: boolean }} [opts]
|
|
62
|
+
* @returns {{ applied: boolean, files: { path: string, created: boolean }[] }}
|
|
63
|
+
*/
|
|
64
|
+
export function applyDiff(repoPath, diff, opts = {}) {
|
|
65
|
+
const root = realpathSync(resolve(repoPath));
|
|
66
|
+
const parsed = parsePatch(diff);
|
|
67
|
+
if (parsed.length === 0) throw new FixError("The diff contains no file changes", "EMPTY");
|
|
68
|
+
if (!opts.allowDirty) assertCleanTree(root);
|
|
69
|
+
|
|
70
|
+
const writes = [];
|
|
71
|
+
for (const f of parsed) {
|
|
72
|
+
if (!f.newPath) throw new FixError(`Refusing to delete ${f.oldPath}: fix diffs never delete files`, "DELETE");
|
|
73
|
+
if (f.oldPath && f.oldPath !== f.newPath) throw new FixError(`Refusing to rename ${f.oldPath}: fix diffs never rename files`, "RENAME");
|
|
74
|
+
const target = safeTarget(root, f.newPath);
|
|
75
|
+
const exists = existsSync(target);
|
|
76
|
+
if (!f.oldPath && exists) throw new FixError(`${f.newPath} already exists; the plan expected to create it`, "CONFLICT");
|
|
77
|
+
if (f.oldPath && !exists) throw new FixError(`${f.newPath} does not exist; the plan expected to edit it`, "CONFLICT");
|
|
78
|
+
const before = exists ? readFileSync(target, "utf-8") : null;
|
|
79
|
+
let after;
|
|
80
|
+
try {
|
|
81
|
+
after = applyHunks(before, f.hunks);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
throw new FixError(`${f.newPath}: ${e.message}`, "CONFLICT");
|
|
84
|
+
}
|
|
85
|
+
writes.push({ path: f.newPath, target, after, created: !exists });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!opts.dryRun) {
|
|
89
|
+
for (const w of writes) {
|
|
90
|
+
mkdirSync(dirname(w.target), { recursive: true });
|
|
91
|
+
writeFileSync(w.target, w.after);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { applied: !opts.dryRun, files: writes.map((w) => ({ path: w.path, created: w.created })) };
|
|
95
|
+
}
|