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,410 @@
1
+ /**
2
+ * CSRWaiter — Makes JAKU's page analysis Supabase/CSR-aware.
3
+ *
4
+ * Problem: Vibe-coded apps (Lovable, Bolt, Cursor) are almost always:
5
+ * - React/Vue SPAs with client-side rendering (CSR)
6
+ * - Supabase auth + DB queries that resolve ~500ms–3s after page load
7
+ * - Empty DOM shell at networkidle → real content appears later
8
+ *
9
+ * This causes a class of false positives in JAKU:
10
+ * - Broken flows flagged because content selectors don't exist yet
11
+ * - Forms not found, smoke tests fail, console errors from loading state
12
+ * - XSS inputs injected into hidden/non-existent fields
13
+ *
14
+ * Solution: Detect the auth provider in use, then apply the correct wait
15
+ * strategy before any test phase touches the page.
16
+ *
17
+ * Supported providers detected automatically:
18
+ * • Supabase (sb-*-auth-token in localStorage, GoTrueClient in JS)
19
+ * • Clerk (window.Clerk, __clerk_db_jwt in cookies)
20
+ * • Firebase (window.firebase, firebaseapp.com fetch)
21
+ * • Auth0 (window.auth0, .auth0.com/.well-known)
22
+ * • NextAuth (window.__NEXTAUTH, /api/auth/session)
23
+ * • Generic CSR (React / Vue / Angular detected, any)
24
+ */
25
+
26
+ const LOADING_SELECTORS = [
27
+ // Generic loading indicators
28
+ '[aria-busy="true"]',
29
+ '[data-loading="true"]',
30
+ '[data-state="loading"]',
31
+ '.loading', '.spinner', '.skeleton', '.shimmer',
32
+ '[class*="loading"]', '[class*="spinner"]', '[class*="skeleton"]', '[class*="shimmer"]',
33
+ // Specific framework loading components
34
+ '[class*="Skeleton"]', '[class*="Loading"]', '[class*="Spinner"]',
35
+ // Radix/shadcn patterns common in vibe-coded apps
36
+ '[data-radix-popper-content-wrapper]',
37
+ ].join(', ');
38
+
39
+ // Auth error messages that are unconditionally safe to suppress —
40
+ // these ONLY appear from known auth library internals, never from app code.
41
+ const ALWAYS_SUPPRESS = [
42
+ /supabase.*auth.*session/i,
43
+ /getSession/i,
44
+ /AuthSessionMissingError/i,
45
+ /AuthRetryableFetchError/i,
46
+ /invalid.*refresh.*token/i,
47
+ /signIn.*required/i,
48
+ /not.*authenticated/i,
49
+ /jwt.*expired/i,
50
+ /ResizeObserver loop/i, // Harmless Radix/browser layout warning
51
+ /Warning:.*defaultProps/i, // React dev-mode warning, not a runtime error
52
+ ];
53
+
54
+ // Errors that are ONLY suppressed within the first N ms of navigation.
55
+ // After that window closes, they surface as real findings — a component
56
+ // accessing uninitialized state after load is done is a genuine bug.
57
+ const EARLY_NAVIGATION_ERRORS = [
58
+ /Cannot read propert.*undefined/i,
59
+ /Cannot read propert.*null/i,
60
+ /Failed to fetch/i,
61
+ /NetworkError/i,
62
+ ];
63
+
64
+ // How long (ms) after navigation start to consider errors as loading-state noise
65
+ const EARLY_NAVIGATION_WINDOW_MS = 2500;
66
+
67
+ export class CSRWaiter {
68
+ constructor(logger) {
69
+ this.logger = logger;
70
+ this._detectedProvider = null; // cached after first detection
71
+ this._navigationStartTime = null; // set each time waitForContent is called
72
+ }
73
+
74
+ /**
75
+ * The main entry point — call this after page.goto() in any JAKU module.
76
+ * Detects the auth provider and waits for content to fully settle.
77
+ *
78
+ * @param {import('playwright').Page} page
79
+ * @param {object} options
80
+ * @param {number} options.timeout — max ms to wait (default: 15000)
81
+ * @param {boolean} options.strict — throw if page never settles (default: false)
82
+ * @returns {{ provider: string, waited: boolean, elapsedMs: number }}
83
+ */
84
+ async waitForContent(page, { timeout = 15000, strict = false } = {}) {
85
+ const start = Date.now();
86
+ this._navigationStartTime = start; // used by time-gated error filter
87
+
88
+ // Step 1: Detect provider (cache after first call per scan).
89
+ // Detection is two-pass: primary signals first, then structural fallbacks.
90
+ const provider = this._detectedProvider ?? await this._detectProvider(page);
91
+ this._detectedProvider = provider;
92
+
93
+ // Always log the detected provider so misdetections are debuggable
94
+ this.logger?.debug?.(`CSRWaiter: provider=${provider} url=${page.url()}`);
95
+
96
+ try {
97
+ await Promise.race([
98
+ this._waitStrategy(page, provider),
99
+ new Promise((_, reject) =>
100
+ setTimeout(() => reject(new Error('CSRWaiter timeout')), timeout)
101
+ ),
102
+ ]);
103
+ } catch (err) {
104
+ if (strict) throw err;
105
+ this.logger?.debug?.(
106
+ `CSRWaiter: wait timed out for ${provider} after ${Date.now() - start}ms — proceeding with partial content`
107
+ );
108
+ }
109
+
110
+ const elapsed = Date.now() - start;
111
+ return { provider, waited: provider !== 'none', elapsedMs: elapsed };
112
+ }
113
+
114
+ /**
115
+ * Run the appropriate wait strategy for the detected provider.
116
+ */
117
+ async _waitStrategy(page, provider) {
118
+ switch (provider) {
119
+ case 'supabase':
120
+ await this._waitForSupabase(page);
121
+ break;
122
+ case 'clerk':
123
+ await this._waitForClerk(page);
124
+ break;
125
+ case 'firebase':
126
+ case 'auth0':
127
+ case 'nextauth':
128
+ await this._waitForGenericAuth(page, provider);
129
+ break;
130
+ case 'csr':
131
+ await this._waitForCSRSettled(page);
132
+ break;
133
+ default:
134
+ break; // SSR page, no special waiting needed
135
+ }
136
+
137
+ // Always run the DOM stability check last
138
+ await this._waitForDOMStability(page, 400);
139
+ await this._waitForLoadingGone(page);
140
+ }
141
+
142
+ /**
143
+ * Detect which auth provider (if any) the app uses.
144
+ *
145
+ * Two-pass detection:
146
+ * Pass 1 — Primary signals: globals, localStorage keys, cookies.
147
+ * Fast; works on return visits where state is already written.
148
+ * Pass 2 — Structural signals: script src URLs, DOM markers, meta tags.
149
+ * Catches first-visit / incognito where localStorage is empty
150
+ * but the SDK is still loaded on the page.
151
+ *
152
+ * Fallback chain (explicit):
153
+ * Supabase primary → Supabase structural → Clerk → Firebase → Auth0
154
+ * → NextAuth → Generic CSR → None (SSR)
155
+ */
156
+ async _detectProvider(page) {
157
+ try {
158
+ const result = await page.evaluate(() => {
159
+ // ── Pass 1: Primary runtime signals ──────────────────────────
160
+
161
+ // Supabase: GoTrueClient global OR resolved auth token in localStorage
162
+ const lsKeys = Object.keys(localStorage);
163
+ const hasSupabaseLs = lsKeys.some(k => /^sb-.*-auth-token/.test(k));
164
+ const hasSupabaseGlobal = typeof window.supabase !== 'undefined' ||
165
+ typeof window._supabase !== 'undefined';
166
+ if (hasSupabaseLs || hasSupabaseGlobal) return 'supabase';
167
+
168
+ // Clerk
169
+ if (typeof window.Clerk !== 'undefined') return 'clerk';
170
+ if (/__clerk_db_jwt/.test(document.cookie)) return 'clerk';
171
+
172
+ // Firebase
173
+ if (typeof window.firebase !== 'undefined' ||
174
+ typeof window.__FIREBASE_DEFAULTS__ !== 'undefined') return 'firebase';
175
+
176
+ // Auth0
177
+ if (typeof window.auth0 !== 'undefined') return 'auth0';
178
+
179
+ // NextAuth
180
+ if (typeof window.__NEXTAUTH !== 'undefined') return 'nextauth';
181
+
182
+ // ── Pass 2: Structural / script-tag signals ───────────────────
183
+ // Covers first-visit / incognito where localStorage is empty but
184
+ // the SDK bundle is still present on the page.
185
+
186
+ const scripts = [...document.querySelectorAll('script[src]')]
187
+ .map(s => s.src);
188
+
189
+ // Supabase JS bundle loaded but not yet initialised (incognito / first visit)
190
+ const hasSupabaseScript = scripts.some(s =>
191
+ /supabase/.test(s) || /cdn\.supabase/.test(s)
192
+ );
193
+ // Also check <meta> tags Lovable/Bolt sometimes emit
194
+ const hasSupabaseMeta = !!document.querySelector(
195
+ '[data-supabase-url], meta[name="supabase-url"]'
196
+ );
197
+ if (hasSupabaseScript || hasSupabaseMeta) return 'supabase';
198
+
199
+ // Clerk script tag
200
+ if (scripts.some(s => /clerk/.test(s))) return 'clerk';
201
+
202
+ // Firebase script tag
203
+ if (scripts.some(s => /firebase/.test(s))) return 'firebase';
204
+
205
+ // Auth0 script tag
206
+ if (scripts.some(s => /auth0/.test(s))) return 'auth0';
207
+
208
+ // ── Pass 3: Generic CSR framework markers ─────────────────────
209
+ const hasCSRMarker = (
210
+ typeof window.React !== 'undefined' ||
211
+ typeof window.Vue !== 'undefined' ||
212
+ typeof window.angular !== 'undefined' ||
213
+ !!document.getElementById('__NEXT_DATA__') ||
214
+ !!document.getElementById('__nuxt') ||
215
+ !!document.querySelector('[data-reactroot], [ng-version]')
216
+ );
217
+ if (hasCSRMarker) return 'csr';
218
+
219
+ return 'none';
220
+ });
221
+
222
+ return result;
223
+ } catch (err) {
224
+ // Page context lost or evaluate threw — log and fall back to CSR
225
+ // so we still apply some waiting rather than nothing
226
+ this.logger?.warn?.(`CSRWaiter: provider detection failed (${err.message}), defaulting to csr`);
227
+ return 'csr';
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Supabase-specific: wait for GoTrueClient to resolve auth state.
233
+ * Supabase v2 fires `supabase:auth:INITIAL_SESSION` custom event or
234
+ * updates localStorage once the session is determined.
235
+ */
236
+ async _waitForSupabase(page) {
237
+ await page.waitForFunction(() => {
238
+ // Strategy 1: Supabase has written auth state to localStorage
239
+ const lsKeys = Object.keys(localStorage);
240
+ const authKey = lsKeys.find(k => /^sb-.*-auth-token/.test(k));
241
+
242
+ if (authKey) {
243
+ try {
244
+ const val = JSON.parse(localStorage.getItem(authKey));
245
+ // Not loading if it resolved to null (anon) or has a user object
246
+ return val === null || (val && (val.user || val.access_token));
247
+ } catch { return true; } // parse error = key exists = init done
248
+ }
249
+
250
+ // Strategy 2: Check if any auth-gated loading spinner is still visible
251
+ const spinners = document.querySelectorAll(
252
+ '[aria-busy="true"], [data-loading], [class*="loading"], [class*="spinner"]'
253
+ );
254
+ return spinners.length === 0;
255
+ }, { timeout: 8000, polling: 200 }).catch(() => {
256
+ // Fallback: just wait a fixed time for Supabase init
257
+ return page.waitForTimeout(2000);
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Clerk-specific: wait for Clerk to finish loading and resolve user state.
263
+ */
264
+ async _waitForClerk(page) {
265
+ await page.waitForFunction(() => {
266
+ if (typeof window.Clerk === 'undefined') return true;
267
+ // Clerk exposes `isReady` once auth state is resolved
268
+ return window.Clerk.isReady || window.Clerk.loaded;
269
+ }, { timeout: 8000 }).catch(() => page.waitForTimeout(2000));
270
+ }
271
+
272
+ /**
273
+ * Generic auth wait: poll for no loading indicators + network quiet.
274
+ */
275
+ async _waitForGenericAuth(page, provider) {
276
+ // Wait for any in-flight fetches to complete
277
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => null);
278
+ await this._waitForLoadingGone(page);
279
+ }
280
+
281
+ /**
282
+ * Generic CSR wait: DOM stability + loading indicator gone.
283
+ */
284
+ async _waitForCSRSettled(page) {
285
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => null);
286
+ await this._waitForLoadingGone(page);
287
+ }
288
+
289
+ /**
290
+ * Wait until all loading spinners/skeletons/busy elements have disappeared.
291
+ */
292
+ async _waitForLoadingGone(page, timeout = 8000) {
293
+ await page.waitForFunction((selectors) => {
294
+ const el = document.querySelector(selectors);
295
+ return !el;
296
+ }, LOADING_SELECTORS, { timeout, polling: 150 }).catch(() => null);
297
+ }
298
+
299
+ /**
300
+ * Wait until the DOM stops changing size (content settled).
301
+ * Polls innerHTML length every 100ms and waits for stabilityMs of no change.
302
+ *
303
+ * Hard cap: DOM_STABILITY_MAX_MS. If hit, logs a warning and proceeds
304
+ * rather than hanging — covers live-data dashboards with continuous mutations.
305
+ */
306
+ async _waitForDOMStability(page, stabilityMs = 400) {
307
+ // Hard cap prevents indefinite hanging on dashboards with background
308
+ // polling / websocket-driven DOM updates (e.g. realtime Supabase listeners)
309
+ const DOM_STABILITY_MAX_MS = 4000;
310
+ const deadline = Date.now() + DOM_STABILITY_MAX_MS;
311
+ let lastSize = -1;
312
+ let stableFor = 0;
313
+ let hitCap = false;
314
+
315
+ while (Date.now() < deadline) {
316
+ const size = await page.evaluate(
317
+ () => document.body?.innerHTML?.length ?? 0
318
+ ).catch(() => 0);
319
+
320
+ if (size === lastSize) {
321
+ stableFor += 100;
322
+ if (stableFor >= stabilityMs) break; // DOM settled — done
323
+ } else {
324
+ stableFor = 0;
325
+ lastSize = size;
326
+ }
327
+
328
+ await page.waitForTimeout(100);
329
+ }
330
+
331
+ if (stableFor < stabilityMs) {
332
+ hitCap = true;
333
+ // Log warning so operators know why the wait ended early;
334
+ // this is expected on live-data dashboards and is not an error.
335
+ this.logger?.warn?.(
336
+ `CSRWaiter: DOM stability cap hit after ${DOM_STABILITY_MAX_MS}ms on ${page.url()} — ` +
337
+ `DOM may still be mutating (live data dashboard?). Proceeding with current state.`
338
+ );
339
+ }
340
+
341
+ return { hitCap };
342
+ }
343
+
344
+ /**
345
+ * Filter console messages to remove known loading-state noise.
346
+ *
347
+ * Two tiers:
348
+ * 1. ALWAYS_SUPPRESS — unconditionally removed (auth library internals only)
349
+ * 2. EARLY_NAVIGATION_ERRORS — only suppressed within EARLY_NAVIGATION_WINDOW_MS
350
+ * of the navigation start time. After that window closes, these surface as
351
+ * real findings. A component crashing after load is done is a genuine bug.
352
+ *
353
+ * @param {string} message — console message text
354
+ * @param {number|null} navigationStartTime — Date.now() at navigation start
355
+ * @returns {boolean} — true if this message is a real error worth reporting
356
+ */
357
+ static isRealError(message, navigationStartTime = null) {
358
+ // Tier 1: unconditional suppression
359
+ if (ALWAYS_SUPPRESS.some(p => p.test(message))) return false;
360
+
361
+ // Tier 2: time-gated suppression
362
+ if (EARLY_NAVIGATION_ERRORS.some(p => p.test(message))) {
363
+ if (navigationStartTime === null) {
364
+ // No timing context available — suppress conservatively
365
+ return false;
366
+ }
367
+ const age = Date.now() - navigationStartTime;
368
+ // Within the early window → loading-state noise, suppress
369
+ // Outside the window → genuine bug, surface it
370
+ return age > EARLY_NAVIGATION_WINDOW_MS;
371
+ }
372
+
373
+ return true;
374
+ }
375
+
376
+ /**
377
+ * Install a filtered console listener on a page. Returns an array
378
+ * that will be populated with real (non-loading-noise) errors.
379
+ * Captures navigationStartTime at install time for time-gated filtering.
380
+ *
381
+ * @param {import('playwright').Page} page
382
+ * @param {number} [navigationStartTime] — defaults to Date.now()
383
+ * @returns {Array<{type, text, timestamp}>}
384
+ */
385
+ static installConsoleFilter(page, navigationStartTime = Date.now()) {
386
+ const realErrors = [];
387
+
388
+ page.on('console', (msg) => {
389
+ const type = msg.type();
390
+ const text = msg.text();
391
+
392
+ // Only capture warnings and errors
393
+ if (type !== 'error' && type !== 'warning') return;
394
+
395
+ // Apply two-tier filter with timing context
396
+ if (!CSRWaiter.isRealError(text, navigationStartTime)) return;
397
+
398
+ realErrors.push({
399
+ type,
400
+ text,
401
+ timestamp: Date.now(),
402
+ url: page.url(),
403
+ });
404
+ });
405
+
406
+ return realErrors;
407
+ }
408
+ }
409
+
410
+ export default CSRWaiter;
@@ -0,0 +1,240 @@
1
+ import { chromium } from 'playwright';
2
+ import { createFinding } from '../utils/finding.js';
3
+ import { CSRWaiter } from './csr-waiter.js';
4
+
5
+ /**
6
+ * Form Validator — Deep form validation testing.
7
+ * Tests required fields, type constraints, boundaries, and submission feedback.
8
+ */
9
+ export class FormValidator {
10
+ constructor(config, logger) {
11
+ this.config = config;
12
+ this.logger = logger;
13
+ this.findings = [];
14
+ }
15
+
16
+ /**
17
+ * Validate all forms discovered during crawling.
18
+ */
19
+ async validate(surfaceInventory) {
20
+ if (surfaceInventory.forms.length === 0) {
21
+ this.logger?.info?.('No forms found to validate');
22
+ return [];
23
+ }
24
+
25
+ const browser = await chromium.launch({ headless: true });
26
+ const context = await browser.newContext({
27
+ viewport: { width: 1440, height: 900 },
28
+ ignoreHTTPSErrors: true,
29
+ });
30
+
31
+ for (const form of surfaceInventory.forms) {
32
+ try {
33
+ await this._validateForm(context, form);
34
+ } catch (err) {
35
+ this.logger?.warn?.(`Failed to validate form ${form.id}: ${err.message}`);
36
+ }
37
+ }
38
+
39
+ await browser.close();
40
+ this.logger?.info?.(`Form validator found ${this.findings.length} issues`);
41
+ return this.findings;
42
+ }
43
+
44
+ async _validateForm(context, form) {
45
+ // Test required fields enforcement
46
+ await this._testRequiredFields(context, form);
47
+ // Test type constraints
48
+ await this._testTypeConstraints(context, form);
49
+ // Check for error message presence
50
+ this._checkErrorMessageCapability(form);
51
+ }
52
+
53
+ /**
54
+ * Test that required fields are enforced on submission.
55
+ */
56
+ async _testRequiredFields(context, form) {
57
+ const requiredFields = form.fields.filter(f => f.required);
58
+ if (requiredFields.length === 0) {
59
+ // No required fields — this might be a finding itself
60
+ const hasInputs = form.fields.filter(f => !['hidden', 'submit', 'button'].includes(f.type));
61
+ if (hasInputs.length > 0) {
62
+ this.findings.push(
63
+ createFinding({
64
+ module: 'qa',
65
+ title: `No Required Fields: ${form.id}`,
66
+ severity: 'low',
67
+ affected_surface: form.page,
68
+ description: `Form "${form.id}" has ${hasInputs.length} input field(s) but none are marked as required. This may allow empty or incomplete submissions.`,
69
+ reproduction: [
70
+ `1. Navigate to ${form.page}`,
71
+ `2. Locate form "${form.id}"`,
72
+ `3. Note that no fields have the 'required' attribute`,
73
+ ],
74
+ remediation: 'Add the `required` attribute to essential form fields to prevent incomplete submissions.',
75
+ })
76
+ );
77
+ }
78
+ return;
79
+ }
80
+
81
+ // Try submitting with empty required fields
82
+ const page = await context.newPage();
83
+ const csrWaiter = new CSRWaiter(this.logger);
84
+ try {
85
+ await page.goto(form.page, { waitUntil: 'domcontentloaded', timeout: 20000 });
86
+ await csrWaiter.waitForContent(page, { timeout: 12000 });
87
+
88
+ // Click submit without filling anything
89
+ const submitBtn = await page.$(`#${form.id} button[type="submit"], #${form.id} input[type="submit"]`)
90
+ || await page.$('button[type="submit"], input[type="submit"]');
91
+
92
+ if (submitBtn) {
93
+ await submitBtn.click();
94
+ await page.waitForTimeout(1500);
95
+
96
+ // Check if the page navigated away (submission went through)
97
+ const currentUrl = page.url();
98
+ if (form.action && currentUrl.includes(form.action) && form.action !== form.page) {
99
+ this.findings.push(
100
+ createFinding({
101
+ module: 'qa',
102
+ title: `Required Field Bypass: ${form.id}`,
103
+ severity: 'medium',
104
+ affected_surface: form.page,
105
+ description: `Form "${form.id}" has ${requiredFields.length} required field(s) but submitted successfully when empty. Server-side validation may be missing.`,
106
+ reproduction: [
107
+ `1. Navigate to ${form.page}`,
108
+ `2. Click submit without filling any fields`,
109
+ `3. Observe the form submits successfully`,
110
+ ],
111
+ remediation: 'Implement both client-side and server-side validation. Never rely solely on HTML required attributes.',
112
+ })
113
+ );
114
+ }
115
+ }
116
+ } catch (err) {
117
+ this.logger?.debug?.(`Required field test failed for ${form.id}: ${err.message}`);
118
+ } finally {
119
+ await page.close();
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Test type constraints on form fields.
125
+ */
126
+ async _testTypeConstraints(context, form) {
127
+ const typeTestable = form.fields.filter(f =>
128
+ ['email', 'number', 'url', 'tel', 'date'].includes(f.type)
129
+ );
130
+
131
+ if (typeTestable.length === 0) return;
132
+
133
+ const page = await context.newPage();
134
+ const csrWaiter2 = new CSRWaiter(this.logger);
135
+ try {
136
+ await page.goto(form.page, { waitUntil: 'domcontentloaded', timeout: 20000 });
137
+ await csrWaiter2.waitForContent(page, { timeout: 12000 });
138
+
139
+ for (const field of typeTestable) {
140
+ const invalidValues = this._getInvalidValues(field.type);
141
+ for (const { value, desc } of invalidValues) {
142
+ try {
143
+ const input = await page.$(`[name="${field.name}"]`) || await page.$(`#${field.name}`);
144
+ if (!input) continue;
145
+
146
+ await input.fill('');
147
+ await input.fill(value);
148
+
149
+ // Check if HTML validation catches it
150
+ const isValid = await page.evaluate((name) => {
151
+ const el = document.querySelector(`[name="${name}"]`) || document.getElementById(name);
152
+ return el?.checkValidity?.() ?? true;
153
+ }, field.name);
154
+
155
+ if (isValid) {
156
+ this.findings.push(
157
+ createFinding({
158
+ module: 'qa',
159
+ title: `Weak Type Validation: ${field.name} (${field.type})`,
160
+ severity: 'low',
161
+ affected_surface: form.page,
162
+ description: `Field "${field.name}" (type=${field.type}) accepted invalid value "${value}" (${desc}). Client-side type validation may be insufficient.`,
163
+ reproduction: [
164
+ `1. Navigate to ${form.page}`,
165
+ `2. Enter "${value}" into the ${field.name} field`,
166
+ `3. Observe the value is accepted without validation error`,
167
+ ],
168
+ remediation: `Add proper validation for ${field.type} fields. Use pattern attributes, custom validation, or JavaScript validation.`,
169
+ })
170
+ );
171
+ }
172
+ } catch {
173
+ // Best-effort
174
+ }
175
+ }
176
+ }
177
+ } catch (err) {
178
+ this.logger?.debug?.(`Type constraint test failed for ${form.id}: ${err.message}`);
179
+ } finally {
180
+ await page.close();
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Check if form has proper error messaging capability.
186
+ */
187
+ _checkErrorMessageCapability(form) {
188
+ const inputFields = form.fields.filter(f => !['hidden', 'submit', 'button'].includes(f.type));
189
+ const fieldsWithoutPlaceholder = inputFields.filter(f => !f.placeholder);
190
+
191
+ if (inputFields.length > 0 && fieldsWithoutPlaceholder.length === inputFields.length) {
192
+ this.findings.push(
193
+ createFinding({
194
+ module: 'qa',
195
+ title: `No Input Hints: ${form.id}`,
196
+ severity: 'info',
197
+ affected_surface: form.page,
198
+ description: `Form "${form.id}" has ${inputFields.length} field(s) but none have placeholder text. Placeholders help users understand expected input format.`,
199
+ reproduction: [
200
+ `1. Navigate to ${form.page}`,
201
+ `2. Locate form "${form.id}"`,
202
+ `3. Note that no fields have placeholder hints`,
203
+ ],
204
+ remediation: 'Add placeholder attributes to form fields to guide users on expected input format.',
205
+ })
206
+ );
207
+ }
208
+ }
209
+
210
+ _getInvalidValues(type) {
211
+ switch (type) {
212
+ case 'email':
213
+ return [
214
+ { value: 'notanemail', desc: 'missing @ symbol' },
215
+ { value: '@nodomain', desc: 'missing local part' },
216
+ ];
217
+ case 'number':
218
+ return [
219
+ { value: 'abc', desc: 'alphabetic text' },
220
+ { value: '12.34.56', desc: 'multiple decimals' },
221
+ ];
222
+ case 'url':
223
+ return [
224
+ { value: 'not-a-url', desc: 'no protocol' },
225
+ ];
226
+ case 'tel':
227
+ return [
228
+ { value: 'abcdef', desc: 'alphabetic text' },
229
+ ];
230
+ case 'date':
231
+ return [
232
+ { value: '99-99-9999', desc: 'invalid date' },
233
+ ];
234
+ default:
235
+ return [];
236
+ }
237
+ }
238
+ }
239
+
240
+ export default FormValidator;