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.
- package/LICENSE +52 -0
- package/README.md +636 -0
- package/action.yml +264 -0
- package/bin/jaku +2 -0
- package/package.json +62 -0
- package/src/agents/ai-agent.js +175 -0
- package/src/agents/api-agent.js +95 -0
- package/src/agents/base-agent.js +158 -0
- package/src/agents/crawl-agent.js +175 -0
- package/src/agents/event-bus.js +59 -0
- package/src/agents/findings-ledger.js +410 -0
- package/src/agents/logic-agent.js +144 -0
- package/src/agents/orchestrator.js +323 -0
- package/src/agents/qa-agent.js +149 -0
- package/src/agents/security-agent.js +211 -0
- package/src/cli.js +423 -0
- package/src/core/accessibility-checker.js +171 -0
- package/src/core/ai/ai-endpoint-detector.js +227 -0
- package/src/core/ai/guardrail-prober.js +362 -0
- package/src/core/ai/indirect-injector.js +106 -0
- package/src/core/ai/jailbreak-tester.js +212 -0
- package/src/core/ai/model-dos-tester.js +174 -0
- package/src/core/ai/model-fingerprinter.js +246 -0
- package/src/core/ai/multi-turn-attacker.js +297 -0
- package/src/core/ai/output-analyzer.js +182 -0
- package/src/core/ai/prompt-injector.js +543 -0
- package/src/core/ai/system-prompt-extractor.js +244 -0
- package/src/core/api/api-key-auditor.js +266 -0
- package/src/core/api/auth-flow-tester.js +430 -0
- package/src/core/api/cors-ws-tester.js +263 -0
- package/src/core/api/graphql-tester.js +287 -0
- package/src/core/api/oauth-prober.js +343 -0
- package/src/core/auth-manager.js +902 -0
- package/src/core/broken-flow-detector.js +207 -0
- package/src/core/browser-manager.js +119 -0
- package/src/core/console-monitor.js +111 -0
- package/src/core/crawler.js +430 -0
- package/src/core/csr-waiter.js +410 -0
- package/src/core/form-validator.js +240 -0
- package/src/core/logic/abuse-pattern-scanner.js +291 -0
- package/src/core/logic/access-boundary-tester.js +448 -0
- package/src/core/logic/business-rule-inferrer.js +196 -0
- package/src/core/logic/graphql-auditor.js +298 -0
- package/src/core/logic/parameter-polluter.js +212 -0
- package/src/core/logic/pricing-exploiter.js +299 -0
- package/src/core/logic/race-condition-detector.js +222 -0
- package/src/core/logic/workflow-enforcer.js +284 -0
- package/src/core/performance-checker.js +204 -0
- package/src/core/responsive-checker.js +228 -0
- package/src/core/security/cors-prober.js +150 -0
- package/src/core/security/csrf-prober.js +217 -0
- package/src/core/security/dependency-auditor.js +182 -0
- package/src/core/security/file-upload-tester.js +340 -0
- package/src/core/security/header-analyzer.js +324 -0
- package/src/core/security/infra-scanner.js +391 -0
- package/src/core/security/path-traversal.js +112 -0
- package/src/core/security/prototype-pollution.js +147 -0
- package/src/core/security/secret-detector.js +517 -0
- package/src/core/security/sqli-prober.js +257 -0
- package/src/core/security/tls-checker.js +223 -0
- package/src/core/security/xss-scanner.js +225 -0
- package/src/core/test-generator.js +339 -0
- package/src/core/test-runner.js +398 -0
- package/src/reporting/diff-reporter.js +172 -0
- package/src/reporting/report-generator.js +408 -0
- package/src/reporting/sarif-generator.js +190 -0
- package/src/utils/config.js +57 -0
- package/src/utils/finding.js +67 -0
- 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;
|