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.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +561 -0
- package/SECURITY.md +31 -0
- package/bin/penthera.js +3 -0
- package/bin/scan.js +11 -0
- package/docs/index.md +60 -0
- package/docs/owasp-wstg-coverage.md +79 -0
- package/lib/adaptive-probes.js +334 -0
- package/lib/attack-surface.js +149 -0
- package/lib/audit-loop.js +170 -0
- package/lib/blackbox/adaptive-scan.js +137 -0
- package/lib/blackbox/client-auth.js +60 -0
- package/lib/blackbox/email-dns.js +135 -0
- package/lib/blackbox/headers.js +91 -0
- package/lib/blackbox/idor.js +113 -0
- package/lib/blackbox/jwt.js +73 -0
- package/lib/blackbox/oauth.js +50 -0
- package/lib/blackbox/openapi.js +227 -0
- package/lib/compliance/incident.js +169 -0
- package/lib/compliance/index.js +163 -0
- package/lib/compliance/policy-pack.js +293 -0
- package/lib/compliance/questionnaire.js +113 -0
- package/lib/compliance/readiness.js +229 -0
- package/lib/compliance/self-assessment.js +66 -0
- package/lib/crawler.js +257 -0
- package/lib/fuzzer.js +233 -0
- package/lib/injections.js +676 -0
- package/lib/jurisdictions/nl.json +48 -0
- package/lib/knowledge-graph.js +232 -0
- package/lib/machine.js +814 -0
- package/lib/nuclei-loader.js +283 -0
- package/lib/owasp-wstg.js +204 -0
- package/lib/params.js +235 -0
- package/lib/plugins.js +70 -0
- package/lib/provenance.js +82 -0
- package/lib/recon.js +193 -0
- package/lib/remediation/index.js +219 -0
- package/lib/retirejs.js +239 -0
- package/lib/supply-chain.js +131 -0
- package/lib/templates.js +364 -0
- package/lib/tls.js +145 -0
- package/lib/whitebox/frameworks.js +139 -0
- package/lib/whitebox/sbom.js +144 -0
- package/lib/whitebox/secrets.js +111 -0
- package/package.json +76 -0
- package/pentest.config.example.js +82 -0
- package/scripts/package-skill.sh +16 -0
- package/skills/penthera/SKILL.md +272 -0
- package/skills/penthera/references/authorization.md +36 -0
- package/skills/penthera/references/output-and-ci.md +109 -0
- package/skills/penthera/references/profiles-and-flags.md +74 -0
- package/skills/penthera/references/remediation.md +267 -0
- package/skills/penthera/references/reporting.md +113 -0
- package/skills/penthera/references/secure-defaults.md +41 -0
- package/skills/penthera/references/troubleshooting.md +100 -0
- package/skills/penthera/scripts/preflight.sh +66 -0
- package/skills/penthera/scripts/validate-report.mjs +53 -0
- package/skills/penthera/scripts/validate-report.sh +8 -0
- package/src/cli/ansi.js +70 -0
- package/src/cli/baseline.js +37 -0
- package/src/cli/merge-results.js +71 -0
- package/src/cli/onboarding.js +216 -0
- package/src/cli/profiles.js +53 -0
- package/src/cli/prompt.js +64 -0
- package/src/cli/run-scan.js +152 -0
- package/src/cli.js +536 -0
- package/src/report/markdown.js +182 -0
- package/src/reporter.js +512 -0
- package/src/scan-repo.js +205 -0
- package/src/scan-url.js +563 -0
- package/src/utils/auth.js +13 -0
- package/src/utils/http.js +38 -0
- 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
|
+
}
|