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,364 @@
1
+ /**
2
+ * YAML Template Engine (inspired by Nuclei)
3
+ *
4
+ * Run declarative vulnerability checks defined in YAML files.
5
+ * Compatible with a subset of Nuclei's template format.
6
+ */
7
+ import { joinUrl } from "../src/utils/url.js";
8
+
9
+ /**
10
+ * Execute a single template check against a target.
11
+ *
12
+ * @param {string} baseUrl - Target URL
13
+ * @param {object} template - Parsed YAML template object
14
+ * @returns {Array<object>} Findings
15
+ */
16
+ export async function executeTemplate(baseUrl, template) {
17
+ const findings = [];
18
+
19
+ for (const req of template.http || []) {
20
+ const url = joinUrl(baseUrl, req.path.replace("{{BaseURL}}", ""));
21
+ const controller = new AbortController();
22
+ const timeout = setTimeout(() => controller.abort(), 10_000);
23
+
24
+ try {
25
+ const fetchOpts = {
26
+ method: req.method || "GET",
27
+ headers: req.headers || {},
28
+ signal: controller.signal,
29
+ redirect: "manual",
30
+ };
31
+ if (req.body && ["POST", "PUT", "PATCH"].includes(fetchOpts.method)) {
32
+ fetchOpts.body = req.body;
33
+ if (!fetchOpts.headers["Content-Type"]) {
34
+ fetchOpts.headers["Content-Type"] = "application/json";
35
+ }
36
+ }
37
+
38
+ const res = await fetch(url, fetchOpts);
39
+ const body = await res.text().catch(() => "");
40
+ const headerString = [...res.headers.entries()]
41
+ .map(([k, v]) => `${k}: ${v}`).join("\n");
42
+
43
+ // Run matchers
44
+ const matchResults = (req.matchers || []).map(matcher =>
45
+ runMatcher(matcher, { status: res.status, body, headers: headerString, res }),
46
+ );
47
+
48
+ // Evaluate matchers-condition (default: OR for single matcher, AND for multiple)
49
+ const condition = req["matchers-condition"] || (matchResults.length > 1 ? "and" : "or");
50
+ const matched = condition === "and"
51
+ ? matchResults.every(Boolean)
52
+ : matchResults.some(Boolean);
53
+
54
+ if (matched && matchResults.length > 0) {
55
+ // Run extractors
56
+ const extracted = {};
57
+ for (const extractor of req.extractors || []) {
58
+ const result = runExtractor(extractor, { body, headers: headerString });
59
+ if (result) extracted[extractor.name || "extracted"] = result;
60
+ }
61
+
62
+ findings.push({
63
+ templateId: template.id,
64
+ name: template.info?.name || template.id,
65
+ severity: template.info?.severity || "info",
66
+ tags: template.info?.tags || [],
67
+ description: template.info?.description || "",
68
+ matchedUrl: url,
69
+ status: res.status,
70
+ extracted,
71
+ });
72
+ }
73
+ } catch (err) {
74
+ if (err.name !== "AbortError") {
75
+ // Network error, skip
76
+ }
77
+ } finally {
78
+ clearTimeout(timeout);
79
+ }
80
+ }
81
+
82
+ return findings;
83
+ }
84
+
85
+ /**
86
+ * Run a single matcher against response data.
87
+ */
88
+ function runMatcher(matcher, ctx) {
89
+ const part = matcher.part || "body";
90
+ const target = part === "header" ? ctx.headers : ctx.body;
91
+
92
+ switch (matcher.type) {
93
+ case "status":
94
+ return (matcher.status || []).includes(ctx.status);
95
+
96
+ case "word": {
97
+ const words = matcher.words || [];
98
+ const check = (w) => target.includes(w);
99
+ return matcher.condition === "and"
100
+ ? words.every(check)
101
+ : words.some(check);
102
+ }
103
+
104
+ case "regex": {
105
+ const patterns = matcher.regex || [];
106
+ const check = (p) => new RegExp(p, matcher.flags || "").test(target);
107
+ return matcher.condition === "and"
108
+ ? patterns.every(check)
109
+ : patterns.some(check);
110
+ }
111
+
112
+ case "negative-word": {
113
+ const words = matcher.words || [];
114
+ return words.every(w => !target.includes(w));
115
+ }
116
+
117
+ case "negative-regex": {
118
+ const patterns = matcher.regex || [];
119
+ return patterns.every(p => !new RegExp(p).test(target));
120
+ }
121
+
122
+ default:
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Run a single extractor against response data.
129
+ */
130
+ function runExtractor(extractor, ctx) {
131
+ const part = extractor.part || "body";
132
+ const target = part === "header" ? ctx.headers : ctx.body;
133
+
134
+ switch (extractor.type) {
135
+ case "regex": {
136
+ for (const pattern of extractor.regex || []) {
137
+ const match = target.match(new RegExp(pattern));
138
+ if (match) return match[extractor.group || 0];
139
+ }
140
+ return null;
141
+ }
142
+
143
+ case "json": {
144
+ try {
145
+ const data = JSON.parse(target);
146
+ for (const path of extractor.json || []) {
147
+ const value = path.split(".").reduce((obj, key) => obj?.[key], data);
148
+ if (value !== undefined) return value;
149
+ }
150
+ } catch { /* not JSON */ }
151
+ return null;
152
+ }
153
+
154
+ default:
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Built-in security templates (no YAML parser needed).
161
+ * These cover the most common web app vulnerabilities.
162
+ * Inspired by Nuclei's community templates.
163
+ */
164
+ export const BUILT_IN_TEMPLATES = [
165
+ // ── Exposed sensitive files ──────────────────────────────────────────
166
+ {
167
+ id: "env-file-exposure",
168
+ info: { name: ".env file exposed", severity: "critical", tags: ["exposure", "config"] },
169
+ http: [{
170
+ method: "GET", path: "/.env",
171
+ matchers: [
172
+ { type: "status", status: [200] },
173
+ { type: "word", words: ["DB_", "API_KEY", "SECRET", "PASSWORD", "FIREBASE_", "STRIPE_"], condition: "or" },
174
+ ],
175
+ "matchers-condition": "and",
176
+ }],
177
+ },
178
+ {
179
+ id: "env-local-exposure",
180
+ info: { name: ".env.local file exposed", severity: "critical", tags: ["exposure", "config"] },
181
+ http: [{
182
+ method: "GET", path: "/.env.local",
183
+ matchers: [
184
+ { type: "status", status: [200] },
185
+ { type: "word", words: ["="], condition: "or" },
186
+ ],
187
+ "matchers-condition": "and",
188
+ }],
189
+ },
190
+ {
191
+ id: "git-config-exposure",
192
+ info: { name: "Git config exposed", severity: "high", tags: ["exposure", "git"] },
193
+ http: [{
194
+ method: "GET", path: "/.git/config",
195
+ matchers: [
196
+ { type: "status", status: [200] },
197
+ { type: "word", words: ["[core]", "[remote"], condition: "or" },
198
+ ],
199
+ "matchers-condition": "and",
200
+ }],
201
+ },
202
+ {
203
+ id: "git-head-exposure",
204
+ info: { name: "Git HEAD exposed", severity: "medium", tags: ["exposure", "git"] },
205
+ http: [{
206
+ method: "GET", path: "/.git/HEAD",
207
+ matchers: [
208
+ { type: "status", status: [200] },
209
+ { type: "word", words: ["ref: refs/"] },
210
+ ],
211
+ "matchers-condition": "and",
212
+ }],
213
+ },
214
+ {
215
+ id: "service-account-key-exposure",
216
+ info: { name: "Service account key exposed", severity: "critical", tags: ["exposure", "firebase", "gcp"] },
217
+ http: [{
218
+ method: "GET", path: "/serviceAccountKey.json",
219
+ matchers: [
220
+ { type: "status", status: [200] },
221
+ { type: "word", words: ["private_key", "client_email"], condition: "and" },
222
+ ],
223
+ "matchers-condition": "and",
224
+ }],
225
+ },
226
+
227
+ // ── Source map exposure ──────────────────────────────────────────────
228
+ {
229
+ id: "nextjs-sourcemap-exposure",
230
+ info: { name: "Next.js source maps exposed", severity: "medium", tags: ["exposure", "nextjs"],
231
+ description: "Source maps allow attackers to read your original source code" },
232
+ http: [{
233
+ method: "GET", path: "/_next/static/chunks/main.js.map",
234
+ matchers: [
235
+ { type: "status", status: [200] },
236
+ { type: "word", words: ["mappings", "sources"], condition: "or" },
237
+ ],
238
+ "matchers-condition": "and",
239
+ }],
240
+ },
241
+
242
+ // ── Firebase specific ───────────────────────────────────────────────
243
+ {
244
+ id: "firebase-database-open-read",
245
+ info: { name: "Firebase Realtime DB open read", severity: "high", tags: ["firebase", "misconfiguration"] },
246
+ http: [{
247
+ method: "GET", path: "/.json",
248
+ matchers: [
249
+ { type: "status", status: [200] },
250
+ { type: "negative-word", words: ["Permission denied", "error"] },
251
+ ],
252
+ "matchers-condition": "and",
253
+ }],
254
+ },
255
+
256
+ // ── Security headers (also checked by lib/blackbox/headers.js) ────────
257
+
258
+ // ── Debug / internal endpoints ──────────────────────────────────────
259
+ {
260
+ id: "nextjs-debug-endpoint",
261
+ info: { name: "Next.js debug endpoint accessible", severity: "medium", tags: ["nextjs", "debug"] },
262
+ http: [{
263
+ method: "GET", path: "/api/__nextjs_original-stack-frame",
264
+ matchers: [{ type: "status", status: [200, 400] }],
265
+ }],
266
+ },
267
+ {
268
+ id: "graphql-introspection",
269
+ info: { name: "GraphQL introspection enabled", severity: "medium", tags: ["graphql", "misconfiguration"] },
270
+ http: [{
271
+ method: "POST", path: "/graphql",
272
+ headers: { "Content-Type": "application/json" },
273
+ body: '{"query":"{ __schema { types { name } } }"}',
274
+ matchers: [
275
+ { type: "status", status: [200] },
276
+ { type: "word", words: ["__schema", "__type"], condition: "or" },
277
+ ],
278
+ "matchers-condition": "and",
279
+ }],
280
+ },
281
+ {
282
+ id: "swagger-ui-exposed",
283
+ info: { name: "Swagger UI exposed", severity: "low", tags: ["exposure", "api-docs"] },
284
+ http: [{
285
+ method: "GET", path: "/api/docs",
286
+ matchers: [
287
+ { type: "status", status: [200] },
288
+ { type: "word", words: ["swagger", "openapi", "api-docs"], condition: "or" },
289
+ ],
290
+ "matchers-condition": "and",
291
+ }],
292
+ },
293
+
294
+ // ── CVE checks ────────────��─────────────────────────────────────────
295
+ {
296
+ id: "cve-2025-29927-nextjs-middleware-bypass",
297
+ info: { name: "CVE-2025-29927: Next.js middleware bypass", severity: "critical",
298
+ tags: ["cve", "nextjs", "auth-bypass"],
299
+ description: "x-middleware-subrequest header can bypass Next.js middleware auth checks" },
300
+ http: [{
301
+ method: "GET", path: "/api/admin/users",
302
+ headers: { "x-middleware-subrequest": "middleware:middleware:middleware:middleware:middleware" },
303
+ matchers: [{ type: "status", status: [200] }],
304
+ }],
305
+ },
306
+
307
+ // ── CORS misconfiguration ───────────────────────────────────────────
308
+ {
309
+ id: "cors-wildcard",
310
+ info: { name: "CORS allows wildcard origin", severity: "medium", tags: ["cors", "misconfiguration"] },
311
+ http: [{
312
+ method: "GET", path: "/api/health",
313
+ headers: { Origin: "https://evil-attacker.com" },
314
+ matchers: [
315
+ { type: "word", words: ["https://evil-attacker.com"], part: "header" },
316
+ ],
317
+ }],
318
+ },
319
+ {
320
+ id: "cors-null-origin",
321
+ info: { name: "CORS allows null origin", severity: "high", tags: ["cors", "misconfiguration"] },
322
+ http: [{
323
+ method: "GET", path: "/api/health",
324
+ headers: { Origin: "null" },
325
+ matchers: [
326
+ { type: "regex", regex: ["access-control-allow-origin:\\s*null"], part: "header" },
327
+ ],
328
+ }],
329
+ },
330
+
331
+ // (An "open redirect via callback" template lived here but was removed: a
332
+ // declarative word-matcher on the Location header can't tell a real open
333
+ // redirect from a same-site canonicalization redirect that merely preserves
334
+ // the payload in the query string. The code-based OAuth probe covers this
335
+ // path correctly by resolving the redirect's destination origin.)
336
+
337
+ // ── Directory listing ───────────────────────────────────────────────
338
+ {
339
+ id: "directory-listing",
340
+ info: { name: "Directory listing enabled", severity: "low", tags: ["exposure", "misconfiguration"] },
341
+ http: [{
342
+ method: "GET", path: "/api/",
343
+ matchers: [
344
+ { type: "status", status: [200] },
345
+ { type: "word", words: ["Index of", "Directory listing", "<pre>"], condition: "or" },
346
+ ],
347
+ "matchers-condition": "and",
348
+ }],
349
+ },
350
+ ];
351
+
352
+ /**
353
+ * Run all built-in templates against a target.
354
+ * @param {string} baseUrl
355
+ * @returns {Array<object>} All findings
356
+ */
357
+ export async function runBuiltInTemplates(baseUrl) {
358
+ const allFindings = [];
359
+ for (const template of BUILT_IN_TEMPLATES) {
360
+ const findings = await executeTemplate(baseUrl, template);
361
+ allFindings.push(...findings);
362
+ }
363
+ return allFindings;
364
+ }
package/lib/tls.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * TLS/SSL Scanner
3
+ *
4
+ * Checks certificate validity, cipher strength, and protocol version
5
+ * using Node.js built-in `tls` module, zero dependencies.
6
+ *
7
+ * Inspired by: httpx (JARM fingerprinting), testssl.sh, ssl-labs
8
+ */
9
+ import tls from "node:tls";
10
+
11
+ const WEAK_CIPHERS = new Set([
12
+ "DES-CBC3-SHA", "RC4-SHA", "RC4-MD5", "DES-CBC-SHA",
13
+ "EXP-RC4-MD5", "EXP-DES-CBC-SHA", "EXP-RC2-CBC-MD5",
14
+ "NULL-MD5", "NULL-SHA", "NULL-SHA256",
15
+ ]);
16
+
17
+ const WEAK_PROTOCOLS = new Set(["TLSv1", "TLSv1.1", "SSLv3"]);
18
+
19
+ /**
20
+ * Check TLS configuration for a hostname.
21
+ *
22
+ * @param {string} hostname - Target hostname (no protocol)
23
+ * @param {number} port - Target port (default: 443)
24
+ * @param {number} timeout - Connection timeout in ms
25
+ * @returns {object} TLS scan result
26
+ */
27
+ export function checkTls(hostname, port = 443, timeout = 10_000) {
28
+ return new Promise((resolve) => {
29
+ const socket = tls.connect(
30
+ { host: hostname, port, servername: hostname, rejectUnauthorized: false },
31
+ () => {
32
+ const cert = socket.getPeerCertificate(true);
33
+ const protocol = socket.getProtocol();
34
+ const cipher = socket.getCipher();
35
+ const authorized = socket.authorized;
36
+ const authError = socket.authorizationError || null;
37
+ socket.end();
38
+
39
+ const now = new Date();
40
+ const validFrom = new Date(cert.valid_from);
41
+ const validTo = new Date(cert.valid_to);
42
+ const daysUntilExpiry = Math.floor((validTo - now) / 86400000);
43
+
44
+ // Extract SAN (Subject Alternative Names)
45
+ const altNames = cert.subjectaltname
46
+ ? cert.subjectaltname.split(", ").map((s) => s.replace("DNS:", ""))
47
+ : [];
48
+
49
+ // Determine findings
50
+ const findings = [];
51
+
52
+ if (!authorized) {
53
+ findings.push({
54
+ severity: "high",
55
+ title: "TLS certificate not trusted",
56
+ description: authError || "Certificate validation failed",
57
+ });
58
+ }
59
+
60
+ if (daysUntilExpiry < 0) {
61
+ findings.push({
62
+ severity: "critical",
63
+ title: "TLS certificate expired",
64
+ description: `Expired ${Math.abs(daysUntilExpiry)} days ago (${validTo.toISOString().slice(0, 10)})`,
65
+ });
66
+ } else if (daysUntilExpiry < 30) {
67
+ findings.push({
68
+ severity: "medium",
69
+ title: "TLS certificate expiring soon",
70
+ description: `Expires in ${daysUntilExpiry} days (${validTo.toISOString().slice(0, 10)})`,
71
+ });
72
+ }
73
+
74
+ if (WEAK_PROTOCOLS.has(protocol)) {
75
+ findings.push({
76
+ severity: "high",
77
+ title: `Weak TLS protocol: ${protocol}`,
78
+ description: "TLSv1.0 and TLSv1.1 are deprecated. Use TLSv1.2 or TLSv1.3.",
79
+ });
80
+ }
81
+
82
+ if (WEAK_CIPHERS.has(cipher?.name)) {
83
+ findings.push({
84
+ severity: "high",
85
+ title: `Weak cipher suite: ${cipher.name}`,
86
+ description: "This cipher is considered insecure.",
87
+ });
88
+ }
89
+
90
+ if (cipher?.name && !cipher.name.includes("GCM") && !cipher.name.includes("CHACHA")) {
91
+ findings.push({
92
+ severity: "low",
93
+ title: `Non-AEAD cipher: ${cipher.name}`,
94
+ description: "Consider using AEAD ciphers (GCM, ChaCha20-Poly1305) for better security.",
95
+ });
96
+ }
97
+
98
+ resolve({
99
+ valid: authorized,
100
+ protocol,
101
+ cipher: cipher?.name || "unknown",
102
+ cipherVersion: cipher?.version || "unknown",
103
+ issuer: cert.issuer?.O || cert.issuer?.CN || "unknown",
104
+ subject: cert.subject?.CN || "unknown",
105
+ altNames,
106
+ validFrom: validFrom.toISOString(),
107
+ validTo: validTo.toISOString(),
108
+ daysUntilExpiry,
109
+ serialNumber: cert.serialNumber,
110
+ fingerprint256: cert.fingerprint256,
111
+ findings,
112
+ });
113
+ },
114
+ );
115
+
116
+ socket.setTimeout(timeout);
117
+ socket.on("timeout", () => {
118
+ socket.destroy();
119
+ resolve({
120
+ valid: false,
121
+ protocol: null,
122
+ cipher: null,
123
+ error: "Connection timeout",
124
+ findings: [{
125
+ severity: "medium",
126
+ title: "TLS connection timeout",
127
+ description: `Could not establish TLS connection within ${timeout}ms`,
128
+ }],
129
+ });
130
+ });
131
+ socket.on("error", (err) => {
132
+ resolve({
133
+ valid: false,
134
+ protocol: null,
135
+ cipher: null,
136
+ error: err.message,
137
+ findings: [{
138
+ severity: "medium",
139
+ title: "TLS connection failed",
140
+ description: err.message,
141
+ }],
142
+ });
143
+ });
144
+ });
145
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Discover API routes in Express, Hono, and Fastify projects.
3
+ */
4
+ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
5
+ import { join, relative } from "node:path";
6
+
7
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor"]);
8
+ const CODE_EXT = /\.(js|ts|mjs|cjs)$/;
9
+
10
+ const ROUTE_DIRS = ["routes", "src/routes", "api", "src/api", "server/routes", "src/server/routes"];
11
+
12
+ // Express / Hono / Fastify route patterns
13
+ const ROUTE_RES = [
14
+ /(?:app|router|server|api|hono)\.(get|post|put|patch|delete|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
15
+ /(?:app|router|server|api|hono)\.(get|post|put|patch|delete|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
16
+ /fastify\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
17
+ /\.(get|post|put|patch|delete)\s*\(\s*['"`](\/[^'"`]+)['"`]/gi,
18
+ /method:\s*['"](GET|POST|PUT|PATCH|DELETE)['"]\s*,\s*url:\s*['"`]([^'"`]+)['"`]/gi,
19
+ ];
20
+
21
+ function walkCodeFiles(dir, files = []) {
22
+ if (!existsSync(dir)) return files;
23
+ for (const entry of readdirSync(dir)) {
24
+ if (SKIP.has(entry)) continue;
25
+ const full = join(dir, entry);
26
+ if (statSync(full).isDirectory()) {
27
+ walkCodeFiles(full, files);
28
+ } else if (CODE_EXT.test(entry)) {
29
+ files.push(full);
30
+ }
31
+ }
32
+ return files;
33
+ }
34
+
35
+ function extractRoutesFromSource(source, filePath, repoPath) {
36
+ const routes = [];
37
+ const seen = new Set();
38
+
39
+ for (const re of ROUTE_RES) {
40
+ re.lastIndex = 0;
41
+ let m;
42
+ while ((m = re.exec(source)) !== null) {
43
+ const method = m[1].toUpperCase();
44
+ let path = m[2];
45
+ if (!path.startsWith("/")) path = `/${path}`;
46
+ const key = `${method}:${path}`;
47
+ if (seen.has(key)) continue;
48
+ seen.add(key);
49
+
50
+ routes.push({
51
+ url: path,
52
+ filePath: relative(repoPath, filePath),
53
+ methods: [method],
54
+ auth: classifyAuth(source),
55
+ inputs: [],
56
+ externalCalls: [],
57
+ securityPatterns: detectRisk(source),
58
+ hasParams: path.includes(":") || path.includes("*"),
59
+ framework: detectFramework(source),
60
+ sourceLength: source.length,
61
+ });
62
+ }
63
+ }
64
+
65
+ return routes;
66
+ }
67
+
68
+ function detectFramework(source) {
69
+ if (/from\s+['"]hono['"]|new Hono/.test(source)) return "hono";
70
+ if (/from\s+['"]fastify['"]|fastify\(/.test(source)) return "fastify";
71
+ if (/from\s+['"]express['"]|require\s*\(\s*['"]express['"]\)/.test(source)) return "express";
72
+ return "node";
73
+ }
74
+
75
+ function classifyAuth(source) {
76
+ const auth = [];
77
+ if (/verifyAdmin|isAdmin|requireAdmin/.test(source)) auth.push("admin");
78
+ if (/requireAuth|getSession|verifyToken|jwt\.verify/.test(source)) auth.push("user");
79
+ if (/CRON_SECRET|verifyCron/.test(source)) auth.push("cron");
80
+ if (auth.length === 0) auth.push("public");
81
+ return auth;
82
+ }
83
+
84
+ function detectRisk(source) {
85
+ const patterns = [];
86
+ if (/eval\s*\(/.test(source)) patterns.push("RISK:eval");
87
+ if (/innerHTML/.test(source)) patterns.push("RISK:innerHTML");
88
+ if (/exec\s*\(|spawn\s*\(/.test(source)) patterns.push("RISK:command-injection");
89
+ return patterns;
90
+ }
91
+
92
+ /**
93
+ * @param {string} repoPath
94
+ * @returns {object[]} routes in attack-surface shape
95
+ */
96
+ export function discoverFrameworkRoutes(repoPath) {
97
+ const all = [];
98
+ const seen = new Set();
99
+
100
+ const scanFiles = (dir) => {
101
+ for (const file of walkCodeFiles(dir)) {
102
+ let source;
103
+ try {
104
+ source = readFileSync(file, "utf-8");
105
+ } catch {
106
+ continue;
107
+ }
108
+ if (!/(express|hono|fastify|Router|\.get\s*\(|\.post\s*\()/i.test(source)) continue;
109
+
110
+ for (const route of extractRoutesFromSource(source, file, repoPath)) {
111
+ const key = `${route.methods[0]}:${route.url}:${route.filePath}`;
112
+ if (seen.has(key)) continue;
113
+ seen.add(key);
114
+ all.push(route);
115
+ }
116
+ }
117
+ };
118
+
119
+ for (const dir of ROUTE_DIRS) {
120
+ scanFiles(join(repoPath, dir));
121
+ }
122
+
123
+ // Also scan top-level server entry files
124
+ for (const entry of ["server.js", "server.ts", "index.js", "index.ts", "app.js", "app.ts", "src/index.ts"]) {
125
+ const full = join(repoPath, entry);
126
+ if (!existsSync(full)) continue;
127
+ try {
128
+ const source = readFileSync(full, "utf-8");
129
+ for (const route of extractRoutesFromSource(source, full, repoPath)) {
130
+ const key = `${route.methods[0]}:${route.url}:${route.filePath}`;
131
+ if (seen.has(key)) continue;
132
+ seen.add(key);
133
+ all.push(route);
134
+ }
135
+ } catch { /* skip */ }
136
+ }
137
+
138
+ return all.sort((a, b) => a.url.localeCompare(b.url));
139
+ }