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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings for `penthera deps`: turn scanned packages, capability diffs,
|
|
3
|
+
* typosquat hits and IOC hits into Penthera's standard finding shape.
|
|
4
|
+
*
|
|
5
|
+
* Pure functions with no file or network access, so the browser check on the
|
|
6
|
+
* site (site/check) builds its findings with the same code as the CLI.
|
|
7
|
+
*/
|
|
8
|
+
import { CAPABILITIES, maxSeverity, capabilityWhy } from "./capabilities.js";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export const SOURCE = "deps-scan";
|
|
12
|
+
export const CATEGORY = "dependency-risk";
|
|
13
|
+
const KNOWN_BAD = ["ai-config-write", "ai-cli-invoke", "token-harvest"];
|
|
14
|
+
// Owners of these scopes legitimately manage their own AI tool config.
|
|
15
|
+
const KNOWN_BAD_EXEMPT = { "ai-config-write": [/^@anthropic-ai\//], "ai-cli-invoke": [/^@anthropic-ai\//] };
|
|
16
|
+
|
|
17
|
+
// ── Findings ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const capList = (ids) => ids.map((id) => CAPABILITIES[id]?.label || id).join(", ");
|
|
20
|
+
|
|
21
|
+
function evidenceText(caps, ids) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
for (const id of ids) {
|
|
24
|
+
for (const e of (caps?.[id]?.evidence || []).slice(0, 2)) {
|
|
25
|
+
lines.push(`${CAPABILITIES[id].label}: ${e.file}:${e.line} \`${e.snippet}\``);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return lines.join("; ");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function firstEvidence(caps, ids) {
|
|
32
|
+
for (const id of ids) {
|
|
33
|
+
const e = caps?.[id]?.evidence?.[0];
|
|
34
|
+
if (e) return e;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function finding({ title, severity, confidence, method, description, pkg, ev, capabilities, extra = {} }) {
|
|
40
|
+
return {
|
|
41
|
+
title,
|
|
42
|
+
severity,
|
|
43
|
+
category: CATEGORY,
|
|
44
|
+
source: SOURCE,
|
|
45
|
+
method,
|
|
46
|
+
confidence,
|
|
47
|
+
description,
|
|
48
|
+
url: ev?.file || pkg.path,
|
|
49
|
+
line: ev?.line || undefined,
|
|
50
|
+
package: pkg.name,
|
|
51
|
+
version: pkg.version,
|
|
52
|
+
capabilities,
|
|
53
|
+
...extra,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Risk findings that stand on their own, without a diff. */
|
|
58
|
+
export function riskFindings(pkg) {
|
|
59
|
+
const out = [];
|
|
60
|
+
const caps = pkg.capabilities;
|
|
61
|
+
const at = `${pkg.name}@${pkg.version}`;
|
|
62
|
+
if (pkg.unscanned) {
|
|
63
|
+
out.push(finding({
|
|
64
|
+
title: `${at}: could not be scanned`,
|
|
65
|
+
severity: "high",
|
|
66
|
+
confidence: "confirmed",
|
|
67
|
+
method: "deps-capability-scan",
|
|
68
|
+
description: `${pkg.unscanned}. Penthera fails closed here: treat this package as able to do anything, including run install scripts, until you have read its ${pkg.ecosystem === "pypi" ? "files" : "tarball"} yourself.`,
|
|
69
|
+
pkg, ev: { file: pkg.ecosystem === "pypi" ? pkg.path : `${pkg.path}/package.json` }, capabilities: [],
|
|
70
|
+
extra: { unscanned: pkg.unscanned },
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
if (pkg.scriptMismatch) {
|
|
74
|
+
out.push(finding({
|
|
75
|
+
title: `${at}: lockfile lists an install script the scan did not find`,
|
|
76
|
+
severity: "high",
|
|
77
|
+
confidence: "confirmed",
|
|
78
|
+
method: "deps-capability-scan",
|
|
79
|
+
description: "package-lock.json marks this package `hasInstallScript`, but the package.json Penthera read declares no preinstall, install or postinstall script and there is no binding.gyp. Either the lockfile was edited, or the tarball shows a scanner different files than npm installs. Do not install it until the difference is explained.",
|
|
80
|
+
pkg, ev: { file: `${pkg.path}/package.json` }, capabilities: ["install-scripts"],
|
|
81
|
+
extra: { scriptMismatch: true },
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
if (!caps) return out;
|
|
85
|
+
const py = pkg.ecosystem === "pypi";
|
|
86
|
+
const why = (id) => capabilityWhy(id, pkg.ecosystem);
|
|
87
|
+
|
|
88
|
+
for (const id of KNOWN_BAD) {
|
|
89
|
+
if (!caps[id]) continue;
|
|
90
|
+
if ((KNOWN_BAD_EXEMPT[id] || []).some((re) => re.test(pkg.name))) continue;
|
|
91
|
+
const ev = caps[id].evidence[0];
|
|
92
|
+
out.push(finding({
|
|
93
|
+
title: `${at}: ${CAPABILITIES[id].label}`,
|
|
94
|
+
severity: "critical",
|
|
95
|
+
confidence: CAPABILITIES[id].confidence,
|
|
96
|
+
method: "deps-capability-scan",
|
|
97
|
+
description: `${why(id)} Evidence: ${evidenceText(caps, [id])}`,
|
|
98
|
+
pkg, ev, capabilities: [id],
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
if (caps.obfuscated) {
|
|
102
|
+
const ev = caps.obfuscated.evidence[0];
|
|
103
|
+
out.push(finding({
|
|
104
|
+
title: `${at}: obfuscated code`,
|
|
105
|
+
severity: "high",
|
|
106
|
+
confidence: "potential",
|
|
107
|
+
method: "deps-capability-scan",
|
|
108
|
+
description: `${why("obfuscated")} Signals at ${ev.file}:${ev.line}: ${ev.snippet}. Read the file before trusting it; minified code alone does not trigger this.`,
|
|
109
|
+
pkg, ev, capabilities: ["obfuscated"],
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
if (pkg.exfil?.length) {
|
|
113
|
+
const ev = pkg.exfil[0];
|
|
114
|
+
out.push(finding({
|
|
115
|
+
title: `${at}: serialises the whole environment next to network code`,
|
|
116
|
+
severity: "high",
|
|
117
|
+
confidence: "likely",
|
|
118
|
+
method: "deps-capability-scan",
|
|
119
|
+
description: `${py ? "os.environ" : "process.env"} (tokens, keys, CI secrets) is serialised within a few lines of a network call, the shape of credential exfiltration. Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. ${evidenceText(caps, ["network"])}`,
|
|
120
|
+
pkg, ev, capabilities: ["env-bulk", "network"],
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
if (caps["install-scripts"]) {
|
|
124
|
+
const ev = caps["install-scripts"].evidence[0];
|
|
125
|
+
const hooks = Object.entries(pkg.installScripts).map(([h, cmd]) => `${h}: \`${cmd.slice(0, 100)}\``).join("; ");
|
|
126
|
+
const risky = pkg.installRisk.length > 0;
|
|
127
|
+
out.push(finding({
|
|
128
|
+
title: `${at}: runs ${risky ? "a risky " : ""}install script`,
|
|
129
|
+
severity: risky ? "medium" : "low",
|
|
130
|
+
confidence: "confirmed",
|
|
131
|
+
method: "deps-capability-scan",
|
|
132
|
+
description: `${why("install-scripts")} ${hooks}.${risky ? ` Why it stands out: ${pkg.installRisk.join("; ")}.` : ""} ${py ? "Install from a wheel (`pip install --only-binary :all:`) so no build code runs, or read setup.py first." : "Install with `npm ci --ignore-scripts` if the package works without it."}`,
|
|
133
|
+
pkg, ev, capabilities: ["install-scripts"],
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
// Python: .pth import lines run on every interpreter start.
|
|
137
|
+
const hooks = pkg.startupHooks || [];
|
|
138
|
+
if (hooks.length) {
|
|
139
|
+
const risky = hooks.find((h) => h.risk.length);
|
|
140
|
+
const ev = risky || hooks[0];
|
|
141
|
+
out.push(finding({
|
|
142
|
+
title: `${at}: ${risky ? "runs risky code" : "runs code"} at every Python start (.pth)`,
|
|
143
|
+
severity: risky ? "high" : "low",
|
|
144
|
+
confidence: "confirmed",
|
|
145
|
+
method: "deps-capability-scan",
|
|
146
|
+
description: `${why("startup-hook")} ${ev.file}:${ev.line} \`${ev.snippet}\`.${risky ? ` Why it stands out: ${risky.risk.join("; ")}.` : " Editable installs, setuptools and virtualenv ship .pth files like this; check it is one of those."}`,
|
|
147
|
+
pkg, ev, capabilities: ["startup-hook"],
|
|
148
|
+
}));
|
|
149
|
+
} else if (caps["startup-hook"]) {
|
|
150
|
+
const ev = caps["startup-hook"].evidence[0];
|
|
151
|
+
out.push(finding({
|
|
152
|
+
title: `${at}: writes a .pth file (code that runs at every Python start)`,
|
|
153
|
+
severity: "medium",
|
|
154
|
+
confidence: "potential",
|
|
155
|
+
method: "deps-capability-scan",
|
|
156
|
+
description: `${why("startup-hook")} Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. Installers and environment tools do this legitimately; a library has no reason to.`,
|
|
157
|
+
pkg, ev, capabilities: ["startup-hook"],
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
if (pkg.modified?.length) {
|
|
161
|
+
out.push(finding({
|
|
162
|
+
title: `${at}: ${pkg.modified.length} installed file(s) changed since install`,
|
|
163
|
+
severity: "medium",
|
|
164
|
+
confidence: "confirmed",
|
|
165
|
+
method: "deps-capability-scan",
|
|
166
|
+
description: `These files no longer match the hashes pip recorded in RECORD when the package was installed: ${pkg.modified.slice(0, 5).join(", ")}${pkg.modified.length > 5 ? ", …" : ""}. Something edited them afterwards: a local patch, or code planting itself in the environment. Reinstall the package and see if it comes back.`,
|
|
167
|
+
pkg, ev: { file: pkg.modified[0], line: 1 }, capabilities: [],
|
|
168
|
+
extra: { modified: pkg.modified },
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** .pth files in site-packages that no installed distribution claims. */
|
|
175
|
+
export function unownedHookFindings(hooks) {
|
|
176
|
+
return hooks.map((h) => ({
|
|
177
|
+
title: `Unowned startup hook ${h.file.split("/").pop()} in site-packages`,
|
|
178
|
+
severity: h.risk.length ? "high" : "medium",
|
|
179
|
+
category: CATEGORY,
|
|
180
|
+
source: SOURCE,
|
|
181
|
+
method: "deps-capability-scan",
|
|
182
|
+
confidence: "confirmed",
|
|
183
|
+
description: `${CAPABILITIES["startup-hook"].why} No installed distribution lists this file in its RECORD, so something wrote it after install. ${h.file}:${h.line} \`${h.snippet}\`.${h.risk.length ? ` The line itself uses ${h.risk.join(", ")}.` : ""} Delete it if you cannot trace where it came from.`,
|
|
184
|
+
url: h.file,
|
|
185
|
+
line: h.line,
|
|
186
|
+
capabilities: ["startup-hook"],
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Capabilities that riskFindings already reports on their own.
|
|
191
|
+
const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts", "startup-hook"]);
|
|
192
|
+
|
|
193
|
+
export function diffFindings(diff, ecosystem = "npm") {
|
|
194
|
+
const out = [];
|
|
195
|
+
const why = (id) => capabilityWhy(id, ecosystem);
|
|
196
|
+
for (const a of diff.added) {
|
|
197
|
+
const ids = a.capabilities || [];
|
|
198
|
+
const notable = ids.filter((id) => id !== "env" && id !== "dynamic-require" && !OWN_FINDING.has(id));
|
|
199
|
+
if (notable.length === 0) continue;
|
|
200
|
+
out.push(finding({
|
|
201
|
+
title: `New dependency ${a.name}@${a.version}: ${capList(notable)}`,
|
|
202
|
+
severity: maxSeverity(notable.map((id) => CAPABILITIES[id].severity)),
|
|
203
|
+
confidence: "likely",
|
|
204
|
+
method: "deps-capability-diff",
|
|
205
|
+
description: `A package that was not in the base tree. It can: ${notable.map(why).join(" ")} Evidence: ${evidenceText(a.pkg.capabilities, notable)}`,
|
|
206
|
+
pkg: a.pkg, ev: firstEvidence(a.pkg.capabilities, notable), capabilities: notable,
|
|
207
|
+
extra: { change: "added" },
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
for (const u of diff.upgraded) {
|
|
211
|
+
if (!u.gained || u.gained.length === 0) continue;
|
|
212
|
+
const sev = maxSeverity(u.gained.map((id) => CAPABILITIES[id].severity));
|
|
213
|
+
out.push(finding({
|
|
214
|
+
title: `${u.name} ${u.from} → ${u.to} gained ${capList(u.gained)}`,
|
|
215
|
+
severity: sev,
|
|
216
|
+
confidence: u.gained.some((id) => CAPABILITIES[id].confidence === "potential") && u.gained.length === 1 ? "potential" : "likely",
|
|
217
|
+
method: "deps-capability-diff",
|
|
218
|
+
description: `This upgrade can do things the previous version could not. ${u.gained.map(why).join(" ")} Evidence: ${evidenceText(u.pkg.capabilities, u.gained)}`,
|
|
219
|
+
pkg: u.pkg, ev: firstEvidence(u.pkg.capabilities, u.gained), capabilities: u.gained,
|
|
220
|
+
extra: { change: "upgraded", from: u.from },
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
const missing = diff.upgraded.filter((u) => u.unavailable);
|
|
224
|
+
if (missing.length) {
|
|
225
|
+
out.push({
|
|
226
|
+
title: `${missing.length} upgraded package(s) not compared: old version not available offline`,
|
|
227
|
+
severity: "info",
|
|
228
|
+
category: CATEGORY,
|
|
229
|
+
source: SOURCE,
|
|
230
|
+
method: "deps-capability-diff",
|
|
231
|
+
confidence: "confirmed",
|
|
232
|
+
description: `No local copy to compare against for: ${missing.map((u) => `${u.name} ${u.from} → ${u.to}`).join(", ")}. ${ecosystem === "pypi"
|
|
233
|
+
? "Create the base environment (`uv sync` or `pip install -r requirements.txt` in a checkout of the base, in a .venv) and pass that folder to --diff; old wheels already in pip's or uv's cache are used automatically when their hash matches the base lockfile."
|
|
234
|
+
: "Install the base tree (`npm ci --ignore-scripts` in a checkout of the base) and pass that folder to --diff, or rerun with --online to fetch the old tarballs from the registry."}`,
|
|
235
|
+
url: ecosystem === "pypi" ? diff.lockfile || "requirements.txt" : "package-lock.json",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function typosquatFindings(hits, pkgByName, direct) {
|
|
242
|
+
return hits.map((h) => {
|
|
243
|
+
const pkg = pkgByName.get(h.name) || { name: h.name, version: null, path: "package-lock.json" };
|
|
244
|
+
const isDirect = direct.has(h.name);
|
|
245
|
+
return finding({
|
|
246
|
+
title: `${h.name} looks like ${h.target} (${h.kind})`,
|
|
247
|
+
severity: isDirect ? "medium" : "low",
|
|
248
|
+
confidence: "potential",
|
|
249
|
+
method: "deps-typosquat",
|
|
250
|
+
description: `\`${h.name}\` is ${h.distance} edit(s) from \`${h.target}\` (#${h.rank} in the vendored popular-package list) and is not itself on that list. Typosquats and AI-hallucinated names (slopsquatting) look exactly like this. Check it is the package you meant to install${isDirect ? "" : ` and why a dependency pulls it in`}.`,
|
|
251
|
+
pkg, ev: { file: pkg.path, line: undefined }, capabilities: [],
|
|
252
|
+
extra: { typosquatOf: h.target, direct: isDirect },
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Rule-pack IOC hits: known-malicious versions (from the lockfile, installed
|
|
259
|
+
* or not) and campaign hosts or dropped files found in package contents.
|
|
260
|
+
*/
|
|
261
|
+
export function iocFindings(tree, iocs) {
|
|
262
|
+
const out = [];
|
|
263
|
+
if (!iocs) return out;
|
|
264
|
+
for (const p of tree) {
|
|
265
|
+
const hits = [...iocs.matchPackage(p.name, p.version), ...(p.iocHits || [])];
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
for (const h of hits) {
|
|
268
|
+
const key = `${h.rule.pack}/${h.rule.id}/${h.type}/${h.value}`;
|
|
269
|
+
if (seen.has(key)) continue;
|
|
270
|
+
seen.add(key);
|
|
271
|
+
const r = h.rule;
|
|
272
|
+
const at = `${p.name}@${p.version}`;
|
|
273
|
+
const what = h.type === "package"
|
|
274
|
+
? `known-malicious version (${r.campaign})`
|
|
275
|
+
: h.type === "file" ? `contains a file dropped by ${r.campaign}: ${h.value}` : `references ${r.campaign} exfiltration endpoint ${h.value}`;
|
|
276
|
+
out.push(finding({
|
|
277
|
+
title: `${at}: ${what}`,
|
|
278
|
+
severity: r.severity,
|
|
279
|
+
confidence: r.confidence,
|
|
280
|
+
method: "deps-ioc",
|
|
281
|
+
description: `${h.type === "package" ? `\`${at}\` is a published malicious version.` : `Indicator found in ${h.file}:${h.line}.`} Listed in rule pack ${r.pack}@${r.packVersion} (rule ${r.id}, recorded ${r.date}). Source: ${r.source}${r.description ? ` ${r.description}` : ""} Remove or pin away from this version, reinstall with \`npm ci --ignore-scripts\`, and treat any machine or CI runner that installed it as compromised: rotate the npm, GitHub and cloud credentials it could read.`,
|
|
282
|
+
pkg: p, ev: h.type === "package" ? { file: p.path } : { file: h.file, line: h.line }, capabilities: [],
|
|
283
|
+
extra: { ioc: { pack: r.pack, packVersion: r.packVersion, rule: r.id, type: h.type, value: h.value, campaign: r.campaign, source: r.source } },
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function registryFindings(results, pkgByKey) {
|
|
291
|
+
const out = [];
|
|
292
|
+
for (const [key, r] of [...results].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
293
|
+
const pkg = pkgByKey.get(key);
|
|
294
|
+
if (!pkg) continue;
|
|
295
|
+
for (const s of r.signals) {
|
|
296
|
+
out.push(finding({
|
|
297
|
+
title: `${key}: ${s.text}`,
|
|
298
|
+
severity: s.severity,
|
|
299
|
+
confidence: "confirmed",
|
|
300
|
+
method: "deps-registry",
|
|
301
|
+
description: `From npm registry metadata (--online). ${s.id === "publisher-change" ? "A new publisher on an established package is how account takeovers ship malware; check the release notes and diff." : s.id === "new-package" ? "Brand-new packages are where typosquats and slopsquats (names AI assistants hallucinate) live." : s.id === "fresh-version" ? "Most malicious versions are caught and pulled within days; waiting out a short cooldown on new releases avoids them." : ""}`,
|
|
302
|
+
pkg, ev: { file: pkg.path }, capabilities: [],
|
|
303
|
+
extra: { registrySignal: s.id },
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
package/lib/deps/index.js
CHANGED
|
@@ -19,17 +19,13 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
19
19
|
import { join, dirname, relative, resolve, basename } from "node:path";
|
|
20
20
|
import { findLockfile, readLockfile, parseLockfile, directDependencies, readLockfileAtRef } from "./lockfile.js";
|
|
21
21
|
import { DirSource, sourceFromNpmCache, npmCacheDir } from "./source.js";
|
|
22
|
-
import { scanPackage,
|
|
22
|
+
import { scanPackage, CAPABILITY_ORDER } from "./capabilities.js";
|
|
23
23
|
import { diffTrees } from "./diff.js";
|
|
24
24
|
import { findTyposquats, loadPopular } from "./typosquat.js";
|
|
25
25
|
import { checkLicenses } from "./license.js";
|
|
26
26
|
import { hasPythonInputs, loadPythonTree, loadPythonBase } from "./python/index.js";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const CATEGORY = "dependency-risk";
|
|
30
|
-
const KNOWN_BAD = ["ai-config-write", "ai-cli-invoke", "token-harvest"];
|
|
31
|
-
// Owners of these scopes legitimately manage their own AI tool config.
|
|
32
|
-
const KNOWN_BAD_EXEMPT = { "ai-config-write": [/^@anthropic-ai\//], "ai-cli-invoke": [/^@anthropic-ai\//] };
|
|
27
|
+
import { loadIocs } from "./iocs.js";
|
|
28
|
+
import { riskFindings, unownedHookFindings, diffFindings, typosquatFindings, iocFindings, registryFindings } from "./findings.js";
|
|
33
29
|
|
|
34
30
|
// ── Resolving package contents ────────────────────────────────────────────
|
|
35
31
|
|
|
@@ -64,7 +60,7 @@ function scanTree(pkgs, treeRoot, ctx) {
|
|
|
64
60
|
const key = `${p.name}@${p.version}#${p.integrity || ""}`;
|
|
65
61
|
if (!ctx.cache.has(key)) {
|
|
66
62
|
const src = resolveSource(p, treeRoot, ctx.cacheDirs);
|
|
67
|
-
ctx.cache.set(key, src ? { ...scanPackage(src, { prefix: `${p.path}
|
|
63
|
+
ctx.cache.set(key, src ? { ...scanPackage(src, { prefix: `${p.path}/`, iocs: ctx.iocs }), source: src.kind } : null);
|
|
68
64
|
}
|
|
69
65
|
const r = ctx.cache.get(key);
|
|
70
66
|
// Fail closed: a package that could not be fully read has unknown
|
|
@@ -90,270 +86,11 @@ function scanTree(pkgs, treeRoot, ctx) {
|
|
|
90
86
|
installRisk: r?.installRisk || [],
|
|
91
87
|
exfil: r?.exfil || [],
|
|
92
88
|
filesScanned: r?.filesScanned || 0,
|
|
89
|
+
iocHits: r?.iocHits || [],
|
|
93
90
|
};
|
|
94
91
|
});
|
|
95
92
|
}
|
|
96
93
|
|
|
97
|
-
// ── Findings ──────────────────────────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
const capList = (ids) => ids.map((id) => CAPABILITIES[id]?.label || id).join(", ");
|
|
100
|
-
|
|
101
|
-
function evidenceText(caps, ids) {
|
|
102
|
-
const lines = [];
|
|
103
|
-
for (const id of ids) {
|
|
104
|
-
for (const e of (caps?.[id]?.evidence || []).slice(0, 2)) {
|
|
105
|
-
lines.push(`${CAPABILITIES[id].label}: ${e.file}:${e.line} \`${e.snippet}\``);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return lines.join("; ");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function firstEvidence(caps, ids) {
|
|
112
|
-
for (const id of ids) {
|
|
113
|
-
const e = caps?.[id]?.evidence?.[0];
|
|
114
|
-
if (e) return e;
|
|
115
|
-
}
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function finding({ title, severity, confidence, method, description, pkg, ev, capabilities, extra = {} }) {
|
|
120
|
-
return {
|
|
121
|
-
title,
|
|
122
|
-
severity,
|
|
123
|
-
category: CATEGORY,
|
|
124
|
-
source: SOURCE,
|
|
125
|
-
method,
|
|
126
|
-
confidence,
|
|
127
|
-
description,
|
|
128
|
-
url: ev?.file || pkg.path,
|
|
129
|
-
line: ev?.line || undefined,
|
|
130
|
-
package: pkg.name,
|
|
131
|
-
version: pkg.version,
|
|
132
|
-
capabilities,
|
|
133
|
-
...extra,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Risk findings that stand on their own, without a diff. */
|
|
138
|
-
function riskFindings(pkg) {
|
|
139
|
-
const out = [];
|
|
140
|
-
const caps = pkg.capabilities;
|
|
141
|
-
const at = `${pkg.name}@${pkg.version}`;
|
|
142
|
-
if (pkg.unscanned) {
|
|
143
|
-
out.push(finding({
|
|
144
|
-
title: `${at}: could not be scanned`,
|
|
145
|
-
severity: "high",
|
|
146
|
-
confidence: "confirmed",
|
|
147
|
-
method: "deps-capability-scan",
|
|
148
|
-
description: `${pkg.unscanned}. Penthera fails closed here: treat this package as able to do anything, including run install scripts, until you have read its tarball yourself.`,
|
|
149
|
-
pkg, ev: { file: `${pkg.path}/package.json` }, capabilities: [],
|
|
150
|
-
extra: { unscanned: pkg.unscanned },
|
|
151
|
-
}));
|
|
152
|
-
}
|
|
153
|
-
if (pkg.scriptMismatch) {
|
|
154
|
-
out.push(finding({
|
|
155
|
-
title: `${at}: lockfile lists an install script the scan did not find`,
|
|
156
|
-
severity: "high",
|
|
157
|
-
confidence: "confirmed",
|
|
158
|
-
method: "deps-capability-scan",
|
|
159
|
-
description: "package-lock.json marks this package `hasInstallScript`, but the package.json Penthera read declares no preinstall, install or postinstall script and there is no binding.gyp. Either the lockfile was edited, or the tarball shows a scanner different files than npm installs. Do not install it until the difference is explained.",
|
|
160
|
-
pkg, ev: { file: `${pkg.path}/package.json` }, capabilities: ["install-scripts"],
|
|
161
|
-
extra: { scriptMismatch: true },
|
|
162
|
-
}));
|
|
163
|
-
}
|
|
164
|
-
if (!caps) return out;
|
|
165
|
-
const py = pkg.ecosystem === "pypi";
|
|
166
|
-
const why = (id) => capabilityWhy(id, pkg.ecosystem);
|
|
167
|
-
|
|
168
|
-
for (const id of KNOWN_BAD) {
|
|
169
|
-
if (!caps[id]) continue;
|
|
170
|
-
if ((KNOWN_BAD_EXEMPT[id] || []).some((re) => re.test(pkg.name))) continue;
|
|
171
|
-
const ev = caps[id].evidence[0];
|
|
172
|
-
out.push(finding({
|
|
173
|
-
title: `${at}: ${CAPABILITIES[id].label}`,
|
|
174
|
-
severity: "critical",
|
|
175
|
-
confidence: CAPABILITIES[id].confidence,
|
|
176
|
-
method: "deps-capability-scan",
|
|
177
|
-
description: `${why(id)} Evidence: ${evidenceText(caps, [id])}`,
|
|
178
|
-
pkg, ev, capabilities: [id],
|
|
179
|
-
}));
|
|
180
|
-
}
|
|
181
|
-
if (caps.obfuscated) {
|
|
182
|
-
const ev = caps.obfuscated.evidence[0];
|
|
183
|
-
out.push(finding({
|
|
184
|
-
title: `${at}: obfuscated code`,
|
|
185
|
-
severity: "high",
|
|
186
|
-
confidence: "potential",
|
|
187
|
-
method: "deps-capability-scan",
|
|
188
|
-
description: `${why("obfuscated")} Signals at ${ev.file}:${ev.line}: ${ev.snippet}. Read the file before trusting it; minified code alone does not trigger this.`,
|
|
189
|
-
pkg, ev, capabilities: ["obfuscated"],
|
|
190
|
-
}));
|
|
191
|
-
}
|
|
192
|
-
if (pkg.exfil?.length) {
|
|
193
|
-
const ev = pkg.exfil[0];
|
|
194
|
-
out.push(finding({
|
|
195
|
-
title: `${at}: serialises the whole environment next to network code`,
|
|
196
|
-
severity: "high",
|
|
197
|
-
confidence: "likely",
|
|
198
|
-
method: "deps-capability-scan",
|
|
199
|
-
description: `${py ? "os.environ" : "process.env"} (tokens, keys, CI secrets) is serialised within a few lines of a network call, the shape of credential exfiltration. Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. ${evidenceText(caps, ["network"])}`,
|
|
200
|
-
pkg, ev, capabilities: ["env-bulk", "network"],
|
|
201
|
-
}));
|
|
202
|
-
}
|
|
203
|
-
if (caps["install-scripts"]) {
|
|
204
|
-
const ev = caps["install-scripts"].evidence[0];
|
|
205
|
-
const hooks = Object.entries(pkg.installScripts).map(([h, cmd]) => `${h}: \`${cmd.slice(0, 100)}\``).join("; ");
|
|
206
|
-
const risky = pkg.installRisk.length > 0;
|
|
207
|
-
out.push(finding({
|
|
208
|
-
title: `${at}: runs ${risky ? "a risky " : ""}install script`,
|
|
209
|
-
severity: risky ? "medium" : "low",
|
|
210
|
-
confidence: "confirmed",
|
|
211
|
-
method: "deps-capability-scan",
|
|
212
|
-
description: `${why("install-scripts")} ${hooks}.${risky ? ` Why it stands out: ${pkg.installRisk.join("; ")}.` : ""} ${py ? "Install from a wheel (`pip install --only-binary :all:`) so no build code runs, or read setup.py first." : "Install with `npm ci --ignore-scripts` if the package works without it."}`,
|
|
213
|
-
pkg, ev, capabilities: ["install-scripts"],
|
|
214
|
-
}));
|
|
215
|
-
}
|
|
216
|
-
// Python: .pth import lines run on every interpreter start.
|
|
217
|
-
const hooks = pkg.startupHooks || [];
|
|
218
|
-
if (hooks.length) {
|
|
219
|
-
const risky = hooks.find((h) => h.risk.length);
|
|
220
|
-
const ev = risky || hooks[0];
|
|
221
|
-
out.push(finding({
|
|
222
|
-
title: `${at}: ${risky ? "runs risky code" : "runs code"} at every Python start (.pth)`,
|
|
223
|
-
severity: risky ? "high" : "low",
|
|
224
|
-
confidence: "confirmed",
|
|
225
|
-
method: "deps-capability-scan",
|
|
226
|
-
description: `${why("startup-hook")} ${ev.file}:${ev.line} \`${ev.snippet}\`.${risky ? ` Why it stands out: ${risky.risk.join("; ")}.` : " Editable installs, setuptools and virtualenv ship .pth files like this; check it is one of those."}`,
|
|
227
|
-
pkg, ev, capabilities: ["startup-hook"],
|
|
228
|
-
}));
|
|
229
|
-
} else if (caps["startup-hook"]) {
|
|
230
|
-
const ev = caps["startup-hook"].evidence[0];
|
|
231
|
-
out.push(finding({
|
|
232
|
-
title: `${at}: writes a .pth file (code that runs at every Python start)`,
|
|
233
|
-
severity: "medium",
|
|
234
|
-
confidence: "potential",
|
|
235
|
-
method: "deps-capability-scan",
|
|
236
|
-
description: `${why("startup-hook")} Evidence: ${ev.file}:${ev.line} \`${ev.snippet}\`. Installers and environment tools do this legitimately; a library has no reason to.`,
|
|
237
|
-
pkg, ev, capabilities: ["startup-hook"],
|
|
238
|
-
}));
|
|
239
|
-
}
|
|
240
|
-
if (pkg.modified?.length) {
|
|
241
|
-
out.push(finding({
|
|
242
|
-
title: `${at}: ${pkg.modified.length} installed file(s) changed since install`,
|
|
243
|
-
severity: "medium",
|
|
244
|
-
confidence: "confirmed",
|
|
245
|
-
method: "deps-capability-scan",
|
|
246
|
-
description: `These files no longer match the hashes pip recorded in RECORD when the package was installed: ${pkg.modified.slice(0, 5).join(", ")}${pkg.modified.length > 5 ? ", …" : ""}. Something edited them afterwards: a local patch, or code planting itself in the environment. Reinstall the package and see if it comes back.`,
|
|
247
|
-
pkg, ev: { file: pkg.modified[0], line: 1 }, capabilities: [],
|
|
248
|
-
extra: { modified: pkg.modified },
|
|
249
|
-
}));
|
|
250
|
-
}
|
|
251
|
-
return out;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** .pth files in site-packages that no installed distribution claims. */
|
|
255
|
-
function unownedHookFindings(hooks) {
|
|
256
|
-
return hooks.map((h) => ({
|
|
257
|
-
title: `Unowned startup hook ${h.file.split("/").pop()} in site-packages`,
|
|
258
|
-
severity: h.risk.length ? "high" : "medium",
|
|
259
|
-
category: CATEGORY,
|
|
260
|
-
source: SOURCE,
|
|
261
|
-
method: "deps-capability-scan",
|
|
262
|
-
confidence: "confirmed",
|
|
263
|
-
description: `${CAPABILITIES["startup-hook"].why} No installed distribution lists this file in its RECORD, so something wrote it after install. ${h.file}:${h.line} \`${h.snippet}\`.${h.risk.length ? ` The line itself uses ${h.risk.join(", ")}.` : ""} Delete it if you cannot trace where it came from.`,
|
|
264
|
-
url: h.file,
|
|
265
|
-
line: h.line,
|
|
266
|
-
capabilities: ["startup-hook"],
|
|
267
|
-
}));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Capabilities that riskFindings already reports on their own.
|
|
271
|
-
const OWN_FINDING = new Set([...KNOWN_BAD, "obfuscated", "install-scripts", "startup-hook"]);
|
|
272
|
-
|
|
273
|
-
function diffFindings(diff, ecosystem = "npm") {
|
|
274
|
-
const out = [];
|
|
275
|
-
const why = (id) => capabilityWhy(id, ecosystem);
|
|
276
|
-
for (const a of diff.added) {
|
|
277
|
-
const ids = a.capabilities || [];
|
|
278
|
-
const notable = ids.filter((id) => id !== "env" && id !== "dynamic-require" && !OWN_FINDING.has(id));
|
|
279
|
-
if (notable.length === 0) continue;
|
|
280
|
-
out.push(finding({
|
|
281
|
-
title: `New dependency ${a.name}@${a.version}: ${capList(notable)}`,
|
|
282
|
-
severity: maxSeverity(notable.map((id) => CAPABILITIES[id].severity)),
|
|
283
|
-
confidence: "likely",
|
|
284
|
-
method: "deps-capability-diff",
|
|
285
|
-
description: `A package that was not in the base tree. It can: ${notable.map(why).join(" ")} Evidence: ${evidenceText(a.pkg.capabilities, notable)}`,
|
|
286
|
-
pkg: a.pkg, ev: firstEvidence(a.pkg.capabilities, notable), capabilities: notable,
|
|
287
|
-
extra: { change: "added" },
|
|
288
|
-
}));
|
|
289
|
-
}
|
|
290
|
-
for (const u of diff.upgraded) {
|
|
291
|
-
if (!u.gained || u.gained.length === 0) continue;
|
|
292
|
-
const sev = maxSeverity(u.gained.map((id) => CAPABILITIES[id].severity));
|
|
293
|
-
out.push(finding({
|
|
294
|
-
title: `${u.name} ${u.from} → ${u.to} gained ${capList(u.gained)}`,
|
|
295
|
-
severity: sev,
|
|
296
|
-
confidence: u.gained.some((id) => CAPABILITIES[id].confidence === "potential") && u.gained.length === 1 ? "potential" : "likely",
|
|
297
|
-
method: "deps-capability-diff",
|
|
298
|
-
description: `This upgrade can do things the previous version could not. ${u.gained.map(why).join(" ")} Evidence: ${evidenceText(u.pkg.capabilities, u.gained)}`,
|
|
299
|
-
pkg: u.pkg, ev: firstEvidence(u.pkg.capabilities, u.gained), capabilities: u.gained,
|
|
300
|
-
extra: { change: "upgraded", from: u.from },
|
|
301
|
-
}));
|
|
302
|
-
}
|
|
303
|
-
const missing = diff.upgraded.filter((u) => u.unavailable);
|
|
304
|
-
if (missing.length) {
|
|
305
|
-
out.push({
|
|
306
|
-
title: `${missing.length} upgraded package(s) not compared: old version not available offline`,
|
|
307
|
-
severity: "info",
|
|
308
|
-
category: CATEGORY,
|
|
309
|
-
source: SOURCE,
|
|
310
|
-
method: "deps-capability-diff",
|
|
311
|
-
confidence: "confirmed",
|
|
312
|
-
description: `No local copy to compare against for: ${missing.map((u) => `${u.name} ${u.from} → ${u.to}`).join(", ")}. ${ecosystem === "pypi"
|
|
313
|
-
? "Create the base environment (`uv sync` or `pip install -r requirements.txt` in a checkout of the base, in a .venv) and pass that folder to --diff; old wheels already in pip's or uv's cache are used automatically when their hash matches the base lockfile."
|
|
314
|
-
: "Install the base tree (`npm ci --ignore-scripts` in a checkout of the base) and pass that folder to --diff, or rerun with --online to fetch the old tarballs from the registry."}`,
|
|
315
|
-
url: ecosystem === "pypi" ? diff.lockfile || "requirements.txt" : "package-lock.json",
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
return out;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function typosquatFindings(hits, pkgByName, direct) {
|
|
322
|
-
return hits.map((h) => {
|
|
323
|
-
const pkg = pkgByName.get(h.name) || { name: h.name, version: null, path: "package-lock.json" };
|
|
324
|
-
const isDirect = direct.has(h.name);
|
|
325
|
-
return finding({
|
|
326
|
-
title: `${h.name} looks like ${h.target} (${h.kind})`,
|
|
327
|
-
severity: isDirect ? "medium" : "low",
|
|
328
|
-
confidence: "potential",
|
|
329
|
-
method: "deps-typosquat",
|
|
330
|
-
description: `\`${h.name}\` is ${h.distance} edit(s) from \`${h.target}\` (#${h.rank} in the vendored popular-package list) and is not itself on that list. Typosquats and AI-hallucinated names (slopsquatting) look exactly like this. Check it is the package you meant to install${isDirect ? "" : ` and why a dependency pulls it in`}.`,
|
|
331
|
-
pkg, ev: { file: pkg.path, line: undefined }, capabilities: [],
|
|
332
|
-
extra: { typosquatOf: h.target, direct: isDirect },
|
|
333
|
-
});
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function registryFindings(results, pkgByKey) {
|
|
338
|
-
const out = [];
|
|
339
|
-
for (const [key, r] of [...results].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
340
|
-
const pkg = pkgByKey.get(key);
|
|
341
|
-
if (!pkg) continue;
|
|
342
|
-
for (const s of r.signals) {
|
|
343
|
-
out.push(finding({
|
|
344
|
-
title: `${key}: ${s.text}`,
|
|
345
|
-
severity: s.severity,
|
|
346
|
-
confidence: "confirmed",
|
|
347
|
-
method: "deps-registry",
|
|
348
|
-
description: `From npm registry metadata (--online). ${s.id === "publisher-change" ? "A new publisher on an established package is how account takeovers ship malware; check the release notes and diff." : s.id === "new-package" ? "Brand-new packages are where typosquats and slopsquats (names AI assistants hallucinate) live." : s.id === "fresh-version" ? "Most malicious versions are caught and pulled within days; waiting out a short cooldown on new releases avoids them." : ""}`,
|
|
349
|
-
pkg, ev: { file: pkg.path }, capabilities: [],
|
|
350
|
-
extra: { registrySignal: s.id },
|
|
351
|
-
}));
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
return out;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
94
|
// ── Entry point ───────────────────────────────────────────────────────────
|
|
358
95
|
|
|
359
96
|
function summarize(tree) {
|
|
@@ -395,7 +132,7 @@ async function scanNpm(root, opts = {}) {
|
|
|
395
132
|
const lockPath = opts.lock ? join(root, "package-lock.json") : findLockfile(root);
|
|
396
133
|
if (!lockPath) throw new Error(`No package-lock.json in ${root}. penthera deps reads the lockfile; run \`npm install --package-lock-only\` first.`);
|
|
397
134
|
const lock = opts.lock || readLockfile(lockPath);
|
|
398
|
-
const ctx = { cache: new Map(), cacheDirs: [].concat(opts.cacheDir || npmCacheDir()) };
|
|
135
|
+
const ctx = { cache: new Map(), cacheDirs: [].concat(opts.cacheDir || npmCacheDir()), iocs: loadIocs() };
|
|
399
136
|
const keep = (p) => !p.link && p.version && (!opts.prod || !p.dev);
|
|
400
137
|
|
|
401
138
|
const diffing = !!(opts.diff || opts.base || opts.baseLock);
|
|
@@ -460,6 +197,8 @@ async function scanNpm(root, opts = {}) {
|
|
|
460
197
|
? new Set([...diff.added.map((a) => a.pkg), ...diff.upgraded.map((u) => u.pkg)])
|
|
461
198
|
: new Set(tree);
|
|
462
199
|
for (const p of tree) if (inScope.has(p)) findings.push(...riskFindings(p));
|
|
200
|
+
// A known-bad package is reported wherever it sits, diff or not.
|
|
201
|
+
findings.push(...iocFindings(tree, ctx.iocs));
|
|
463
202
|
if (diff) findings.push(...diffFindings(diff));
|
|
464
203
|
|
|
465
204
|
// Names you chose (direct deps), or names that are new in this change. A
|
|
@@ -579,7 +318,7 @@ async function scanPython(root, opts = {}) {
|
|
|
579
318
|
online = { checked: 0, unreachable: 0, fetchedTarballs: 0, note: "--online registry checks cover npm only; PyPI packages were scanned offline." };
|
|
580
319
|
}
|
|
581
320
|
|
|
582
|
-
const unavailable = tree.filter((p) => !p.capabilities);
|
|
321
|
+
const unavailable = tree.filter((p) => !p.capabilities && !p.unscanned);
|
|
583
322
|
return {
|
|
584
323
|
target: relCwd(root),
|
|
585
324
|
mode: "deps",
|
|
@@ -600,6 +339,7 @@ async function scanPython(root, opts = {}) {
|
|
|
600
339
|
skippedOptional: 0,
|
|
601
340
|
direct: [...direct],
|
|
602
341
|
notInstalled: unavailable.map((p) => (p.version ? `${p.name}@${p.version}` : `${p.name} (unpinned, not installed)`)),
|
|
342
|
+
unscanned: tree.filter((p) => p.unscanned).map((p) => ({ package: `${p.name}@${p.version}`, reason: p.unscanned })),
|
|
603
343
|
capabilityCounts: summarize(tree),
|
|
604
344
|
popularList: loadPopular("pypi").source,
|
|
605
345
|
packages: tree.map((p) => ({
|
|
@@ -616,8 +356,8 @@ async function scanPython(root, opts = {}) {
|
|
|
616
356
|
online,
|
|
617
357
|
diff: diff ? {
|
|
618
358
|
base: base.label,
|
|
619
|
-
added: diff.added.map(({ pkg, ...a }) => ({ ...a, installScripts: pkg.installScripts })),
|
|
620
|
-
upgraded: diff.upgraded.map(({ pkg, prev, ...u }) => ({ ...u, installScripts: pkg.installScripts, previousInstallScripts: prev?.installScripts || null })),
|
|
359
|
+
added: diff.added.map(({ pkg, ...a }) => ({ ...a, installScripts: pkg.installScripts, unscanned: pkg.unscanned || null })),
|
|
360
|
+
upgraded: diff.upgraded.map(({ pkg, prev, ...u }) => ({ ...u, installScripts: pkg.installScripts, previousInstallScripts: prev?.installScripts || null, unscanned: pkg.unscanned || null })),
|
|
621
361
|
removed: diff.removed,
|
|
622
362
|
evidence: Object.fromEntries([...diff.added, ...diff.upgraded].map((x) => {
|
|
623
363
|
const ids = x.gained || x.capabilities || [];
|
|
@@ -653,6 +393,7 @@ function combineResults(results) {
|
|
|
653
393
|
skippedOptional: sum("skippedOptional"),
|
|
654
394
|
direct: ds.flatMap((d) => d.direct),
|
|
655
395
|
notInstalled: ds.flatMap((d) => d.notInstalled),
|
|
396
|
+
unscanned: ds.flatMap((d) => d.unscanned || []),
|
|
656
397
|
capabilityCounts: counts,
|
|
657
398
|
popularList: ds.map((d) => d.popularList).join(", "),
|
|
658
399
|
packages: ds.flatMap((d) => d.packages.map((p) => ({ ecosystem: d.ecosystem, ...p }))),
|