penthera 1.5.0 → 1.6.1
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 +179 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
package/lib/deps/report.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* the full report, and a short PR comment centred on the capability diff.
|
|
4
4
|
*/
|
|
5
5
|
import { CAPABILITIES, CAPABILITY_ORDER } from "./capabilities.js";
|
|
6
|
+
import { FOOTER } from "../../src/report/footer.js";
|
|
6
7
|
|
|
7
8
|
const plain = (s) => s;
|
|
8
9
|
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
|
+
const RISKY = new Set(["install-scripts", "shell", "network", "fs-write", "env-bulk", "eval", "obfuscated", "ai-config-write", "ai-cli-invoke", "token-harvest", "startup-hook"]);
|
|
10
11
|
|
|
11
12
|
function countsLine(counts) {
|
|
12
13
|
return CAPABILITY_ORDER.filter((id) => counts[id]).map((id) => `${label(id)} ${counts[id]}`).join(" · ");
|
|
@@ -19,10 +20,11 @@ export function formatDepsTerminal(deps, c = {}, opts = {}) {
|
|
|
19
20
|
const k = { bold: plain, dim: plain, red: plain, yellow: plain, green: plain, cyan: plain, ...c };
|
|
20
21
|
const L = [];
|
|
21
22
|
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(` Packages ${k.bold(String(deps.total))} in ${deps.lockfile} ${k.dim(`(${deps.format || `v${deps.lockfileVersion}`}${deps.prod ? ", prod only" : ""}, ${deps.network})`)}`);
|
|
23
24
|
L.push(` Scanned ${deps.scanned}${missing ? k.yellow(` · ${missing} not installed or cached`) : ""}${deps.skippedOptional ? k.dim(` · ${deps.skippedOptional} optional for other platforms`) : ""}`);
|
|
24
25
|
const cl = countsLine(deps.capabilityCounts);
|
|
25
26
|
if (cl) L.push(` Can do ${k.dim(cl)}`);
|
|
27
|
+
if (deps.online?.note) L.push(` Online ${k.dim(deps.online.note)}`);
|
|
26
28
|
if (deps.typosquats.length) L.push(` Names ${k.yellow(`${deps.typosquats.length} look like popular packages`)}`);
|
|
27
29
|
if (deps.licenses) L.push(` Licenses ${licenseLine(deps.licenses, k)}`);
|
|
28
30
|
|
|
@@ -68,8 +70,36 @@ export function formatDepsTerminal(deps, c = {}, opts = {}) {
|
|
|
68
70
|
return L;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
// Package names, versions, paths and snippets come from the lockfile and the
|
|
74
|
+
// package files, so in a PR comment they are attacker-controlled text.
|
|
75
|
+
const MAX_FIELD = 200;
|
|
76
|
+
const ZW = "\u200b"; // zero-width space: breaks mentions, refs and autolinks invisibly
|
|
77
|
+
const cap = (s) => {
|
|
78
|
+
const t = String(s ?? "").replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ");
|
|
79
|
+
return t.length > MAX_FIELD ? `${t.slice(0, MAX_FIELD - 1)}…` : t;
|
|
80
|
+
};
|
|
81
|
+
/** Neutralise GitHub's text-level features: @mentions, #refs and bare-URL autolinks. */
|
|
82
|
+
const defuse = (t) => t
|
|
83
|
+
.replace(/@/g, `@${ZW}`)
|
|
84
|
+
.replace(/#/g, `#${ZW}`)
|
|
85
|
+
.replace(/:\/\//g, `:${ZW}//`)
|
|
86
|
+
.replace(/\bwww\./gi, (m) => `${m.slice(0, 3)}${ZW}.`);
|
|
87
|
+
|
|
88
|
+
/** Plain text safe inside Markdown (tables included): no HTML, links, images, emphasis, mentions. */
|
|
89
|
+
export function esc(s) {
|
|
90
|
+
const t = cap(s)
|
|
91
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
92
|
+
.replace(/[\\`*_{}[\]()!|~]/g, (c) => `\\${c}`);
|
|
93
|
+
return defuse(t);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* An inline code span: one line, no backticks, no table pipes, no angle
|
|
98
|
+
* brackets (an HTML tag that starts before a code span outranks it), capped.
|
|
99
|
+
*/
|
|
100
|
+
export function code(s) {
|
|
101
|
+
return "`" + defuse(cap(s).replace(/`/g, "ˋ").replace(/\|/g, "¦").replace(/</g, "‹").replace(/>/g, "›")) + "`";
|
|
102
|
+
}
|
|
73
103
|
|
|
74
104
|
/** Capability diff as Markdown (used in the full report and the PR comment). */
|
|
75
105
|
export function formatDiffMarkdown(deps) {
|
|
@@ -105,7 +135,7 @@ export function formatDiffMarkdown(deps) {
|
|
|
105
135
|
}
|
|
106
136
|
const unavailable = d.upgraded.filter((u) => u.unavailable);
|
|
107
137
|
if (unavailable.length) {
|
|
108
|
-
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
|
|
138
|
+
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\`${deps.ecosystem === "pypi" ? "" : ", or use \`--online\`"}.`);
|
|
109
139
|
L.push("");
|
|
110
140
|
}
|
|
111
141
|
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))])]
|
|
@@ -144,7 +174,7 @@ export function formatDepsMarkdown(deps) {
|
|
|
144
174
|
const L = [];
|
|
145
175
|
L.push("## Dependencies");
|
|
146
176
|
L.push("");
|
|
147
|
-
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.` : ""}`);
|
|
177
|
+
L.push(`**${deps.total}** packages in \`${esc(deps.lockfile)}\` (${deps.format ? esc(deps.format) : `lockfile v${deps.lockfileVersion}`}${deps.prod ? ", production only" : ""}), ${deps.scanned} scanned ${deps.network}.${deps.notInstalled.length ? ` ${deps.notInstalled.length} not installed or cached.` : ""}`);
|
|
148
178
|
L.push("");
|
|
149
179
|
const rows = CAPABILITY_ORDER.filter((id) => deps.capabilityCounts[id]);
|
|
150
180
|
if (rows.length) {
|
|
@@ -186,5 +216,6 @@ export function formatPrComment(result, opts = {}) {
|
|
|
186
216
|
L.push("");
|
|
187
217
|
}
|
|
188
218
|
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>`);
|
|
219
|
+
if (opts.footer !== false) L.push("", FOOTER);
|
|
189
220
|
return L.join("\n") + "\n";
|
|
190
221
|
}
|
package/lib/deps/source.js
CHANGED
|
@@ -18,9 +18,10 @@ const MAX_FILES = 5000;
|
|
|
18
18
|
|
|
19
19
|
/** Files of an installed package folder. Skips nested node_modules and symlinks. */
|
|
20
20
|
export class DirSource {
|
|
21
|
-
constructor(dir) {
|
|
21
|
+
constructor(dir, { skip } = {}) {
|
|
22
22
|
this.dir = dir;
|
|
23
23
|
this.kind = "installed";
|
|
24
|
+
this.skip = skip || null;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
files() {
|
|
@@ -35,7 +36,7 @@ export class DirSource {
|
|
|
35
36
|
const p = join(d, e.name);
|
|
36
37
|
if (e.isSymbolicLink()) continue;
|
|
37
38
|
if (e.isDirectory()) {
|
|
38
|
-
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
39
|
+
if (e.name === "node_modules" || e.name === ".git" || this.skip?.has(e.name)) continue;
|
|
39
40
|
walk(p);
|
|
40
41
|
} else if (e.isFile()) {
|
|
41
42
|
out.push(relative(this.dir, p).split(sep).join("/"));
|
|
@@ -56,11 +57,17 @@ export class DirSource {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Files of an unpacked tarball, held in memory. `anomaly` is set when the
|
|
62
|
+
* archive could not be read safely (the scan then fails closed, see
|
|
63
|
+
* scanPackage). `oversize` maps files too large to hold to their real size.
|
|
64
|
+
*/
|
|
60
65
|
export class MapSource {
|
|
61
|
-
constructor(map, kind = "npm-cache") {
|
|
66
|
+
constructor(map, kind = "npm-cache", { anomaly = null } = {}) {
|
|
62
67
|
this.map = map;
|
|
63
68
|
this.kind = kind;
|
|
69
|
+
this.anomaly = anomaly;
|
|
70
|
+
this.oversize = map.oversize || new Map();
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
files() {
|
|
@@ -68,7 +75,7 @@ export class MapSource {
|
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
size(rel) {
|
|
71
|
-
return this.map.get(rel)?.length || 0;
|
|
78
|
+
return this.oversize.get(rel) ?? (this.map.get(rel)?.length || 0);
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
read(rel) {
|
|
@@ -78,6 +85,19 @@ export class MapSource {
|
|
|
78
85
|
|
|
79
86
|
// ── Tarballs ──────────────────────────────────────────────────────────────
|
|
80
87
|
|
|
88
|
+
/** Unpacked size limits, enforced while reading, not afterwards. */
|
|
89
|
+
export const MAX_UNPACKED_BYTES = 256 * 1024 * 1024;
|
|
90
|
+
export const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
91
|
+
const MAX_ENTRIES = 50_000;
|
|
92
|
+
|
|
93
|
+
/** A tarball that could not be parsed unambiguously. Callers fail closed. */
|
|
94
|
+
export class TarError extends Error {
|
|
95
|
+
constructor(message) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "TarError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
81
101
|
function cstr(buf, start, len) {
|
|
82
102
|
const s = buf.subarray(start, start + len);
|
|
83
103
|
const z = s.indexOf(0);
|
|
@@ -85,45 +105,141 @@ function cstr(buf, start, len) {
|
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
108
|
+
* A numeric header field: octal (NUL or space terminated), or GNU base-256
|
|
109
|
+
* (high bit of the first byte set). Anything else is an error, never 0: a size
|
|
110
|
+
* read wrong desyncs the parser from what npm (node-tar) extracts.
|
|
91
111
|
*/
|
|
92
|
-
export function
|
|
112
|
+
export function tarNumber(header, start, len, field) {
|
|
113
|
+
const f = header.subarray(start, start + len);
|
|
114
|
+
if (f[0] & 0x80) {
|
|
115
|
+
if (f[0] === 0xff) throw new TarError(`negative base-256 ${field}`);
|
|
116
|
+
let n = BigInt(f[0] & 0x7f);
|
|
117
|
+
for (let i = 1; i < f.length; i++) n = (n << 8n) | BigInt(f[i]);
|
|
118
|
+
if (n > BigInt(Number.MAX_SAFE_INTEGER)) throw new TarError(`${field} too large`);
|
|
119
|
+
return Number(n);
|
|
120
|
+
}
|
|
121
|
+
const s = f.toString("latin1").replace(/[\0 ]+$/, "").replace(/^ +/, "");
|
|
122
|
+
if (s === "") return 0;
|
|
123
|
+
if (!/^[0-7]+$/.test(s)) throw new TarError(`malformed ${field} ${JSON.stringify(s)}`);
|
|
124
|
+
return parseInt(s, 8);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checksumOk(header) {
|
|
128
|
+
const want = tarNumber(header, 148, 8, "checksum");
|
|
129
|
+
let sum = 0;
|
|
130
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
|
|
131
|
+
return sum === want;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** pax extended header records ("<len> <key>=<value>\n"). */
|
|
135
|
+
function parsePax(body) {
|
|
136
|
+
const out = {};
|
|
137
|
+
let i = 0;
|
|
138
|
+
while (i < body.length) {
|
|
139
|
+
if (body[i] === 0) break; // NUL padding after the last record
|
|
140
|
+
const sp = body.indexOf(0x20, i);
|
|
141
|
+
if (sp === -1) throw new TarError("malformed pax header");
|
|
142
|
+
const len = Number(body.subarray(i, sp).toString("latin1"));
|
|
143
|
+
if (!Number.isInteger(len) || len <= sp - i || i + len > body.length || body[i + len - 1] !== 0x0a) {
|
|
144
|
+
throw new TarError("malformed pax record");
|
|
145
|
+
}
|
|
146
|
+
const rec = body.subarray(sp + 1, i + len - 1).toString("utf-8");
|
|
147
|
+
const eq = rec.indexOf("=");
|
|
148
|
+
if (eq === -1) throw new TarError("malformed pax record");
|
|
149
|
+
out[rec.slice(0, eq)] = rec.slice(eq + 1);
|
|
150
|
+
i += len;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Tar reader (ustar, pax and GNU extensions). Returns Map<path, Buffer> with
|
|
157
|
+
* the leading "package/" directory stripped. Only regular files are kept;
|
|
158
|
+
* paths that try to climb out ("..") are dropped. Files over MAX_FILE_BYTES
|
|
159
|
+
* are listed in map.oversize (path -> size) instead of being copied.
|
|
160
|
+
*
|
|
161
|
+
* Fails closed: a bad checksum, a malformed or impossible size, a truncated
|
|
162
|
+
* entry, or data after the end-of-archive marker throws TarError, so a crafted
|
|
163
|
+
* archive can never show this reader different files than npm extracts.
|
|
164
|
+
*/
|
|
165
|
+
export function untar(buf, { maxTotal = MAX_UNPACKED_BYTES, maxFile = MAX_FILE_BYTES } = {}) {
|
|
93
166
|
const files = new Map();
|
|
167
|
+
files.oversize = new Map();
|
|
94
168
|
let off = 0;
|
|
169
|
+
let pax = {};
|
|
95
170
|
let longName = null;
|
|
171
|
+
let total = 0;
|
|
172
|
+
let entries = 0;
|
|
96
173
|
while (off + 512 <= buf.length) {
|
|
97
174
|
const header = buf.subarray(off, off + 512);
|
|
98
|
-
if (header.every((b) => b === 0))
|
|
99
|
-
|
|
100
|
-
|
|
175
|
+
if (header.every((b) => b === 0)) {
|
|
176
|
+
// End of archive. Everything after it must be zero padding too.
|
|
177
|
+
for (let i = off; i < buf.length; i++) if (buf[i] !== 0) throw new TarError(`data after the end-of-archive marker at byte ${off}`);
|
|
178
|
+
return files;
|
|
179
|
+
}
|
|
180
|
+
if (++entries > MAX_ENTRIES) throw new TarError(`more than ${MAX_ENTRIES} entries`);
|
|
181
|
+
if (!checksumOk(header)) throw new TarError(`bad header checksum at byte ${off}`);
|
|
101
182
|
const type = String.fromCharCode(header[156] || 48);
|
|
102
|
-
|
|
183
|
+
let size = tarNumber(header, 124, 12, "size");
|
|
184
|
+
if (pax.size != null && type !== "x" && type !== "g" && type !== "L" && type !== "K") {
|
|
185
|
+
if (!/^\d+$/.test(pax.size)) throw new TarError(`malformed pax size ${JSON.stringify(pax.size)}`);
|
|
186
|
+
size = Number(pax.size);
|
|
187
|
+
}
|
|
188
|
+
if (size > maxTotal) throw new TarError(`entry larger than ${maxTotal} bytes`);
|
|
189
|
+
if (off + 512 + size > buf.length) throw new TarError(`truncated entry at byte ${off}`);
|
|
190
|
+
total += size;
|
|
191
|
+
if (total > maxTotal) throw new TarError(`unpacked size over ${maxTotal} bytes`);
|
|
192
|
+
let name = cstr(header, 0, 100);
|
|
193
|
+
const ustar = header.subarray(257, 263).toString("latin1") === "ustar\0";
|
|
194
|
+
const prefix = ustar ? cstr(header, 345, 155) : "";
|
|
103
195
|
if (prefix) name = `${prefix}/${name}`;
|
|
104
196
|
const body = buf.subarray(off + 512, off + 512 + size);
|
|
105
197
|
off += 512 + Math.ceil(size / 512) * 512;
|
|
106
198
|
|
|
107
|
-
if (type === "x") { // pax extended header
|
|
108
|
-
|
|
109
|
-
|
|
199
|
+
if (type === "x") { // pax extended header, for the next entry
|
|
200
|
+
pax = parsePax(body);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (type === "g") { // pax global header: validated, not used
|
|
204
|
+
parsePax(body);
|
|
110
205
|
continue;
|
|
111
206
|
}
|
|
112
207
|
if (type === "L") { // GNU long name
|
|
113
208
|
longName = cstr(body, 0, body.length);
|
|
114
209
|
continue;
|
|
115
210
|
}
|
|
116
|
-
if (type === "
|
|
117
|
-
if (
|
|
211
|
+
if (type === "K") continue; // GNU long link name
|
|
212
|
+
if (pax.path != null) name = pax.path;
|
|
213
|
+
else if (longName) name = longName;
|
|
214
|
+
pax = {};
|
|
215
|
+
longName = null;
|
|
118
216
|
if (type !== "0" && type !== "\0" && type !== "7") continue;
|
|
119
217
|
const parts = name.split("/").filter(Boolean);
|
|
120
218
|
parts.shift(); // top-level folder, usually "package"
|
|
121
219
|
if (parts.length === 0 || parts.includes("..")) continue;
|
|
122
|
-
|
|
220
|
+
const rel = parts.join("/");
|
|
221
|
+
if (size > maxFile) {
|
|
222
|
+
files.set(rel, Buffer.alloc(0));
|
|
223
|
+
files.oversize.set(rel, size);
|
|
224
|
+
} else {
|
|
225
|
+
files.set(rel, Buffer.from(body));
|
|
226
|
+
files.oversize.delete(rel);
|
|
227
|
+
}
|
|
123
228
|
}
|
|
229
|
+
if (off !== buf.length) throw new TarError(`trailing partial block at byte ${off}`);
|
|
124
230
|
return files;
|
|
125
231
|
}
|
|
126
232
|
|
|
233
|
+
/** gunzip with an output cap, then untar. Throws on any anomaly. */
|
|
234
|
+
export function unpackTgz(tgz) {
|
|
235
|
+
return untar(gunzipSync(tgz, { maxOutputLength: MAX_UNPACKED_BYTES }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A verified tarball that could not be unpacked safely: no files, an anomaly. */
|
|
239
|
+
function unreadable(kind, e) {
|
|
240
|
+
return new MapSource(new Map(), kind, { anomaly: `tarball could not be read safely (${e.message})` });
|
|
241
|
+
}
|
|
242
|
+
|
|
127
243
|
/** Split an SRI string into { algo, hex } entries. */
|
|
128
244
|
export function parseIntegrity(sri) {
|
|
129
245
|
return String(sri || "").split(/\s+/).filter(Boolean).map((s) => {
|
|
@@ -155,11 +271,16 @@ export function sourceFromNpmCache(integrity, cacheDir = npmCacheDir()) {
|
|
|
155
271
|
for (const { algo, hex } of parseIntegrity(integrity)) {
|
|
156
272
|
const p = join(cacheDir, "_cacache", "content-v2", algo, hex.slice(0, 2), hex.slice(2, 4), hex.slice(4));
|
|
157
273
|
if (!existsSync(p)) continue;
|
|
274
|
+
let tgz;
|
|
275
|
+
try { tgz = readFileSync(p); } catch { continue; }
|
|
276
|
+
if (!verifyIntegrity(tgz, integrity)) continue; // corrupt entry, try the next hash
|
|
277
|
+
// The bytes are the ones the lockfile pins. If they do not unpack cleanly,
|
|
278
|
+
// say so: returning null would let the package pass as merely "not cached".
|
|
158
279
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return
|
|
162
|
-
}
|
|
280
|
+
return new MapSource(unpackTgz(tgz), "npm-cache");
|
|
281
|
+
} catch (e) {
|
|
282
|
+
return unreadable("npm-cache", e);
|
|
283
|
+
}
|
|
163
284
|
}
|
|
164
285
|
return null;
|
|
165
286
|
}
|
|
@@ -167,5 +288,9 @@ export function sourceFromNpmCache(integrity, cacheDir = npmCacheDir()) {
|
|
|
167
288
|
/** Unpack a verified tarball buffer (used by --online). */
|
|
168
289
|
export function sourceFromTarball(tgz, integrity, kind = "registry") {
|
|
169
290
|
if (integrity && !verifyIntegrity(tgz, integrity)) return null;
|
|
170
|
-
|
|
291
|
+
try {
|
|
292
|
+
return new MapSource(unpackTgz(tgz), kind);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return unreadable(kind, e);
|
|
295
|
+
}
|
|
171
296
|
}
|
package/lib/deps/typosquat.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Typosquat and slopsquat checks for npm package names (`penthera deps`).
|
|
2
|
+
* Typosquat and slopsquat checks for npm and PyPI package names (`penthera deps`).
|
|
3
3
|
*
|
|
4
4
|
* Compares each dependency name against a vendored list of popular package
|
|
5
5
|
* names (lib/deps/popular-packages.json, refreshed at release time by
|
|
6
6
|
* scripts/refresh-popular-packages.mjs; never fetched at runtime). A name that
|
|
7
7
|
* is one or two edits away from a popular package, but is not itself popular,
|
|
8
8
|
* is worth a second look: that is how `crossenv` (for cross-env) and
|
|
9
|
-
* `electorn` (for electron) shipped malware
|
|
9
|
+
* `electorn` (for electron) shipped malware on npm, and `python3-dateutil`
|
|
10
|
+
* and `jeIlyfish` on PyPI. PyPI names are compared after PEP 503
|
|
11
|
+
* normalisation, so `python_dateutil` (the same project as python-dateutil)
|
|
12
|
+
* is not a hit, while `pythondateutil` is.
|
|
10
13
|
*
|
|
11
14
|
* Slopsquatting is the same attack aimed at AI assistants: registering names
|
|
12
15
|
* a model is likely to hallucinate. Offline, the useful signal is the same
|
|
@@ -14,15 +17,17 @@
|
|
|
14
17
|
*/
|
|
15
18
|
import { readFileSync } from "node:fs";
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
const _popular = {};
|
|
21
|
+
// PyPI's list is lib/deps/popular-pypi.json (scripts/refresh-popular-pypi.mjs).
|
|
22
|
+
const LISTS = { npm: "./popular-packages.json", pypi: "./popular-pypi.json" };
|
|
18
23
|
|
|
19
|
-
/** The vendored popular-name list: { source, names: string[] }. */
|
|
20
|
-
export function loadPopular() {
|
|
21
|
-
if (!_popular) {
|
|
22
|
-
const data = JSON.parse(readFileSync(new URL(
|
|
23
|
-
_popular = { ...data, set: new Set(data.names), rank: new Map(data.names.map((n, i) => [n, i + 1])) };
|
|
24
|
+
/** The vendored popular-name list for an ecosystem: { source, names: string[] }. */
|
|
25
|
+
export function loadPopular(ecosystem = "npm") {
|
|
26
|
+
if (!_popular[ecosystem]) {
|
|
27
|
+
const data = JSON.parse(readFileSync(new URL(LISTS[ecosystem], import.meta.url), "utf-8"));
|
|
28
|
+
_popular[ecosystem] = { ...data, ecosystem, set: new Set(data.names), rank: new Map(data.names.map((n, i) => [n, i + 1])) };
|
|
24
29
|
}
|
|
25
|
-
return _popular;
|
|
30
|
+
return _popular[ecosystem];
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
/** Damerau-Levenshtein (optimal string alignment) distance, capped at max+1. */
|
|
@@ -51,6 +56,8 @@ export function editDistance(a, b, max = 2) {
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
const JS_SUFFIX = /(?:[.-]?js|-node)$/;
|
|
59
|
+
// PyPI: "python-requests", "requests-py", "py-requests" imitate "requests".
|
|
60
|
+
const PY_AFFIX = /^(?:python3?|py)-|-(?:python3?|py)$/;
|
|
54
61
|
|
|
55
62
|
/** Separator- and scope-insensitive form: "@types/lodash.merge" -> "typeslodashmerge". */
|
|
56
63
|
const squash = (s) => s.replace(/^@/, "").replace(/[-_./]/g, "");
|
|
@@ -80,9 +87,12 @@ export function checkName(name, popular = loadPopular()) {
|
|
|
80
87
|
if (squash(name) === tSquash) {
|
|
81
88
|
distance = 1;
|
|
82
89
|
kind = "separator or scope swap";
|
|
83
|
-
} else if (name.replace(JS_SUFFIX, "") === target) {
|
|
90
|
+
} else if (popular.ecosystem !== "pypi" && name.replace(JS_SUFFIX, "") === target) {
|
|
84
91
|
distance = 1;
|
|
85
92
|
kind = "popular name plus a js/node suffix";
|
|
93
|
+
} else if (popular.ecosystem === "pypi" && target.length >= 4 && name.replace(PY_AFFIX, "") === target) {
|
|
94
|
+
distance = 1;
|
|
95
|
+
kind = "popular name plus a python/py affix";
|
|
86
96
|
} else if (max > 0 && !isScoped === !target.startsWith("@")) {
|
|
87
97
|
distance = editDistance(name, target, max);
|
|
88
98
|
if (distance > max) continue;
|
|
@@ -100,10 +110,11 @@ export function checkName(name, popular = loadPopular()) {
|
|
|
100
110
|
|
|
101
111
|
/**
|
|
102
112
|
* Check a list of package names. Returns hits sorted by name.
|
|
103
|
-
* @param {string[]} names
|
|
113
|
+
* @param {string[]} names PyPI names must already be PEP 503 normalised
|
|
114
|
+
* @param {"npm"|"pypi"} ecosystem
|
|
104
115
|
*/
|
|
105
|
-
export function findTyposquats(names) {
|
|
106
|
-
const popular = loadPopular();
|
|
116
|
+
export function findTyposquats(names, ecosystem = "npm") {
|
|
117
|
+
const popular = loadPopular(ecosystem);
|
|
107
118
|
const out = [];
|
|
108
119
|
for (const name of [...new Set(names)].sort()) {
|
|
109
120
|
const hit = checkName(name, popular);
|
package/lib/fix/apply.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* applyDiff: the one place fix recipes' diffs get written to disk.
|
|
3
3
|
*
|
|
4
4
|
* Planning never writes. Applying is a separate, explicit step that refuses to
|
|
5
|
-
* write outside the repo, into .git
|
|
6
|
-
*
|
|
5
|
+
* write outside the repo, into any .git (any case, any depth), to .gitmodules,
|
|
6
|
+
* through a symlink anywhere in the path, or into a dirty git tree (unless the
|
|
7
|
+
* caller passes allowDirty), and writes nothing unless every hunk applies.
|
|
7
8
|
*/
|
|
8
|
-
import { existsSync, readFileSync,
|
|
9
|
-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
-
import {
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync, realpathSync, lstatSync, openSync, writeSync, closeSync, constants } from "node:fs";
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
import { createGit } from "../../src/agent/git.js";
|
|
11
12
|
import { parsePatch, applyHunks } from "./diff.js";
|
|
12
13
|
|
|
13
14
|
export class FixError extends Error {
|
|
@@ -23,19 +24,64 @@ function inside(root, p) {
|
|
|
23
24
|
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// Code points HFS+ ignores in names, so ".g\u200cit" is ".git" on macOS (CVE-2014-9390).
|
|
28
|
+
const IGNORABLE = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u206a-\u206f\ufeff]/g;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The name a path segment resolves to on case-insensitive file systems
|
|
32
|
+
* (macOS, Windows): case folded, ignorable code points dropped, and the
|
|
33
|
+
* trailing dots and spaces Windows strips removed.
|
|
34
|
+
*/
|
|
35
|
+
export function canonicalSegment(seg) {
|
|
36
|
+
return String(seg).normalize("NFC").replace(IGNORABLE, "").replace(/[. ]+$/, "").toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True for a segment that names the git directory: .git, .GIT, .Git., or the 8.3 alias GIT~1. */
|
|
40
|
+
export function isGitDirSegment(seg) {
|
|
41
|
+
const c = canonicalSegment(seg);
|
|
42
|
+
return c === ".git" || /^git~\d+$/.test(c);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Files a fix diff may never touch even though they live in the work tree. */
|
|
46
|
+
const PROTECTED_FILES = new Set([".gitmodules"]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Throws unless no component of `target` below `root` is a symlink. Every
|
|
50
|
+
* existing component is lstat-ed, so neither a symlinked directory nor a
|
|
51
|
+
* dangling symlink at the file itself can redirect a write.
|
|
52
|
+
*/
|
|
53
|
+
function assertNoSymlinks(root, target, file) {
|
|
54
|
+
let cur = root;
|
|
55
|
+
for (const seg of relative(root, target).split(sep)) {
|
|
56
|
+
cur = join(cur, seg);
|
|
57
|
+
let st;
|
|
58
|
+
try {
|
|
59
|
+
st = lstatSync(cur);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
if (e.code === "ENOENT") return;
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
if (st.isSymbolicLink()) {
|
|
65
|
+
throw new FixError(`Refusing to write ${file}: ${relative(root, cur)} is a symlink, and fix diffs never write through symlinks`, "SYMLINK");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
26
70
|
/** Resolve a diff path to an absolute path inside the repo, or throw. */
|
|
27
71
|
function safeTarget(root, file) {
|
|
28
|
-
if (!file || isAbsolute(file) || file.includes("\0")) {
|
|
72
|
+
if (!file || isAbsolute(file) || file.includes("\0") || /^[a-zA-Z]:/.test(file)) {
|
|
29
73
|
throw new FixError(`Refusing to write ${JSON.stringify(file)}: paths in a diff must be relative to the repo`, "OUTSIDE_REPO");
|
|
30
74
|
}
|
|
75
|
+
const segs = file.split(/[\\/]/);
|
|
76
|
+
if (segs.includes("..")) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
|
|
77
|
+
// Any .git at any depth, in any case: on macOS .GIT/hooks/pre-commit is .git/hooks/pre-commit.
|
|
78
|
+
if (segs.some(isGitDirSegment)) throw new FixError(`Refusing to write ${file}: it is inside .git`, "OUTSIDE_REPO");
|
|
79
|
+
if (segs.some((s) => PROTECTED_FILES.has(canonicalSegment(s)))) {
|
|
80
|
+
throw new FixError(`Refusing to write ${file}: fix diffs never edit .gitmodules (it can point git at other repositories)`, "PROTECTED");
|
|
81
|
+
}
|
|
31
82
|
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
|
-
|
|
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");
|
|
83
|
+
if (!inside(root, target) || target === root) throw new FixError(`Refusing to write ${file}: it is outside the repo`, "OUTSIDE_REPO");
|
|
84
|
+
assertNoSymlinks(root, target, file);
|
|
39
85
|
return target;
|
|
40
86
|
}
|
|
41
87
|
|
|
@@ -43,8 +89,10 @@ function safeTarget(root, file) {
|
|
|
43
89
|
export function assertCleanTree(repoPath) {
|
|
44
90
|
let status;
|
|
45
91
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
// The hardened runner: no fsmonitor, no hooks, no transports (src/agent/git.js).
|
|
93
|
+
const git = createGit(repoPath);
|
|
94
|
+
git.run("rev-parse", "--is-inside-work-tree");
|
|
95
|
+
status = git.run("status", "--porcelain", "--", ".");
|
|
48
96
|
} catch {
|
|
49
97
|
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
98
|
}
|
|
@@ -53,6 +101,24 @@ export function assertCleanTree(repoPath) {
|
|
|
53
101
|
}
|
|
54
102
|
}
|
|
55
103
|
|
|
104
|
+
const NOFOLLOW = constants.O_NOFOLLOW || 0;
|
|
105
|
+
|
|
106
|
+
/** Write one file: create exclusively, or overwrite in place, never through a symlink. */
|
|
107
|
+
function writeTarget(root, w) {
|
|
108
|
+
assertNoSymlinks(root, w.target, w.path); // again, right before writing
|
|
109
|
+
mkdirSync(dirname(w.target), { recursive: true });
|
|
110
|
+
assertNoSymlinks(root, w.target, w.path);
|
|
111
|
+
const flags = constants.O_WRONLY | NOFOLLOW | (w.created ? constants.O_CREAT | constants.O_EXCL : constants.O_TRUNC);
|
|
112
|
+
const fd = openSync(w.target, flags, 0o644);
|
|
113
|
+
try {
|
|
114
|
+
const buf = Buffer.from(w.after, "utf-8");
|
|
115
|
+
let off = 0;
|
|
116
|
+
while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
|
|
117
|
+
} finally {
|
|
118
|
+
closeSync(fd);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
56
122
|
/**
|
|
57
123
|
* Apply a unified diff to files under repoPath.
|
|
58
124
|
*
|
|
@@ -73,6 +139,7 @@ export function applyDiff(repoPath, diff, opts = {}) {
|
|
|
73
139
|
if (f.oldPath && f.oldPath !== f.newPath) throw new FixError(`Refusing to rename ${f.oldPath}: fix diffs never rename files`, "RENAME");
|
|
74
140
|
const target = safeTarget(root, f.newPath);
|
|
75
141
|
const exists = existsSync(target);
|
|
142
|
+
if (exists && !lstatSync(target).isFile()) throw new FixError(`Refusing to write ${f.newPath}: it is not a regular file`, "CONFLICT");
|
|
76
143
|
if (!f.oldPath && exists) throw new FixError(`${f.newPath} already exists; the plan expected to create it`, "CONFLICT");
|
|
77
144
|
if (f.oldPath && !exists) throw new FixError(`${f.newPath} does not exist; the plan expected to edit it`, "CONFLICT");
|
|
78
145
|
const before = exists ? readFileSync(target, "utf-8") : null;
|
|
@@ -86,10 +153,7 @@ export function applyDiff(repoPath, diff, opts = {}) {
|
|
|
86
153
|
}
|
|
87
154
|
|
|
88
155
|
if (!opts.dryRun) {
|
|
89
|
-
for (const w of writes)
|
|
90
|
-
mkdirSync(dirname(w.target), { recursive: true });
|
|
91
|
-
writeFileSync(w.target, w.after);
|
|
92
|
-
}
|
|
156
|
+
for (const w of writes) writeTarget(root, w);
|
|
93
157
|
}
|
|
94
158
|
return { applied: !opts.dryRun, files: writes.map((w) => ({ path: w.path, created: w.created })) };
|
|
95
159
|
}
|