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,229 @@
1
+ /**
2
+ * NIS2 readiness report, the "forms/data they need" core.
3
+ *
4
+ * Combines what the scan can measure (technical measures) with a local
5
+ * self-assessment (the organisational measures a scanner can't test) into a
6
+ * per-measure met / partial / gap / n-a report, with proportionality notes and
7
+ * the jurisdiction's incident-reporting duties.
8
+ *
9
+ * Provenance is first-class: every piece of evidence carries
10
+ * `{ source, method, collected_at, confidence }`, so the whole report is
11
+ * reproducible and defensible. Nothing here asserts compliance, it is
12
+ * readiness and self-assessment tooling only.
13
+ */
14
+ import { buildComplianceCoverage } from "./index.js";
15
+ import { normalizeAnswers, questionsForMeasure } from "./self-assessment.js";
16
+
17
+ const PROPORTIONALITY = {
18
+ a: "A short written policy plus a basic risk list is proportionate for a small entity.",
19
+ b: "A one-page runbook with roles, contacts, and the NIS2 deadlines is enough to start.",
20
+ c: "Regular backups plus one real restore test per year.",
21
+ d: "A supplier list plus basic security checks or contractual clauses.",
22
+ e: "A patch cadence and a vulnerability-reporting channel; Penthera covers the technical scan.",
23
+ f: "Periodic re-scan and incident review; baseline diffs are your evidence.",
24
+ g: "A short onboarding briefing and occasional reminders.",
25
+ h: "Encryption in transit (scanned) plus encryption at rest for sensitive stores.",
26
+ i: "Least privilege, a joiner/leaver process, and a simple asset list.",
27
+ j: "MFA on email and admin/privileged accounts is the highest-value control here.",
28
+ };
29
+
30
+ function scanStatusOf(coverageStatus) {
31
+ // Maps the scan's per-measure coverage to a readiness contribution. Note that
32
+ // a measure the scanner can't test ("out-of-scope"/"evidence") returns "none",
33
+ // NOT "n/a": it still matters to the company and is driven by attestation. A
34
+ // true N/A only comes from the user attesting "na".
35
+ switch (coverageStatus) {
36
+ case "tested-clean": return "met";
37
+ case "tested-findings": return "partial";
38
+ default: return "none"; // evidence / planned / not-run / out-of-scope
39
+ }
40
+ }
41
+
42
+ function attestStatusOf(measure, answers) {
43
+ const vals = questionsForMeasure(measure).map((q) => answers[q.id]?.answer).filter(Boolean);
44
+ if (vals.length === 0) return "none";
45
+ const real = vals.filter((v) => v !== "na");
46
+ if (real.length === 0) return "na";
47
+ if (real.includes("no")) return "gap";
48
+ if (real.includes("partial")) return "partial";
49
+ return "met";
50
+ }
51
+
52
+ function combine(scanS, attestS) {
53
+ const known = [];
54
+ if (scanS === "met" || scanS === "partial") known.push(scanS);
55
+ if (["met", "partial", "gap"].includes(attestS)) known.push(attestS);
56
+ if (known.length === 0) return attestS === "na" ? "n/a" : "gap";
57
+ if (known.includes("gap")) return "gap";
58
+ if (known.includes("partial")) return "partial";
59
+ return "met";
60
+ }
61
+
62
+ /**
63
+ * Build a readiness report from a scan result and optional self-assessment.
64
+ * @param {object} scanResult - merged scan result (may be near-empty for attestation-only)
65
+ * @param {object} opts - { answers, jurisdiction, generatedAt, assessedAt }
66
+ */
67
+ export function buildReadiness(scanResult, opts = {}) {
68
+ const answers = normalizeAnswers(opts.answers || {});
69
+ const coverage = buildComplianceCoverage(scanResult, "nis2") || { measures: [] };
70
+ const scanTs = scanResult?.timestamp || null;
71
+
72
+ const measures = coverage.measures.map((m) => {
73
+ const scanS = scanStatusOf(m.status);
74
+ const attestS = attestStatusOf(m.id, answers);
75
+ const status = combine(scanS, attestS);
76
+
77
+ const evidence = [];
78
+ for (const p of m.probesRun || []) {
79
+ evidence.push({ source: "scan", method: `probe:${p}`, collected_at: scanTs, confidence: "measured" });
80
+ }
81
+ for (const q of questionsForMeasure(m.id)) {
82
+ const a = answers[q.id];
83
+ if (a) {
84
+ evidence.push({
85
+ source: "self-assessment",
86
+ method: "attestation",
87
+ collected_at: opts.assessedAt || null,
88
+ confidence: "attested",
89
+ question: q.text,
90
+ answer: a.answer,
91
+ note: a.note || "",
92
+ });
93
+ }
94
+ }
95
+
96
+ const gaps = [];
97
+ for (const q of questionsForMeasure(m.id)) {
98
+ const a = answers[q.id];
99
+ if (!a) gaps.push({ type: "unanswered", question: q.text });
100
+ else if (a.answer === "no") gaps.push({ type: "not-in-place", question: q.text, note: a.note || "" });
101
+ else if (a.answer === "partial") gaps.push({ type: "partial", question: q.text, note: a.note || "" });
102
+ }
103
+
104
+ return {
105
+ id: m.id,
106
+ title: m.title,
107
+ status,
108
+ basis: { scan: scanS, attestation: attestS },
109
+ findings: m.findings,
110
+ evidence,
111
+ gaps,
112
+ proportionality: PROPORTIONALITY[m.id] || "",
113
+ };
114
+ });
115
+
116
+ const summary = { met: 0, partial: 0, gap: 0, "n/a": 0 };
117
+ for (const m of measures) summary[m.status] = (summary[m.status] || 0) + 1;
118
+ const assessed = measures.filter((m) => m.basis.attestation !== "none" || m.basis.scan !== "none").length;
119
+
120
+ const j = opts.jurisdiction || null;
121
+ return {
122
+ framework: "nis2",
123
+ jurisdiction: j && {
124
+ id: j.id, name: j.name, law: j.law, authorities: j.authorities,
125
+ incidentDeadlines: j.incidentDeadlines, penalties: j.penalties, disclaimer: j.disclaimer,
126
+ },
127
+ generated_at: opts.generatedAt || null,
128
+ measures,
129
+ summary,
130
+ assessedCount: assessed,
131
+ totalMeasures: measures.length,
132
+ };
133
+ }
134
+
135
+ const STATUS_LABEL = { met: "Met", partial: "Partial", gap: "Gap", "n/a": "N/A" };
136
+
137
+ /** Render a readiness report as Markdown. */
138
+ export function formatReadinessMarkdown(readiness) {
139
+ if (!readiness) return "";
140
+ const L = [];
141
+ const j = readiness.jurisdiction;
142
+ L.push(`# NIS2 readiness report${j ? `, ${j.name}` : ""}`);
143
+ L.push("");
144
+ if (j?.law) L.push(`> Framework: **${j.law}** (EU NIS2, Article 21 measures).`);
145
+ L.push("> **Readiness, evidence, and self-assessment tooling, not legal advice, not a certification, and not a guarantee of compliance.**");
146
+ if (j?.disclaimer) { L.push(">"); L.push(`> ${j.disclaimer}`); }
147
+ L.push("");
148
+
149
+ const s = readiness.summary;
150
+ L.push(`**Summary:** ${s.met} met · ${s.partial} partial · ${s.gap} gap · ${s["n/a"]} n/a, across ${readiness.totalMeasures} Article 21 measures.`);
151
+ L.push("");
152
+ L.push("Status combines what Penthera measured (technical) with your self-assessment (organisational). *Proportionality applies: for a small entity, a short policy or process that genuinely exists counts.*");
153
+ L.push("");
154
+
155
+ L.push("| Art. 21(2) | Measure | Status | Basis |");
156
+ L.push("|-----------|---------|--------|-------|");
157
+ for (const m of readiness.measures) {
158
+ const basis = [
159
+ m.basis.scan !== "none" && m.basis.scan !== "na" ? "scan" : null,
160
+ ["met", "partial", "gap"].includes(m.basis.attestation) ? "attestation" : null,
161
+ ].filter(Boolean).join(" + ") || "not assessed";
162
+ L.push(`| **(${m.id})** | ${m.title} | ${STATUS_LABEL[m.status]} | ${basis} |`);
163
+ }
164
+ L.push("");
165
+
166
+ // Per-measure detail
167
+ L.push("## Measures in detail");
168
+ L.push("");
169
+ for (const m of readiness.measures) {
170
+ L.push(`### (${m.id}) ${m.title}, ${STATUS_LABEL[m.status]}`);
171
+ L.push("");
172
+ if (m.evidence.length) {
173
+ L.push("**Evidence**");
174
+ for (const e of m.evidence) {
175
+ if (e.source === "scan") {
176
+ L.push(`- scan · \`${e.method}\` · ${e.collected_at || "-"} · confidence: ${e.confidence}`);
177
+ } else {
178
+ L.push(`- self-assessment · "${e.question}" → **${e.answer}**${e.note ? ` (${e.note})` : ""}`);
179
+ }
180
+ }
181
+ L.push("");
182
+ }
183
+ if (m.gaps.length) {
184
+ L.push("**Gaps / to confirm**");
185
+ for (const g of m.gaps) {
186
+ if (g.type === "unanswered") L.push(`- ❓ Not yet assessed: ${g.question}`);
187
+ else if (g.type === "not-in-place") L.push(`- ❌ Not in place: ${g.question}${g.note ? ` (${g.note})` : ""}`);
188
+ else L.push(`- ⚠️ Partial: ${g.question}${g.note ? ` (${g.note})` : ""}`);
189
+ }
190
+ L.push("");
191
+ }
192
+ if (m.proportionality) { L.push(`*Proportionate baseline:* ${m.proportionality}`); L.push(""); }
193
+ }
194
+
195
+ // Incident reporting duties (measure b, practical)
196
+ if (j?.incidentDeadlines?.length) {
197
+ L.push("## Incident-reporting duties");
198
+ L.push("");
199
+ L.push(`Report significant incidents to **${j.authorities?.csirt || "your CSIRT"}** (supervisor: ${j.authorities?.supervisor || "your supervisor"}):`);
200
+ L.push("");
201
+ L.push("| Deadline | When | What |");
202
+ L.push("|----------|------|------|");
203
+ for (const d of j.incidentDeadlines) {
204
+ L.push(`| **${d.within}** | ${d.of} | ${d.content} |`);
205
+ }
206
+ L.push("");
207
+ if (j.penalties) L.push(`> ${j.penalties}`);
208
+ L.push("");
209
+ }
210
+
211
+ // What only you can confirm
212
+ const unanswered = readiness.measures.flatMap((m) => m.gaps.filter((g) => g.type === "unanswered").map((g) => g.question));
213
+ if (unanswered.length) {
214
+ L.push("## What only you can confirm");
215
+ L.push("");
216
+ L.push("These are attestations a scan cannot make. Run the self-assessment to record them:");
217
+ L.push("");
218
+ unanswered.forEach((q) => L.push(`- ${q}`));
219
+ L.push("");
220
+ L.push("Start with: `penthera --assessment-init assessment.json`, fill it in, then re-run with `--assessment assessment.json`.");
221
+ L.push("");
222
+ }
223
+
224
+ L.push("---");
225
+ L.push("");
226
+ L.push(`*Generated by Penthera${readiness.generated_at ? ` at ${readiness.generated_at}` : ""}. Every item above is provenance-linked (source, method, collected_at, confidence) and reproducible by re-running the scan and self-assessment.*`);
227
+ L.push("");
228
+ return L.join("\n");
229
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Local self-assessment for the organisational half of NIS2 Article 21, the
3
+ * measures a scanner cannot test and must capture as an honest attestation.
4
+ *
5
+ * Runs entirely offline. The question bank is data; answers are recorded to a
6
+ * JSON file that the readiness report consumes. Every answer becomes an evidence
7
+ * record with provenance (source: self-assessment / attestation).
8
+ *
9
+ * Proportionality is the governing principle: for a small entity, a two-page
10
+ * policy that genuinely exists counts. The questions ask whether something
11
+ * exists and is used, not whether it is enterprise-grade.
12
+ */
13
+
14
+ // One or more attestation questions per Article 21(2) measure. `id` is stable
15
+ // (used as the answer key and in provenance). Answers: yes | partial | no | na.
16
+ export const QUESTIONS = [
17
+ { id: "a1", measure: "a", text: "Do you have a written information-security policy (even one to two pages)?", guidance: "A short, real policy that staff can find counts. It doesn't need to be long." },
18
+ { id: "a2", measure: "a", text: "Have you done a basic risk assessment of your most important systems and data?", guidance: "Identify what would hurt most if lost/breached, and the top risks to it." },
19
+ { id: "b1", measure: "b", text: "Do you have a documented incident-response process (who does what, who to call)?", guidance: "Even a one-page runbook with roles and contacts." },
20
+ { id: "b2", measure: "b", text: "Do you know your NIS2 reporting deadlines (24h / 72h / 1 month) and who you would notify?", guidance: "See the incident-report helper; the authority is your CSIRT / RDI." },
21
+ { id: "c1", measure: "c", text: "Do you take regular backups of important data and systems?", guidance: "Automated, off-site or immutable backups are best." },
22
+ { id: "c2", measure: "c", text: "Have you successfully restored from a backup in the last 12 months?", guidance: "An untested backup is not a backup. A single real restore test counts." },
23
+ { id: "d1", measure: "d", text: "Do you keep a list of your key suppliers/vendors (especially those with access to your data or systems)?", guidance: "A simple inventory is enough to start." },
24
+ { id: "d2", measure: "d", text: "Do you assess your suppliers' security (questionnaire, certification, or contractual clauses)?", guidance: "Penthera's passive supplier rating can help for the technical part." },
25
+ { id: "e1", measure: "e", text: "Do you have a process for applying security updates and handling reported vulnerabilities?", guidance: "Patching cadence + a way for people to report issues (e.g. a SECURITY.md)." },
26
+ { id: "f1", measure: "f", text: "Do you periodically review whether your security measures are actually working (re-scan, review incidents)?", guidance: "Penthera baseline diffs and re-scans provide this evidence." },
27
+ { id: "g1", measure: "g", text: "Do staff receive basic security-awareness guidance (phishing, passwords, reporting)?", guidance: "A short onboarding briefing and occasional reminders count." },
28
+ { id: "h1", measure: "h", text: "Is sensitive data encrypted at rest (databases, backups, laptops)?", guidance: "Penthera checks encryption in transit (TLS); at-rest is your attestation." },
29
+ { id: "i1", measure: "i", text: "Do you have an access-control approach (least privilege, and removing access when people leave)?", guidance: "A joiner/leaver checklist and role-based access are proportionate for most SMEs." },
30
+ { id: "i2", measure: "i", text: "Do you keep an inventory of your assets (devices, systems, key data)?", guidance: "A spreadsheet is a fine starting point." },
31
+ { id: "j1", measure: "j", text: "Is multi-factor authentication (MFA) enforced for admin/privileged accounts and email?", guidance: "MFA on email and admin panels is the single highest-value control here." },
32
+ ];
33
+
34
+ const VALID_ANSWERS = new Set(["yes", "partial", "no", "na"]);
35
+
36
+ /** A blank assessment template the user or agent fills in. */
37
+ export function blankAssessment() {
38
+ const answers = {};
39
+ for (const q of QUESTIONS) {
40
+ answers[q.id] = { answer: "", note: "" };
41
+ }
42
+ return {
43
+ _about: "Penthera NIS2 self-assessment. Answer each: yes | partial | no | na, with an optional note. Offline; no data leaves your machine. See lib/compliance/self-assessment.js for the questions.",
44
+ framework: "nis2",
45
+ answers,
46
+ };
47
+ }
48
+
49
+ /** Normalise a loaded answers object; ignores unknown ids and invalid values. */
50
+ export function normalizeAnswers(loaded) {
51
+ const raw = loaded?.answers || loaded || {};
52
+ const out = {};
53
+ for (const q of QUESTIONS) {
54
+ const a = raw[q.id];
55
+ const answer = typeof a === "string" ? a : a?.answer;
56
+ if (answer && VALID_ANSWERS.has(String(answer).toLowerCase())) {
57
+ out[q.id] = { answer: String(answer).toLowerCase(), note: (a && a.note) || "" };
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Questions for a given measure id (a-j). */
64
+ export function questionsForMeasure(measure) {
65
+ return QUESTIONS.filter((q) => q.measure === measure);
66
+ }
package/lib/crawler.js ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Runtime Endpoint Discovery (inspired by Katana/httpx)
3
+ *
4
+ * Crawls a live target to discover API endpoints, forms, and links.
5
+ * Compares findings with filesystem-discovered routes to find "shadow" endpoints.
6
+ *
7
+ * Techniques borrowed from:
8
+ * - Katana (ProjectDiscovery), recursive crawl with scope control
9
+ * - httpx, tech fingerprinting from response headers
10
+ * - ffuf, wordlist-based endpoint brute-forcing
11
+ */
12
+
13
+ const DEFAULT_TIMEOUT = 8_000;
14
+
15
+ async function safeFetch(url, opts = {}) {
16
+ const controller = new AbortController();
17
+ const timeout = setTimeout(() => controller.abort(), opts.timeout || DEFAULT_TIMEOUT);
18
+ try {
19
+ return await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
20
+ } catch {
21
+ return null;
22
+ } finally {
23
+ clearTimeout(timeout);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Discover endpoints by brute-forcing common API paths.
29
+ * Like ffuf but built-in, no external tool needed.
30
+ *
31
+ * @param {string} baseUrl - Target base URL
32
+ * @param {object} opts - { concurrency, wordlist, timeout }
33
+ * @returns {Array<{ path, status, size, tech }>} Discovered endpoints
34
+ */
35
+ export async function discoverEndpoints(baseUrl, opts = {}) {
36
+ const concurrency = opts.concurrency || 10;
37
+ const timeout = opts.timeout || DEFAULT_TIMEOUT;
38
+
39
+ // Common API paths, inspired by Nikto's 8k+ check database
40
+ // and common Next.js / Node.js patterns
41
+ const wordlist = opts.wordlist || [
42
+ // Health / status
43
+ "/api/health", "/api/status", "/api/ping", "/api/version",
44
+ "/health", "/healthz", "/ready", "/readyz", "/.well-known/health",
45
+ // Auth
46
+ "/api/auth/login", "/api/auth/register", "/api/auth/logout",
47
+ "/api/auth/me", "/api/auth/session", "/api/auth/refresh",
48
+ "/api/auth/forgot-password", "/api/auth/reset-password",
49
+ "/api/auth/verify-email", "/api/auth/callback",
50
+ "/api/login", "/api/register", "/api/signup",
51
+ // User
52
+ "/api/user", "/api/user/profile", "/api/user/settings",
53
+ "/api/users", "/api/me", "/api/account",
54
+ // Admin
55
+ "/api/admin", "/api/admin/users", "/api/admin/settings",
56
+ "/api/admin/logs", "/api/admin/stats", "/api/admin/dashboard",
57
+ // CRUD common
58
+ "/api/posts", "/api/comments", "/api/items", "/api/products",
59
+ "/api/orders", "/api/invoices", "/api/payments",
60
+ "/api/projects", "/api/tasks", "/api/events",
61
+ "/api/messages", "/api/notifications", "/api/uploads",
62
+ // Common utility
63
+ "/api/search", "/api/contact", "/api/feedback", "/api/subscribe",
64
+ "/api/unsubscribe", "/api/webhook", "/api/webhooks",
65
+ "/api/email", "/api/chat", "/api/ai", "/api/generate",
66
+ // Cron / background
67
+ "/api/cron", "/api/cron/cleanup", "/api/cron/digest",
68
+ "/api/cron/sync", "/api/cron/reindex",
69
+ // Stripe / billing
70
+ "/api/stripe/checkout", "/api/stripe/webhook",
71
+ "/api/billing", "/api/subscription", "/api/pricing",
72
+ // Third-party integrations
73
+ "/api/oauth/callback", "/api/oauth/google", "/api/oauth/github",
74
+ "/api/integrations", "/api/connect",
75
+ // Export / import
76
+ "/api/export", "/api/import", "/api/download",
77
+ // GraphQL
78
+ "/graphql", "/api/graphql",
79
+ // Documentation / debug (should be disabled in prod)
80
+ "/docs", "/redoc", "/openapi.json",
81
+ "/api/docs", "/api/swagger", "/api/openapi",
82
+ "/api/debug", "/api/test", "/api/_debug",
83
+ // Sensitive files
84
+ "/.env", "/.env.local", "/.env.production",
85
+ "/.git/HEAD", "/.git/config",
86
+ "/package.json", "/tsconfig.json",
87
+ "/next.config.js", "/next.config.mjs",
88
+ "/firebase.json", "/firestore.rules",
89
+ "/serviceAccountKey.json", "/service-account.json",
90
+ "/robots.txt", "/sitemap.xml",
91
+ "/.well-known/security.txt",
92
+ // Next.js internals
93
+ "/_next/static/chunks/main.js.map",
94
+ "/_next/data/build-id/index.json",
95
+ "/api/__nextjs_original-stack-frame",
96
+ // Common CMS / frameworks
97
+ "/wp-admin", "/wp-login.php", "/administrator",
98
+ "/.htaccess", "/web.config",
99
+ ];
100
+
101
+ const discovered = [];
102
+
103
+ // Batch requests with concurrency control (like httpx)
104
+ for (let i = 0; i < wordlist.length; i += concurrency) {
105
+ const batch = wordlist.slice(i, i + concurrency);
106
+ const results = await Promise.allSettled(
107
+ batch.map(async (path) => {
108
+ const res = await safeFetch(`${baseUrl}${path}`, { timeout });
109
+ if (!res) return null;
110
+ // Measure the actual body length rather than the Content-Length header,
111
+ // which is often absent on chunked responses. This matches the unit the
112
+ // calibration baseline uses (text.length) so the noise filter in
113
+ // autoCalibrate compares like with like instead of header-vs-body.
114
+ const body = await res.text().catch(() => "");
115
+ return {
116
+ path,
117
+ status: res.status,
118
+ size: body.length,
119
+ contentType: res.headers.get("content-type") || "",
120
+ server: res.headers.get("server") || "",
121
+ poweredBy: res.headers.get("x-powered-by") || "",
122
+ redirectTo: res.headers.get("location") || null,
123
+ };
124
+ }),
125
+ );
126
+
127
+ for (const r of results) {
128
+ if (r.status === "fulfilled" && r.value) {
129
+ discovered.push(r.value);
130
+ }
131
+ }
132
+ }
133
+
134
+ return discovered;
135
+ }
136
+
137
+ /**
138
+ * Fingerprint the target technology stack.
139
+ * Inspired by httpx / Wappalyzer.
140
+ *
141
+ * @param {string} baseUrl - Target base URL
142
+ * @returns {object} Technology fingerprint
143
+ */
144
+ export async function fingerprint(baseUrl) {
145
+ const tech = {
146
+ framework: null,
147
+ server: null,
148
+ cdn: null,
149
+ security: [],
150
+ headers: {},
151
+ };
152
+
153
+ // Fetch the homepage
154
+ const res = await safeFetch(baseUrl);
155
+ if (!res) return tech;
156
+
157
+ tech.headers = Object.fromEntries(res.headers.entries());
158
+
159
+ // Server detection
160
+ const server = res.headers.get("server") || "";
161
+ const poweredBy = res.headers.get("x-powered-by") || "";
162
+ if (server.includes("Vercel")) tech.server = "Vercel";
163
+ else if (server.includes("cloudflare")) { tech.server = "Cloudflare"; tech.cdn = "Cloudflare"; }
164
+ else if (server.includes("nginx")) tech.server = "nginx";
165
+ else if (server.includes("apache")) tech.server = "Apache";
166
+ else if (server) tech.server = server;
167
+
168
+ // Framework detection
169
+ if (poweredBy.includes("Next.js")) tech.framework = "Next.js";
170
+ else if (poweredBy.includes("Express")) tech.framework = "Express";
171
+ else if (poweredBy.includes("Nuxt")) tech.framework = "Nuxt";
172
+
173
+ // CDN / platform detection from headers
174
+ if (res.headers.has("cf-ray")) tech.cdn = "Cloudflare";
175
+ if (res.headers.has("x-vercel-id")) { tech.server = "Vercel"; tech.cdn = "Vercel Edge"; }
176
+ if (res.headers.has("x-amz-cf-id")) tech.cdn = "AWS CloudFront";
177
+
178
+ // Next.js detection from HTML
179
+ try {
180
+ const html = await res.text();
181
+ if (html.includes("/_next/static") || html.includes("__NEXT_DATA__")) {
182
+ tech.framework = "Next.js";
183
+ }
184
+ if (html.includes("__nuxt")) tech.framework = "Nuxt";
185
+ if (html.includes("ng-version")) tech.framework = "Angular";
186
+ if (html.includes("data-reactroot") || html.includes("__react")) tech.framework = "React";
187
+ } catch { /* ignore */ }
188
+
189
+ // Security header audit
190
+ const securityHeaders = [
191
+ "strict-transport-security", "content-security-policy",
192
+ "x-frame-options", "x-content-type-options",
193
+ "x-xss-protection", "referrer-policy",
194
+ "permissions-policy",
195
+ ];
196
+ for (const h of securityHeaders) {
197
+ if (res.headers.has(h)) tech.security.push(h);
198
+ }
199
+
200
+ return tech;
201
+ }
202
+
203
+ /**
204
+ * Auto-calibrate response filtering (inspired by ffuf -ac).
205
+ * Sends baseline requests and identifies the "default" response signature.
206
+ * Returns a filter function that rejects matching responses.
207
+ *
208
+ * @param {string} baseUrl
209
+ * @returns {function} Filter function: (response) => boolean (true = interesting, false = noise)
210
+ */
211
+ export async function autoCalibrate(baseUrl) {
212
+ // Send requests to paths that definitely don't exist
213
+ const baselines = [];
214
+ for (const garbage of [
215
+ `/api/${Date.now()}-nonexistent-calibration-a`,
216
+ `/api/${Date.now()}-nonexistent-calibration-b`,
217
+ `/api/${Date.now()}-nonexistent-calibration-c`,
218
+ ]) {
219
+ const res = await safeFetch(`${baseUrl}${garbage}`);
220
+ if (res) {
221
+ const text = await res.text().catch(() => "");
222
+ baselines.push({
223
+ status: res.status,
224
+ size: text.length,
225
+ contentType: res.headers.get("content-type") || "",
226
+ });
227
+ }
228
+ }
229
+
230
+ // Find common baseline signature
231
+ if (baselines.length === 0) return () => true; // can't calibrate, accept everything
232
+
233
+ const baselineStatus = baselines[0].status;
234
+ const baselineSize = baselines.reduce((sum, b) => sum + b.size, 0) / baselines.length;
235
+ const sizeTolerance = baselineSize * 0.1 + 50; // 10% + 50 bytes tolerance
236
+
237
+ return (response) => {
238
+ // If response matches the baseline signature, it's noise
239
+ if (response.status === baselineStatus &&
240
+ Math.abs(response.size - baselineSize) < sizeTolerance) {
241
+ return false; // noise, filter out
242
+ }
243
+ return true; // interesting, keep
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Compare crawled endpoints with filesystem-discovered routes.
249
+ * Returns "shadow" endpoints, routes that exist at runtime but aren't in source.
250
+ */
251
+ export function findShadowEndpoints(crawledPaths, filesystemRoutes) {
252
+ const knownPaths = new Set(filesystemRoutes.map(r => r.url));
253
+ return crawledPaths.filter(ep => {
254
+ const normalized = ep.path.replace(/\/$/, "");
255
+ return !knownPaths.has(normalized) && ep.status !== 404;
256
+ });
257
+ }