penthera 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
package/lib/deps/iocs.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indicators of compromise from rule packs (lib/rules), for `penthera deps`.
|
|
3
|
+
*
|
|
4
|
+
* ioc-package known-malicious name@version, matched against the lockfile,
|
|
5
|
+
* so it works for packages that are not installed
|
|
6
|
+
* ioc-host exfiltration domains and endpoint URLs, matched as literal
|
|
7
|
+
* strings in package code and package.json
|
|
8
|
+
* ioc-file files a campaign drops into packages, by path and
|
|
9
|
+
* optionally SHA-256
|
|
10
|
+
*
|
|
11
|
+
* Literal matching only, no regexes: a pack cannot make this slow.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { rulesOf } from "../rules/index.js";
|
|
15
|
+
|
|
16
|
+
/** Build the active IOC set once per scan. Returns null when no pack has IOCs. */
|
|
17
|
+
export function loadIocs() {
|
|
18
|
+
const pkgRules = rulesOf("ioc-package");
|
|
19
|
+
const hostRules = rulesOf("ioc-host");
|
|
20
|
+
const fileRules = rulesOf("ioc-file");
|
|
21
|
+
if (!pkgRules.length && !hostRules.length && !fileRules.length) return null;
|
|
22
|
+
|
|
23
|
+
const byName = new Map();
|
|
24
|
+
for (const r of pkgRules) {
|
|
25
|
+
for (const p of r.packages) {
|
|
26
|
+
const list = byName.get(p.name) || [];
|
|
27
|
+
list.push({ rule: r, versions: new Set(p.versions) });
|
|
28
|
+
byName.set(p.name, list);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const needles = [];
|
|
32
|
+
for (const r of hostRules) {
|
|
33
|
+
for (const h of r.hosts) needles.push({ rule: r, value: h, type: "host" });
|
|
34
|
+
for (const u of r.urls) needles.push({ rule: r, value: u, type: "url" });
|
|
35
|
+
}
|
|
36
|
+
const files = fileRules.flatMap((r) => r.files.map((f) => ({ rule: r, ...f })));
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
/** Lockfile entries that match a known-malicious version. */
|
|
40
|
+
matchPackage(name, version) {
|
|
41
|
+
return (byName.get(name) || [])
|
|
42
|
+
.filter((e) => e.versions.has(version) || e.versions.has("*"))
|
|
43
|
+
.map((e) => ({ type: "package", value: `${name}@${version}`, rule: e.rule }));
|
|
44
|
+
},
|
|
45
|
+
/** Host and URL indicators in one file's text. */
|
|
46
|
+
scanText(text, file) {
|
|
47
|
+
const hits = [];
|
|
48
|
+
for (const n of needles) {
|
|
49
|
+
const idx = text.indexOf(n.value);
|
|
50
|
+
if (idx < 0) continue;
|
|
51
|
+
// A host must stand on its own: evil.example should not fire on notevil.example.
|
|
52
|
+
if (n.type === "host" && /[a-z0-9-]/i.test(text[idx - 1] || "")) continue;
|
|
53
|
+
hits.push({ type: n.type, value: n.value, rule: n.rule, file, line: text.slice(0, idx).split("\n").length });
|
|
54
|
+
}
|
|
55
|
+
return hits;
|
|
56
|
+
},
|
|
57
|
+
/** Dropped-file indicators in a package's file list. */
|
|
58
|
+
scanFiles(list, source, prefix) {
|
|
59
|
+
const hits = [];
|
|
60
|
+
for (const f of files) {
|
|
61
|
+
const found = list.filter((p) => p === f.path || p.endsWith(`/${f.path}`));
|
|
62
|
+
for (const p of found) {
|
|
63
|
+
if (f.sha256) {
|
|
64
|
+
let digest;
|
|
65
|
+
try { digest = createHash("sha256").update(source.read(p)).digest("hex"); } catch { continue; }
|
|
66
|
+
if (digest !== f.sha256) continue;
|
|
67
|
+
}
|
|
68
|
+
hits.push({ type: "file", value: f.sha256 ? `${f.path} (sha256 ${f.sha256.slice(0, 12)}…)` : f.path, rule: f.rule, file: `${prefix}${p}`, line: 1 });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return hits;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/lib/deps/lockfile.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync, existsSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import {
|
|
11
|
+
import { git as safeGit } from "../git.js";
|
|
12
12
|
|
|
13
13
|
/** Package name from a lockfile v2/v3 key like node_modules/a/node_modules/@s/b. */
|
|
14
14
|
export function nameFromLockPath(key) {
|
|
@@ -105,14 +105,9 @@ export function assertSafeRef(ref) {
|
|
|
105
105
|
return ref;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/**
|
|
109
|
-
* git for reading a scanned repo: no fsmonitor, no hooks, no transports.
|
|
110
|
-
* Read-only plumbing only (rev-parse, show, ls-tree).
|
|
111
|
-
*/
|
|
108
|
+
/** git for reading a scanned repo (lib/git.js). Read-only plumbing only (rev-parse, show, ls-tree). */
|
|
112
109
|
export function readOnlyGit(dir) {
|
|
113
|
-
return (args) =>
|
|
114
|
-
cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" },
|
|
115
|
-
});
|
|
110
|
+
return (args) => safeGit(dir, args);
|
|
116
111
|
}
|
|
117
112
|
|
|
118
113
|
/**
|
package/lib/deps/online.js
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
* - version deprecated.
|
|
15
15
|
*/
|
|
16
16
|
import { sourceFromTarball } from "./source.js";
|
|
17
|
-
import { request } from "../../src/utils/http.js";
|
|
17
|
+
import { readBody, readJson, request } from "../../src/utils/http.js";
|
|
18
|
+
|
|
19
|
+
// Packuments of popular packages run to tens of MB; tarballs are capped by the
|
|
20
|
+
// unpacked-size limit in source.js anyway.
|
|
21
|
+
const MAX_PACKUMENT_BYTES = 128 * 1024 * 1024;
|
|
22
|
+
const MAX_TARBALL_BYTES = 256 * 1024 * 1024;
|
|
18
23
|
|
|
19
24
|
// Registry calls go through the shared client: same User-Agent, same egress
|
|
20
25
|
// accounting (and refusal under --offline) as every other request.
|
|
@@ -35,7 +40,7 @@ export async function fetchPackument(name, { fetchImpl = registryFetch, registry
|
|
|
35
40
|
try {
|
|
36
41
|
const res = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeout) });
|
|
37
42
|
if (!res.ok) return null;
|
|
38
|
-
return await res
|
|
43
|
+
return await readJson(res, { maxBytes: MAX_PACKUMENT_BYTES, timeout });
|
|
39
44
|
} catch {
|
|
40
45
|
return null;
|
|
41
46
|
}
|
|
@@ -124,7 +129,7 @@ export async function fetchTarballSource(url, integrity, { fetchImpl = registryF
|
|
|
124
129
|
try {
|
|
125
130
|
const res = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) });
|
|
126
131
|
if (!res.ok) return null;
|
|
127
|
-
const buf =
|
|
132
|
+
const buf = await readBody(res, { maxBytes: MAX_TARBALL_BYTES, timeout, binary: true });
|
|
128
133
|
return sourceFromTarball(buf, integrity, "registry");
|
|
129
134
|
} catch {
|
|
130
135
|
return null;
|
|
Binary file
|
|
Binary file
|
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
} from "../capabilities.js";
|
|
22
22
|
|
|
23
23
|
const j = (...parts) => parts.join("");
|
|
24
|
-
|
|
24
|
+
// As for npm: a file over this is not read, and the package is then unscanned.
|
|
25
|
+
const MAX_PY_BYTES = 64 * 1024 * 1024;
|
|
25
26
|
const EXFIL_WINDOW = 40;
|
|
26
27
|
const NEAR_WINDOW = 30;
|
|
27
28
|
const PTH_WINDOW = 5;
|
|
@@ -318,9 +319,10 @@ const NATIVE_EXT = /\.(?:so(?:\.\d+)*|pyd|dylib|dll)$/i;
|
|
|
318
319
|
/**
|
|
319
320
|
* Scan a Python package through a source (lib/deps/source.js interface; a
|
|
320
321
|
* source may also expose recordHashes: Map<rel, "sha256=...">).
|
|
321
|
-
* @param {{ files(): string[], read(rel): Buffer, size(rel): number, recordHashes?: Map }} source
|
|
322
|
+
* @param {{ files(): string[], read(rel): Buffer, size(rel): number, recordHashes?: Map, anomaly?: string, truncated?: boolean }} source
|
|
322
323
|
* @param {{ prefix?: string }} opts
|
|
323
|
-
* @returns same shape as scanPackage() for npm
|
|
324
|
+
* @returns same shape as scanPackage() for npm (including unscanned: why the
|
|
325
|
+
* package could not be fully read, else null), plus startupHooks and modified
|
|
324
326
|
*/
|
|
325
327
|
export function scanPythonPackage(source, opts = {}) {
|
|
326
328
|
const prefix = opts.prefix || "";
|
|
@@ -360,6 +362,16 @@ export function scanPythonPackage(source, opts = {}) {
|
|
|
360
362
|
if (r.exfil) exfil.push(r.exfil);
|
|
361
363
|
}
|
|
362
364
|
|
|
365
|
+
// Fail closed, as for npm: a package whose files were not all read is
|
|
366
|
+
// unscanned, never clean. Every real wheel, sdist or installed distribution
|
|
367
|
+
// has at least its metadata files, so an empty list means nothing was read.
|
|
368
|
+
let unscanned = source.anomaly || null;
|
|
369
|
+
if (!unscanned && files.length === 0) unscanned = "no files could be read for this package";
|
|
370
|
+
if (!unscanned && source.truncated) unscanned = `the package has more than ${source.maxFiles} files, so some were never read`;
|
|
371
|
+
if (!unscanned && skipped.length) {
|
|
372
|
+
unscanned = `${skipped.length} code file(s) over ${MAX_PY_BYTES / 1024 / 1024} MB were not scanned (${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", ..." : ""})`;
|
|
373
|
+
}
|
|
374
|
+
|
|
363
375
|
// Module name -> file, for resolving what a .pth or setup.py hook runs.
|
|
364
376
|
const moduleFile = (mod) => [`${mod}.py`, `${mod}/__init__.py`].find((f) => fileSet.has(f)) || null;
|
|
365
377
|
const riskyOf = (f) => (f && fileCaps[f] ? fileCaps[f].filter((c) => RISKY_FOR_INSTALL.includes(c) || c === "shell") : []);
|
|
@@ -441,6 +453,7 @@ export function scanPythonPackage(source, opts = {}) {
|
|
|
441
453
|
exfil,
|
|
442
454
|
filesScanned,
|
|
443
455
|
skipped,
|
|
456
|
+
unscanned,
|
|
444
457
|
startupHooks,
|
|
445
458
|
modified,
|
|
446
459
|
};
|
package/lib/deps/python/index.js
CHANGED
|
@@ -104,11 +104,15 @@ function scanPyTree(pkgs, env, ctx) {
|
|
|
104
104
|
ctx.cache.set(key, src ? { ...scanPythonPackage(src, { prefix }), source: src.kind } : null);
|
|
105
105
|
}
|
|
106
106
|
const r = ctx.cache.get(key);
|
|
107
|
+
// Fail closed: a package that could not be fully read has unknown
|
|
108
|
+
// capabilities (null), not an empty set.
|
|
109
|
+
const unscanned = r?.unscanned || null;
|
|
107
110
|
return {
|
|
108
111
|
...p,
|
|
109
112
|
version,
|
|
110
113
|
source: r?.source || null,
|
|
111
|
-
capabilities: r ? r.capabilities : null,
|
|
114
|
+
capabilities: r && !unscanned ? r.capabilities : null,
|
|
115
|
+
unscanned,
|
|
112
116
|
installScripts: r?.installScripts || {},
|
|
113
117
|
installRisk: r?.installRisk || [],
|
|
114
118
|
exfil: r?.exfil || [],
|
|
@@ -298,11 +298,17 @@ function parsePipfileLock(text, dir) {
|
|
|
298
298
|
*/
|
|
299
299
|
export function parsePythonInput(text, kind, dir, file = kind) {
|
|
300
300
|
let r;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
301
|
+
try {
|
|
302
|
+
if (kind === "uv.lock") r = parseUvLock(text, dir);
|
|
303
|
+
else if (kind === "poetry.lock") r = parsePoetryLock(text, dir);
|
|
304
|
+
else if (kind === "pdm.lock") r = parsePdmLock(text, dir);
|
|
305
|
+
else if (kind === "Pipfile.lock") r = parsePipfileLock(text, dir);
|
|
306
|
+
else r = { ...parseRequirements(text, { file, baseDir: dir }), version: null };
|
|
307
|
+
} catch (e) {
|
|
308
|
+
// One clear error naming the file, whatever the parser hit (malformed or
|
|
309
|
+
// hostile input): the tree cannot be read, so nothing is reported clean.
|
|
310
|
+
throw new Error(`${file} could not be parsed: ${e.message}`);
|
|
311
|
+
}
|
|
306
312
|
// One entry per name@version (requirements files can repeat a name across -r includes).
|
|
307
313
|
const seen = new Set();
|
|
308
314
|
const packages = r.packages.filter((p) => {
|
|
@@ -20,11 +20,10 @@ import { join, relative, resolve, sep } from "node:path";
|
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
21
|
import { inflateRawSync, gunzipSync } from "node:zlib";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
|
-
import { DirSource, MapSource, untar } from "../source.js";
|
|
23
|
+
import { DirSource, MapSource, untar, MAX_FILES } from "../source.js";
|
|
24
24
|
import { normalizeName } from "./lockfiles.js";
|
|
25
25
|
import { recordHash } from "./capabilities.js";
|
|
26
26
|
|
|
27
|
-
const MAX_FILES = 5000;
|
|
28
27
|
// Not part of a local package's source: environments, caches, build output.
|
|
29
28
|
const LOCAL_SKIP = new Set([".venv", "venv", ".tox", ".nox", "__pycache__", ".mypy_cache", ".pytest_cache", "build", "dist", ".eggs"]);
|
|
30
29
|
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
|
@@ -112,7 +111,10 @@ export class RecordSource {
|
|
|
112
111
|
const root = resolve(siteDir);
|
|
113
112
|
// Only files inside site-packages: console-script wrappers in bin/ are generated by pip.
|
|
114
113
|
const ok = entries.filter((e) => e.path && !e.path.split(/[\\/]/).includes("..") && resolve(root, e.path).startsWith(root + sep));
|
|
115
|
-
|
|
114
|
+
const all = [...new Set(ok.map((e) => e.path.replace(/\\/g, "/")))].sort();
|
|
115
|
+
this.maxFiles = MAX_FILES;
|
|
116
|
+
this.truncated = all.length > MAX_FILES;
|
|
117
|
+
this._files = all.slice(0, MAX_FILES);
|
|
116
118
|
this.recordHashes = new Map(ok.filter((e) => e.hash).map((e) => [e.path.replace(/\\/g, "/"), e.hash]));
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -155,12 +157,18 @@ export function listInstalled(siteDir) {
|
|
|
155
157
|
.map((p) => ({ path: relative(siteDir, resolve(siteDir, e, p)).split(sep).join("/"), hash: null }));
|
|
156
158
|
} catch { /* no file list */ }
|
|
157
159
|
}
|
|
160
|
+
let guessTruncated = false;
|
|
158
161
|
if (!record.length) {
|
|
159
162
|
// No file list: fall back to the folder named after the distribution.
|
|
160
163
|
const guess = md.Name.replace(/-/g, "_");
|
|
161
|
-
if (existsSync(join(siteDir, guess)))
|
|
164
|
+
if (existsSync(join(siteDir, guess))) {
|
|
165
|
+
const dir = new DirSource(join(siteDir, guess));
|
|
166
|
+
record = dir.files().map((f) => ({ path: `${guess}/${f}`, hash: null }));
|
|
167
|
+
guessTruncated = dir.truncated;
|
|
168
|
+
}
|
|
162
169
|
}
|
|
163
170
|
const src = new RecordSource(siteDir, record);
|
|
171
|
+
if (guessTruncated) src.truncated = true;
|
|
164
172
|
for (const f of src.files()) owned.add(f);
|
|
165
173
|
out.push({ name: normalizeName(md.Name), displayName: md.Name, version: md.Version || null, distInfo: e, source: src });
|
|
166
174
|
}
|
|
@@ -170,23 +178,44 @@ export function listInstalled(siteDir) {
|
|
|
170
178
|
|
|
171
179
|
// ── Archives ──────────────────────────────────────────────────────────────
|
|
172
180
|
|
|
181
|
+
/** A wheel that could not be parsed unambiguously. Callers fail closed. */
|
|
182
|
+
export class ZipError extends Error {
|
|
183
|
+
constructor(message) {
|
|
184
|
+
super(message);
|
|
185
|
+
this.name = "ZipError";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
173
189
|
/**
|
|
174
190
|
* Minimal zip reader for wheels: central directory, stored and deflated
|
|
175
|
-
* entries. Paths that climb out are dropped
|
|
176
|
-
*
|
|
191
|
+
* entries. Paths that climb out are dropped. Returns Map<path, Buffer>.
|
|
192
|
+
*
|
|
193
|
+
* Fails closed. The sizes in the central directory are the archive's own
|
|
194
|
+
* claim, so each entry is checked against the bytes really there, and the
|
|
195
|
+
* total is capped on what was actually inflated. A bad record, an entry that
|
|
196
|
+
* does not fit the archive, a size that does not match its data, encryption,
|
|
197
|
+
* ZIP64 or an unknown compression method throws ZipError rather than being
|
|
198
|
+
* skipped: whatever comes after it would otherwise go unread.
|
|
177
199
|
*/
|
|
178
|
-
export function unzip(buf) {
|
|
200
|
+
export function unzip(buf, { maxTotal = MAX_UNPACKED_BYTES, maxEntries = MAX_FILES } = {}) {
|
|
179
201
|
const files = new Map();
|
|
180
202
|
let eocd = -1;
|
|
181
203
|
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
|
|
182
204
|
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
183
205
|
}
|
|
184
|
-
if (eocd === -1) throw new
|
|
206
|
+
if (eocd === -1) throw new ZipError("not a zip file");
|
|
185
207
|
const count = buf.readUInt16LE(eocd + 10);
|
|
208
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
186
209
|
let off = buf.readUInt32LE(eocd + 16);
|
|
210
|
+
if (count === 0xffff || cdSize === 0xffffffff || off === 0xffffffff) throw new ZipError("ZIP64 archives are not supported");
|
|
211
|
+
if (buf.readUInt16LE(eocd + 4) !== 0 || buf.readUInt16LE(eocd + 8) !== count) throw new ZipError("multi-disk archive");
|
|
212
|
+
if (count > maxEntries) throw new ZipError(`more than ${maxEntries} entries`);
|
|
213
|
+
const cdEnd = off + cdSize;
|
|
214
|
+
if (cdEnd > eocd) throw new ZipError("central directory outside the archive");
|
|
187
215
|
let total = 0;
|
|
188
|
-
for (let n = 0; n < count
|
|
189
|
-
if (buf.readUInt32LE(off) !== 0x02014b50)
|
|
216
|
+
for (let n = 0; n < count; n++) {
|
|
217
|
+
if (off + 46 > cdEnd || buf.readUInt32LE(off) !== 0x02014b50) throw new ZipError(`bad central directory record ${n}`);
|
|
218
|
+
const flags = buf.readUInt16LE(off + 8);
|
|
190
219
|
const method = buf.readUInt16LE(off + 10);
|
|
191
220
|
const csize = buf.readUInt32LE(off + 20);
|
|
192
221
|
const usize = buf.readUInt32LE(off + 24);
|
|
@@ -194,30 +223,51 @@ export function unzip(buf) {
|
|
|
194
223
|
const extraLen = buf.readUInt16LE(off + 30);
|
|
195
224
|
const commentLen = buf.readUInt16LE(off + 32);
|
|
196
225
|
const local = buf.readUInt32LE(off + 42);
|
|
226
|
+
if (off + 46 + nameLen + extraLen + commentLen > cdEnd) throw new ZipError(`central directory record ${n} overruns`);
|
|
197
227
|
const name = buf.subarray(off + 46, off + 46 + nameLen).toString("utf-8");
|
|
228
|
+
const label = JSON.stringify(name);
|
|
198
229
|
off += 46 + nameLen + extraLen + commentLen;
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
if (buf.readUInt32LE(local) !== 0x04034b50) continue;
|
|
230
|
+
if (csize === 0xffffffff || usize === 0xffffffff || local === 0xffffffff) throw new ZipError("ZIP64 entries are not supported");
|
|
231
|
+
if (flags & 1) throw new ZipError(`encrypted entry ${label}`);
|
|
232
|
+
if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50) throw new ZipError(`bad local header for ${label}`);
|
|
203
233
|
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
234
|
+
if (start + csize > buf.length) throw new ZipError(`entry ${label} runs past the end of the archive`);
|
|
235
|
+
if (name.endsWith("/")) continue;
|
|
204
236
|
const raw = buf.subarray(start, start + csize);
|
|
205
|
-
total += usize;
|
|
206
|
-
if (total > MAX_UNPACKED_BYTES) break;
|
|
207
237
|
let data;
|
|
208
238
|
if (method === 0) data = Buffer.from(raw);
|
|
209
|
-
else if (method === 8)
|
|
210
|
-
|
|
239
|
+
else if (method === 8) {
|
|
240
|
+
try {
|
|
241
|
+
// Capped on what really comes out, not on the size the entry claims.
|
|
242
|
+
data = inflateRawSync(raw, { maxOutputLength: Math.max(1, maxTotal - total) });
|
|
243
|
+
} catch (e) {
|
|
244
|
+
throw new ZipError(`entry ${label} could not be inflated within ${maxTotal} bytes (${e.code || e.message})`);
|
|
245
|
+
}
|
|
246
|
+
} else throw new ZipError(`unsupported compression method ${method} for ${label}`);
|
|
247
|
+
if (data.length !== usize) throw new ZipError(`entry ${label} holds ${data.length} bytes, the archive says ${usize}`);
|
|
248
|
+
total += data.length;
|
|
249
|
+
if (total > maxTotal) throw new ZipError(`unpacked size over ${maxTotal} bytes`);
|
|
250
|
+
const parts = name.split("/").filter(Boolean);
|
|
251
|
+
if (parts.includes("..") || name.startsWith("/")) continue;
|
|
211
252
|
files.set(parts.join("/"), data);
|
|
212
253
|
}
|
|
213
254
|
return files;
|
|
214
255
|
}
|
|
215
256
|
|
|
216
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Unpack a wheel (zip) or sdist (.tar.gz) into a MapSource. The bytes are an
|
|
259
|
+
* artifact the caller chose to scan (matched by hash, or a local path), so one
|
|
260
|
+
* that cannot be read safely is not "missing": it comes back as a source with
|
|
261
|
+
* an anomaly and no files, and the scan fails closed.
|
|
262
|
+
*/
|
|
217
263
|
export function sourceFromArtifact(bytes, kind) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
264
|
+
try {
|
|
265
|
+
if (bytes.length >= 4 && bytes.readUInt32LE(0) === 0x04034b50) return new MapSource(unzip(bytes), kind);
|
|
266
|
+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) return new MapSource(untar(gunzipSync(bytes, { maxOutputLength: MAX_UNPACKED_BYTES })), kind);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
return new MapSource(new Map(), kind, { anomaly: `archive could not be read safely (${e.message})` });
|
|
269
|
+
}
|
|
270
|
+
return new MapSource(new Map(), kind, { anomaly: "archive is neither a wheel nor a gzipped sdist" });
|
|
221
271
|
}
|
|
222
272
|
|
|
223
273
|
// ── pip's HTTP cache ──────────────────────────────────────────────────────
|
|
@@ -281,10 +331,9 @@ export function sourceFromPipCache(pkg, dir = pipCacheDir()) {
|
|
|
281
331
|
for (const bytes of hits) {
|
|
282
332
|
// Re-verify: the cache may have changed since it was indexed.
|
|
283
333
|
if (!wanted.includes(createHash("sha256").update(bytes).digest("hex"))) continue;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
} catch { /* corrupt artifact */ }
|
|
334
|
+
// These are the bytes the lockfile pins. If they do not unpack cleanly the
|
|
335
|
+
// source carries an anomaly, so the package cannot pass as merely uncached.
|
|
336
|
+
return sourceFromArtifact(bytes, "pip-cache");
|
|
288
337
|
}
|
|
289
338
|
return null;
|
|
290
339
|
}
|
package/lib/deps/python/toml.js
CHANGED
|
@@ -5,13 +5,23 @@
|
|
|
5
5
|
* tables, arrays of tables, dotted keys, basic/literal/multi-line strings,
|
|
6
6
|
* integers, floats, booleans, dates (kept as strings), multi-line arrays and
|
|
7
7
|
* inline tables. Not a validating parser: malformed input yields a best-effort
|
|
8
|
-
* object or throws, never runs anything. Linear in the input size.
|
|
8
|
+
* object or throws, never runs anything. Linear in the input size. Arrays
|
|
9
|
+
* and inline tables nest at most MAX_DEPTH deep, so hostile input throws a
|
|
10
|
+
* TOML error instead of overflowing the stack.
|
|
9
11
|
*/
|
|
10
12
|
|
|
13
|
+
const MAX_DEPTH = 200;
|
|
14
|
+
|
|
11
15
|
class Reader {
|
|
12
16
|
constructor(src) {
|
|
13
17
|
this.s = src.replace(/\r\n/g, "\n");
|
|
14
18
|
this.i = 0;
|
|
19
|
+
this.depth = 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Enter a nested array or inline table. */
|
|
23
|
+
nest() {
|
|
24
|
+
if (++this.depth > MAX_DEPTH) this.error(`nested deeper than ${MAX_DEPTH} levels`);
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
peek(n = 0) { return this.s[this.i + n]; }
|
|
@@ -123,11 +133,12 @@ class Reader {
|
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
array() {
|
|
136
|
+
this.nest();
|
|
126
137
|
this.i++;
|
|
127
138
|
const out = [];
|
|
128
139
|
for (;;) {
|
|
129
140
|
this.ws(true);
|
|
130
|
-
if (this.peek() === "]") { this.i++; return out; }
|
|
141
|
+
if (this.peek() === "]") { this.i++; this.depth--; return out; }
|
|
131
142
|
if (this.eof()) this.error("unterminated array");
|
|
132
143
|
out.push(this.value());
|
|
133
144
|
this.ws(true);
|
|
@@ -137,11 +148,12 @@ class Reader {
|
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
inlineTable() {
|
|
151
|
+
this.nest();
|
|
140
152
|
this.i++;
|
|
141
153
|
const out = {};
|
|
142
154
|
for (;;) {
|
|
143
155
|
this.ws(true);
|
|
144
|
-
if (this.peek() === "}") { this.i++; return out; }
|
|
156
|
+
if (this.peek() === "}") { this.i++; this.depth--; return out; }
|
|
145
157
|
if (this.eof()) this.error("unterminated inline table");
|
|
146
158
|
const k = this.dottedKey();
|
|
147
159
|
this.ws();
|
package/lib/deps/source.js
CHANGED
|
@@ -14,14 +14,22 @@ import { createHash } from "node:crypto";
|
|
|
14
14
|
import { gunzipSync } from "node:zlib";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Most files a package may have. The same as the tarball entry cap, so any
|
|
19
|
+
* tarball the reader accepts is read whole. A source with more sets
|
|
20
|
+
* `truncated`, and the scan then fails closed (see scanPackage): files past
|
|
21
|
+
* the cap were never read, so the package cannot be called clean.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_FILES = 50_000;
|
|
18
24
|
|
|
19
25
|
/** Files of an installed package folder. Skips nested node_modules and symlinks. */
|
|
20
26
|
export class DirSource {
|
|
21
|
-
constructor(dir, { skip } = {}) {
|
|
27
|
+
constructor(dir, { skip, maxFiles = MAX_FILES } = {}) {
|
|
22
28
|
this.dir = dir;
|
|
23
29
|
this.kind = "installed";
|
|
24
30
|
this.skip = skip || null;
|
|
31
|
+
this.maxFiles = maxFiles;
|
|
32
|
+
this.truncated = false;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
files() {
|
|
@@ -32,13 +40,14 @@ export class DirSource {
|
|
|
32
40
|
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
33
41
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
34
42
|
for (const e of entries) {
|
|
35
|
-
if (
|
|
43
|
+
if (this.truncated) return;
|
|
36
44
|
const p = join(d, e.name);
|
|
37
45
|
if (e.isSymbolicLink()) continue;
|
|
38
46
|
if (e.isDirectory()) {
|
|
39
47
|
if (e.name === "node_modules" || e.name === ".git" || this.skip?.has(e.name)) continue;
|
|
40
48
|
walk(p);
|
|
41
49
|
} else if (e.isFile()) {
|
|
50
|
+
if (out.length >= this.maxFiles) { this.truncated = true; return; }
|
|
42
51
|
out.push(relative(this.dir, p).split(sep).join("/"));
|
|
43
52
|
}
|
|
44
53
|
}
|
|
@@ -63,15 +72,17 @@ export class DirSource {
|
|
|
63
72
|
* scanPackage). `oversize` maps files too large to hold to their real size.
|
|
64
73
|
*/
|
|
65
74
|
export class MapSource {
|
|
66
|
-
constructor(map, kind = "npm-cache", { anomaly = null } = {}) {
|
|
75
|
+
constructor(map, kind = "npm-cache", { anomaly = null, maxFiles = MAX_FILES } = {}) {
|
|
67
76
|
this.map = map;
|
|
68
77
|
this.kind = kind;
|
|
69
78
|
this.anomaly = anomaly;
|
|
70
79
|
this.oversize = map.oversize || new Map();
|
|
80
|
+
this.truncated = map.size > maxFiles;
|
|
81
|
+
this.maxFiles = maxFiles;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
files() {
|
|
74
|
-
return [...this.map.keys()].sort().slice(0,
|
|
85
|
+
return [...this.map.keys()].sort().slice(0, this.maxFiles);
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
size(rel) {
|
|
@@ -159,8 +170,10 @@ function parsePax(body) {
|
|
|
159
170
|
* are listed in map.oversize (path -> size) instead of being copied.
|
|
160
171
|
*
|
|
161
172
|
* Fails closed: a bad checksum, a malformed or impossible size, a truncated
|
|
162
|
-
* entry,
|
|
163
|
-
*
|
|
173
|
+
* entry, data after the end-of-archive marker, or a pax global header that
|
|
174
|
+
* sets size, path or linkpath throws TarError. These are the ways known to
|
|
175
|
+
* make this reader frame entries differently from npm (node-tar); this is a
|
|
176
|
+
* small reader, not a proof that the two can never disagree.
|
|
164
177
|
*/
|
|
165
178
|
export function untar(buf, { maxTotal = MAX_UNPACKED_BYTES, maxFile = MAX_FILE_BYTES } = {}) {
|
|
166
179
|
const files = new Map();
|
|
@@ -200,8 +213,14 @@ export function untar(buf, { maxTotal = MAX_UNPACKED_BYTES, maxFile = MAX_FILE_B
|
|
|
200
213
|
pax = parsePax(body);
|
|
201
214
|
continue;
|
|
202
215
|
}
|
|
203
|
-
if (type === "g") {
|
|
204
|
-
|
|
216
|
+
if (type === "g") {
|
|
217
|
+
// pax global header. node-tar applies its keys to every later entry; this
|
|
218
|
+
// reader does not, so one that changes how entries are framed or named
|
|
219
|
+
// is refused rather than read differently from npm.
|
|
220
|
+
const g = parsePax(body);
|
|
221
|
+
for (const k of ["size", "path", "linkpath"]) {
|
|
222
|
+
if (Object.hasOwn(g, k)) throw new TarError(`pax global header sets ${k}`);
|
|
223
|
+
}
|
|
205
224
|
continue;
|
|
206
225
|
}
|
|
207
226
|
if (type === "L") { // GNU long name
|
package/lib/deps/typosquat.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
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
|
-
* names (lib/deps/popular-packages.json, refreshed at release time by
|
|
5
|
+
* names (lib/deps/popular-packages.json.gz, 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
|
|
@@ -16,20 +16,44 @@
|
|
|
16
16
|
* near-miss check, plus (with --online) a very young publish date.
|
|
17
17
|
*/
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
|
+
import { gunzipSync } from "node:zlib";
|
|
20
|
+
import { rulesOf, rulesGeneration } from "../rules/index.js";
|
|
19
21
|
|
|
20
22
|
const _popular = {};
|
|
21
|
-
// PyPI's list is lib/deps/popular-pypi.json (scripts/refresh-popular-pypi.mjs).
|
|
22
|
-
|
|
23
|
+
// PyPI's list is lib/deps/popular-pypi.json.gz (scripts/refresh-popular-pypi.mjs).
|
|
24
|
+
// Both are gzipped JSON: they ship in the npm package, and gzip keeps that
|
|
25
|
+
// within the release size budget without changing what is checked.
|
|
26
|
+
const LISTS = { npm: "./popular-packages.json.gz", pypi: "./popular-pypi.json.gz" };
|
|
23
27
|
|
|
24
28
|
/** The vendored popular-name list for an ecosystem: { source, names: string[] }. */
|
|
25
29
|
export function loadPopular(ecosystem = "npm") {
|
|
26
30
|
if (!_popular[ecosystem]) {
|
|
27
|
-
const data = JSON.parse(readFileSync(new URL(LISTS[ecosystem], import.meta.url)
|
|
31
|
+
const data = JSON.parse(gunzipSync(readFileSync(new URL(LISTS[ecosystem], import.meta.url))).toString("utf-8"));
|
|
28
32
|
_popular[ecosystem] = { ...data, ecosystem, set: new Set(data.names), rank: new Map(data.names.map((n, i) => [n, i + 1])) };
|
|
29
33
|
}
|
|
30
34
|
return _popular[ecosystem];
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
let _merged = null;
|
|
38
|
+
let _mergedGen = -1;
|
|
39
|
+
/**
|
|
40
|
+
* The popular list plus `typosquat-target` names from rule packs, ranked after
|
|
41
|
+
* the vendored list in pack order. Without pack names this is loadPopular().
|
|
42
|
+
*/
|
|
43
|
+
export function loadTargets() {
|
|
44
|
+
const base = loadPopular();
|
|
45
|
+
if (_mergedGen === rulesGeneration()) return _merged;
|
|
46
|
+
const extra = rulesOf("typosquat-target").flatMap((r) => r.names).filter((n) => !base.set.has(n));
|
|
47
|
+
if (extra.length === 0) {
|
|
48
|
+
_merged = base;
|
|
49
|
+
} else {
|
|
50
|
+
const names = [...base.names, ...new Set(extra)];
|
|
51
|
+
_merged = { ...base, names, set: new Set(names), rank: new Map(names.map((n, i) => [n, i + 1])) };
|
|
52
|
+
}
|
|
53
|
+
_mergedGen = rulesGeneration();
|
|
54
|
+
return _merged;
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
/** Damerau-Levenshtein (optimal string alignment) distance, capped at max+1. */
|
|
34
58
|
export function editDistance(a, b, max = 2) {
|
|
35
59
|
if (a === b) return 0;
|
|
@@ -114,7 +138,8 @@ export function checkName(name, popular = loadPopular()) {
|
|
|
114
138
|
* @param {"npm"|"pypi"} ecosystem
|
|
115
139
|
*/
|
|
116
140
|
export function findTyposquats(names, ecosystem = "npm") {
|
|
117
|
-
|
|
141
|
+
// Pack typosquat targets are npm names.
|
|
142
|
+
const popular = ecosystem === "npm" ? loadTargets() : loadPopular(ecosystem);
|
|
118
143
|
const out = [];
|
|
119
144
|
for (const name of [...new Set(names)].sort()) {
|
|
120
145
|
const hit = checkName(name, popular);
|