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
package/src/utils/http.js CHANGED
@@ -9,9 +9,14 @@
9
9
  * A scan runs inside an egress context (see `runWithEgress`). The context
10
10
  * names the scan target and records every host contacted, every request it
11
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.
12
+ * client refuses any host (or port) other than the target's; a redirect may
13
+ * only move to a host on the target's registrable domain. Outside a context
14
+ * (unit tests, library use) requests are unrestricted and nothing is recorded.
15
+ *
16
+ * Response bodies are read through `readBody`, which caps the decoded size
17
+ * and keeps the request's timeout running until the last byte, so a server
18
+ * that drips, floods or sends a decompression bomb cannot stall or exhaust a
19
+ * scan.
15
20
  */
16
21
  import { AsyncLocalStorage } from "node:async_hooks";
17
22
  import { readFileSync } from "node:fs";
@@ -19,6 +24,13 @@ import { registrableDomain } from "./url.js";
19
24
 
20
25
  const DEFAULT_TIMEOUT = 8_000;
21
26
  const MAX_REDIRECTS = 10;
27
+ /** Default cap on a decoded response body (bytes). */
28
+ export const DEFAULT_MAX_BODY = 5 * 1024 * 1024;
29
+ // Set on every Response `request` returns: when its timeout expires, and how
30
+ // to stop that timer once the body has been read.
31
+ const BODY_DEADLINE = Symbol("penthera.bodyDeadline");
32
+ // Request headers that carry credentials; never forwarded to another origin.
33
+ const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
22
34
  const REPO_URL = "https://github.com/danoszz/penthera";
23
35
 
24
36
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
@@ -47,9 +59,11 @@ export class EgressBlockedError extends Error {
47
59
  * @param {string[]} [opts.allowHosts] - extra hosts to allow under offline
48
60
  */
49
61
  export function createEgressContext(opts = {}) {
50
- const targetHost = opts.target ? new URL(opts.target).hostname.toLowerCase() : null;
62
+ const target = opts.target ? new URL(opts.target) : null;
63
+ const targetHost = target ? target.hostname.toLowerCase() : null;
51
64
  return {
52
65
  targetHost,
66
+ targetPort: target ? effectivePort(target) : null,
53
67
  targetDomain: targetHost ? registrableDomain(targetHost) : null,
54
68
  offline: opts.offline === true,
55
69
  userAgent: opts.userAgent || DEFAULT_USER_AGENT,
@@ -85,18 +99,44 @@ function isThirdParty(ctx, host) {
85
99
  */
86
100
  export function checkEgress(url, opts = {}, ctx = currentEgress()) {
87
101
  if (!ctx || !ctx.offline) return { allowed: true };
88
- let host;
102
+ let parsed;
89
103
  try {
90
- host = new URL(url).hostname.toLowerCase();
104
+ parsed = new URL(url);
91
105
  } catch {
92
106
  return { allowed: false, reason: "unparseable URL" };
93
107
  }
94
- if (host === ctx.targetHost || ctx.allowHosts.has(host)) return { allowed: true };
108
+ const host = parsed.hostname.toLowerCase();
109
+ if (ctx.allowHosts.has(host)) return { allowed: true };
110
+ const sameHost = host === ctx.targetHost;
95
111
  // A redirect may move within the target's registrable domain (apex -> www).
96
- if (opts.redirect && ctx.targetDomain && !isIpLike(host) && registrableDomain(host) === ctx.targetDomain) {
112
+ const sameSite = opts.redirect && ctx.targetDomain && !isIpLike(host) && registrableDomain(host) === ctx.targetDomain;
113
+ if (!sameHost && !sameSite) {
114
+ return { allowed: false, reason: `${host} is not the scan target (${ctx.targetHost})` };
115
+ }
116
+ // Same host is not enough: another port is another service. The one
117
+ // exception is the scheme's default port on both sides (http -> https).
118
+ const port = effectivePort(parsed);
119
+ if (ctx.targetPort == null || port === ctx.targetPort || (isDefaultPort(port) && isDefaultPort(ctx.targetPort))) {
97
120
  return { allowed: true };
98
121
  }
99
- return { allowed: false, reason: `${host} is not the scan target (${ctx.targetHost})` };
122
+ return { allowed: false, reason: `${host}:${port} is not the scan target (${ctx.targetHost}:${ctx.targetPort})` };
123
+ }
124
+
125
+ function effectivePort(u) {
126
+ if (u.port) return Number(u.port);
127
+ return u.protocol === "https:" ? 443 : u.protocol === "http:" ? 80 : null;
128
+ }
129
+
130
+ function isDefaultPort(port) {
131
+ return port === 80 || port === 443;
132
+ }
133
+
134
+ function sameOrigin(a, b) {
135
+ try {
136
+ return new URL(a).origin === new URL(b).origin;
137
+ } catch {
138
+ return false;
139
+ }
100
140
  }
101
141
 
102
142
  function isIpLike(host) {
@@ -145,6 +185,10 @@ export async function request(url, opts = {}) {
145
185
 
146
186
  const controller = new AbortController();
147
187
  const timer = setTimeout(() => controller.abort(), timeout);
188
+ const deadline = Date.now() + timeout;
189
+ // The timer keeps running after the headers arrive so the body is covered
190
+ // too; it is cleared here on failure or by readBody once the body is read.
191
+ let handedOff = false;
148
192
  const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
149
193
 
150
194
  let current = String(url);
@@ -164,25 +208,116 @@ export async function request(url, opts = {}) {
164
208
 
165
209
  const location = res.headers.get("location");
166
210
  const isRedirect = res.status >= 300 && res.status < 400 && location;
167
- if (redirect !== "follow" || !isRedirect) return res;
211
+ const next = isRedirect ? new URL(location, current).toString() : null;
212
+ const crossOrigin = next && !sameOrigin(current, next);
213
+ // A 307/308 replays the method and body; never replay a body to
214
+ // another origin, hand the redirect back to the caller instead.
215
+ const replaysBody = (res.status === 307 || res.status === 308) && init.body != null;
216
+ if (redirect !== "follow" || !isRedirect || (crossOrigin && replaysBody)) {
217
+ handedOff = true;
218
+ timer.unref?.();
219
+ res[BODY_DEADLINE] = { at: deadline, release: () => clearTimeout(timer) };
220
+ return res;
221
+ }
168
222
  if (hop >= MAX_REDIRECTS) throw new Error(`too many redirects (${MAX_REDIRECTS})`);
169
223
 
170
224
  // Follow by hand so every hop passes the egress check.
171
225
  await res.body?.cancel().catch(() => {});
172
- current = new URL(location, current).toString();
226
+ current = next;
173
227
  const method = (init.method || "GET").toUpperCase();
174
228
  if (res.status === 303 || ((res.status === 301 || res.status === 302) && method === "POST")) {
175
229
  init = { ...init, method: "GET", body: undefined };
176
230
  }
231
+ if (crossOrigin) {
232
+ // Credentials belong to the origin they were set for.
233
+ const headers = new Headers(init.headers);
234
+ for (const name of CREDENTIAL_HEADERS) headers.delete(name);
235
+ init = { ...init, headers };
236
+ }
177
237
  }
178
238
  } catch (err) {
179
239
  recordError(ctx, probe, current, err instanceof EgressBlockedError ? err.message : describeError(err, timeout));
180
240
  throw err;
241
+ } finally {
242
+ if (!handedOff) clearTimeout(timer);
243
+ }
244
+ }
245
+
246
+ /** Thrown by readBody when a body is too large or too slow. */
247
+ export class BodyLimitError extends Error {
248
+ constructor(message) {
249
+ super(message);
250
+ this.name = "BodyLimitError";
251
+ this.code = "BODY_LIMIT";
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Read a response body as text with a size cap and a deadline.
257
+ *
258
+ * Counts decoded bytes (after Content-Encoding), so a small compressed body
259
+ * that inflates past the cap is stopped as soon as it crosses it. The
260
+ * deadline defaults to the one `request` set from its `timeout`, so headers
261
+ * and body share one budget. Rejects with BodyLimitError on either limit and
262
+ * cancels the stream.
263
+ *
264
+ * @param {Response} res
265
+ * @param {{ maxBytes?: number, deadline?: number, timeout?: number, binary?: boolean }} [opts]
266
+ * deadline: absolute epoch ms; timeout: ms from now. The earliest wins.
267
+ * binary: return the raw bytes as a Buffer instead of text.
268
+ * @returns {Promise<string|Buffer>}
269
+ */
270
+ export async function readBody(res, opts = {}) {
271
+ const { maxBytes = DEFAULT_MAX_BODY, binary = false } = opts;
272
+ const attached = res?.[BODY_DEADLINE];
273
+ let at = Infinity;
274
+ if (attached) at = attached.at;
275
+ if (opts.deadline != null) at = Math.min(at, opts.deadline);
276
+ if (opts.timeout != null) at = Math.min(at, Date.now() + opts.timeout);
277
+
278
+ let timer;
279
+ let reader;
280
+ try {
281
+ if (!res?.body) return binary ? Buffer.alloc(0) : "";
282
+ const declared = Number(res.headers?.get("content-length"));
283
+ if (!res.headers?.get("content-encoding") && declared > maxBytes) {
284
+ throw new BodyLimitError(`response body exceeds ${maxBytes} bytes`);
285
+ }
286
+ reader = res.body.getReader();
287
+ const expired = new Promise((_, reject) => {
288
+ if (at === Infinity) return;
289
+ timer = setTimeout(() => reject(new BodyLimitError("response body timed out")), Math.max(0, at - Date.now()));
290
+ });
291
+ expired.catch(() => {});
292
+
293
+ const decoder = new TextDecoder();
294
+ const parts = [];
295
+ let total = 0;
296
+ for (;;) {
297
+ const { done, value } = await Promise.race([reader.read(), expired]);
298
+ if (done) break;
299
+ total += value.byteLength;
300
+ if (total > maxBytes) throw new BodyLimitError(`response body exceeds ${maxBytes} bytes`);
301
+ parts.push(binary ? value : decoder.decode(value, { stream: true }));
302
+ }
303
+ if (binary) return Buffer.concat(parts);
304
+ parts.push(decoder.decode());
305
+ return parts.join("");
306
+ } catch (err) {
307
+ if (reader) reader.cancel().catch(() => {});
308
+ else res?.body?.cancel?.().catch(() => {});
309
+ throw err;
181
310
  } finally {
182
311
  clearTimeout(timer);
312
+ attached?.release();
183
313
  }
184
314
  }
185
315
 
316
+ /** readBody, then JSON.parse. */
317
+ export async function readJson(res, opts) {
318
+ return JSON.parse(await readBody(res, opts));
319
+ }
320
+
186
321
  function safeHost(url) {
187
322
  try {
188
323
  return new URL(url).hostname.toLowerCase();
@@ -202,6 +337,20 @@ export async function safeFetch(url, opts = {}) {
202
337
  }
203
338
  }
204
339
 
340
+ /**
341
+ * Fetch and parse JSON, never throwing: null on a network error, a non-2xx
342
+ * status, a body over `maxBytes`, or JSON that does not parse.
343
+ */
344
+ export async function fetchJson(url, { maxBytes, ...opts } = {}) {
345
+ const res = await safeFetch(url, opts);
346
+ if (!res || !res.ok) return null;
347
+ try {
348
+ return await readJson(res, { maxBytes });
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
205
354
  /**
206
355
  * Fetch helper that always returns status, body text, and headers
207
356
  * (null on failure).
@@ -210,7 +359,7 @@ export async function probeFetch(url, opts = {}) {
210
359
  const res = await safeFetch(url, opts);
211
360
  if (!res) return null;
212
361
 
213
- const body = await res.text().catch(() => "");
362
+ const body = await readBody(res).catch(() => "");
214
363
  const headers = Object.fromEntries(res.headers.entries());
215
364
 
216
365
  return {
@@ -231,7 +380,7 @@ export async function fetchText(url, opts = {}) {
231
380
  const start = Date.now();
232
381
  try {
233
382
  const res = await request(url, opts);
234
- const body = await res.text().catch(() => "");
383
+ const body = await readBody(res).catch(() => "");
235
384
  return {
236
385
  status: res.status,
237
386
  body,
@@ -1,375 +0,0 @@
1
- # Methodology
2
-
3
- ## Abstract
4
-
5
- Penthera is an open-source, offline security scanner for web applications,
6
- their dependencies, and the AI tooling used to build them. This document
7
- specifies its method. It states the threat model, separates what black-box
8
- (HTTP) and white-box (repository) analysis can and cannot observe, and defines
9
- a four-level confidence model that distinguishes deterministic evidence from
10
- heuristic signals. It describes the false-positive controls built into each
11
- probe (randomized canaries, context-aware payload selection, baseline
12
- comparison, and repeated timing measurement) and the non-destructive defaults
13
- that follow from fail-safe design. Detection rules are distributed only in
14
- reviewed, versioned releases, never through a live feed, which removes an
15
- update channel from the attack surface. Each design choice is tied to prior
16
- research or a published standard. On a bundled benchmark of labelled local
17
- targets, Penthera reaches a precision of 0.86 and a recall of 0.90. Because the
18
- same authors wrote the scanner and the targets, these figures measure
19
- regressions, not accuracy on arbitrary applications.
20
-
21
- ## How to cite
22
-
23
- If you use Penthera or this methodology in research or a report, cite the
24
- software. The metadata are in [CITATION.cff](../CITATION.cff), which GitHub
25
- renders as "Cite this repository".
26
-
27
- ```text
28
- van der Zwaag, D. (2026). Penthera: An offline security scanner for web apps and AI-built code (Version 1.7.0) [Computer software]. https://github.com/danoszz/penthera
29
- ```
30
-
31
- ```bibtex
32
- @software{vanderzwaag2026penthera,
33
- author = {van der Zwaag, Daan},
34
- title = {Penthera: An Offline Security Scanner for Web Apps and {AI}-Built Code},
35
- year = {2026},
36
- version = {1.7.0},
37
- license = {MIT},
38
- url = {https://github.com/danoszz/penthera}
39
- }
40
- ```
41
-
42
- Citations in this document follow APA 7th edition. Each one resolves to an
43
- entry in [references.md](references.md), generated from
44
- [references.bib](references.bib); a test fails if a citation has no entry or an
45
- entry is never cited. Measured accuracy is in
46
- [bench/RESULTS.md](../bench/RESULTS.md), and the method behind those numbers is
47
- in [bench/README.md](../bench/README.md).
48
-
49
- ## Contents
50
-
51
- - [Threat model](#threat-model)
52
- - [Black-box and white-box: what each can find](#black-box-and-white-box-what-each-can-find)
53
- - [Confidence model](#confidence-model)
54
- - [False-positive controls](#false-positive-controls)
55
- - [Non-destructive by default](#non-destructive-by-default)
56
- - [Offline and no telemetry](#offline-and-no-telemetry)
57
- - [How new attack techniques are adopted](#how-new-attack-techniques-are-adopted)
58
- - [Citations in reports](#citations-in-reports)
59
- - [Known limitations](#known-limitations)
60
-
61
- ## Threat model
62
-
63
- Penthera is built for small teams that ship web apps and APIs, often with AI
64
- coding tools, and need a first line of evidence before a human review or a paid
65
- penetration test. The threat model follows the four questions of Shostack
66
- (2014): what are we building, what can go wrong, what are we going to do about
67
- it, and did we do a good job.
68
-
69
- **Assets.** User data behind the API; credentials and API keys, including AI
70
- provider keys; the session and identity layer; and the integrity of the code and
71
- dependencies that get deployed.
72
-
73
- **Adversaries in scope.**
74
-
75
- 1. A remote, unauthenticated attacker on the internet who can send arbitrary
76
- HTTP requests to the deployed app. The black-box scan models this attacker.
77
- 2. An authenticated user who tries to reach another user's data. Broken object
78
- level authorization is the first item in the OWASP API Security Top 10
79
- (OWASP Foundation, 2023). Penthera tests for it when given credentials.
80
- 3. Anyone who obtains a copy of the source: a leaked repository, a public fork,
81
- or a compromised CI log. Meli et al. (2019) found secrets leaked in over
82
- 100,000 public GitHub repositories, with thousands of new, unique secrets
83
- leaked every day. The white-box secret scan models this adversary.
84
- 4. A supply-chain attacker who publishes or compromises a dependency (Ladisa et
85
- al., 2023; Ohm et al., 2020; Zimmermann et al., 2019), including packages
86
- that code-generating models invent and attackers then register (Spracklen
87
- et al., 2025).
88
-
89
- **Out of scope.** Physical access, insiders with production access, denial of
90
- service, social engineering, and anything that needs exploitation beyond a
91
- single benign probe. Penthera does not try to replace a human tester. It tries
92
- to find the issues a scanner can find reliably, and to say clearly which ones
93
- it cannot judge.
94
-
95
- **Penthera itself is in the threat model.** A security tool runs with the
96
- user's credentials and network position, so a compromised scanner is a
97
- compromised machine. Thompson (1984) showed that you cannot fully trust code you
98
- did not build yourself; the argument applies to scanners and their rule feeds.
99
- This is why Penthera fetches no rules at run time
100
- ([below](#how-new-attack-techniques-are-adopted)).
101
-
102
- STRIDE, the threat taxonomy that Shostack's book builds on, originates at
103
- Microsoft (Kohnfelder & Garg, 1999). Penthera's probes map onto it as follows:
104
-
105
- | STRIDE category | Penthera coverage |
106
- |---|---|
107
- | Spoofing | Authentication, JWT, OAuth, email SPF and DMARC |
108
- | Tampering | Injection, CORS |
109
- | Repudiation | Not tested |
110
- | Information disclosure | Exposed files, secrets, verbose errors |
111
- | Denial of service | Not tested; only rate limiting is checked |
112
- | Elevation of privilege | IDOR/BOLA, client-side-only authorization |
113
-
114
- ## Black-box and white-box: what each can find
115
-
116
- Penthera runs two kinds of analysis and reports them together.
117
-
118
- ### Black-box (URL scan)
119
-
120
- The scanner sees only HTTP responses. Its tests are those of the OWASP Web
121
- Security Testing Guide (OWASP Foundation, n.d.-b); each probe's WSTG IDs are
122
- listed in [owasp-wstg-coverage.md](owasp-wstg-coverage.md).
123
-
124
- Black-box testing finds three kinds of issue well: properties visible in every
125
- response (missing security headers, TLS configuration, cookie flags, CORS
126
- policy), files and interfaces that should not be public, and injection flaws
127
- whose effect shows in the response or its timing. Protocol checks follow the
128
- relevant specifications: HSTS (Hodges et al., 2012), JWT (Jones et al., 2015;
129
- Sheffer et al., 2020), and OAuth redirect and PKCE handling (Fett et al., 2016;
130
- Lodderstedt et al., 2025; Sakimura et al., 2015).
131
-
132
- Two classic evaluations of black-box scanners show what this approach misses.
133
- Bau et al. (2010) tested eight commercial scanners against a custom vulnerable
134
- application and older versions of real ones. As a group the tools did
135
- reasonably well, but many of them missed second-order flaws such as stored XSS
136
- and stored SQL injection. Doupé et al. (2010) tested eleven commercial and
137
- open-source scanners against WackoPicko, a realistic purpose-built app with
138
- sixteen planted flaws. Every scanner missed eight of the sixteen, among them
139
- stored SQL injection, multi-step stored XSS, forceful browsing, and a business
140
- logic flaw. They concluded that crawling matters as much as detection: a
141
- scanner cannot test a page it never reaches, and losing track of state (logins,
142
- multi-step forms) is a main reason it does not reach it.
143
-
144
- Penthera's design responds to each point:
145
-
146
- - **Crawl coverage.** Endpoint discovery uses a wordlist plus the app's own
147
- OpenAPI document when one is exposed, and records exactly which probes ran.
148
- The WSTG coverage table in every report lists each test as run or not run, so
149
- a missing check is never presented as a pass.
150
- - **State and logic.** Penthera does not claim to find business logic flaws,
151
- which no scanner in the study by Doupé et al. (2010) found either. IDOR/BOLA
152
- results are labelled `needs-human-review` because a scanner cannot know
153
- whether user A is allowed to read object B.
154
- - **Stored and second-order flaws.** Not tested. They need a write followed by
155
- a read elsewhere, which conflicts with the non-destructive default.
156
-
157
- ### White-box (repository scan)
158
-
159
- The scanner reads source files. It finds hardcoded secrets, route handlers and
160
- their authentication boundaries, and risky code patterns.
161
-
162
- Secret detection uses provider-specific prefixes (for example `AKIA` for AWS
163
- and `sk-ant-` for Anthropic) plus a small number of assignment patterns such as
164
- `api_key = "..."` with a placeholder filter. Meli et al. (2019) took the same
165
- conservative route and restricted detection to private keys and API keys with
166
- distinctive formats. The cost is recall: a secret with no recognisable format
167
- is invisible to this approach. SecretBench, a labelled dataset of real secrets
168
- across many formats, is the natural yardstick for that gap (Basak et al.,
169
- 2023). The bundled benchmark includes an unprefixed signing key that Penthera
170
- misses for this reason.
171
-
172
- White-box scanning cannot see runtime configuration (headers added by a CDN, a
173
- WAF in front of the app), secrets injected at deploy time, or anything in
174
- dependencies that is only known from a vulnerability database.
175
-
176
- ### Combining them
177
-
178
- Running both against the same app catches cases neither sees alone: a route
179
- the repository marks as public that the black-box scan confirms is reachable,
180
- or a header the code sets that the edge strips. The report keeps the origin of
181
- each finding (`source`, `method`) so a reader can tell which side produced it.
182
-
183
- ## Confidence model
184
-
185
- Severity says how bad a finding would be if real. Confidence says how likely it
186
- is to be real. Penthera reports both and never lets a heuristic look like a
187
- fact. The labels are defined in [lib/provenance.js](../lib/provenance.js):
188
-
189
- | Label | Meaning | Typical sources |
190
- |---|---|---|
191
- | `confirmed` | Deterministic evidence. The header is absent, the certificate expired, the key matches a provider format. | Header audit, TLS check, cookie audit, secret scan, OpenAPI exposure |
192
- | `likely` | A strong signal with a small false-positive chance. | Injection probes with a verified canary, auth and JWT probes, framework checks |
193
- | `potential` | A heuristic or one-shot signal that needs corroboration. | Fuzzer, adaptive probes |
194
- | `needs-human-review` | Exploitability cannot be judged from outside. | IDOR/BOLA, client-side-only auth |
195
-
196
- Every finding also carries `source`, `method`, and `collected_at`. These travel
197
- into SARIF, so the label survives into GitHub code scanning.
198
-
199
- The benchmark reports precision per confidence label, which tests whether the
200
- labels mean what they say. On the current run, all `likely` and
201
- `needs-human-review` findings were true positives and the one `potential`
202
- finding was a false positive. The two `confirmed` false positives are cases
203
- where the evidence is real but the judgement is wrong: a health endpoint that
204
- is public by design, and AWS's published example key. See
205
- [bench/RESULTS.md](../bench/RESULTS.md).
206
-
207
- ## False-positive controls
208
-
209
- A black-box scanner loses trust through false positives faster than through
210
- misses. The probes apply these controls:
211
-
212
- - **Randomized canaries.** The SSTI probe multiplies two random four-digit
213
- numbers and looks for the seven- or eight-digit product, and only when
214
- neither the raw payload nor the literal expression appears in the response
215
- ([lib/injections.js](../lib/injections.js), `buildSstiProbes`). The common
216
- fixed canary `7*7 = 49` fires on any page that happens to contain "49". XSS
217
- probes use a random reflection marker per probe (`makeCanary`).
218
- - **Context before payload.** The XSS probe first checks where its marker is
219
- reflected (HTML body, attribute, or script), sends only payloads that would
220
- execute in that context, and then checks that they came back unencoded.
221
- Context-aware tools such as Dalfox take the same approach.
222
- - **Baseline comparison.** Error-based SQL injection is reported only when the
223
- database error appears in the injected response and not in the baseline.
224
- - **Timing re-sampling.** A single slow response proves nothing: garbage
225
- collection, cold starts, and network jitter all look like an injected sleep.
226
- Time-based SQL and command injection candidates are re-sent and reported only
227
- when the median delay holds (`confirmTimingDelay`). Crosby et al. (2009)
228
- showed that network jitter dominates single remote timing measurements and
229
- that filtering over many samples is what makes them usable, building on the
230
- original timing attacks of Kocher (1996). Penthera's injected delays are
231
- seconds, not microseconds, so a median of three samples is enough.
232
- - **Host-checked redirects.** An open redirect is reported only when the
233
- `Location` header resolves to the attacker host, not when the payload merely
234
- appears in the query string of a same-site redirect.
235
- - **Context-aware suppression.** On localhost and private hosts, findings that
236
- do not apply there (no HTTPS, missing `Secure` on cookies) are suppressed.
237
-
238
- The benchmark's decoy target exercises each control with an endpoint built to
239
- fool a naive scanner. Writing it exposed one real flaw in the decoy itself: a
240
- "safe" redirect that accepted `/\evil.com`, which browsers treat as
241
- `//evil.com`. Penthera reported it correctly.
242
-
243
- ## Non-destructive by default
244
-
245
- The `quick` and `standard` profiles send only requests a normal browser or API
246
- client would send: `GET`, `OPTIONS`, and invalid login attempts. They do not
247
- send attack payloads, create or modify data, or exploit anything they find.
248
-
249
- Payload-based testing (`--deep`, `--fuzz`, `--adaptive`, or `--profile deep`)
250
- is opt-in and gated behind a confirmation prompt. Even then, each probe sends a
251
- benign marker (a random string, an arithmetic expression, a short sleep, `id`)
252
- rather than a working exploit, and stops at the first confirmation per
253
- endpoint. The adaptive engine may send unexpected HTTP methods, including
254
- `DELETE`, to routes with placeholder IDs, so it is never part of a default run.
255
-
256
- This follows the principles of fail-safe defaults and least privilege set out by
257
- Saltzer and Schroeder (1975): the default does the least that is still useful,
258
- and anything riskier needs an explicit decision. NIST SP 800-115 draws the same
259
- line between non-intrusive assessment and penetration testing, and recommends
260
- written rules of engagement before the latter (Scarfone et al., 2008).
261
-
262
- ## Offline and no telemetry
263
-
264
- Penthera does not phone home. It sends no usage data, analytics, or crash
265
- reports, and it has no accounts. Reports are written to local files.
266
-
267
- Network traffic goes to the target you name, with two documented exceptions:
268
-
269
- 1. `--recon` (and the `deep` profile) queries public OSINT sources (crt.sh, the
270
- Wayback Machine, AlienVault OTX) with the target domain. It is skipped for
271
- localhost.
272
- 2. The Retire.js check downloads the public Retire.js vulnerability database
273
- from GitHub and compares JavaScript library versions locally. Outdated
274
- client-side libraries are common on the web and often carry known
275
- vulnerabilities (Lauinger et al., 2017). The database is data, not code: it
276
- is parsed as JSON and never executed. It is the one place a detection input
277
- arrives at run time. Under `--offline`, and whenever the download fails,
278
- Penthera uses the snapshot vendored in the release instead.
279
-
280
- The benchmark enforces offline operation mechanically. It wraps `fetch` so any
281
- request to a non-loopback host is refused and counted, and it fails if the
282
- count is not zero.
283
-
284
- These choices follow the principles of economy of mechanism and complete
285
- mediation (Saltzer & Schroeder, 1975): fewer network paths mean fewer things to
286
- audit, and a tool that talks only to its target is easy to reason about in a
287
- regulated environment.
288
-
289
- ## How new attack techniques are adopted
290
-
291
- Attack techniques change quickly. Recent examples include indirect prompt
292
- injection against LLM-integrated applications (Greshake et al., 2023; Liu et
293
- al., 2023; Perez & Ribeiro, 2022), package hallucination and slopsquatting
294
- (Spracklen et al., 2025), and attacks on agent tooling and MCP servers,
295
- catalogued by OWASP (OWASP Foundation, 2024, 2025a, 2025b) and in MITRE ATLAS
296
- (The MITRE Corporation, n.d.-b). A scanner that does not keep up gives a false
297
- sense of security.
298
-
299
- Penthera keeps up through reviewed releases, not live updates:
300
-
301
- - **Rules ship as code in a release.** New probes, patterns, and templates are
302
- added by pull request, reviewed, tested against the benchmark, and published
303
- in a versioned release with a CHANGELOG entry. There is no rule server, no
304
- background update, and no command that fetches rules from the internet.
305
- - **Every new rule carries a source and a date.** A rule added after v1.2.0
306
- records where the technique comes from (a paper, an advisory, an OWASP or
307
- ATLAS entry, an incident write-up) and the date it was added. Finding
308
- categories are already linked to their sources in
309
- [lib/references.json](../lib/references.json); the built-in rules that
310
- predate this policy are being backfilled.
311
- - **Every new rule comes with a benchmark case.** A detection rule is added
312
- together with a labelled true positive and, where a false positive is
313
- plausible, a decoy in [bench/](../bench/). The CI floor in the benchmark
314
- stops a rule that adds false positives.
315
- - **Your own templates stay local.** `--nuclei <path>` and `--templates <dirs>`
316
- load YAML templates from directories you choose. Penthera does not download
317
- them.
318
-
319
- Why not update automatically? A rule feed is a code path from someone else's
320
- server into a tool that runs with your network access and often your
321
- credentials. Supply-chain research shows that update channels and package
322
- registries are exactly where attackers insert themselves (Ladisa et al., 2023;
323
- Ohm et al., 2020; Zimmermann et al., 2019), and that securing an update channel
324
- needs signed metadata, key rotation, and explicit trust delegation (Samuel et
325
- al., 2010; Torres-Arias et al., 2019). Shipping rules inside the signed,
326
- versioned package reuses the trust you already place in the release, and gives
327
- a clear audit trail: the rules you ran are the rules in the version you
328
- installed.
329
-
330
- ## Citations in reports
331
-
332
- Each finding category maps to one to three references in
333
- [lib/references.json](../lib/references.json), chosen to explain why the
334
- finding matters. Besides the sources discussed above, these include the
335
- empirical work behind specific header and policy checks: Content Security
336
- Policy (Weichselbaum et al., 2016), cookie handling and cross-site request
337
- forgery (Barth et al., 2008), and CORS misconfiguration
338
- (Chen et al., 2018). Email authentication findings cite the SPF and DMARC
339
- specifications (Kitterman, 2014; Kucherawy & Zwicky, 2015). Injection,
340
- exposure, and configuration findings cite the OWASP Top 10 (OWASP Foundation,
341
- 2021, 2025c), code-pattern findings cite the Common Weakness Enumeration (The
342
- MITRE Corporation, n.d.-a), and license findings use the identifiers of the
343
- SPDX License List (SPDX Workgroup, The Linux Foundation, n.d.).
344
-
345
- The references appear in two places:
346
-
347
- - in the Markdown report, as a "Why this matters / Sources" line under each
348
- finding and a "Sources" line under each action-plan item;
349
- - in SARIF (Fanning & Golding, 2023), as the rule's `helpUri` (first source)
350
- and `help` text (all sources), and as `properties.references` (the BibTeX
351
- keys).
352
-
353
- [references.md](references.md) and the `sources` block of `references.json`
354
- are generated from [references.bib](references.bib) by
355
- `node scripts/build-references.mjs`, and a test fails if they drift.
356
-
357
- ## Known limitations
358
-
359
- - No authenticated crawling beyond the bearer token or cookie you pass, so
360
- pages behind multi-step flows are not reached (Doupé et al., 2010).
361
- - No stored or second-order injection testing (Bau et al., 2010).
362
- - No DOM-based XSS analysis, because Penthera does not run JavaScript (Lekies et
363
- al., 2013; Stock et al., 2014).
364
- - No business logic testing. IDOR/BOLA findings need a human to confirm them.
365
- - Secret detection is pattern-based and skips Markdown and other documentation
366
- files, so unprefixed secrets and secrets in notes are missed.
367
- - The bundled benchmark was written by the same people as the scanner. Its
368
- numbers measure regressions and the false-positive controls, not accuracy on
369
- arbitrary applications. Independent benchmarks such as the OWASP Benchmark
370
- (OWASP Foundation, n.d.-a) and the benchmarking approach of Antunes and Vieira
371
- (2010, 2015) are the model for a fairer comparison. Scoring against labelled
372
- third-party targets (Juice Shop, crAPI) is the next step.
373
- - Compliance output for the NIS2 Directive (European Parliament and Council of
374
- the European Union, 2022) is evidence toward a duty of care, not a
375
- certification.
@@ -1,24 +0,0 @@
1
- # Commands
2
-
3
- One page per command, with every flag that matters and an example. The
4
- [README](../../README.md) has the short version; `penthera <command> --help`
5
- has the full flag list.
6
-
7
- | Command | Page | What it does |
8
- |---|---|---|
9
- | `penthera`, `penthera init` | [init.md](init.md) | Set up in one command: detect, check, then hook, workflow and dashboard |
10
- | `penthera <url>`, `--repo .` | [scan.md](scan.md) | Scan a running app and its source: modes, profiles, outputs, exit codes, CI |
11
- | `penthera agents` | [agents.md](agents.md) | Audit skills, MCP servers, hooks, plugins and rule files |
12
- | `penthera deps` | [deps.md](deps.md) | What each package can do, the upgrade diff, typosquats, license policy |
13
- | `penthera install` | [install.md](install.md) | Check a new package before any install script runs |
14
- | `penthera fix`, `penthera agent --loop` | [fix.md](fix.md) | Deterministic fix recipes and the local scan, fix, verify loop |
15
- | `penthera hook` | [hook.md](hook.md) | Pre-commit hook for secrets, agent config and dependency changes |
16
- | GitHub Action | [action.md](action.md) | The capability diff as a pull-request comment, plus SARIF |
17
- | Agent Skill | [skill.md](skill.md) | Let Claude Code, Cursor and other agents drive Penthera |
18
- | `penthera ui` | [dashboard.md](../dashboard.md) | The local dashboard: one view with projects, findings, side cards, and the fix loop from the page |
19
-
20
- Compliance flags (`--framework nis2`, `--readiness`, `--cra`, `--aibom`, ...)
21
- are covered in [compliance-nis2.md](../compliance-nis2.md) and
22
- [compliance-cra.md](../compliance-cra.md). Reports and evidence bundles are in
23
- [evidence.md](../evidence.md). Network use and `--offline` are in
24
- [privacy.md](../privacy.md).