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,284 @@
|
|
|
1
|
+
import { createFinding } from '../../utils/finding.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WorkflowEnforcer — Tests that multi-step workflows enforce ordering.
|
|
5
|
+
*
|
|
6
|
+
* Probes:
|
|
7
|
+
* - Step skipping (jump to final step)
|
|
8
|
+
* - Form resubmission (submit same step twice)
|
|
9
|
+
* - State transition bypass (skip verification)
|
|
10
|
+
* - Direct access to confirmation pages
|
|
11
|
+
* - Back-button manipulation
|
|
12
|
+
*/
|
|
13
|
+
export class WorkflowEnforcer {
|
|
14
|
+
constructor(logger) {
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
|
|
17
|
+
// Common final/sensitive steps
|
|
18
|
+
this.FINAL_STEP_PATTERNS = [
|
|
19
|
+
/\/confirm/i, /\/complete/i, /\/finalize/i, /\/success/i,
|
|
20
|
+
/\/receipt/i, /\/thank/i, /\/done/i, /\/result/i,
|
|
21
|
+
/step[_-]?(final|last|\d{2})/i,
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Common intermediate steps that shouldn't be skippable
|
|
25
|
+
this.VERIFICATION_STEPS = [
|
|
26
|
+
/\/verify/i, /\/validate/i, /\/review/i, /\/otp/i,
|
|
27
|
+
/\/2fa/i, /\/mfa/i, /\/captcha/i, /\/consent/i,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Test workflow enforcement.
|
|
33
|
+
*/
|
|
34
|
+
async enforce(businessContext, surfaceInventory) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
|
|
37
|
+
this.logger?.info?.('Workflow Enforcer: starting tests');
|
|
38
|
+
|
|
39
|
+
// 1. Test multi-step flow skipping
|
|
40
|
+
const flowFindings = await this._testFlowSkipping(businessContext, surfaceInventory);
|
|
41
|
+
findings.push(...flowFindings);
|
|
42
|
+
|
|
43
|
+
// 2. Test direct access to confirmation pages
|
|
44
|
+
const confirmFindings = await this._testDirectConfirmation(surfaceInventory);
|
|
45
|
+
findings.push(...confirmFindings);
|
|
46
|
+
|
|
47
|
+
// 3. Test verification step bypass
|
|
48
|
+
const verifyFindings = await this._testVerificationBypass(surfaceInventory);
|
|
49
|
+
findings.push(...verifyFindings);
|
|
50
|
+
|
|
51
|
+
// 4. Test form resubmission
|
|
52
|
+
const resubFindings = await this._testResubmission(businessContext);
|
|
53
|
+
findings.push(...resubFindings);
|
|
54
|
+
|
|
55
|
+
this.logger?.info?.(`Workflow Enforcer: found ${findings.length} issues`);
|
|
56
|
+
return findings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Test if multi-step flows can be skipped.
|
|
61
|
+
*/
|
|
62
|
+
async _testFlowSkipping(businessContext, surfaceInventory) {
|
|
63
|
+
const findings = [];
|
|
64
|
+
const flows = businessContext.multiStepFlows || [];
|
|
65
|
+
const baseUrl = this._getBaseUrl(surfaceInventory);
|
|
66
|
+
if (!baseUrl) return findings;
|
|
67
|
+
|
|
68
|
+
for (const flow of flows) {
|
|
69
|
+
if (flow.pages.length < 2) continue;
|
|
70
|
+
|
|
71
|
+
// Try to access the last step directly without doing previous steps
|
|
72
|
+
const lastStep = flow.pages[flow.pages.length - 1];
|
|
73
|
+
try {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
76
|
+
|
|
77
|
+
const response = await fetch(lastStep, {
|
|
78
|
+
method: 'GET',
|
|
79
|
+
redirect: 'manual',
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
|
|
84
|
+
if (response.status === 200) {
|
|
85
|
+
const text = await response.text();
|
|
86
|
+
if (text.length > 200 && !this._isRedirectPage(text)) {
|
|
87
|
+
findings.push(createFinding({
|
|
88
|
+
module: 'logic',
|
|
89
|
+
title: 'Workflow Skip: Final Step Accessible Directly',
|
|
90
|
+
severity: 'high',
|
|
91
|
+
affected_surface: lastStep,
|
|
92
|
+
description: `The final step of a multi-step flow (${lastStep}) is accessible without completing previous steps. An attacker can skip validation, payment, or verification steps.`,
|
|
93
|
+
reproduction: [
|
|
94
|
+
`1. Flow has ${flow.stepCount} steps: ${flow.pages.join(' → ')}`,
|
|
95
|
+
`2. Navigate directly to the last step: ${lastStep}`,
|
|
96
|
+
`3. The page loads without requiring prior step completion`,
|
|
97
|
+
],
|
|
98
|
+
evidence: `Flow steps: ${flow.stepCount}\nDirect access: ${lastStep}\nStatus: ${response.status}`,
|
|
99
|
+
remediation: 'Implement server-side session state tracking for multi-step flows. Verify that all previous steps are completed before allowing access to subsequent steps. Store progress in a server-side session, not client-side.',
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return findings;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Test direct access to confirmation/success pages.
|
|
113
|
+
*/
|
|
114
|
+
async _testDirectConfirmation(surfaceInventory) {
|
|
115
|
+
const findings = [];
|
|
116
|
+
const pages = surfaceInventory.pages || [];
|
|
117
|
+
|
|
118
|
+
for (const page of pages) {
|
|
119
|
+
const url = page.url || page;
|
|
120
|
+
const isFinalStep = this.FINAL_STEP_PATTERNS.some(p => p.test(url));
|
|
121
|
+
if (!isFinalStep) continue;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
126
|
+
|
|
127
|
+
const response = await fetch(url, {
|
|
128
|
+
method: 'GET',
|
|
129
|
+
redirect: 'manual',
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
});
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
|
|
134
|
+
if (response.status === 200) {
|
|
135
|
+
const text = await response.text();
|
|
136
|
+
if (this._isConfirmationPage(text)) {
|
|
137
|
+
findings.push(createFinding({
|
|
138
|
+
module: 'logic',
|
|
139
|
+
title: 'Workflow Bypass: Confirmation Page Directly Accessible',
|
|
140
|
+
severity: 'medium',
|
|
141
|
+
affected_surface: url,
|
|
142
|
+
description: `Confirmation/success page at ${url} is directly accessible without completing the required workflow. This may allow bypassing payment, verification, or other critical steps.`,
|
|
143
|
+
reproduction: [
|
|
144
|
+
`1. Navigate directly to ${url}`,
|
|
145
|
+
`2. Confirmation page loads without prior flow completion`,
|
|
146
|
+
],
|
|
147
|
+
evidence: `URL: ${url}\nStatus: ${response.status}`,
|
|
148
|
+
remediation: 'Verify server-side that the workflow was completed before showing confirmation pages. Use session tokens to track step completion.',
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return findings;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Test if verification steps can be bypassed.
|
|
162
|
+
*/
|
|
163
|
+
async _testVerificationBypass(surfaceInventory) {
|
|
164
|
+
const findings = [];
|
|
165
|
+
const pages = surfaceInventory.pages || [];
|
|
166
|
+
|
|
167
|
+
for (const page of pages) {
|
|
168
|
+
const url = page.url || page;
|
|
169
|
+
const isVerifyStep = this.VERIFICATION_STEPS.some(p => p.test(url));
|
|
170
|
+
if (!isVerifyStep) continue;
|
|
171
|
+
|
|
172
|
+
// Try to POST to the verification endpoint with empty/dummy data
|
|
173
|
+
try {
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
176
|
+
|
|
177
|
+
const response = await fetch(url, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
180
|
+
body: JSON.stringify({ code: '000000', token: 'bypass', verified: true }),
|
|
181
|
+
signal: controller.signal,
|
|
182
|
+
});
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
|
|
185
|
+
if (response.ok) {
|
|
186
|
+
const text = await response.text();
|
|
187
|
+
if (/success|verified|confirmed|valid/i.test(text)) {
|
|
188
|
+
findings.push(createFinding({
|
|
189
|
+
module: 'logic',
|
|
190
|
+
title: 'Verification Bypass: Step Accepted Without Valid Input',
|
|
191
|
+
severity: 'critical',
|
|
192
|
+
affected_surface: url,
|
|
193
|
+
description: `The verification step at ${url} accepted dummy data (code: "000000", verified: true). This allows bypassing OTP/2FA/captcha verification.`,
|
|
194
|
+
reproduction: [
|
|
195
|
+
`1. POST to ${url} with {"code": "000000", "verified": true}`,
|
|
196
|
+
`2. Server responds with success`,
|
|
197
|
+
],
|
|
198
|
+
evidence: `Response contained success indicators`,
|
|
199
|
+
remediation: 'Validate verification codes server-side against stored values. Never trust client-supplied "verified" flags. Implement rate limiting on verification attempts.',
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return findings;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Test form resubmission (idempotency).
|
|
213
|
+
*/
|
|
214
|
+
async _testResubmission(businessContext) {
|
|
215
|
+
const findings = [];
|
|
216
|
+
const paymentForms = (businessContext.domains.payments || [])
|
|
217
|
+
.filter(s => s.type === 'form' || s.type === 'api');
|
|
218
|
+
|
|
219
|
+
for (const surface of paymentForms) {
|
|
220
|
+
const url = surface.url;
|
|
221
|
+
if (!url) continue;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const body = { amount: 100, action: 'submit' };
|
|
225
|
+
const requests = [];
|
|
226
|
+
|
|
227
|
+
// Fire same request twice rapidly
|
|
228
|
+
for (let i = 0; i < 2; i++) {
|
|
229
|
+
requests.push(
|
|
230
|
+
fetch(url, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify(body),
|
|
234
|
+
signal: AbortSignal.timeout(5000),
|
|
235
|
+
}).then(r => ({ status: r.status, ok: r.ok })).catch(() => null)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const results = await Promise.all(requests);
|
|
240
|
+
const successes = results.filter(r => r?.ok).length;
|
|
241
|
+
|
|
242
|
+
if (successes === 2) {
|
|
243
|
+
findings.push(createFinding({
|
|
244
|
+
module: 'logic',
|
|
245
|
+
title: 'Duplicate Submission: No Idempotency Protection',
|
|
246
|
+
severity: 'high',
|
|
247
|
+
affected_surface: url,
|
|
248
|
+
description: `Payment/transaction endpoint ${url} accepted the same request twice. This could lead to double charges or duplicate orders. No idempotency key or duplicate detection was observed.`,
|
|
249
|
+
reproduction: [
|
|
250
|
+
`1. Submit a POST to ${url}`,
|
|
251
|
+
`2. Immediately submit the identical request again`,
|
|
252
|
+
`3. Both requests succeed`,
|
|
253
|
+
],
|
|
254
|
+
evidence: `Both requests returned success (${successes}/2 accepted)`,
|
|
255
|
+
remediation: 'Implement idempotency keys for payment/transaction endpoints. Use unique request IDs and check for duplicates before processing. Add server-side deduplication with a short TTL cache.',
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return findings;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_getBaseUrl(surfaceInventory) {
|
|
267
|
+
const pages = surfaceInventory.pages || [];
|
|
268
|
+
if (pages.length === 0) return null;
|
|
269
|
+
try {
|
|
270
|
+
const parsed = new URL(pages[0].url || pages[0]);
|
|
271
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
272
|
+
} catch { return null; }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_isRedirectPage(text) {
|
|
276
|
+
return /redirect|location\.href|window\.location/i.test(text) && text.length < 500;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_isConfirmationPage(text) {
|
|
280
|
+
return /thank|success|confirmed|complete|receipt|order.*placed/i.test(text);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export default WorkflowEnforcer;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { createFinding } from '../utils/finding.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PerformanceChecker — Measures Core Web Vitals per page and reports regressions.
|
|
6
|
+
*
|
|
7
|
+
* Metrics:
|
|
8
|
+
* - LCP (Largest Contentful Paint) — Good: <2.5s, Poor: >4s
|
|
9
|
+
* - CLS (Cumulative Layout Shift) — Good: <0.1, Poor: >0.25
|
|
10
|
+
* - TTFB (Time to First Byte) — Good: <800ms, Poor: >1800ms
|
|
11
|
+
* - FCP (First Contentful Paint) — Good: <1.8s, Poor: >3s
|
|
12
|
+
* - TBT (Total Blocking Time) — Good: <200ms, Poor: >600ms
|
|
13
|
+
*/
|
|
14
|
+
export class PerformanceChecker {
|
|
15
|
+
// Google Core Web Vitals thresholds (ms, except CLS which is score)
|
|
16
|
+
static THRESHOLDS = {
|
|
17
|
+
LCP: { good: 2500, poor: 4000 },
|
|
18
|
+
FCP: { good: 1800, poor: 3000 },
|
|
19
|
+
TTFB: { good: 800, poor: 1800 },
|
|
20
|
+
TBT: { good: 200, poor: 600 },
|
|
21
|
+
CLS: { good: 0.1, poor: 0.25 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
constructor(config, logger) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async check(surfaceInventory) {
|
|
30
|
+
const findings = [];
|
|
31
|
+
const pages = surfaceInventory.pages.filter(p => p.status < 400).slice(0, 15);
|
|
32
|
+
|
|
33
|
+
if (pages.length === 0) return findings;
|
|
34
|
+
|
|
35
|
+
const browser = await chromium.launch({
|
|
36
|
+
headless: true,
|
|
37
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
for (const pageData of pages) {
|
|
42
|
+
const metrics = await this._measurePage(browser, pageData.url);
|
|
43
|
+
if (!metrics) continue;
|
|
44
|
+
|
|
45
|
+
const issues = this._evaluateMetrics(metrics, pageData.url);
|
|
46
|
+
findings.push(...issues);
|
|
47
|
+
|
|
48
|
+
this.logger?.debug?.(`Performance: ${pageData.url} — LCP:${metrics.LCP}ms FCP:${metrics.FCP}ms TTFB:${metrics.TTFB}ms CLS:${metrics.CLS}`);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
await browser.close();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.logger?.info?.(`Performance Checker: found ${findings.length} issues`);
|
|
55
|
+
return findings;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async _measurePage(browser, url) {
|
|
59
|
+
const page = await browser.newPage({
|
|
60
|
+
viewport: { width: 1440, height: 900 },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// Enable CDPSession for TBT/LCP metrics
|
|
65
|
+
const client = await page.context().newCDPSession(page);
|
|
66
|
+
await client.send('Performance.enable');
|
|
67
|
+
|
|
68
|
+
const startTime = Date.now();
|
|
69
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
|
70
|
+
|
|
71
|
+
// Give page time to fully paint
|
|
72
|
+
await page.waitForTimeout(2000);
|
|
73
|
+
|
|
74
|
+
const metrics = await page.evaluate(() => {
|
|
75
|
+
const perf = window.performance;
|
|
76
|
+
const navEntry = perf.getEntriesByType('navigation')[0];
|
|
77
|
+
const paintEntries = perf.getEntriesByType('paint');
|
|
78
|
+
|
|
79
|
+
const fcp = paintEntries.find(e => e.name === 'first-contentful-paint')?.startTime || null;
|
|
80
|
+
|
|
81
|
+
// LCP via PerformanceObserver (collected passively)
|
|
82
|
+
let lcp = null;
|
|
83
|
+
const lcpEntries = perf.getEntriesByType('largest-contentful-paint');
|
|
84
|
+
if (lcpEntries.length > 0) {
|
|
85
|
+
lcp = lcpEntries[lcpEntries.length - 1].startTime;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// CLS via LayoutShift entries
|
|
89
|
+
let cls = 0;
|
|
90
|
+
let clsSessionValue = 0;
|
|
91
|
+
let clsSessionStart = 0;
|
|
92
|
+
let clsLastTimestamp = 0;
|
|
93
|
+
const layoutShiftEntries = perf.getEntriesByType('layout-shift');
|
|
94
|
+
for (const entry of layoutShiftEntries) {
|
|
95
|
+
if (!entry.hadRecentInput) {
|
|
96
|
+
if (entry.startTime - clsLastTimestamp > 5000 || entry.startTime - clsSessionStart > 1000) {
|
|
97
|
+
clsSessionValue = entry.value;
|
|
98
|
+
clsSessionStart = entry.startTime;
|
|
99
|
+
} else {
|
|
100
|
+
clsSessionValue += entry.value;
|
|
101
|
+
}
|
|
102
|
+
clsLastTimestamp = entry.startTime;
|
|
103
|
+
if (clsSessionValue > cls) cls = clsSessionValue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// TBT via Long Tasks
|
|
108
|
+
let tbt = 0;
|
|
109
|
+
const longTasks = perf.getEntriesByType('longtask');
|
|
110
|
+
for (const task of longTasks) {
|
|
111
|
+
if (task.duration > 50) tbt += task.duration - 50;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
TTFB: navEntry ? Math.round(navEntry.responseStart - navEntry.requestStart) : null,
|
|
116
|
+
FCP: fcp ? Math.round(fcp) : null,
|
|
117
|
+
LCP: lcp ? Math.round(lcp) : null,
|
|
118
|
+
CLS: Math.round(cls * 1000) / 1000,
|
|
119
|
+
TBT: Math.round(tbt),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return metrics;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
this.logger?.debug?.(`Performance measure failed for ${url}: ${err.message}`);
|
|
126
|
+
return null;
|
|
127
|
+
} finally {
|
|
128
|
+
await page.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_evaluateMetrics(metrics, url) {
|
|
133
|
+
const findings = [];
|
|
134
|
+
const thresholds = PerformanceChecker.THRESHOLDS;
|
|
135
|
+
|
|
136
|
+
const checks = [
|
|
137
|
+
{ key: 'LCP', label: 'Largest Contentful Paint', unit: 'ms' },
|
|
138
|
+
{ key: 'FCP', label: 'First Contentful Paint', unit: 'ms' },
|
|
139
|
+
{ key: 'TTFB', label: 'Time to First Byte', unit: 'ms' },
|
|
140
|
+
{ key: 'TBT', label: 'Total Blocking Time', unit: 'ms' },
|
|
141
|
+
{ key: 'CLS', label: 'Cumulative Layout Shift', unit: '' },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
for (const { key, label, unit } of checks) {
|
|
145
|
+
const value = metrics[key];
|
|
146
|
+
if (value === null || value === undefined) continue;
|
|
147
|
+
|
|
148
|
+
const { good, poor } = thresholds[key];
|
|
149
|
+
if (value <= good) continue; // Passes — no finding
|
|
150
|
+
|
|
151
|
+
const isPoor = value > poor;
|
|
152
|
+
const severity = isPoor ? 'medium' : 'low';
|
|
153
|
+
const rating = isPoor ? 'Poor' : 'Needs Improvement';
|
|
154
|
+
const displayVal = unit === 'ms' ? `${value}${unit}` : String(value);
|
|
155
|
+
const goodDisplay = unit === 'ms' ? `${good}${unit}` : String(good);
|
|
156
|
+
const poorDisplay = unit === 'ms' ? `${poor}${unit}` : String(poor);
|
|
157
|
+
|
|
158
|
+
findings.push(createFinding({
|
|
159
|
+
module: 'qa',
|
|
160
|
+
title: `Performance: ${label} ${rating} (${displayVal}) — ${new URL(url).pathname}`,
|
|
161
|
+
severity,
|
|
162
|
+
affected_surface: url,
|
|
163
|
+
description: `The page at ${url} has a ${label} of ${displayVal}, which Google rates as "${rating}" (Good: <${goodDisplay}, Poor: >${poorDisplay}). ${this._getImpact(key, value)}`,
|
|
164
|
+
reproduction: [
|
|
165
|
+
`1. Run Lighthouse on ${url}`,
|
|
166
|
+
`2. Check ${label} in the Performance panel`,
|
|
167
|
+
`3. Current value: ${displayVal}`,
|
|
168
|
+
],
|
|
169
|
+
evidence: `${key}: ${displayVal} (Good: <${goodDisplay}, Poor: >${poorDisplay})\nFull metrics: ${JSON.stringify(metrics)}`,
|
|
170
|
+
remediation: this._getRemediation(key),
|
|
171
|
+
references: [
|
|
172
|
+
'https://web.dev/articles/vitals',
|
|
173
|
+
`https://web.dev/articles/${key.toLowerCase()}`,
|
|
174
|
+
],
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return findings;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_getImpact(key, value) {
|
|
182
|
+
const impacts = {
|
|
183
|
+
LCP: 'Slow LCP typically indicates render-blocking resources, slow server response, or large unoptimized images. Google uses this as a Core Web Vital for search ranking.',
|
|
184
|
+
FCP: 'A slow FCP suggests excessive render-blocking CSS or scripts that prevent any content from appearing. Users may abandon the page before it loads.',
|
|
185
|
+
TTFB: 'A high TTFB indicates slow server processing, database queries, or CDN latency. Every other metric is blocked until the first byte arrives.',
|
|
186
|
+
TBT: 'High TBT means the main thread is blocked by long JavaScript tasks, making the page unresponsive to user input. Correlated with poor INP scores.',
|
|
187
|
+
CLS: 'A high CLS causes layout instability — content shifting after initial render. This degrades UX and is a Core Web Vital used in Google\'s search ranking.',
|
|
188
|
+
};
|
|
189
|
+
return impacts[key] || '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_getRemediation(key) {
|
|
193
|
+
const remediations = {
|
|
194
|
+
LCP: 'Optimize LCP: use preload <link> for hero images, serve images in WebP/AVIF, reduce TTFB via edge caching, remove render-blocking CSS (inline critical CSS), use font-display: swap.',
|
|
195
|
+
FCP: 'Optimize FCP: eliminate render-blocking resources, minimize CSS and JS bundle sizes, use resource hints (preconnect, preload), defer non-critical scripts.',
|
|
196
|
+
TTFB: 'Optimize TTFB: use CDN edge caching, optimize database queries (add indexes, avoid N+1), implement server-side caching (Redis), reduce DNS lookup time.',
|
|
197
|
+
TBT: 'Reduce TBT: split large JavaScript bundles via code splitting, defer or async non-critical scripts, move heavy computation to Web Workers, minimize third-party script impact.',
|
|
198
|
+
CLS: 'Reduce CLS: always set explicit width/height on images and videos, use CSS aspect-ratio, avoid inserting content above existing content dynamically, use transform animations instead of layout-triggering properties.',
|
|
199
|
+
};
|
|
200
|
+
return remediations[key] || 'Follow Google\'s Core Web Vitals guidelines at web.dev/vitals.';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export default PerformanceChecker;
|