penthera 1.2.0 → 1.5.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 (101) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +493 -45
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/compliance-cra.md +141 -0
  5. package/docs/compliance-nis2.md +3 -1
  6. package/docs/evidence.md +158 -0
  7. package/docs/examples/github-actions-deps.yml +105 -0
  8. package/docs/examples/github-actions-scan.yml +1 -1
  9. package/docs/examples/pr-dependency-review.yml +56 -0
  10. package/docs/funding/due-diligence-checklist.md +79 -0
  11. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  12. package/docs/funding/partner-one-pager.md +84 -0
  13. package/docs/funding/sponsor-tiers.md +81 -0
  14. package/docs/index.md +12 -1
  15. package/docs/local-loop.md +276 -0
  16. package/docs/plan.md +100 -0
  17. package/docs/references.bib +539 -0
  18. package/docs/references.md +77 -0
  19. package/docs/release-plan.md +228 -0
  20. package/lib/adaptive-probes.js +8 -5
  21. package/lib/agents/discover.js +384 -0
  22. package/lib/agents/hidden-content.js +387 -0
  23. package/lib/agents/index.js +286 -0
  24. package/lib/agents/lock.js +115 -0
  25. package/lib/agents/owasp.js +83 -0
  26. package/lib/agents/rules.js +376 -0
  27. package/lib/compliance/cra.js +525 -0
  28. package/lib/crawler.js +5 -15
  29. package/lib/data/retire-jsrepository.json.gz +0 -0
  30. package/lib/data/retire-jsrepository.meta.json +7 -0
  31. package/lib/deps/capabilities.js +523 -0
  32. package/lib/deps/diff.js +84 -0
  33. package/lib/deps/index.js +440 -0
  34. package/lib/deps/license.js +329 -0
  35. package/lib/deps/lockfile.js +109 -0
  36. package/lib/deps/online.js +132 -0
  37. package/lib/deps/popular-packages.json +1 -0
  38. package/lib/deps/report.js +190 -0
  39. package/lib/deps/source.js +171 -0
  40. package/lib/deps/spdx.json +1 -0
  41. package/lib/deps/typosquat.js +113 -0
  42. package/lib/fix/apply.js +95 -0
  43. package/lib/fix/diff.js +199 -0
  44. package/lib/fix/index.js +129 -0
  45. package/lib/fix/model.js +108 -0
  46. package/lib/fix/recipes/cookies.js +130 -0
  47. package/lib/fix/recipes/cors.js +116 -0
  48. package/lib/fix/recipes/headers.js +250 -0
  49. package/lib/fix/recipes/rate-limit.js +211 -0
  50. package/lib/fix/recipes/secrets.js +110 -0
  51. package/lib/fix/util.js +231 -0
  52. package/lib/fuzzer.js +14 -8
  53. package/lib/hook/index.js +312 -0
  54. package/lib/hook/policy.js +62 -0
  55. package/lib/injections.js +19 -15
  56. package/lib/install/index.js +271 -0
  57. package/lib/install/policy.js +125 -0
  58. package/lib/jurisdictions/nl.json +9 -0
  59. package/lib/memory/history.js +52 -0
  60. package/lib/memory/index.js +111 -0
  61. package/lib/memory/repo-suppress.js +64 -0
  62. package/lib/memory/store.js +325 -0
  63. package/lib/memory/views.js +198 -0
  64. package/lib/params.js +4 -12
  65. package/lib/provenance.js +2 -0
  66. package/lib/recon.js +12 -20
  67. package/lib/references.js +31 -0
  68. package/lib/references.json +374 -0
  69. package/lib/remediation/index.js +28 -0
  70. package/lib/retirejs.js +50 -22
  71. package/lib/templates.js +7 -10
  72. package/lib/whitebox/aibom.js +384 -0
  73. package/lib/whitebox/frameworks.js +1 -1
  74. package/lib/whitebox/sbom.js +45 -10
  75. package/lib/whitebox/secrets.js +11 -1
  76. package/package.json +17 -4
  77. package/skills/penthera/SKILL.md +132 -22
  78. package/src/agent/git.js +87 -0
  79. package/src/agent/loop.js +519 -0
  80. package/src/agent/tests.js +48 -0
  81. package/src/cli/agent.js +183 -0
  82. package/src/cli/deps.js +104 -0
  83. package/src/cli/evidence.js +67 -0
  84. package/src/cli/fix.js +191 -0
  85. package/src/cli/hook.js +131 -0
  86. package/src/cli/install.js +225 -0
  87. package/src/cli/memory.js +146 -0
  88. package/src/cli/merge-results.js +2 -0
  89. package/src/cli/report-outputs.js +72 -0
  90. package/src/cli/run-scan.js +2 -0
  91. package/src/cli.js +212 -8
  92. package/src/report/audience.js +270 -0
  93. package/src/report/evidence.js +253 -0
  94. package/src/report/markdown.js +65 -2
  95. package/src/report/model.js +212 -0
  96. package/src/report/template.js +175 -0
  97. package/src/report/timeline.js +145 -0
  98. package/src/reporter.js +109 -5
  99. package/src/scan-url.js +31 -7
  100. package/src/utils/http.js +252 -11
  101. package/src/utils/url.js +23 -0
package/src/utils/http.js CHANGED
@@ -1,26 +1,210 @@
1
+ /**
2
+ * The one HTTP client every Penthera probe uses.
3
+ *
4
+ * It owns three things so no module has to:
5
+ * - timeouts (one AbortController per request),
6
+ * - the User-Agent (`penthera/<version> (+repo url)`, overridable),
7
+ * - egress policy: which hosts a scan may talk to.
8
+ *
9
+ * A scan runs inside an egress context (see `runWithEgress`). The context
10
+ * names the scan target and records every host contacted, every request it
11
+ * refused, and every probe request that failed. Under `offline: true` the
12
+ * client refuses any host other than the target; a redirect may only move to
13
+ * a host on the target's registrable domain. Outside a context (unit tests,
14
+ * library use) requests are unrestricted and nothing is recorded.
15
+ */
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+ import { readFileSync } from "node:fs";
18
+ import { registrableDomain } from "./url.js";
19
+
1
20
  const DEFAULT_TIMEOUT = 8_000;
21
+ const MAX_REDIRECTS = 10;
22
+ const REPO_URL = "https://github.com/danoszz/penthera";
23
+
24
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
25
+
26
+ export const DEFAULT_USER_AGENT = `penthera/${pkg.version} (+${REPO_URL})`;
27
+
28
+ const store = new AsyncLocalStorage();
29
+
30
+ /** Thrown (and recorded) when the egress policy refuses a request. */
31
+ export class EgressBlockedError extends Error {
32
+ constructor(url, reason) {
33
+ super(`blocked by --offline: ${reason}`);
34
+ this.name = "EgressBlockedError";
35
+ this.code = "EGRESS_BLOCKED";
36
+ this.url = url;
37
+ }
38
+ }
2
39
 
3
40
  /**
4
- * Fetch with timeout and graceful failure (returns null on network error).
41
+ * Create the per-scan egress context.
42
+ *
43
+ * @param {object} opts
44
+ * @param {string} opts.target - scan target URL; its host is always allowed
45
+ * @param {boolean} [opts.offline] - refuse every host except the target
46
+ * @param {string} [opts.userAgent] - override the default User-Agent
47
+ * @param {string[]} [opts.allowHosts] - extra hosts to allow under offline
5
48
  */
6
- export async function safeFetch(url, opts = {}) {
49
+ export function createEgressContext(opts = {}) {
50
+ const targetHost = opts.target ? new URL(opts.target).hostname.toLowerCase() : null;
51
+ return {
52
+ targetHost,
53
+ targetDomain: targetHost ? registrableDomain(targetHost) : null,
54
+ offline: opts.offline === true,
55
+ userAgent: opts.userAgent || DEFAULT_USER_AGENT,
56
+ allowHosts: new Set((opts.allowHosts || []).map((h) => h.toLowerCase())),
57
+ contacted: new Map(), // host -> request count
58
+ blocked: new Map(), // host -> refused request count
59
+ errors: new Map(), // `${probe}\0${message}` -> { probe, url, message, count }
60
+ };
61
+ }
62
+
63
+ /** Run `fn` with `ctx` as the active egress context (propagates across awaits). */
64
+ export function runWithEgress(ctx, fn) {
65
+ return store.run(ctx, fn);
66
+ }
67
+
68
+ /** The active egress context, or null outside a scan. */
69
+ export function currentEgress() {
70
+ return store.getStore() ?? null;
71
+ }
72
+
73
+ function isThirdParty(ctx, host) {
74
+ if (!ctx.targetHost) return false;
75
+ if (host === ctx.targetHost) return false;
76
+ return registrableDomain(host) !== ctx.targetDomain;
77
+ }
78
+
79
+ /**
80
+ * Decide whether `url` may be requested under the active policy.
81
+ *
82
+ * @param {string|URL} url
83
+ * @param {{ redirect?: boolean }} [opts] - true when following a redirect hop
84
+ * @returns {{ allowed: boolean, reason?: string }}
85
+ */
86
+ export function checkEgress(url, opts = {}, ctx = currentEgress()) {
87
+ if (!ctx || !ctx.offline) return { allowed: true };
88
+ let host;
89
+ try {
90
+ host = new URL(url).hostname.toLowerCase();
91
+ } catch {
92
+ return { allowed: false, reason: "unparseable URL" };
93
+ }
94
+ if (host === ctx.targetHost || ctx.allowHosts.has(host)) return { allowed: true };
95
+ // A redirect may move within the target's registrable domain (apex -> www).
96
+ if (opts.redirect && ctx.targetDomain && !isIpLike(host) && registrableDomain(host) === ctx.targetDomain) {
97
+ return { allowed: true };
98
+ }
99
+ return { allowed: false, reason: `${host} is not the scan target (${ctx.targetHost})` };
100
+ }
101
+
102
+ function isIpLike(host) {
103
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":") || host.startsWith("[");
104
+ }
105
+
106
+ function bump(map, key) {
107
+ map.set(key, (map.get(key) || 0) + 1);
108
+ }
109
+
110
+ function recordError(ctx, probe, url, message) {
111
+ if (!ctx || probe === false) return;
112
+ const key = `${probe}\0${message}`;
113
+ const existing = ctx.errors.get(key);
114
+ if (existing) existing.count++;
115
+ else ctx.errors.set(key, { probe, url: String(url), message, count: 1 });
116
+ }
117
+
118
+ function withUserAgent(headers, userAgent) {
119
+ const h = new Headers(headers || {});
120
+ if (!h.has("user-agent")) h.set("user-agent", userAgent);
121
+ return h;
122
+ }
123
+
124
+ function describeError(err, timeoutMs) {
125
+ if (err?.name === "AbortError" || err?.name === "TimeoutError") return `timeout after ${timeoutMs}ms`;
126
+ const cause = err?.cause?.code || err?.cause?.message;
127
+ return cause ? `${err.message} (${cause})` : String(err?.message || err);
128
+ }
129
+
130
+ /**
131
+ * Issue one request through the egress policy.
132
+ *
133
+ * Resolves to the `Response` (3xx included when `redirect` is "manual", the
134
+ * default) and throws on refusal, timeout or network error. Failures are
135
+ * recorded on the active context under `opts.probe`.
136
+ *
137
+ * @param {string|URL} url
138
+ * @param {object} [opts] - fetch options plus `timeout` (ms) and `probe` (name
139
+ * for result.errors; `false` to not record failures, e.g. when a fallback exists)
140
+ */
141
+ export async function request(url, opts = {}) {
142
+ const ctx = currentEgress();
143
+ const { timeout = DEFAULT_TIMEOUT, probe = "http", redirect = "manual", signal, ...fetchOpts } = opts;
144
+ const userAgent = ctx?.userAgent || DEFAULT_USER_AGENT;
145
+
7
146
  const controller = new AbortController();
8
- const timeout = setTimeout(() => controller.abort(), opts.timeout || DEFAULT_TIMEOUT);
147
+ const timer = setTimeout(() => controller.abort(), timeout);
148
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
149
+
150
+ let current = String(url);
151
+ let init = { ...fetchOpts, headers: withUserAgent(fetchOpts.headers, userAgent) };
152
+
9
153
  try {
10
- return await fetch(url, {
11
- ...opts,
12
- signal: controller.signal,
13
- redirect: opts.redirect ?? "manual",
14
- });
154
+ for (let hop = 0; ; hop++) {
155
+ const verdict = checkEgress(current, { redirect: hop > 0 }, ctx);
156
+ if (!verdict.allowed) {
157
+ const blockedHost = safeHost(current);
158
+ if (ctx && blockedHost) bump(ctx.blocked, blockedHost);
159
+ throw new EgressBlockedError(current, verdict.reason);
160
+ }
161
+
162
+ const res = await globalThis.fetch(current, { ...init, signal: combined, redirect: "manual" });
163
+ if (ctx) bump(ctx.contacted, new URL(current).hostname.toLowerCase());
164
+
165
+ const location = res.headers.get("location");
166
+ const isRedirect = res.status >= 300 && res.status < 400 && location;
167
+ if (redirect !== "follow" || !isRedirect) return res;
168
+ if (hop >= MAX_REDIRECTS) throw new Error(`too many redirects (${MAX_REDIRECTS})`);
169
+
170
+ // Follow by hand so every hop passes the egress check.
171
+ await res.body?.cancel().catch(() => {});
172
+ current = new URL(location, current).toString();
173
+ const method = (init.method || "GET").toUpperCase();
174
+ if (res.status === 303 || ((res.status === 301 || res.status === 302) && method === "POST")) {
175
+ init = { ...init, method: "GET", body: undefined };
176
+ }
177
+ }
178
+ } catch (err) {
179
+ recordError(ctx, probe, current, err instanceof EgressBlockedError ? err.message : describeError(err, timeout));
180
+ throw err;
181
+ } finally {
182
+ clearTimeout(timer);
183
+ }
184
+ }
185
+
186
+ function safeHost(url) {
187
+ try {
188
+ return new URL(url).hostname.toLowerCase();
15
189
  } catch {
16
190
  return null;
17
- } finally {
18
- clearTimeout(timeout);
19
191
  }
20
192
  }
21
193
 
22
194
  /**
23
- * Fetch helper that always returns status, body text, and headers.
195
+ * Fetch with timeout and graceful failure (returns null on any error).
196
+ */
197
+ export async function safeFetch(url, opts = {}) {
198
+ try {
199
+ return await request(url, opts);
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Fetch helper that always returns status, body text, and headers
207
+ * (null on failure).
24
208
  */
25
209
  export async function probeFetch(url, opts = {}) {
26
210
  const res = await safeFetch(url, opts);
@@ -36,3 +220,60 @@ export async function probeFetch(url, opts = {}) {
36
220
  ok: res.ok,
37
221
  };
38
222
  }
223
+
224
+ /**
225
+ * Fetch and read the body, never throwing. Timing covers headers and body.
226
+ * Failures come back as `{ status: 0, ok: false, error }`.
227
+ *
228
+ * @returns {Promise<{ status, body, size, headers, elapsed, ok, error? }>}
229
+ */
230
+ export async function fetchText(url, opts = {}) {
231
+ const start = Date.now();
232
+ try {
233
+ const res = await request(url, opts);
234
+ const body = await res.text().catch(() => "");
235
+ return {
236
+ status: res.status,
237
+ body,
238
+ size: body.length,
239
+ headers: Object.fromEntries(res.headers.entries()),
240
+ elapsed: Date.now() - start,
241
+ ok: true,
242
+ };
243
+ } catch (err) {
244
+ return {
245
+ status: 0,
246
+ body: "",
247
+ size: 0,
248
+ headers: {},
249
+ elapsed: Date.now() - start,
250
+ ok: false,
251
+ error: err instanceof EgressBlockedError ? err.message : describeError(err, opts.timeout ?? DEFAULT_TIMEOUT),
252
+ };
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Summarise what the active (or given) context saw, for the scan result.
258
+ */
259
+ export function egressSummary(ctx = currentEgress()) {
260
+ if (!ctx) return null;
261
+ const sortHosts = (map) => [...map.entries()]
262
+ .map(([host, requests]) => ({ host, requests }))
263
+ .sort((a, b) => a.host.localeCompare(b.host));
264
+ const contacted = sortHosts(ctx.contacted);
265
+ return {
266
+ offline: ctx.offline,
267
+ userAgent: ctx.userAgent,
268
+ target: ctx.targetHost,
269
+ hosts: contacted,
270
+ thirdPartyHosts: contacted.filter((h) => isThirdParty(ctx, h.host)),
271
+ blocked: sortHosts(ctx.blocked),
272
+ };
273
+ }
274
+
275
+ /** Probe failures recorded on the context, as `result.errors[]`. */
276
+ export function probeErrors(ctx = currentEgress()) {
277
+ if (!ctx) return [];
278
+ return [...ctx.errors.values()].map((e) => ({ ...e }));
279
+ }
package/src/utils/url.js CHANGED
@@ -44,6 +44,29 @@ export function redirectsToHost(locationHeader, requestUrl, attackerHost) {
44
44
  }
45
45
  }
46
46
 
47
+ // Second-level labels that act as public suffixes under country TLDs
48
+ // (example.co.uk, example.com.au). Not a full public-suffix list: a miss makes
49
+ // two sibling hosts look unrelated, which errs toward blocking, not allowing.
50
+ const SECOND_LEVEL_SUFFIXES = new Set(["ac", "co", "com", "edu", "gov", "ltd", "ne", "net", "or", "org", "plc"]);
51
+
52
+ /**
53
+ * Registrable domain of a hostname, e.g. "app.staging.example.com" →
54
+ * "example.com", "shop.example.co.uk" → "example.co.uk". IPs and
55
+ * single-label hosts come back unchanged.
56
+ */
57
+ export function registrableDomain(hostname) {
58
+ const host = String(hostname).toLowerCase().replace(/\.$/, "");
59
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":") || host.startsWith("[")) return host;
60
+ const parts = host.split(".");
61
+ if (parts.length <= 2) return host;
62
+ const tld = parts[parts.length - 1];
63
+ const second = parts[parts.length - 2];
64
+ if (tld.length === 2 && SECOND_LEVEL_SUFFIXES.has(second)) {
65
+ return parts.slice(-3).join(".");
66
+ }
67
+ return parts.slice(-2).join(".");
68
+ }
69
+
47
70
  /** Convert a URL into a safe filename fragment. */
48
71
  export function urlToFilename(url) {
49
72
  return url