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
package/src/scan-url.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Penthera, URL Scanner (black-box)
|
|
3
|
+
*
|
|
4
|
+
* Multi-phase scanning against a live target:
|
|
5
|
+
*
|
|
6
|
+
* Default phases (fast, non-intrusive):
|
|
7
|
+
* 1. Reachability check
|
|
8
|
+
* 2. TLS/SSL certificate & cipher audit
|
|
9
|
+
* 3. Tech fingerprinting (httpx-style)
|
|
10
|
+
* 4. Endpoint brute-force with auto-calibration (ffuf-style)
|
|
11
|
+
* 5. Nuclei-style template scanning (14 built-in templates)
|
|
12
|
+
* 6. Supplementary sensitive file checks
|
|
13
|
+
* 7. CORS validation
|
|
14
|
+
* 8. Framework-specific checks (CVEs)
|
|
15
|
+
* 9. Cookie security audit (HttpOnly, Secure, SameSite)
|
|
16
|
+
* 10. Retire.js, vulnerable JS library detection
|
|
17
|
+
* 11. Arjun-style batched HTTP parameter discovery
|
|
18
|
+
*
|
|
19
|
+
* --recon (passive OSINT, skipped for localhost):
|
|
20
|
+
* 12. Subdomain discovery via Certificate Transparency (crt.sh)
|
|
21
|
+
* 13. Historical URL mining (Wayback Machine + AlienVault OTX)
|
|
22
|
+
*
|
|
23
|
+
* --deep (active injection probes, sends attack payloads):
|
|
24
|
+
* 14. SQL injection (error-based + time-based blind)
|
|
25
|
+
* 15. Server-Side Template Injection (SSTI)
|
|
26
|
+
* 16. Server-Side Request Forgery (SSRF)
|
|
27
|
+
* 17. Open redirect detection
|
|
28
|
+
* 18. Command injection
|
|
29
|
+
* 19. Reflected XSS (Dalfox-style context-aware)
|
|
30
|
+
*
|
|
31
|
+
* --fuzz (property-based API fuzzing):
|
|
32
|
+
* 20. Fuzz discovered POST endpoints with edge-case payloads
|
|
33
|
+
*
|
|
34
|
+
* --nuclei <path> (community templates):
|
|
35
|
+
* 21. Load and execute YAML templates from nuclei-templates
|
|
36
|
+
*
|
|
37
|
+
* Localhost support:
|
|
38
|
+
* Auto-detects localhost/private IPs. Skips OSINT recon,
|
|
39
|
+
* suppresses irrelevant findings (e.g. missing Secure flag on http://),
|
|
40
|
+
* and handles http:// gracefully (no TLS check).
|
|
41
|
+
*/
|
|
42
|
+
import { fingerprint, discoverEndpoints, autoCalibrate } from "../lib/crawler.js";
|
|
43
|
+
import { runBuiltInTemplates } from "../lib/templates.js";
|
|
44
|
+
import { fuzzEndpoint } from "../lib/fuzzer.js";
|
|
45
|
+
import { checkTls } from "../lib/tls.js";
|
|
46
|
+
import { runRecon, extractDomain } from "../lib/recon.js";
|
|
47
|
+
import { runInjectionProbes } from "../lib/injections.js";
|
|
48
|
+
import { scanJsLibraries } from "../lib/retirejs.js";
|
|
49
|
+
import { discoverAllParams } from "../lib/params.js";
|
|
50
|
+
import { scanOpenApi, probeAuthEndpoints } from "../lib/blackbox/openapi.js";
|
|
51
|
+
import { probeIdorBola } from "../lib/blackbox/idor.js";
|
|
52
|
+
import { probeOAuthMisconfig } from "../lib/blackbox/oauth.js";
|
|
53
|
+
import { auditSecurityHeaders } from "../lib/blackbox/headers.js";
|
|
54
|
+
import { probeClientSideAuth } from "../lib/blackbox/client-auth.js";
|
|
55
|
+
import { probeJwt } from "../lib/blackbox/jwt.js";
|
|
56
|
+
import { probeEmailDns } from "../lib/blackbox/email-dns.js";
|
|
57
|
+
import { resolveAuth } from "./utils/auth.js";
|
|
58
|
+
import { normalizeBaseUrl, isPrivateHost, joinUrl } from "./utils/url.js";
|
|
59
|
+
import { safeFetch } from "./utils/http.js";
|
|
60
|
+
import { dedupeFindings } from "./cli/merge-results.js";
|
|
61
|
+
|
|
62
|
+
const SENSITIVE_RE = /private_key|SECRET|PASSWORD|API_KEY|FIREBASE_|STRIPE_|DATABASE_URL|client_email|\[core\]|\[remote/i;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run a full black-box scan against a URL.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} target - Base URL to scan
|
|
68
|
+
* @param {object} opts - { timeout, concurrency, fuzz, recon, deep, nucleiPath, onPhase }
|
|
69
|
+
* @returns {object} Scan result with findings
|
|
70
|
+
*/
|
|
71
|
+
export async function scanUrl(rawTarget, opts = {}) {
|
|
72
|
+
const target = normalizeBaseUrl(rawTarget);
|
|
73
|
+
const timeout = opts.timeout || 10_000;
|
|
74
|
+
const concurrency = opts.concurrency || 15;
|
|
75
|
+
const progress = opts.onPhase || (() => {});
|
|
76
|
+
const startTime = Date.now();
|
|
77
|
+
|
|
78
|
+
const local = isPrivateHost(target);
|
|
79
|
+
const hostname = new URL(target).hostname;
|
|
80
|
+
const isIpHost = /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":");
|
|
81
|
+
|
|
82
|
+
const result = {
|
|
83
|
+
target,
|
|
84
|
+
mode: "url",
|
|
85
|
+
local,
|
|
86
|
+
timestamp: new Date().toISOString(),
|
|
87
|
+
duration: 0,
|
|
88
|
+
reachable: false,
|
|
89
|
+
tls: null,
|
|
90
|
+
fingerprint: null,
|
|
91
|
+
openapi: null,
|
|
92
|
+
recon: null,
|
|
93
|
+
endpoints: { total: 0, discovered: 0, byStatus: {}, list: [] },
|
|
94
|
+
cookies: null,
|
|
95
|
+
jsLibraries: null,
|
|
96
|
+
paramDiscovery: null,
|
|
97
|
+
emailAuth: null,
|
|
98
|
+
executedProbes: [],
|
|
99
|
+
findings: [],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ── Phase 1: Reachability ──────────────────────────────────────────────
|
|
103
|
+
progress("Checking target reachability...");
|
|
104
|
+
try {
|
|
105
|
+
const res = await safeFetch(target, { timeout });
|
|
106
|
+
result.reachable = res != null && res.status < 500;
|
|
107
|
+
} catch {
|
|
108
|
+
result.reachable = false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!result.reachable) {
|
|
112
|
+
result.duration = Date.now() - startTime;
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Phase 2: TLS/SSL audit ─────────────────────────────────────────────
|
|
117
|
+
const parsedUrl = new URL(target);
|
|
118
|
+
if (parsedUrl.protocol === "https:") {
|
|
119
|
+
progress("Auditing TLS certificate and ciphers...");
|
|
120
|
+
try {
|
|
121
|
+
const tlsResult = await checkTls(parsedUrl.hostname, parseInt(parsedUrl.port) || 443);
|
|
122
|
+
result.tls = tlsResult;
|
|
123
|
+
// Add TLS findings
|
|
124
|
+
for (const f of tlsResult.findings || []) {
|
|
125
|
+
result.findings.push({
|
|
126
|
+
...f,
|
|
127
|
+
url: target,
|
|
128
|
+
category: "tls",
|
|
129
|
+
source: "tls-check",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// TLS check failed, not critical
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Phase 3: Fingerprint ───────────────────────────────────────────────
|
|
138
|
+
progress("Fingerprinting target...");
|
|
139
|
+
result.fingerprint = await fingerprint(target);
|
|
140
|
+
|
|
141
|
+
// ── Phase 4: Endpoint discovery ────────────────────────────────────────
|
|
142
|
+
progress("Discovering endpoints (brute-force)...");
|
|
143
|
+
const isInteresting = await autoCalibrate(target);
|
|
144
|
+
const raw = await discoverEndpoints(target, { concurrency, timeout });
|
|
145
|
+
|
|
146
|
+
const filtered = raw.filter((ep) => {
|
|
147
|
+
if (ep.status === 404) return false;
|
|
148
|
+
if (ep.status === 405) return true;
|
|
149
|
+
if (!isInteresting) return ep.status !== 404;
|
|
150
|
+
return isInteresting(ep);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
result.endpoints.total = raw.length;
|
|
154
|
+
result.endpoints.discovered = filtered.length;
|
|
155
|
+
result.endpoints.list = filtered;
|
|
156
|
+
for (const ep of filtered) {
|
|
157
|
+
result.endpoints.byStatus[ep.status] = (result.endpoints.byStatus[ep.status] || 0) + 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Phase 4b: Security headers ─────────────────────────────────────────
|
|
161
|
+
progress("Auditing security headers...");
|
|
162
|
+
if (result.fingerprint?.headers) {
|
|
163
|
+
result.findings.push(...auditSecurityHeaders(result.fingerprint.headers, target, { local }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Phase 4c: OpenAPI / auth probes ────────────────────────────────────
|
|
167
|
+
progress("Scanning OpenAPI spec and auth endpoints...");
|
|
168
|
+
const openApiResult = await scanOpenApi(target, { timeout });
|
|
169
|
+
result.openapi = {
|
|
170
|
+
specUrl: openApiResult.specUrl,
|
|
171
|
+
pathCount: openApiResult.paths.length,
|
|
172
|
+
unprotectedRoutes: openApiResult.unprotectedRoutes?.length || 0,
|
|
173
|
+
};
|
|
174
|
+
result.findings.push(...openApiResult.findings);
|
|
175
|
+
|
|
176
|
+
const authFindings = await probeAuthEndpoints(target, { timeout });
|
|
177
|
+
result.findings.push(...authFindings);
|
|
178
|
+
|
|
179
|
+
const clientAuthFindings = await probeClientSideAuth(target, { timeout });
|
|
180
|
+
result.findings.push(...clientAuthFindings);
|
|
181
|
+
|
|
182
|
+
const auth = resolveAuth(opts);
|
|
183
|
+
if (auth.bearer) {
|
|
184
|
+
const jwtFindings = await probeJwt(target, {
|
|
185
|
+
timeout,
|
|
186
|
+
bearerToken: auth.bearer.replace(/^Bearer\s+/i, ""),
|
|
187
|
+
testPath: opts.authTestPath || "/api/user/profile",
|
|
188
|
+
});
|
|
189
|
+
result.findings.push(...jwtFindings);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
progress("Probing IDOR/BOLA on parameterized routes...");
|
|
193
|
+
const idorFindings = await probeIdorBola(target, {
|
|
194
|
+
timeout,
|
|
195
|
+
spec: openApiResult.spec,
|
|
196
|
+
paths: openApiResult.paths,
|
|
197
|
+
authHeaders: auth.headers,
|
|
198
|
+
});
|
|
199
|
+
result.findings.push(...idorFindings);
|
|
200
|
+
|
|
201
|
+
progress("Checking OAuth misconfigurations...");
|
|
202
|
+
result.findings.push(...await probeOAuthMisconfig(target, { timeout }));
|
|
203
|
+
|
|
204
|
+
// ── Phase 5: Template scanning ─────────────────────────────────────────
|
|
205
|
+
progress("Running template scan (built-in templates)...");
|
|
206
|
+
const templateFindings = await runBuiltInTemplates(target);
|
|
207
|
+
for (const f of templateFindings) {
|
|
208
|
+
result.findings.push({
|
|
209
|
+
severity: f.severity,
|
|
210
|
+
title: f.name,
|
|
211
|
+
description: f.description || "",
|
|
212
|
+
url: f.matchedUrl,
|
|
213
|
+
status: f.status,
|
|
214
|
+
category: f.tags?.[0] || "template",
|
|
215
|
+
source: "template",
|
|
216
|
+
templateId: f.templateId,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Phase 6: Supplementary sensitive file checks ───────────────────────
|
|
221
|
+
progress("Checking sensitive files...");
|
|
222
|
+
const extraFiles = [
|
|
223
|
+
{ path: "/.env.production", severity: "critical", desc: "Production environment secrets" },
|
|
224
|
+
{ path: "/service-account.json", severity: "critical", desc: "GCP service account key" },
|
|
225
|
+
{ path: "/firebase.json", severity: "medium", desc: "Firebase project config" },
|
|
226
|
+
{ path: "/firestore.rules", severity: "medium", desc: "Firestore security rules" },
|
|
227
|
+
{ path: "/storage.rules", severity: "medium", desc: "Firebase Storage rules" },
|
|
228
|
+
{ path: "/.htaccess", severity: "medium", desc: "Apache configuration" },
|
|
229
|
+
{ path: "/web.config", severity: "medium", desc: "IIS configuration" },
|
|
230
|
+
{ path: "/phpinfo.php", severity: "high", desc: "PHP info page" },
|
|
231
|
+
{ path: "/server-status", severity: "medium", desc: "Apache server status" },
|
|
232
|
+
{ path: "/actuator", severity: "high", desc: "Spring Boot actuator" },
|
|
233
|
+
{ path: "/actuator/env", severity: "critical", desc: "Spring Boot environment" },
|
|
234
|
+
{ path: "/wp-login.php", severity: "info", desc: "WordPress login page" },
|
|
235
|
+
{ path: "/robots.txt", severity: "info", desc: "Robots configuration" },
|
|
236
|
+
{ path: "/.well-known/security.txt", severity: "info", desc: "Security contact" },
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
const checkedPaths = new Set(templateFindings.map((f) => new URL(f.matchedUrl).pathname));
|
|
240
|
+
const unchecked = extraFiles.filter((f) => !checkedPaths.has(f.path));
|
|
241
|
+
|
|
242
|
+
await Promise.allSettled(
|
|
243
|
+
unchecked.map(async (file) => {
|
|
244
|
+
const res = await safeFetch(joinUrl(target, file.path), { timeout: 5_000 });
|
|
245
|
+
if (!res || res.status !== 200) return;
|
|
246
|
+
const text = await res.text().catch(() => "");
|
|
247
|
+
|
|
248
|
+
if (file.severity === "info") {
|
|
249
|
+
result.findings.push({
|
|
250
|
+
severity: "info",
|
|
251
|
+
title: `${file.path} accessible`,
|
|
252
|
+
description: file.desc,
|
|
253
|
+
url: joinUrl(target, file.path),
|
|
254
|
+
status: 200,
|
|
255
|
+
category: "exposure",
|
|
256
|
+
source: "sensitive-file-check",
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (SENSITIVE_RE.test(text)) {
|
|
262
|
+
result.findings.push({
|
|
263
|
+
severity: file.severity,
|
|
264
|
+
title: `${file.path} contains sensitive data`,
|
|
265
|
+
description: file.desc,
|
|
266
|
+
url: joinUrl(target, file.path),
|
|
267
|
+
status: 200,
|
|
268
|
+
category: "exposure",
|
|
269
|
+
source: "sensitive-file-check",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// ── Phase 7: CORS spot-check ───────────────────────────────────────────
|
|
276
|
+
progress("Testing CORS policy...");
|
|
277
|
+
const corsEndpoint = filtered.find((ep) => ep.path.includes("/api/"))?.path || "/";
|
|
278
|
+
for (const evilOrigin of ["https://evil-attacker.com", "null"]) {
|
|
279
|
+
const res = await safeFetch(joinUrl(target, corsEndpoint), {
|
|
280
|
+
headers: { Origin: evilOrigin },
|
|
281
|
+
timeout: 5_000,
|
|
282
|
+
});
|
|
283
|
+
if (!res) continue;
|
|
284
|
+
const acao = res.headers.get("access-control-allow-origin");
|
|
285
|
+
if (acao === evilOrigin) {
|
|
286
|
+
result.findings.push({
|
|
287
|
+
severity: evilOrigin === "null" ? "high" : "medium",
|
|
288
|
+
title: `CORS reflects ${evilOrigin === "null" ? "null" : "arbitrary"} origin`,
|
|
289
|
+
description: `${corsEndpoint} reflects Origin: ${evilOrigin}`,
|
|
290
|
+
url: joinUrl(target, corsEndpoint),
|
|
291
|
+
status: res.status,
|
|
292
|
+
category: "cors",
|
|
293
|
+
source: "cors-check",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Phase 8: Framework-specific checks ─────────────────────────────────
|
|
299
|
+
progress("Running framework-specific checks...");
|
|
300
|
+
const adminEndpoints = filtered.filter(
|
|
301
|
+
(ep) => ep.path.includes("/admin") || ep.path.includes("/api/admin"),
|
|
302
|
+
);
|
|
303
|
+
for (const ep of adminEndpoints.slice(0, 3)) {
|
|
304
|
+
const res = await safeFetch(`${target}${ep.path}`, {
|
|
305
|
+
headers: {
|
|
306
|
+
"x-middleware-subrequest": "middleware:middleware:middleware:middleware:middleware",
|
|
307
|
+
},
|
|
308
|
+
timeout: 5_000,
|
|
309
|
+
});
|
|
310
|
+
if (res && res.status === 200) {
|
|
311
|
+
const alreadyFound = result.findings.some(
|
|
312
|
+
(f) => f.templateId === "cve-2025-29927-nextjs-middleware-bypass" && f.url?.includes(ep.path),
|
|
313
|
+
);
|
|
314
|
+
if (!alreadyFound) {
|
|
315
|
+
result.findings.push({
|
|
316
|
+
severity: "critical",
|
|
317
|
+
title: `CVE-2025-29927: middleware bypass on ${ep.path}`,
|
|
318
|
+
description: "x-middleware-subrequest header bypasses auth",
|
|
319
|
+
url: `${target}${ep.path}`,
|
|
320
|
+
status: 200,
|
|
321
|
+
category: "cve",
|
|
322
|
+
source: "framework-check",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── Phase 9: Cookie Security Audit ──────────────────────────────────────
|
|
329
|
+
progress("Auditing cookie security...");
|
|
330
|
+
try {
|
|
331
|
+
const cookieRes = await safeFetch(target, { timeout });
|
|
332
|
+
if (cookieRes) {
|
|
333
|
+
const setCookies = cookieRes.headers.getSetCookie?.() || [];
|
|
334
|
+
if (setCookies.length > 0) {
|
|
335
|
+
const cookies = [];
|
|
336
|
+
for (const raw of setCookies) {
|
|
337
|
+
const name = raw.split("=")[0].trim();
|
|
338
|
+
const lower = raw.toLowerCase();
|
|
339
|
+
const flags = {
|
|
340
|
+
secure: lower.includes("secure"),
|
|
341
|
+
httpOnly: lower.includes("httponly"),
|
|
342
|
+
sameSite: lower.match(/samesite=(strict|lax|none)/i)?.[1] || null,
|
|
343
|
+
};
|
|
344
|
+
cookies.push({ name, ...flags, raw });
|
|
345
|
+
|
|
346
|
+
const issues = [];
|
|
347
|
+
if (!flags.httpOnly) issues.push("missing HttpOnly");
|
|
348
|
+
if (!flags.secure && !local) issues.push("missing Secure");
|
|
349
|
+
if (!flags.sameSite) issues.push("missing SameSite");
|
|
350
|
+
|
|
351
|
+
if (issues.length > 0) {
|
|
352
|
+
const isSession = /sess|token|auth|jwt|sid/i.test(name);
|
|
353
|
+
result.findings.push({
|
|
354
|
+
severity: isSession ? "medium" : "low",
|
|
355
|
+
title: `Cookie "${name}" ${issues.join(", ")}`,
|
|
356
|
+
description: `Set-Cookie: ${raw.slice(0, 120)}`,
|
|
357
|
+
url: target,
|
|
358
|
+
category: "cookie",
|
|
359
|
+
source: "cookie-audit",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
result.cookies = cookies;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch { /* non-critical */ }
|
|
367
|
+
|
|
368
|
+
// ── Phase 9b: Email authentication posture (SPF/DMARC via DNS) ─────────
|
|
369
|
+
if (!local && !isIpHost) {
|
|
370
|
+
progress("Checking email authentication (SPF, DMARC)...");
|
|
371
|
+
try {
|
|
372
|
+
const emailResult = await probeEmailDns(extractDomain(target));
|
|
373
|
+
result.emailAuth = {
|
|
374
|
+
spf: emailResult.spf,
|
|
375
|
+
dmarc: emailResult.dmarc,
|
|
376
|
+
dmarcPolicy: emailResult.dmarcPolicy,
|
|
377
|
+
mxCount: emailResult.mxCount,
|
|
378
|
+
};
|
|
379
|
+
result.findings.push(...emailResult.findings);
|
|
380
|
+
} catch { /* DNS failure, non-critical */ }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Phase 10: Retire.js, Vulnerable JS Libraries ─────────────────────
|
|
384
|
+
if (!opts.skipRetireJs) {
|
|
385
|
+
progress("Scanning for vulnerable JS libraries (Retire.js)...");
|
|
386
|
+
try {
|
|
387
|
+
const jsResult = await scanJsLibraries(target, { onPhase: progress });
|
|
388
|
+
if (jsResult.libraries.length > 0) {
|
|
389
|
+
result.jsLibraries = jsResult.libraries;
|
|
390
|
+
}
|
|
391
|
+
result.findings.push(...jsResult.findings);
|
|
392
|
+
} catch { /* non-critical */ }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Phase 11: Parameter Discovery (Arjun-style) ───────────────────────
|
|
396
|
+
if (!opts.skipParamDiscovery && filtered.length > 0) {
|
|
397
|
+
progress("Discovering hidden parameters (Arjun-style)...");
|
|
398
|
+
try {
|
|
399
|
+
const paramResult = await discoverAllParams(target, filtered, {
|
|
400
|
+
onPhase: progress,
|
|
401
|
+
maxEndpoints: 5,
|
|
402
|
+
});
|
|
403
|
+
if (paramResult.results.length > 0) {
|
|
404
|
+
result.paramDiscovery = paramResult.results.filter((r) => r.params.length > 0);
|
|
405
|
+
}
|
|
406
|
+
result.findings.push(...paramResult.findings);
|
|
407
|
+
} catch { /* non-critical */ }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Phase 12-13: OSINT Recon (--recon) ────────────────────────────────
|
|
411
|
+
if (opts.recon && local) {
|
|
412
|
+
progress("Skipping OSINT recon (localhost target)...");
|
|
413
|
+
}
|
|
414
|
+
if (opts.recon && !local) {
|
|
415
|
+
const domain = extractDomain(target);
|
|
416
|
+
progress(`Running OSINT recon on ${domain}...`);
|
|
417
|
+
try {
|
|
418
|
+
result.recon = await runRecon(domain, { onPhase: progress });
|
|
419
|
+
|
|
420
|
+
// Add recon-discovered endpoints to the discovery pool
|
|
421
|
+
if (result.recon.endpoints.length > 0) {
|
|
422
|
+
progress(`Probing ${result.recon.endpoints.length} historically known endpoints...`);
|
|
423
|
+
for (const path of result.recon.endpoints.slice(0, 50)) {
|
|
424
|
+
const res = await safeFetch(`${target}${path}`, { timeout: 5_000 });
|
|
425
|
+
if (res && res.status !== 404 && res.status < 500) {
|
|
426
|
+
const alreadyKnown = filtered.some((ep) => ep.path === path);
|
|
427
|
+
if (!alreadyKnown) {
|
|
428
|
+
filtered.push({ path, status: res.status, size: 0 });
|
|
429
|
+
result.endpoints.discovered++;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Update byStatus
|
|
434
|
+
result.endpoints.byStatus = {};
|
|
435
|
+
for (const ep of filtered) {
|
|
436
|
+
result.endpoints.byStatus[ep.status] = (result.endpoints.byStatus[ep.status] || 0) + 1;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} catch (e) {
|
|
440
|
+
result.findings.push({
|
|
441
|
+
severity: "info",
|
|
442
|
+
title: "OSINT recon partially failed",
|
|
443
|
+
description: e.message,
|
|
444
|
+
category: "recon",
|
|
445
|
+
source: "recon",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Phase 14-19: Injection Probes (--deep) ─────────────────────────────
|
|
451
|
+
if (opts.deep) {
|
|
452
|
+
progress("Running active injection probes (SQLi, SSTI, SSRF, redirect, CMDi, XSS)...");
|
|
453
|
+
const injectionFindings = await runInjectionProbes(target, filtered, {
|
|
454
|
+
onPhase: progress,
|
|
455
|
+
maxEndpoints: 10,
|
|
456
|
+
});
|
|
457
|
+
result.findings.push(...injectionFindings);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Phase 20: Fuzzing (--fuzz) ──────────────────────────────────────────
|
|
461
|
+
if (opts.fuzz) {
|
|
462
|
+
progress("Fuzzing discovered POST endpoints...");
|
|
463
|
+
const postCandidates = filtered.filter(
|
|
464
|
+
(ep) => ep.status !== 404 && (ep.path.includes("/api/") || ep.path.includes("/graphql")),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
for (const ep of postCandidates.slice(0, 5)) {
|
|
468
|
+
const findings = await fuzzEndpoint(
|
|
469
|
+
target,
|
|
470
|
+
ep.path,
|
|
471
|
+
{ email: "test@example.com", name: "Test", message: "Hello", id: "test-id" },
|
|
472
|
+
{ maxPayloads: 15, timeout: 8_000 },
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
for (const f of findings) {
|
|
476
|
+
result.findings.push({
|
|
477
|
+
severity: f.status === 500 ? "medium" : "low",
|
|
478
|
+
title: `Fuzz: ${f.detail || "interesting response"}`,
|
|
479
|
+
description: `field=${f.field} payload=${String(f.payload).slice(0, 60)}`,
|
|
480
|
+
url: `${target}${ep.path}`,
|
|
481
|
+
status: f.status,
|
|
482
|
+
category: "fuzzing",
|
|
483
|
+
source: "fuzzer",
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── Phase 21: Custom templates (--templates / --nuclei) ─────────────────
|
|
490
|
+
const templatePaths = [
|
|
491
|
+
...(opts.templatePaths || []),
|
|
492
|
+
...(opts.nucleiPath ? [opts.nucleiPath] : []),
|
|
493
|
+
];
|
|
494
|
+
|
|
495
|
+
if (templatePaths.length > 0) {
|
|
496
|
+
progress(`Loading templates from ${templatePaths.length} path(s)...`);
|
|
497
|
+
try {
|
|
498
|
+
const { loadTemplatesFromPaths, runTemplateScan } = await import("../lib/plugins.js");
|
|
499
|
+
const templates = await loadTemplatesFromPaths(templatePaths, {
|
|
500
|
+
severity: ["critical", "high", "medium"],
|
|
501
|
+
});
|
|
502
|
+
progress(`Loaded ${templates.length} templates, scanning...`);
|
|
503
|
+
const pluginFindings = await runTemplateScan(target, templates, { onPhase: progress });
|
|
504
|
+
result.findings.push(...pluginFindings);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
result.findings.push({
|
|
507
|
+
severity: "info",
|
|
508
|
+
title: "Template loading failed",
|
|
509
|
+
description: e.message,
|
|
510
|
+
category: "config",
|
|
511
|
+
source: "plugin-loader",
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── Phase 22: Adaptive probes (--adaptive) ─────────────────────────────
|
|
517
|
+
if (opts.adaptive) {
|
|
518
|
+
progress("Running adaptive security probes...");
|
|
519
|
+
try {
|
|
520
|
+
const { runAdaptiveProbes } = await import("../lib/blackbox/adaptive-scan.js");
|
|
521
|
+
const adaptiveFindings = await runAdaptiveProbes(target, {
|
|
522
|
+
spec: openApiResult.spec,
|
|
523
|
+
paths: openApiResult.paths,
|
|
524
|
+
endpoints: filtered,
|
|
525
|
+
onPhase: progress,
|
|
526
|
+
});
|
|
527
|
+
result.findings.push(...adaptiveFindings);
|
|
528
|
+
} catch (e) {
|
|
529
|
+
result.findings.push({
|
|
530
|
+
severity: "info",
|
|
531
|
+
title: "Adaptive probe phase failed",
|
|
532
|
+
description: e.message,
|
|
533
|
+
category: "config",
|
|
534
|
+
source: "adaptive-probe",
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Record which probes actually executed ──────────────────────────────
|
|
540
|
+
// Drives an honest WSTG coverage report: we only claim a probe ran when it
|
|
541
|
+
// truly did. (Unreachable targets return early above with executedProbes: [].)
|
|
542
|
+
const hadEndpoints = filtered.length > 0;
|
|
543
|
+
result.executedProbes = [
|
|
544
|
+
"reachability", "endpoint-discovery", "openapi", "auth-hardening",
|
|
545
|
+
"client-auth", "idor", "oauth", "sensitive-files", "cors", "cookie",
|
|
546
|
+
...(!local && !isIpHost ? ["email-dns"] : []),
|
|
547
|
+
...(result.tls ? ["tls"] : []),
|
|
548
|
+
...(result.fingerprint?.headers ? ["security-headers"] : []),
|
|
549
|
+
...(auth.bearer ? ["jwt"] : []),
|
|
550
|
+
...(!opts.skipRetireJs ? ["retirejs"] : []),
|
|
551
|
+
...(!opts.skipParamDiscovery && hadEndpoints ? ["param-discovery"] : []),
|
|
552
|
+
...(opts.recon && !local ? ["recon"] : []),
|
|
553
|
+
...(opts.deep && hadEndpoints ? ["sqli", "ssti", "ssrf", "xss", "cmdi", "open-redirect"] : []),
|
|
554
|
+
...(opts.fuzz && filtered.some((ep) => ep.path.includes("/api/") || ep.path.includes("/graphql")) ? ["api-fuzzing"] : []),
|
|
555
|
+
...(opts.adaptive ? ["adaptive"] : []),
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
// ── Deduplicate findings ───────────────────────────────────────────────
|
|
559
|
+
result.findings = dedupeFindings(result.findings);
|
|
560
|
+
|
|
561
|
+
result.duration = Date.now() - startTime;
|
|
562
|
+
return result;
|
|
563
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build auth headers from scan options or environment.
|
|
3
|
+
*/
|
|
4
|
+
export function resolveAuth(opts = {}) {
|
|
5
|
+
const cookie = opts.authCookie || process.env.PENTHERA_COOKIE || null;
|
|
6
|
+
const bearer = opts.authBearer || process.env.PENTHERA_BEARER || null;
|
|
7
|
+
|
|
8
|
+
const headers = {};
|
|
9
|
+
if (cookie) headers.Cookie = cookie;
|
|
10
|
+
if (bearer) headers.Authorization = bearer.startsWith("Bearer ") ? bearer : `Bearer ${bearer}`;
|
|
11
|
+
|
|
12
|
+
return { cookie, bearer, headers };
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT = 8_000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch with timeout and graceful failure (returns null on network error).
|
|
5
|
+
*/
|
|
6
|
+
export async function safeFetch(url, opts = {}) {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeout || DEFAULT_TIMEOUT);
|
|
9
|
+
try {
|
|
10
|
+
return await fetch(url, {
|
|
11
|
+
...opts,
|
|
12
|
+
signal: controller.signal,
|
|
13
|
+
redirect: opts.redirect ?? "manual",
|
|
14
|
+
});
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
} finally {
|
|
18
|
+
clearTimeout(timeout);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fetch helper that always returns status, body text, and headers.
|
|
24
|
+
*/
|
|
25
|
+
export async function probeFetch(url, opts = {}) {
|
|
26
|
+
const res = await safeFetch(url, opts);
|
|
27
|
+
if (!res) return null;
|
|
28
|
+
|
|
29
|
+
const body = await res.text().catch(() => "");
|
|
30
|
+
const headers = Object.fromEntries(res.headers.entries());
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
status: res.status,
|
|
34
|
+
body,
|
|
35
|
+
headers,
|
|
36
|
+
ok: res.ok,
|
|
37
|
+
};
|
|
38
|
+
}
|
package/src/utils/url.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const PRIVATE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]);
|
|
2
|
+
|
|
3
|
+
/** Strip trailing slash so path joins do not produce double slashes. */
|
|
4
|
+
export function normalizeBaseUrl(url) {
|
|
5
|
+
const parsed = new URL(url);
|
|
6
|
+
const path = parsed.pathname.replace(/\/+$/, "") || "";
|
|
7
|
+
return `${parsed.origin}${path}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Join base URL and path safely: `http://host` + `/api` → `http://host/api`. */
|
|
11
|
+
export function joinUrl(baseUrl, path = "/") {
|
|
12
|
+
const base = normalizeBaseUrl(baseUrl);
|
|
13
|
+
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
14
|
+
return `${base}${suffix}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isPrivateHost(url) {
|
|
18
|
+
try {
|
|
19
|
+
const { hostname } = new URL(url);
|
|
20
|
+
if (PRIVATE_HOSTS.has(hostname)) return true;
|
|
21
|
+
if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname)) return true;
|
|
22
|
+
return false;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True only when a redirect actually SENDS the browser to `attackerHost`, i.e.
|
|
30
|
+
* the Location header resolves to that host's origin. A Location that merely
|
|
31
|
+
* contains the attacker string in its path or query (e.g. an apex→www
|
|
32
|
+
* canonicalization redirect that preserves the query string) is NOT an open
|
|
33
|
+
* redirect, and must not be flagged as one.
|
|
34
|
+
*/
|
|
35
|
+
export function redirectsToHost(locationHeader, requestUrl, attackerHost) {
|
|
36
|
+
if (!locationHeader) return false;
|
|
37
|
+
try {
|
|
38
|
+
const dest = new URL(locationHeader, requestUrl); // resolve relative/protocol-relative
|
|
39
|
+
const host = dest.hostname.toLowerCase();
|
|
40
|
+
const attacker = String(attackerHost).toLowerCase();
|
|
41
|
+
return host === attacker || host.endsWith(`.${attacker}`);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convert a URL into a safe filename fragment. */
|
|
48
|
+
export function urlToFilename(url) {
|
|
49
|
+
return url
|
|
50
|
+
.replace(/^https?:\/\//, "")
|
|
51
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
52
|
+
.replace(/_+/g, "_")
|
|
53
|
+
.slice(0, 80);
|
|
54
|
+
}
|