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.
Files changed (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. 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, CAPABILITIES, CAPABILITY_ORDER, maxSeverity, capabilityWhy } from "./capabilities.js";
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
- const SOURCE = "deps-scan";
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}/` }), source: src.kind } : null);
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 }))),