jaku.sh 1.0.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 (69) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +636 -0
  3. package/action.yml +264 -0
  4. package/bin/jaku +2 -0
  5. package/package.json +62 -0
  6. package/src/agents/ai-agent.js +175 -0
  7. package/src/agents/api-agent.js +95 -0
  8. package/src/agents/base-agent.js +158 -0
  9. package/src/agents/crawl-agent.js +175 -0
  10. package/src/agents/event-bus.js +59 -0
  11. package/src/agents/findings-ledger.js +410 -0
  12. package/src/agents/logic-agent.js +144 -0
  13. package/src/agents/orchestrator.js +323 -0
  14. package/src/agents/qa-agent.js +149 -0
  15. package/src/agents/security-agent.js +211 -0
  16. package/src/cli.js +423 -0
  17. package/src/core/accessibility-checker.js +171 -0
  18. package/src/core/ai/ai-endpoint-detector.js +227 -0
  19. package/src/core/ai/guardrail-prober.js +362 -0
  20. package/src/core/ai/indirect-injector.js +106 -0
  21. package/src/core/ai/jailbreak-tester.js +212 -0
  22. package/src/core/ai/model-dos-tester.js +174 -0
  23. package/src/core/ai/model-fingerprinter.js +246 -0
  24. package/src/core/ai/multi-turn-attacker.js +297 -0
  25. package/src/core/ai/output-analyzer.js +182 -0
  26. package/src/core/ai/prompt-injector.js +543 -0
  27. package/src/core/ai/system-prompt-extractor.js +244 -0
  28. package/src/core/api/api-key-auditor.js +266 -0
  29. package/src/core/api/auth-flow-tester.js +430 -0
  30. package/src/core/api/cors-ws-tester.js +263 -0
  31. package/src/core/api/graphql-tester.js +287 -0
  32. package/src/core/api/oauth-prober.js +343 -0
  33. package/src/core/auth-manager.js +902 -0
  34. package/src/core/broken-flow-detector.js +207 -0
  35. package/src/core/browser-manager.js +119 -0
  36. package/src/core/console-monitor.js +111 -0
  37. package/src/core/crawler.js +430 -0
  38. package/src/core/csr-waiter.js +410 -0
  39. package/src/core/form-validator.js +240 -0
  40. package/src/core/logic/abuse-pattern-scanner.js +291 -0
  41. package/src/core/logic/access-boundary-tester.js +448 -0
  42. package/src/core/logic/business-rule-inferrer.js +196 -0
  43. package/src/core/logic/graphql-auditor.js +298 -0
  44. package/src/core/logic/parameter-polluter.js +212 -0
  45. package/src/core/logic/pricing-exploiter.js +299 -0
  46. package/src/core/logic/race-condition-detector.js +222 -0
  47. package/src/core/logic/workflow-enforcer.js +284 -0
  48. package/src/core/performance-checker.js +204 -0
  49. package/src/core/responsive-checker.js +228 -0
  50. package/src/core/security/cors-prober.js +150 -0
  51. package/src/core/security/csrf-prober.js +217 -0
  52. package/src/core/security/dependency-auditor.js +182 -0
  53. package/src/core/security/file-upload-tester.js +340 -0
  54. package/src/core/security/header-analyzer.js +324 -0
  55. package/src/core/security/infra-scanner.js +391 -0
  56. package/src/core/security/path-traversal.js +112 -0
  57. package/src/core/security/prototype-pollution.js +147 -0
  58. package/src/core/security/secret-detector.js +517 -0
  59. package/src/core/security/sqli-prober.js +257 -0
  60. package/src/core/security/tls-checker.js +223 -0
  61. package/src/core/security/xss-scanner.js +225 -0
  62. package/src/core/test-generator.js +339 -0
  63. package/src/core/test-runner.js +398 -0
  64. package/src/reporting/diff-reporter.js +172 -0
  65. package/src/reporting/report-generator.js +408 -0
  66. package/src/reporting/sarif-generator.js +190 -0
  67. package/src/utils/config.js +57 -0
  68. package/src/utils/finding.js +67 -0
  69. package/src/utils/logger.js +50 -0
@@ -0,0 +1,228 @@
1
+ import { chromium } from 'playwright';
2
+ import { createFinding } from '../utils/finding.js';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+
6
+ /**
7
+ * Responsive Checker — Tests pages across viewport breakpoints.
8
+ * Detects horizontal overflow, overlapping elements, and captures screenshots.
9
+ */
10
+ export class ResponsiveChecker {
11
+ constructor(config, logger) {
12
+ this.config = config;
13
+ this.logger = logger;
14
+ this.findings = [];
15
+ this.screenshotDir = path.join(config.output_dir || 'jaku-reports', 'screenshots', 'responsive');
16
+ this.viewports = config.viewports || {
17
+ mobile: { width: 375, height: 812 },
18
+ tablet: { width: 768, height: 1024 },
19
+ desktop: { width: 1440, height: 900 },
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Check responsiveness of all crawled pages.
25
+ */
26
+ async check(surfaceInventory) {
27
+ if (!fs.existsSync(this.screenshotDir)) {
28
+ fs.mkdirSync(this.screenshotDir, { recursive: true });
29
+ }
30
+
31
+ // Only test a subset of pages for responsiveness (top-level pages, not API endpoints)
32
+ const pagesToTest = surfaceInventory.pages
33
+ .filter(p => typeof p.status === 'number' && p.status < 400)
34
+ .slice(0, 20); // Cap at 20 pages
35
+
36
+ if (pagesToTest.length === 0) {
37
+ this.logger?.info?.('No valid pages to check responsiveness');
38
+ return [];
39
+ }
40
+
41
+ const browser = await chromium.launch({ headless: true });
42
+
43
+ for (const page of pagesToTest) {
44
+ await this._checkPage(browser, page);
45
+ }
46
+
47
+ await browser.close();
48
+ this.logger?.info?.(`Responsive checker found ${this.findings.length} issues`);
49
+ return this.findings;
50
+ }
51
+
52
+ async _checkPage(browser, pageData) {
53
+ for (const [viewportName, viewport] of Object.entries(this.viewports)) {
54
+ const context = await browser.newContext({
55
+ viewport,
56
+ ignoreHTTPSErrors: true,
57
+ });
58
+ const page = await context.newPage();
59
+
60
+ try {
61
+ await page.goto(pageData.url, { waitUntil: 'networkidle', timeout: 15000 });
62
+
63
+ // Capture screenshot
64
+ const screenshotName = `${this._sanitizeFilename(pageData.url)}-${viewportName}.png`;
65
+ const screenshotPath = path.join(this.screenshotDir, screenshotName);
66
+ await page.screenshot({ path: screenshotPath, fullPage: true });
67
+
68
+ // Check for horizontal overflow
69
+ const overflowData = await page.evaluate(() => {
70
+ const docWidth = document.documentElement.scrollWidth;
71
+ const viewWidth = window.innerWidth;
72
+ const isOverflowing = docWidth > viewWidth;
73
+
74
+ // Find elements causing overflow
75
+ const overflowingElements = [];
76
+ if (isOverflowing) {
77
+ const allElements = document.querySelectorAll('*');
78
+ for (const el of allElements) {
79
+ const rect = el.getBoundingClientRect();
80
+ if (rect.right > viewWidth + 5) {
81
+ overflowingElements.push({
82
+ tag: el.tagName,
83
+ id: el.id,
84
+ class: el.className?.toString?.()?.substring(0, 50) || '',
85
+ width: Math.round(rect.width),
86
+ right: Math.round(rect.right),
87
+ });
88
+ if (overflowingElements.length >= 5) break;
89
+ }
90
+ }
91
+ }
92
+
93
+ return {
94
+ docWidth,
95
+ viewWidth,
96
+ isOverflowing,
97
+ overflowingElements,
98
+ };
99
+ });
100
+
101
+ if (overflowData.isOverflowing) {
102
+ this.findings.push(
103
+ createFinding({
104
+ module: 'qa',
105
+ title: `Horizontal Overflow at ${viewportName}: ${this._shortUrl(pageData.url)}`,
106
+ severity: 'medium',
107
+ affected_surface: pageData.url,
108
+ description: `Page content (${overflowData.docWidth}px) exceeds ${viewportName} viewport width (${overflowData.viewWidth}px). This causes a horizontal scrollbar and breaks the responsive layout.\n\nOverflowing elements:\n${overflowData.overflowingElements.map(e => `- <${e.tag}> (${e.id || e.class || 'no id/class'}): ${e.width}px wide, extends to ${e.right}px`).join('\n')}`,
109
+ reproduction: [
110
+ `1. Open ${pageData.url}`,
111
+ `2. Set viewport to ${viewport.width}×${viewport.height} (${viewportName})`,
112
+ `3. Observe horizontal scrollbar`,
113
+ ],
114
+ evidence: JSON.stringify(overflowData, null, 2),
115
+ remediation: `Fix the overflow by adding max-width: 100%, overflow-x: hidden, or using responsive CSS. Check the identified elements for fixed widths.`,
116
+ })
117
+ );
118
+ }
119
+
120
+ // Check for overlapping interactive elements
121
+ const overlapData = await page.evaluate(() => {
122
+ const interactive = Array.from(
123
+ document.querySelectorAll('a, button, input, select, textarea, [role="button"]')
124
+ );
125
+ const overlaps = [];
126
+
127
+ for (let i = 0; i < interactive.length - 1; i++) {
128
+ for (let j = i + 1; j < interactive.length; j++) {
129
+ const r1 = interactive[i].getBoundingClientRect();
130
+ const r2 = interactive[j].getBoundingClientRect();
131
+
132
+ // Skip invisible elements
133
+ if (r1.width === 0 || r1.height === 0 || r2.width === 0 || r2.height === 0) continue;
134
+
135
+ const overlap = !(r1.right < r2.left || r1.left > r2.right ||
136
+ r1.bottom < r2.top || r1.top > r2.bottom);
137
+
138
+ if (overlap) {
139
+ overlaps.push({
140
+ el1: { tag: interactive[i].tagName, text: interactive[i].textContent?.substring(0, 30) },
141
+ el2: { tag: interactive[j].tagName, text: interactive[j].textContent?.substring(0, 30) },
142
+ });
143
+ if (overlaps.length >= 3) break;
144
+ }
145
+ }
146
+ if (overlaps.length >= 3) break;
147
+ }
148
+
149
+ return overlaps;
150
+ });
151
+
152
+ if (overlapData.length > 0) {
153
+ this.findings.push(
154
+ createFinding({
155
+ module: 'qa',
156
+ title: `Overlapping Elements at ${viewportName}: ${this._shortUrl(pageData.url)}`,
157
+ severity: 'low',
158
+ affected_surface: pageData.url,
159
+ description: `${overlapData.length} pair(s) of interactive elements overlap at ${viewportName} (${viewport.width}×${viewport.height}). This makes them difficult or impossible to click.\n\n${overlapData.map(o => `- <${o.el1.tag}> "${o.el1.text}" overlaps with <${o.el2.tag}> "${o.el2.text}"`).join('\n')}`,
160
+ reproduction: [
161
+ `1. Open ${pageData.url}`,
162
+ `2. Set viewport to ${viewport.width}×${viewport.height} (${viewportName})`,
163
+ `3. Observe overlapping interactive elements`,
164
+ ],
165
+ evidence: JSON.stringify(overlapData, null, 2),
166
+ remediation: 'Use responsive CSS, media queries, or flexbox/grid to ensure interactive elements do not overlap at smaller viewports.',
167
+ })
168
+ );
169
+ }
170
+
171
+ // Check for tiny text (< 12px)
172
+ const tinyText = await page.evaluate(() => {
173
+ const allText = document.querySelectorAll('p, span, a, li, td, th, label, div');
174
+ let tinyCount = 0;
175
+ for (const el of allText) {
176
+ const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
177
+ if (fontSize < 12 && el.textContent?.trim()) {
178
+ tinyCount++;
179
+ }
180
+ }
181
+ return tinyCount;
182
+ });
183
+
184
+ if (viewportName === 'mobile' && tinyText > 10) {
185
+ this.findings.push(
186
+ createFinding({
187
+ module: 'qa',
188
+ title: `Tiny Text on Mobile: ${this._shortUrl(pageData.url)}`,
189
+ severity: 'low',
190
+ affected_surface: pageData.url,
191
+ description: `Found ${tinyText} text elements with font-size below 12px on mobile viewport. This makes content difficult to read without zooming.`,
192
+ reproduction: [
193
+ `1. Open ${pageData.url} on mobile (375×812)`,
194
+ `2. Observe small, hard-to-read text`,
195
+ ],
196
+ remediation: 'Use a minimum font-size of 14px on mobile viewports. Add media queries to scale text appropriately.',
197
+ })
198
+ );
199
+ }
200
+ } catch (err) {
201
+ this.logger?.debug?.(`Responsive check failed for ${pageData.url} at ${viewportName}: ${err.message}`);
202
+ } finally {
203
+ await page.close();
204
+ await context.close();
205
+ }
206
+ }
207
+ }
208
+
209
+ _shortUrl(url) {
210
+ try {
211
+ const u = new URL(url);
212
+ return u.pathname === '/' ? u.hostname : u.pathname;
213
+ } catch {
214
+ return url;
215
+ }
216
+ }
217
+
218
+ _sanitizeFilename(url) {
219
+ try {
220
+ const u = new URL(url);
221
+ return (u.hostname + u.pathname).replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50);
222
+ } catch {
223
+ return 'page';
224
+ }
225
+ }
226
+ }
227
+
228
+ export default ResponsiveChecker;
@@ -0,0 +1,150 @@
1
+ import { createFinding } from '../../utils/finding.js';
2
+
3
+ /**
4
+ * CORSProber — Tests for Cross-Origin Resource Sharing misconfigurations.
5
+ *
6
+ * Common misconfigs:
7
+ * 1. Wildcard with credentials — Access-Control-Allow-Origin: * + Allow-Credentials: true (impossible per spec, but some servers do it anyway)
8
+ * 2. Arbitrary origin reflection — server mirrors back whatever Origin: header is sent
9
+ * 3. Null origin accepted — Origin: null bypasses same-origin policy
10
+ * 4. Pre-flight bypass — complex request proceeds without OPTIONS check
11
+ * 5. Subdomain wildcard — *.yourapp.com allows evil.yourapp.com (subdomain takeover vector)
12
+ * 6. Trusted domain substring match — trusts "evil-yourapp.com" because it contains "yourapp.com"
13
+ */
14
+ export class CORSProber {
15
+ constructor(logger) {
16
+ this.logger = logger;
17
+ }
18
+
19
+ async probe(surfaceInventory) {
20
+ const findings = [];
21
+ const tested = new Set();
22
+
23
+ // Test API endpoints and key pages
24
+ const targets = [
25
+ ...surfaceInventory.pages.filter(p => p.status < 400),
26
+ ...(surfaceInventory.apis || []),
27
+ ];
28
+
29
+ for (const target of targets) {
30
+ const url = target.url || target;
31
+ if (!url || tested.has(url)) continue;
32
+ tested.add(url);
33
+
34
+ try {
35
+ const result = await this._testCORS(url);
36
+ if (result) findings.push(result);
37
+ } catch (err) {
38
+ this.logger?.debug?.(`CORS probe failed for ${url}: ${err.message}`);
39
+ }
40
+ }
41
+
42
+ this.logger?.info?.(`CORS Prober: found ${findings.length} misconfigurations`);
43
+ return findings;
44
+ }
45
+
46
+ async _testCORS(url) {
47
+ const testOrigins = [
48
+ { origin: 'https://evil.attacker.com', label: 'arbitrary external origin' },
49
+ { origin: 'null', label: 'null origin' },
50
+ { origin: `https://evil.${new URL(url).hostname}`, label: 'evil subdomain of target' },
51
+ { origin: `https://evil-${new URL(url).hostname}`, label: 'substring match bypass' },
52
+ ];
53
+
54
+ for (const { origin, label } of testOrigins) {
55
+ try {
56
+ const controller = new AbortController();
57
+ const timeout = setTimeout(() => controller.abort(), 10000);
58
+
59
+ const response = await fetch(url, {
60
+ method: 'GET',
61
+ headers: { 'Origin': origin },
62
+ signal: controller.signal,
63
+ });
64
+ clearTimeout(timeout);
65
+
66
+ const acao = response.headers.get('access-control-allow-origin');
67
+ const acac = response.headers.get('access-control-allow-credentials');
68
+
69
+ if (!acao) continue;
70
+
71
+ // Check 1: Arbitrary origin reflected
72
+ if (acao === origin || acao === '*') {
73
+ const withCredentials = acac === 'true';
74
+ const severity = withCredentials ? 'critical' : (acao === '*' ? 'low' : 'high');
75
+
76
+ if (withCredentials && acao === '*') continue; // Impossible per spec, browser blocks it
77
+
78
+ return createFinding({
79
+ module: 'security',
80
+ title: `CORS Misconfiguration: ${label}`,
81
+ severity,
82
+ affected_surface: url,
83
+ description: `The server at ${url} reflects a ${label} in its CORS response header (Access-Control-Allow-Origin: ${acao}${withCredentials ? ', Access-Control-Allow-Credentials: true' : ''}). ${withCredentials
84
+ ? 'This is a CRITICAL misconfiguration — credentialed cross-origin requests from any origin are permitted, enabling session hijacking from any website.'
85
+ : 'This allows cross-origin reads from any origin.'}`,
86
+ reproduction: [
87
+ `1. Send a request to ${url} with header: Origin: ${origin}`,
88
+ `2. Response includes: Access-Control-Allow-Origin: ${acao}`,
89
+ withCredentials ? '3. Access-Control-Allow-Credentials: true is also present' : '',
90
+ `4. Any website on the internet can now read the response with ${withCredentials ? 'user credentials' : 'no credentials'}`,
91
+ ].filter(Boolean),
92
+ evidence: `Request Origin: ${origin}\nResponse ACAO: ${acao}\nResponse ACAC: ${acac || 'not set'}`,
93
+ remediation: 'Only allow specific, known origins in CORS policy. Never reflect the request Origin back directly. Never combine Access-Control-Allow-Credentials: true with a wildcard or arbitrary origin. Maintain an explicit allowlist of trusted origins.',
94
+ references: [
95
+ 'https://portswigger.net/web-security/cors',
96
+ 'https://owasp.org/www-community/attacks/CORS_RequestPreflightScrutiny',
97
+ 'CWE-942',
98
+ ],
99
+ });
100
+ }
101
+ } catch { /* continue to next origin */ }
102
+ }
103
+
104
+ // Check pre-flight bypass: send complex request without OPTIONS
105
+ await this._testPreflightBypass(url, findings);
106
+
107
+ return null;
108
+ }
109
+
110
+ async _testPreflightBypass(url, findings) {
111
+ try {
112
+ const controller = new AbortController();
113
+ const timeout = setTimeout(() => controller.abort(), 8000);
114
+
115
+ // Complex request that SHOULD trigger pre-flight but might not
116
+ const response = await fetch(url, {
117
+ method: 'PUT',
118
+ headers: {
119
+ 'Origin': 'https://evil.attacker.com',
120
+ 'Content-Type': 'application/json',
121
+ 'X-Custom-Header': 'jaku-test',
122
+ },
123
+ body: JSON.stringify({ test: 'preflight-bypass' }),
124
+ signal: controller.signal,
125
+ });
126
+ clearTimeout(timeout);
127
+
128
+ const acao = response.headers.get('access-control-allow-origin');
129
+ if (acao === 'https://evil.attacker.com' || acao === '*') {
130
+ findings.push(createFinding({
131
+ module: 'security',
132
+ title: 'CORS Pre-flight Bypass: Complex requests allowed from arbitrary origin',
133
+ severity: 'high',
134
+ affected_surface: url,
135
+ description: `${url} accepts complex cross-origin PUT requests from arbitrary origins without requiring a proper OPTIONS pre-flight check. This allows attackers to make state-changing requests on behalf of authenticated users from any origin.`,
136
+ reproduction: [
137
+ `1. Send PUT ${url} with Origin: https://evil.attacker.com`,
138
+ `2. Server responds with Access-Control-Allow-Origin: ${acao}`,
139
+ '3. Complex cross-origin request succeeds without pre-flight',
140
+ ],
141
+ evidence: `Method: PUT | Origin: https://evil.attacker.com | Response ACAO: ${acao}`,
142
+ remediation: 'Ensure all state-changing endpoints trigger and validate CORS pre-flight requests. On the server side, explicitly validate the Origin header against an allowlist for all methods other than GET/HEAD.',
143
+ references: ['https://portswigger.net/web-security/cors'],
144
+ }));
145
+ }
146
+ } catch { /* ignore */ }
147
+ }
148
+ }
149
+
150
+ export default CORSProber;
@@ -0,0 +1,217 @@
1
+ import { chromium } from 'playwright';
2
+ import { createFinding } from '../../utils/finding.js';
3
+
4
+ /**
5
+ * CSRFProber — Tests for Cross-Site Request Forgery vulnerabilities.
6
+ *
7
+ * Active tests:
8
+ * 1. State-changing GET requests (no CSRF protection by design)
9
+ * 2. Missing SameSite cookie attribute
10
+ * 3. CSRF token absent on state-changing forms
11
+ * 4. Weak CSRF token (predictable, short, static)
12
+ * 5. Double-submit cookie bypass
13
+ * 6. Custom header bypass attempt (some apps use X-Requested-With as CSRF protection)
14
+ */
15
+ export class CSRFProber {
16
+ constructor(logger) {
17
+ this.logger = logger;
18
+ }
19
+
20
+ async probe(surfaceInventory) {
21
+ const findings = [];
22
+
23
+ // Test 1: State-changing GET endpoints
24
+ findings.push(...await this._testStateChangingGET(surfaceInventory));
25
+
26
+ // Test 2: Cookie SameSite attribute
27
+ findings.push(...await this._testCookieSameSite(surfaceInventory));
28
+
29
+ // Test 3: CSRF token validation on forms
30
+ findings.push(...await this._testFormCSRFTokens(surfaceInventory));
31
+
32
+ this.logger?.info?.(`CSRF Prober: found ${findings.length} issues`);
33
+ return findings;
34
+ }
35
+
36
+ /**
37
+ * Detect state-changing GET endpoints which are inherently CSRF-vulnerable
38
+ * (Safe HTTP methods should never change state).
39
+ */
40
+ async _testStateChangingGET(surfaceInventory) {
41
+ const findings = [];
42
+ const stateChangingPatterns = [
43
+ /\/(delete|remove|destroy|logout|signout|clear|reset|confirm|activate|deactivate|ban|unban|approve|reject|cancel|archive)/i,
44
+ /[?&](action|do|cmd|command)=(delete|remove|logout|reset|confirm|activate|deactivate)/i,
45
+ ];
46
+
47
+ for (const page of surfaceInventory.pages) {
48
+ if (!page.url || page.status >= 400) continue;
49
+ const url = page.url;
50
+
51
+ for (const pattern of stateChangingPatterns) {
52
+ if (pattern.test(url)) {
53
+ try {
54
+ const controller = new AbortController();
55
+ const timeout = setTimeout(() => controller.abort(), 8000);
56
+
57
+ const response = await fetch(url, {
58
+ method: 'GET',
59
+ redirect: 'manual',
60
+ signal: controller.signal,
61
+ });
62
+ clearTimeout(timeout);
63
+
64
+ // If it returns 200 (not a redirect to login) it's likely processing the action
65
+ if (response.status === 200) {
66
+ findings.push(createFinding({
67
+ module: 'security',
68
+ title: `CSRF: State-Changing GET Request at ${new URL(url).pathname}`,
69
+ severity: 'high',
70
+ affected_surface: url,
71
+ description: `The endpoint ${url} appears to perform a state-changing action (${url.match(stateChangingPatterns[0])?.[1] || 'action'}) via GET request. GET requests cannot be protected by CSRF tokens in standard implementations, and browsers will silently follow GET-based CSRF via <img>, <link> or fetch() from any origin.`,
72
+ reproduction: [
73
+ `1. While victim is authenticated, embed: <img src="${url}">`,
74
+ `2. Victim's browser silently GETs the URL with their session cookies`,
75
+ `3. Action is performed without victim's knowledge`,
76
+ ],
77
+ evidence: `URL: ${url}\nMethod: GET\nResponse: ${response.status}`,
78
+ remediation: 'All state-changing operations must use POST, PUT, PATCH, or DELETE. Never use GET for actions that modify data. Combine with CSRF tokens on all state-changing endpoints.',
79
+ references: ['https://owasp.org/www-community/attacks/csrf', 'CWE-352'],
80
+ }));
81
+ }
82
+ } catch { /* skip */ }
83
+ break;
84
+ }
85
+ }
86
+ }
87
+
88
+ return findings;
89
+ }
90
+
91
+ /**
92
+ * Check cookies for missing SameSite attribute.
93
+ */
94
+ async _testCookieSameSite(surfaceInventory) {
95
+ const findings = [];
96
+ const baseUrl = surfaceInventory.pages[0]?.url;
97
+ if (!baseUrl) return findings;
98
+
99
+ try {
100
+ const controller = new AbortController();
101
+ const timeout = setTimeout(() => controller.abort(), 10000);
102
+
103
+ const response = await fetch(baseUrl, { signal: controller.signal });
104
+ clearTimeout(timeout);
105
+
106
+ const cookies = response.headers.getSetCookie?.() || [];
107
+
108
+ for (const cookie of cookies) {
109
+ const isSession = /session|auth|token|sid|jwt|access|refresh/i.test(cookie);
110
+ const hasSameSite = /samesite=/i.test(cookie);
111
+ const isStrict = /samesite=strict/i.test(cookie);
112
+ const isLax = /samesite=lax/i.test(cookie);
113
+ const isSecure = /;\s*secure/i.test(cookie);
114
+ const isHttpOnly = /;\s*httponly/i.test(cookie);
115
+ const cookieName = cookie.split('=')[0].trim();
116
+
117
+ if (isSession && !hasSameSite) {
118
+ findings.push(createFinding({
119
+ module: 'security',
120
+ title: `CSRF: Session Cookie Missing SameSite Attribute (${cookieName})`,
121
+ severity: 'medium',
122
+ affected_surface: baseUrl,
123
+ description: `The session cookie "${cookieName}" does not have a SameSite attribute. Without SameSite=Lax or SameSite=Strict, the cookie is sent on all cross-site requests, enabling CSRF attacks against all state-changing endpoints. Modern browsers default to Lax for cookies without SameSite, but this is not enforced in all scenarios (e.g., top-level POST navigations).`,
124
+ reproduction: [
125
+ `1. Auth cookie "${cookieName}" is set without SameSite`,
126
+ '2. A cross-site form submission targeting a state-changing endpoint will include this cookie',
127
+ '3. Server processes the request as authenticated',
128
+ ],
129
+ evidence: `Set-Cookie: ${cookie.substring(0, 200)}`,
130
+ remediation: 'Set SameSite=Strict on session cookies for the highest protection. If Strict breaks legitimate cross-site navigation, use SameSite=Lax. Combine with CSRF tokens for defense-in-depth. Also ensure Secure and HttpOnly flags are set.',
131
+ references: ['https://owasp.org/www-community/SameSite', 'CWE-352'],
132
+ }));
133
+ }
134
+
135
+ // Separate finding for missing Secure flag on session cookies
136
+ if (isSession && !isSecure) {
137
+ findings.push(createFinding({
138
+ module: 'security',
139
+ title: `Insecure Cookie: Missing Secure Flag (${cookieName})`,
140
+ severity: 'medium',
141
+ affected_surface: baseUrl,
142
+ description: `The session cookie "${cookieName}" does not have the Secure flag. This means the cookie may be transmitted over unencrypted HTTP connections, where it can be intercepted by network attackers.`,
143
+ reproduction: [
144
+ `1. Make an HTTP (non-HTTPS) request to ${baseUrl}`,
145
+ `2. Cookie "${cookieName}" may be sent in plaintext`,
146
+ ],
147
+ evidence: `Set-Cookie: ${cookie.substring(0, 200)}`,
148
+ remediation: 'Always set the Secure flag on session and authentication cookies. Enforce HTTPS everywhere and use HSTS.',
149
+ references: ['https://owasp.org/www-community/HttpOnly', 'CWE-614'],
150
+ }));
151
+ }
152
+ }
153
+ } catch (err) {
154
+ this.logger?.debug?.(`CSRF cookie test failed: ${err.message}`);
155
+ }
156
+
157
+ return findings;
158
+ }
159
+
160
+ /**
161
+ * Test if state-changing forms have CSRF token validation.
162
+ */
163
+ async _testFormCSRFTokens(surfaceInventory) {
164
+ const findings = [];
165
+
166
+ const stateChangingForms = (surfaceInventory.forms || []).filter(f => {
167
+ const method = (f.method || 'GET').toUpperCase();
168
+ return method === 'POST' || method === 'PUT' || method === 'DELETE';
169
+ });
170
+
171
+ if (stateChangingForms.length === 0) return findings;
172
+
173
+ const browser = await chromium.launch({ headless: true });
174
+ const context = await browser.newContext({ ignoreHTTPSErrors: true });
175
+
176
+ for (const form of stateChangingForms.slice(0, 10)) { // limit to 10
177
+ const page = await context.newPage();
178
+ try {
179
+ await page.goto(form.page, { waitUntil: 'domcontentloaded', timeout: 15000 });
180
+
181
+ // Check if form has a CSRF token field
182
+ const csrfField = await page.$('[name*="csrf"], [name*="_token"], [name*="authenticity_token"], [name*="nonce"], [name*="__RequestVerificationToken"]');
183
+
184
+ if (!csrfField) {
185
+ // No CSRF token — check if the form action changes state
186
+ const formEl = await page.$(`#${form.id}`) || await page.$('form');
187
+ const action = form.action || await formEl?.getAttribute('action') || form.page;
188
+
189
+ findings.push(createFinding({
190
+ module: 'security',
191
+ title: `CSRF: No Token on State-Changing Form at ${new URL(form.page).pathname}`,
192
+ severity: 'high',
193
+ affected_surface: form.page,
194
+ description: `The form at ${form.page} (action: ${action}) submits via ${form.method || 'POST'} but does not contain a CSRF token. An attacker can host a forged form on any website that will automatically submit with the victim's session cookies.`,
195
+ reproduction: [
196
+ `1. Host this HTML on a malicious site: <form action="${action}" method="post"><input type="submit"></form>`,
197
+ '2. Trick victim into visiting the malicious page while authenticated',
198
+ '3. Form auto-submits with victim\'s cookies',
199
+ ],
200
+ evidence: `Form page: ${form.page}\nForm action: ${action}\nCSRF token: none found`,
201
+ remediation: 'Add a cryptographically random CSRF token to all state-changing forms. Validate the token server-side on every submission. Use the Synchronizer Token Pattern or Double Submit Cookie pattern. Combine with SameSite=Strict cookies.',
202
+ references: ['https://owasp.org/www-community/attacks/csrf', 'CWE-352'],
203
+ }));
204
+ }
205
+ } catch (err) {
206
+ this.logger?.debug?.(`CSRF form test failed for ${form.page}: ${err.message}`);
207
+ } finally {
208
+ await page.close();
209
+ }
210
+ }
211
+
212
+ await browser.close();
213
+ return findings;
214
+ }
215
+ }
216
+
217
+ export default CSRFProber;