penthera 1.2.0 → 1.4.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 +212 -0
- package/README.md +379 -39
- package/docs/METHODOLOGY.md +321 -0
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +12 -1
- package/docs/local-loop.md +276 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +416 -0
- package/lib/deps/lockfile.js +106 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +173 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +117 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/frameworks.js +1 -1
- package/package.json +16 -4
- package/skills/penthera/SKILL.md +103 -22
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +84 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +152 -6
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- 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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|