penthera 1.1.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 (74) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +561 -0
  4. package/SECURITY.md +31 -0
  5. package/bin/penthera.js +3 -0
  6. package/bin/scan.js +11 -0
  7. package/docs/index.md +60 -0
  8. package/docs/owasp-wstg-coverage.md +79 -0
  9. package/lib/adaptive-probes.js +334 -0
  10. package/lib/attack-surface.js +149 -0
  11. package/lib/audit-loop.js +170 -0
  12. package/lib/blackbox/adaptive-scan.js +137 -0
  13. package/lib/blackbox/client-auth.js +60 -0
  14. package/lib/blackbox/email-dns.js +135 -0
  15. package/lib/blackbox/headers.js +91 -0
  16. package/lib/blackbox/idor.js +113 -0
  17. package/lib/blackbox/jwt.js +73 -0
  18. package/lib/blackbox/oauth.js +50 -0
  19. package/lib/blackbox/openapi.js +227 -0
  20. package/lib/compliance/incident.js +169 -0
  21. package/lib/compliance/index.js +163 -0
  22. package/lib/compliance/policy-pack.js +293 -0
  23. package/lib/compliance/questionnaire.js +113 -0
  24. package/lib/compliance/readiness.js +229 -0
  25. package/lib/compliance/self-assessment.js +66 -0
  26. package/lib/crawler.js +257 -0
  27. package/lib/fuzzer.js +233 -0
  28. package/lib/injections.js +676 -0
  29. package/lib/jurisdictions/nl.json +48 -0
  30. package/lib/knowledge-graph.js +232 -0
  31. package/lib/machine.js +814 -0
  32. package/lib/nuclei-loader.js +283 -0
  33. package/lib/owasp-wstg.js +204 -0
  34. package/lib/params.js +235 -0
  35. package/lib/plugins.js +70 -0
  36. package/lib/provenance.js +82 -0
  37. package/lib/recon.js +193 -0
  38. package/lib/remediation/index.js +219 -0
  39. package/lib/retirejs.js +239 -0
  40. package/lib/supply-chain.js +131 -0
  41. package/lib/templates.js +364 -0
  42. package/lib/tls.js +145 -0
  43. package/lib/whitebox/frameworks.js +139 -0
  44. package/lib/whitebox/sbom.js +144 -0
  45. package/lib/whitebox/secrets.js +111 -0
  46. package/package.json +76 -0
  47. package/pentest.config.example.js +82 -0
  48. package/scripts/package-skill.sh +16 -0
  49. package/skills/penthera/SKILL.md +272 -0
  50. package/skills/penthera/references/authorization.md +36 -0
  51. package/skills/penthera/references/output-and-ci.md +109 -0
  52. package/skills/penthera/references/profiles-and-flags.md +74 -0
  53. package/skills/penthera/references/remediation.md +267 -0
  54. package/skills/penthera/references/reporting.md +113 -0
  55. package/skills/penthera/references/secure-defaults.md +41 -0
  56. package/skills/penthera/references/troubleshooting.md +100 -0
  57. package/skills/penthera/scripts/preflight.sh +66 -0
  58. package/skills/penthera/scripts/validate-report.mjs +53 -0
  59. package/skills/penthera/scripts/validate-report.sh +8 -0
  60. package/src/cli/ansi.js +70 -0
  61. package/src/cli/baseline.js +37 -0
  62. package/src/cli/merge-results.js +71 -0
  63. package/src/cli/onboarding.js +216 -0
  64. package/src/cli/profiles.js +53 -0
  65. package/src/cli/prompt.js +64 -0
  66. package/src/cli/run-scan.js +152 -0
  67. package/src/cli.js +536 -0
  68. package/src/report/markdown.js +182 -0
  69. package/src/reporter.js +512 -0
  70. package/src/scan-repo.js +205 -0
  71. package/src/scan-url.js +563 -0
  72. package/src/utils/auth.js +13 -0
  73. package/src/utils/http.js +38 -0
  74. package/src/utils/url.js +54 -0
@@ -0,0 +1,79 @@
1
+ # OWASP WSTG coverage map
2
+
3
+ Penthera maps its probes to the [OWASP Web Security Testing Guide (WSTG) v4.2](https://owasp.org/www-project-web-security-testing-guide/). This document is the canonical reference; scan reports include a summary table generated from `lib/owasp-wstg.js`.
4
+
5
+ ## Coverage by profile
6
+
7
+ | Profile | Penthera probes | WSTG areas | Destructive payloads |
8
+ |---------|-----------------|------------|----------------------|
9
+ | **quick** | Headers, OpenAPI, auth smoke | INFO, CONF, ATHN | No |
10
+ | **standard** | Full non-destructive black-box + repo | INFO, CONF, CRYP, ATHN, ATHZ, SESS, CLNT | No |
11
+ | **deep** | standard + recon + injections + fuzz | All above + INPV, BUSL | Yes |
12
+
13
+ ## Probe → WSTG mapping
14
+
15
+ | Penthera probe | WSTG IDs | Profiles | Mode |
16
+ |----------------|----------|----------|------|
17
+ | Reachability & fingerprint | WSTG-INFO-02, WSTG-INFO-04 | quick, standard, deep | URL |
18
+ | Endpoint discovery | WSTG-INFO-06, WSTG-INFO-07 | standard, deep | URL |
19
+ | TLS / certificate audit | WSTG-CRYP-01, WSTG-CRYP-02 | standard, deep | URL |
20
+ | Security headers | WSTG-CONF-12, WSTG-CONF-13, WSTG-CONF-14 | quick, standard, deep | URL |
21
+ | OpenAPI / Swagger exposure | WSTG-INFO-09, WSTG-CONF-02 | quick, standard, deep | URL |
22
+ | Sensitive file templates | WSTG-CONF-02, WSTG-CONF-04 | standard, deep | URL |
23
+ | CORS validation | WSTG-CLNT-07 | standard, deep | URL |
24
+ | Cookie security | WSTG-SESS-02 | standard, deep | URL |
25
+ | Auth endpoint hardening | WSTG-ATHN-03, WSTG-ATHN-10 | quick, standard, deep | URL |
26
+ | Client-side-only auth | WSTG-ATHZ-02, WSTG-CLNT-01 | standard, deep | URL |
27
+ | JWT probes | WSTG-ATHN-04, WSTG-SESS-10 | standard, deep | URL |
28
+ | IDOR / BOLA | WSTG-ATHZ-04, WSTG-ATHZ-05 | standard, deep | URL |
29
+ | OAuth open redirect | WSTG-ATHN-11, WSTG-CLNT-04 | standard, deep | URL |
30
+ | Retire.js (JS CVEs) | WSTG-CONF-04, WSTG-CLNT-02 | standard, deep | URL |
31
+ | Parameter discovery | WSTG-INFO-06 | standard, deep | URL |
32
+ | OSINT recon | WSTG-INFO-01, WSTG-INFO-07 | deep | URL |
33
+ | SQL injection | WSTG-INPV-05 | deep | URL |
34
+ | SSTI | WSTG-INPV-18 | deep | URL |
35
+ | SSRF | WSTG-INPV-19 | deep | URL |
36
+ | Reflected XSS | WSTG-INPV-01, WSTG-CLNT-01 | deep | URL |
37
+ | Command injection | WSTG-INPV-12 | deep | URL |
38
+ | Open redirect (injection suite) | WSTG-CLNT-04 | deep | URL |
39
+ | API fuzzing | WSTG-INPV-11, WSTG-BUSL-05 | deep | URL |
40
+ | Secret scanning | WSTG-CONF-02, WSTG-CONF-05 | quick, standard, deep | Repo |
41
+ | Route / trust-boundary analysis | WSTG-ATHZ-02, WSTG-CONF-02 | quick, standard, deep | Repo |
42
+ | Risky code patterns | WSTG-INPV-01, WSTG-INPV-12 | standard, deep | Repo |
43
+
44
+ ## WSTG categories not covered (yet)
45
+
46
+ Penthera does **not** currently automate these WSTG areas:
47
+
48
+ | Category | Examples | Planned |
49
+ |----------|----------|---------|
50
+ | WSTG-IDNT | Registration, username policy | v0.4 |
51
+ | WSTG-ERRH | Verbose stack traces in all contexts | Partial (template scans) |
52
+ | WSTG-BUSL | Full business-logic workflows | Partial (fuzz only) |
53
+ | WSTG-APIT | GraphQL-specific tests | v0.4 |
54
+ | Manual / social | Phishing, physical access | Out of scope |
55
+
56
+ ## Finding category → WSTG
57
+
58
+ When a finding is reported, Penthera tags it with relevant WSTG IDs in JSON (`finding.wstg[]`), SARIF (`properties.tags`), and Markdown reports.
59
+
60
+ | Finding category | WSTG IDs |
61
+ |------------------|----------|
62
+ | `tls` | WSTG-CRYP-01, WSTG-CRYP-02 |
63
+ | `exposure` | WSTG-CONF-02, WSTG-CONF-04 |
64
+ | `cors` | WSTG-CLNT-07 |
65
+ | `cookie` | WSTG-SESS-02 |
66
+ | `auth` | WSTG-ATHN-03, WSTG-ATHN-04 |
67
+ | `secrets` | WSTG-CONF-02, WSTG-CONF-05 |
68
+ | `sqli` | WSTG-INPV-05 |
69
+ | `xss` | WSTG-INPV-01, WSTG-CLNT-01 |
70
+ | `ssrf` | WSTG-INPV-19 |
71
+ | `cmdi` | WSTG-INPV-12 |
72
+ | `open-redirect` | WSTG-CLNT-04, WSTG-ATHN-11 |
73
+ | `rate-limiting` | WSTG-ATHN-10 |
74
+
75
+ ## References
76
+
77
+ - [OWASP WSTG on GitHub](https://github.com/OWASP/wstg)
78
+ - Penthera implementation: `lib/owasp-wstg.js`
79
+ - Report integration: `src/report/markdown.js`, `src/reporter.js`
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Adaptive Probe Engine (Portable)
3
+ *
4
+ * Multi-step probes that escalate based on results:
5
+ * Observe → Hypothesize → Test → Analyze → Repeat
6
+ *
7
+ * Each probe chain starts with a hypothesis and adapts.
8
+ * Results feed into the SecurityKnowledgeGraph for chain discovery.
9
+ */
10
+
11
+ const DEFAULT_TIMEOUT = 10_000;
12
+
13
+ async function probe(base, path, opts = {}) {
14
+ const url = `${base}${path}`;
15
+ const controller = new AbortController();
16
+ const timeout = setTimeout(() => controller.abort(), opts.timeout || DEFAULT_TIMEOUT);
17
+ const start = Date.now();
18
+ try {
19
+ const res = await fetch(url, { ...opts, signal: controller.signal });
20
+ const elapsed = Date.now() - start;
21
+ let body = null;
22
+ const ct = res.headers.get("content-type") || "";
23
+ if (ct.includes("json")) {
24
+ try { body = await res.json(); } catch { body = null; }
25
+ } else {
26
+ try { body = await res.text(); } catch { body = null; }
27
+ }
28
+ return {
29
+ url: path, status: res.status,
30
+ headers: Object.fromEntries(res.headers.entries()),
31
+ body, elapsed, ok: res.ok,
32
+ };
33
+ } catch (err) {
34
+ return {
35
+ url: path, status: 0, headers: {}, body: null,
36
+ elapsed: Date.now() - start, ok: false,
37
+ error: err.name === "AbortError" ? "timeout" : err.message,
38
+ };
39
+ } finally {
40
+ clearTimeout(timeout);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Chain 1: Auth Escalation, tries increasingly privileged access
46
+ */
47
+ export async function probeAuthEscalation(base, route, graph) {
48
+ const findings = [];
49
+ const url = route.url.replace(/:(\w+)/g, "test-$1");
50
+
51
+ // Step 1: No auth
52
+ const noAuth = await probe(base, url, {
53
+ method: route.methods[0] === "GET" ? "GET" : "POST",
54
+ ...(route.methods[0] !== "GET" ? {
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({}),
57
+ } : {}),
58
+ });
59
+ graph.addProbeResult({ probe: "auth-escalation", step: "no-auth", ...noAuth });
60
+
61
+ if (noAuth.status === 200 && !route.auth.includes("public")) {
62
+ findings.push(graph.addFinding({
63
+ severity: "critical", category: "auth-bypass",
64
+ title: `Unauthenticated access to ${url}`,
65
+ detail: `Expected 401 for ${route.auth.join("+")} route, got 200`,
66
+ routeUrl: route.url, probeData: noAuth,
67
+ }));
68
+ return findings;
69
+ }
70
+
71
+ // Step 2: Fake Bearer token
72
+ const fakeAuth = await probe(base, url, {
73
+ method: route.methods[0] === "GET" ? "GET" : "POST",
74
+ headers: { "Content-Type": "application/json", Authorization: "Bearer fake-token-AAAA" },
75
+ ...(route.methods[0] !== "GET" ? { body: JSON.stringify({}) } : {}),
76
+ });
77
+ graph.addProbeResult({ probe: "auth-escalation", step: "fake-bearer", ...fakeAuth });
78
+
79
+ if (fakeAuth.status === 200 && !route.auth.includes("public")) {
80
+ findings.push(graph.addFinding({
81
+ severity: "critical", category: "auth-bypass",
82
+ title: `Fake Bearer token accepted on ${url}`,
83
+ detail: `Route accepted an arbitrary Bearer token`,
84
+ routeUrl: route.url, probeData: fakeAuth,
85
+ }));
86
+ }
87
+
88
+ // Step 3: CVE-2025-29927 middleware bypass (admin routes)
89
+ if (route.auth.includes("admin")) {
90
+ const cveBypass = await probe(base, url, {
91
+ method: route.methods[0] === "GET" ? "GET" : "POST",
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ "x-middleware-subrequest": "middleware:middleware:middleware:middleware:middleware",
95
+ },
96
+ ...(route.methods[0] !== "GET" ? { body: JSON.stringify({}) } : {}),
97
+ });
98
+ graph.addProbeResult({ probe: "auth-escalation", step: "cve-2025-29927", ...cveBypass });
99
+
100
+ if (cveBypass.status === 200) {
101
+ findings.push(graph.addFinding({
102
+ severity: "critical", category: "auth-bypass",
103
+ title: `CVE-2025-29927 middleware bypass on ${url}`,
104
+ detail: `x-middleware-subrequest header bypassed auth`,
105
+ routeUrl: route.url, probeData: cveBypass,
106
+ }));
107
+ }
108
+ }
109
+
110
+ // Step 4: Common secret guesses (cron routes)
111
+ if (route.auth.includes("cron")) {
112
+ for (const guess of ["secret", "password", "cron", "Bearer cron", ""]) {
113
+ const secretProbe = await probe(base, url, {
114
+ method: "GET", headers: { Authorization: `Bearer ${guess}` },
115
+ });
116
+ if (secretProbe.status === 200) {
117
+ findings.push(graph.addFinding({
118
+ severity: "high", category: "auth-bypass",
119
+ title: `Weak CRON_SECRET on ${url}`,
120
+ detail: `Guessed secret: "${guess}"`,
121
+ routeUrl: route.url, probeData: secretProbe,
122
+ }));
123
+ break;
124
+ }
125
+ }
126
+ }
127
+
128
+ return findings;
129
+ }
130
+
131
+ /**
132
+ * Chain 2: HTTP Method Confusion
133
+ */
134
+ export async function probeMethodConfusion(base, route, graph) {
135
+ const findings = [];
136
+ const url = route.url.replace(/:(\w+)/g, "test-$1");
137
+ const unexpected = ["GET", "POST", "PUT", "DELETE", "PATCH"].filter(m => !route.methods.includes(m));
138
+
139
+ for (const method of unexpected) {
140
+ const res = await probe(base, url, {
141
+ method,
142
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" },
143
+ ...(["POST", "PUT", "PATCH"].includes(method) ? { body: JSON.stringify({}) } : {}),
144
+ });
145
+ graph.addProbeResult({ probe: "method-confusion", method, ...res });
146
+
147
+ if (res.status === 200) {
148
+ findings.push(graph.addFinding({
149
+ severity: "low", category: "method-confusion",
150
+ title: `Unexpected ${method} accepted on ${url}`,
151
+ detail: `Route exports ${route.methods.join(",")} but accepted ${method}`,
152
+ routeUrl: route.url, probeData: res,
153
+ }));
154
+ }
155
+ }
156
+ return findings;
157
+ }
158
+
159
+ /**
160
+ * Chain 3: Prototype Pollution
161
+ */
162
+ export async function probePrototypePollution(base, route, graph) {
163
+ const findings = [];
164
+ if (!route.methods.includes("POST")) return findings;
165
+ const url = route.url.replace(/:(\w+)/g, "test-$1");
166
+
167
+ for (const payload of [
168
+ { "__proto__": { "isAdmin": true } },
169
+ { "constructor": { "prototype": { "isAdmin": true } } },
170
+ { "__proto__": { "role": "admin" } },
171
+ ]) {
172
+ const res = await probe(base, url, {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" },
175
+ body: JSON.stringify(payload),
176
+ });
177
+ graph.addProbeResult({ probe: "prototype-pollution", payload: Object.keys(payload)[0], ...res });
178
+
179
+ if (res.status === 200 && res.body?.isAdmin) {
180
+ findings.push(graph.addFinding({
181
+ severity: "critical", category: "injection",
182
+ title: `Prototype pollution on ${url}`,
183
+ detail: `__proto__ payload resulted in isAdmin:true`,
184
+ routeUrl: route.url, probeData: res,
185
+ }));
186
+ }
187
+ }
188
+ return findings;
189
+ }
190
+
191
+ /**
192
+ * Chain 4: Information Leakage
193
+ */
194
+ export async function probeInfoLeakage(base, route, graph) {
195
+ const findings = [];
196
+ const url = route.url.replace(/:(\w+)/g, "test-$1");
197
+
198
+ const triggers = [
199
+ { body: "not-json", contentType: "application/json" },
200
+ { body: JSON.stringify({ __invalid: true }), contentType: "application/json" },
201
+ { body: "", contentType: "application/json" },
202
+ ];
203
+
204
+ for (const trigger of triggers) {
205
+ const res = await probe(base, url, {
206
+ method: route.methods.includes("POST") ? "POST" : "GET",
207
+ headers: { "Content-Type": trigger.contentType, Authorization: "Bearer trigger-error-token" },
208
+ ...(route.methods.includes("POST") ? { body: trigger.body } : {}),
209
+ });
210
+
211
+ if (res.status >= 400 && typeof res.body === "string") {
212
+ for (const { pattern, name } of [
213
+ { pattern: /at\s+\w+\s+\(.*\.js:\d+:\d+\)/, name: "stack-trace" },
214
+ { pattern: /node_modules/, name: "dependency-path" },
215
+ { pattern: /\/Users\/|\/home\/|\/var\//, name: "filesystem-path" },
216
+ { pattern: /FIREBASE_|GOOGLE_|STRIPE_|OPENAI_|DATABASE_/, name: "env-var-name" },
217
+ { pattern: /192\.168\.|10\.\d+\.|172\.(?:1[6-9]|2\d|3[01])\./, name: "internal-ip" },
218
+ ]) {
219
+ if (pattern.test(res.body)) {
220
+ findings.push(graph.addFinding({
221
+ severity: name === "stack-trace" || name === "env-var-name" ? "medium" : "low",
222
+ category: "info-leak",
223
+ title: `${name} leaked on ${url}`,
224
+ detail: `Error response contains ${name} pattern`,
225
+ routeUrl: route.url, probeData: { status: res.status },
226
+ }));
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ return findings;
233
+ }
234
+
235
+ /**
236
+ * Chain 5: CORS Probe
237
+ */
238
+ export async function probeCors(base, route, graph) {
239
+ const findings = [];
240
+ const url = route.url.replace(/:(\w+)/g, "test-$1");
241
+
242
+ for (const origin of [
243
+ "https://evil-attacker.com", "null",
244
+ "https://your-app.evil.com", "https://your-app-typo.com",
245
+ ]) {
246
+ const res = await probe(base, url, {
247
+ method: "OPTIONS",
248
+ headers: { Origin: origin, "Access-Control-Request-Method": "POST" },
249
+ });
250
+ const acao = res.headers["access-control-allow-origin"];
251
+ if (acao === origin) {
252
+ findings.push(graph.addFinding({
253
+ severity: origin === "null" ? "high" : "medium",
254
+ category: "cors-misconfiguration",
255
+ title: `CORS reflects evil origin on ${url}`,
256
+ detail: `Origin "${origin}" reflected`,
257
+ routeUrl: route.url, probeData: { origin, reflected: acao },
258
+ }));
259
+ }
260
+ if (acao === "*") {
261
+ findings.push(graph.addFinding({
262
+ severity: "medium", category: "cors-misconfiguration",
263
+ title: `CORS wildcard on ${url}`,
264
+ detail: `Access-Control-Allow-Origin: *`,
265
+ routeUrl: route.url, probeData: { origin, reflected: acao },
266
+ }));
267
+ }
268
+ }
269
+ return findings;
270
+ }
271
+
272
+ /**
273
+ * Chain 6: Content-Type Confusion
274
+ */
275
+ export async function probeContentTypeConfusion(base, route, graph) {
276
+ const findings = [];
277
+ if (!route.methods.includes("POST")) return findings;
278
+ const url = route.url.replace(/:(\w+)/g, "test-$1");
279
+
280
+ for (const { ct, body } of [
281
+ { ct: "application/xml", body: '<?xml version="1.0"?><root><evil>true</evil></root>' },
282
+ { ct: "application/x-www-form-urlencoded", body: "email=test@test.com&admin=true" },
283
+ ]) {
284
+ const res = await probe(base, url, {
285
+ method: "POST",
286
+ headers: { "Content-Type": ct, Authorization: "Bearer test-token" },
287
+ body,
288
+ });
289
+ graph.addProbeResult({ probe: "content-type-confusion", contentType: ct, ...res });
290
+
291
+ if (res.status === 200) {
292
+ findings.push(graph.addFinding({
293
+ severity: "low", category: "content-type-confusion",
294
+ title: `Unexpected Content-Type accepted on ${url}`,
295
+ detail: `Sent ${ct} but got 200`,
296
+ routeUrl: route.url, probeData: res,
297
+ }));
298
+ }
299
+ }
300
+ return findings;
301
+ }
302
+
303
+ /**
304
+ * Chain 7: Timing Analysis
305
+ */
306
+ export async function probeTimingAnalysis(base, route, graph) {
307
+ const findings = [];
308
+ const url = route.url.replace(/:(\w+)/g, "test-$1");
309
+
310
+ const timings = [];
311
+ for (let i = 0; i < 5; i++) {
312
+ const res = await probe(base, url, {
313
+ method: route.methods[0] === "GET" ? "GET" : "POST",
314
+ headers: { "Content-Type": "application/json", Authorization: "Bearer timing-test-token" },
315
+ ...(route.methods[0] !== "GET" ? { body: JSON.stringify({}) } : {}),
316
+ });
317
+ timings.push(res.elapsed);
318
+ }
319
+
320
+ const avg = timings.reduce((a, b) => a + b, 0) / timings.length;
321
+ const variance = Math.sqrt(timings.reduce((sum, t) => sum + (t - avg) ** 2, 0) / timings.length);
322
+
323
+ graph.addProbeResult({ probe: "timing-analysis", url: route.url, avgMs: Math.round(avg), varianceMs: Math.round(variance) });
324
+
325
+ if (variance > 100 && route.auth.includes("cron")) {
326
+ findings.push(graph.addFinding({
327
+ severity: "info", category: "timing",
328
+ title: `High timing variance on ${url}`,
329
+ detail: `avg=${Math.round(avg)}ms variance=${Math.round(variance)}ms`,
330
+ routeUrl: route.url,
331
+ }));
332
+ }
333
+ return findings;
334
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Attack Surface Discovery (Portable)
3
+ *
4
+ * Auto-discovers API routes from the filesystem for Next.js App Router projects.
5
+ * Classifies auth patterns, input types, data flows, and security patterns.
6
+ *
7
+ * Set API_ROOT_PATH in pentest.config.js to your app/api directory.
8
+ */
9
+ import { readdirSync, readFileSync, statSync, existsSync } from "fs";
10
+ import { join, relative } from "path";
11
+
12
+ /** Recursively find all route.js/ts files under a directory */
13
+ function findRouteFiles(dir, files = []) {
14
+ if (!existsSync(dir)) return files;
15
+ for (const entry of readdirSync(dir)) {
16
+ const full = join(dir, entry);
17
+ if (statSync(full).isDirectory()) {
18
+ findRouteFiles(full, files);
19
+ } else if (entry === "route.js" || entry === "route.ts") {
20
+ files.push(full);
21
+ }
22
+ }
23
+ return files;
24
+ }
25
+
26
+ /** Convert filesystem path to URL path */
27
+ function pathToUrl(filePath, apiRoot) {
28
+ const rel = relative(apiRoot, filePath).replace(/\/route\.(js|ts)$/, "");
29
+ return "/api/" + rel.replace(/\[([^\]]+)\]/g, ":$1");
30
+ }
31
+
32
+ /** Extract exported HTTP methods from source */
33
+ function extractMethods(source) {
34
+ const methods = [];
35
+ if (/export\s+(async\s+)?function\s+GET/m.test(source)) methods.push("GET");
36
+ if (/export\s+(async\s+)?function\s+POST/m.test(source)) methods.push("POST");
37
+ if (/export\s+(async\s+)?function\s+PUT/m.test(source)) methods.push("PUT");
38
+ if (/export\s+(async\s+)?function\s+DELETE/m.test(source)) methods.push("DELETE");
39
+ if (/export\s+(async\s+)?function\s+PATCH/m.test(source)) methods.push("PATCH");
40
+ if (/export\s+(async\s+)?function\s+OPTIONS/m.test(source)) methods.push("OPTIONS");
41
+ return methods;
42
+ }
43
+
44
+ /** Classify the auth mechanism from source code patterns */
45
+ function classifyAuth(source) {
46
+ const auth = [];
47
+ // Common auth function names, extend for your framework
48
+ if (/verifyAdmin|isAdmin|requireAdmin|adminAuth/.test(source)) auth.push("admin");
49
+ if (/verifyUser|requireAuth|getSession|getServerSession|verifyIdToken|getAdminAuth/.test(source)) {
50
+ if (!auth.includes("admin")) auth.push("user");
51
+ }
52
+ if (/CRON_SECRET|cronSecret|verifyCron/.test(source)) auth.push("cron");
53
+ if (/throttle|rateLimit|rateLimiter/.test(source)) auth.push("rate-limited");
54
+ if (/NODE_ENV\s*!==\s*["']development["']|NODE_ENV\s*===\s*["']development["']/.test(source)) {
55
+ auth.push("dev-only");
56
+ }
57
+ if (auth.length === 0) auth.push("public");
58
+ return auth;
59
+ }
60
+
61
+ /** Detect user-controlled input fields */
62
+ function detectInputs(source) {
63
+ const inputs = [];
64
+ const bodyMatch = source.match(/(?:const|let)\s*\{([^}]+)\}\s*=\s*await\s+(?:req|request)\.json/);
65
+ if (bodyMatch) {
66
+ inputs.push(...bodyMatch[1].split(",").map(s => s.trim().split("=")[0].trim()).filter(Boolean));
67
+ }
68
+ const paramMatches = source.matchAll(/params\.(\w+)/g);
69
+ for (const m of paramMatches) inputs.push(`param:${m[1]}`);
70
+ const queryMatches = source.matchAll(/searchParams\.get\(["'](\w+)["']\)/g);
71
+ for (const m of queryMatches) inputs.push(`query:${m[1]}`);
72
+ return [...new Set(inputs)];
73
+ }
74
+
75
+ /** Detect external services called */
76
+ function detectExternalCalls(source) {
77
+ const calls = [];
78
+ if (/replicate\.com|REPLICATE_API_TOKEN/.test(source)) calls.push("replicate");
79
+ if (/stripe\.|STRIPE_/.test(source)) calls.push("stripe");
80
+ if (/GoogleGenAI|GEMINI_API_KEY|gemini|openai|OPENAI_/.test(source)) calls.push("ai-api");
81
+ if (/pinterest\.com|Pinterest/.test(source)) calls.push("pinterest");
82
+ if (/Resend|RESEND_API_KEY|sendgrid|SENDGRID/.test(source)) calls.push("email-service");
83
+ if (/firebaseAdmin|getAdminFirestore|prisma|mongoose/.test(source)) calls.push("database");
84
+ if (/getAdminAuth/.test(source)) calls.push("auth-service");
85
+ return calls;
86
+ }
87
+
88
+ /** Detect security-relevant patterns */
89
+ function detectSecurityPatterns(source) {
90
+ const patterns = [];
91
+ if (/escapeHtml|sanitize|DOMPurify/.test(source)) patterns.push("html-escaped");
92
+ if (/timingSafeEqual|safeCompare/.test(source)) patterns.push("timing-safe");
93
+ if (/runTransaction|BEGIN.*COMMIT/.test(source)) patterns.push("transactional");
94
+ if (/isAllowedImageUrl|urlAllowlist/.test(source)) patterns.push("url-allowlist");
95
+ if (/err\.message|error\.message/.test(source)) patterns.push("RISK:error-leak");
96
+ if (/origin\s*\|\|\s*"\*"/.test(source)) patterns.push("RISK:cors-wildcard");
97
+ if (/JSON\.parse/.test(source) && !/\.catch/.test(source)) patterns.push("RISK:json-parse-unguarded");
98
+ if (/eval\s*\(/.test(source)) patterns.push("RISK:eval");
99
+ if (/innerHTML/.test(source)) patterns.push("RISK:innerHTML");
100
+ if (/exec\s*\(|spawn\s*\(|execSync/.test(source)) patterns.push("RISK:command-injection");
101
+ return patterns;
102
+ }
103
+
104
+ /**
105
+ * Discover the full attack surface from the filesystem.
106
+ * @param {string} apiRoot - Path to app/api directory
107
+ */
108
+ export function discoverAttackSurface(apiRoot) {
109
+ if (!apiRoot || !existsSync(apiRoot)) {
110
+ console.warn(`[PENTEST] API_ROOT_PATH not found: ${apiRoot}, skipping filesystem discovery`);
111
+ return [];
112
+ }
113
+ const routeFiles = findRouteFiles(apiRoot);
114
+ return routeFiles.map((filePath) => {
115
+ const source = readFileSync(filePath, "utf-8");
116
+ const url = pathToUrl(filePath, apiRoot);
117
+ return {
118
+ url,
119
+ filePath: relative(join(apiRoot, "../.."), filePath),
120
+ methods: extractMethods(source),
121
+ auth: classifyAuth(source),
122
+ inputs: detectInputs(source),
123
+ externalCalls: detectExternalCalls(source),
124
+ securityPatterns: detectSecurityPatterns(source),
125
+ hasParams: url.includes(":"),
126
+ sourceLength: source.length,
127
+ };
128
+ }).sort((a, b) => a.url.localeCompare(b.url));
129
+ }
130
+
131
+ /**
132
+ * Group routes by trust boundary.
133
+ */
134
+ export function groupByTrustBoundary(routes) {
135
+ return {
136
+ public: routes.filter(r => r.auth.includes("public")),
137
+ userAuth: routes.filter(r => r.auth.includes("user") && !r.auth.includes("admin")),
138
+ adminAuth: routes.filter(r => r.auth.includes("admin")),
139
+ cronAuth: routes.filter(r => r.auth.includes("cron") && !r.auth.includes("admin")),
140
+ mixed: routes.filter(r => r.auth.length > 1 && !r.auth.includes("public")),
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Find routes with security risk patterns.
146
+ */
147
+ export function findRiskyRoutes(routes) {
148
+ return routes.filter(r => r.securityPatterns.some(p => p.startsWith("RISK:")));
149
+ }