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
package/src/cli.js ADDED
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import fs from 'fs';
6
+ import { loadConfig } from './utils/config.js';
7
+ import { createLogger } from './utils/logger.js';
8
+ import { Orchestrator } from './agents/orchestrator.js';
9
+ import { CrawlAgent } from './agents/crawl-agent.js';
10
+ import { QAAgent } from './agents/qa-agent.js';
11
+ import { SecurityAgent } from './agents/security-agent.js';
12
+ import { AIAgent } from './agents/ai-agent.js';
13
+ import { LogicAgent } from './agents/logic-agent.js';
14
+ import { APIAgent } from './agents/api-agent.js';
15
+ import { ReportGenerator } from './reporting/report-generator.js';
16
+ import { AuthManager } from './core/auth-manager.js';
17
+
18
+ const BANNER = `
19
+ ${chalk.hex('#00ff88').bold(' ╦╔═╗╦╔═╦ ╦')}
20
+ ${chalk.hex('#00ff88').bold(' ║╠═╣╠╩╗║ ║')} ${chalk.dim('呪 Autonomous Security & Quality Intelligence')}
21
+ ${chalk.hex('#00ff88').bold(' ╚╝╩ ╩╩ ╩╚═╝')} ${chalk.dim('v1.0.0 · Multi-Agent')}
22
+ `;
23
+
24
+ const program = new Command();
25
+
26
+ program
27
+ .name('jaku')
28
+ .description('JAKU (呪) — Autonomous QA & Security scanning agent for vibe-coded apps')
29
+ .version('1.0.0');
30
+
31
+ // ═══════════════════════════════════════════════
32
+ // Multi-Agent Scan Runner
33
+ // ═══════════════════════════════════════════════
34
+
35
+ async function runScan(url, options, modulesToRun) {
36
+ console.log(BANNER);
37
+
38
+ const config = loadConfig({ ...options, targetUrl: url });
39
+ config.target_url = url;
40
+ config.crawler = {
41
+ ...config.crawler,
42
+ max_pages: parseInt(options.maxPages) || config.crawler?.max_pages || 50,
43
+ max_depth: parseInt(options.maxDepth) || config.crawler?.max_depth || 5,
44
+ };
45
+
46
+ // Propagate CLI flags to config
47
+ if (options.haltOnCritical) config.halt_on_critical = true;
48
+ if (options.webhook) config.notify_webhook = options.webhook;
49
+
50
+ // Propagate auth flags from CLI
51
+ if (options.username || options.password || options.loginUrl || options.authStrategy) {
52
+ config.auth = config.auth || {};
53
+ if (options.authStrategy) config.auth.strategy = options.authStrategy;
54
+ if (options.loginUrl) config.auth.login_url = options.loginUrl;
55
+ if (options.username && options.password) {
56
+ config.credentials = config.credentials || [];
57
+ // Add CLI credentials as a "cli" role if not already present
58
+ const hasCliRole = config.credentials.some(c => c.role === 'cli');
59
+ if (!hasCliRole) {
60
+ config.credentials.push({
61
+ role: 'cli',
62
+ username: options.username,
63
+ password: options.password,
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ // ── prod_safe guard ──
70
+ const prodIndicators = /\b(prod|production|live|www\.)\b/i;
71
+ const isLikelyProd = prodIndicators.test(url) && !/\b(staging|dev|test|local|sandbox)\b/i.test(url);
72
+ if (isLikelyProd && !options.prodSafe && !config.prod_safe) {
73
+ console.error(chalk.red('\n ⛔ PRODUCTION TARGET DETECTED'));
74
+ console.error(chalk.red(` URL "${url}" looks like a production environment.`));
75
+ console.error(chalk.red(' Add --prod-safe flag to confirm you have authorization to test.'));
76
+ console.error(chalk.dim(' Example: jaku scan https://prod.example.com --prod-safe\n'));
77
+ process.exit(1);
78
+ }
79
+
80
+ const logger = createLogger({ verbose: options.verbose });
81
+ const startTime = Date.now();
82
+
83
+ const runQA = modulesToRun.includes('qa');
84
+ const runSecurity = modulesToRun.includes('security');
85
+ const runAI = modulesToRun.includes('ai');
86
+ const runLogic = modulesToRun.includes('logic');
87
+ const runAPI = modulesToRun.includes('api');
88
+ const moduleLabel = modulesToRun.join(' + ').toUpperCase();
89
+
90
+ console.log(chalk.hex('#00ff88')(' Target: ') + chalk.white(url));
91
+ console.log(chalk.hex('#00ff88')(' Modules: ') + chalk.white(moduleLabel));
92
+ console.log(chalk.hex('#00ff88')(' Mode: ') + chalk.white('Multi-Agent Orchestration'));
93
+ console.log(chalk.hex('#00ff88')(' Severity:') + chalk.white(` ≥ ${config.severity_threshold}`));
94
+ console.log();
95
+
96
+ // ═══════════════════════════════════════
97
+ // Phase 0: Authentication (before spinners)
98
+ // ═══════════════════════════════════════
99
+ const authManager = new AuthManager(config, logger);
100
+ const authSpinner = ora({ text: chalk.dim('Detecting login forms...'), color: 'yellow' }).start();
101
+
102
+ // Pause spinner before prompting (so readline works cleanly)
103
+ authManager._onBeforePrompt = () => authSpinner.stop();
104
+ authManager._onAfterPrompt = () => { }; // don't restart — prompt handles its own output
105
+
106
+ await authManager.authenticate();
107
+
108
+ if (authManager.isAuthenticated) {
109
+ authSpinner.succeed(chalk.dim('Authenticated: ') + authManager.roles.map(r => chalk.hex('#00ff88')(r)).join(', '));
110
+ } else {
111
+ authSpinner.info(chalk.dim('No credentials — scanning unauthenticated'));
112
+ }
113
+
114
+ // Inject auth manager into config for agents to access
115
+ config._authManager = authManager;
116
+
117
+ // ═══════════════════════════════════════
118
+ // Build the agent constellation
119
+ // ═══════════════════════════════════════
120
+ const orchestrator = new Orchestrator(config, logger);
121
+
122
+ // JAKU-CRAWL always runs (all modules depend on it)
123
+ orchestrator.register(new CrawlAgent());
124
+
125
+ // Register module agents
126
+ let qaAgent = null;
127
+ let secAgent = null;
128
+
129
+ if (runQA) {
130
+ qaAgent = new QAAgent();
131
+ orchestrator.register(qaAgent);
132
+ }
133
+ if (runSecurity) {
134
+ secAgent = new SecurityAgent();
135
+ orchestrator.register(secAgent);
136
+ }
137
+ if (runAI) {
138
+ orchestrator.register(new AIAgent());
139
+ }
140
+ if (runLogic) {
141
+ orchestrator.register(new LogicAgent());
142
+ }
143
+ if (runAPI) {
144
+ orchestrator.register(new APIAgent());
145
+ }
146
+
147
+ // ═══════════════════════════════════════
148
+ // Wire up CLI progress display
149
+ // ═══════════════════════════════════════
150
+ const spinners = {};
151
+ let activeAgentCount = 0;
152
+ const parallelIndicator = () => activeAgentCount > 1 ? chalk.cyan(' ⚡parallel') : '';
153
+
154
+ orchestrator.on('agent:started', ({ agentName }) => {
155
+ activeAgentCount++;
156
+ const color = agentName === 'JAKU-SEC' ? 'yellow' : agentName === 'JAKU-AI' ? 'magenta' : agentName === 'JAKU-LOGIC' ? 'cyan' : agentName === 'JAKU-API' ? 'red' : 'green';
157
+ spinners[agentName] = ora({
158
+ text: chalk.dim(`[${agentName}] `) + 'Starting...' + parallelIndicator(),
159
+ color,
160
+ }).start();
161
+ });
162
+
163
+ orchestrator.on('agent:progress', ({ agentName, phase, message }) => {
164
+ if (spinners[agentName]) {
165
+ spinners[agentName].text = chalk.dim(`[${agentName}] `) + message + parallelIndicator();
166
+ }
167
+ });
168
+
169
+ orchestrator.on('agent:completed', ({ agentName, duration, findingsCount }) => {
170
+ activeAgentCount--;
171
+ if (spinners[agentName]) {
172
+ spinners[agentName].succeed(
173
+ chalk.dim(`[${agentName}] `) +
174
+ `Complete — ${chalk.hex('#00ff88').bold(findingsCount)} findings in ${(duration / 1000).toFixed(1)}s`
175
+ );
176
+ }
177
+ });
178
+
179
+ orchestrator.on('agent:error', ({ agentName, error }) => {
180
+ activeAgentCount--;
181
+ if (spinners[agentName]) {
182
+ spinners[agentName].fail(chalk.dim(`[${agentName}] `) + chalk.red(`Error: ${error}`));
183
+ }
184
+ });
185
+
186
+ // ═══════════════════════════════════════
187
+ // Execute the multi-agent pipeline
188
+ // ═══════════════════════════════════════
189
+ let results;
190
+ try {
191
+ results = await orchestrator.run();
192
+ } catch (err) {
193
+ console.error(chalk.red(`\n Orchestrator failed: ${err.message}`));
194
+ process.exit(1);
195
+ }
196
+
197
+ // ═══════════════════════════════════════
198
+ // Report Generation
199
+ // ═══════════════════════════════════════
200
+ const reportSpinner = ora({
201
+ text: 'Generating reports...',
202
+ color: 'green',
203
+ }).start();
204
+
205
+ try {
206
+ const duration = Date.now() - startTime;
207
+ const reporter = new ReportGenerator(config, logger);
208
+
209
+ const testSummary = qaAgent?.testSummary || {};
210
+
211
+ const { reportDir, summary, dedupSummary } = await reporter.generate({
212
+ findings: results.findings,
213
+ deduplicated: results.deduplicated,
214
+ dedupStats: results.dedupStats,
215
+ correlations: results.correlations || [],
216
+ modules: modulesToRun,
217
+ testSummary: { ...testSummary, duration },
218
+ surfaceInventory: results.surfaceInventory,
219
+ outputDir: config.output_dir,
220
+ });
221
+
222
+ reportSpinner.succeed(`Reports saved to ${chalk.underline(reportDir)}`);
223
+
224
+ // ═══════════════════════════════════════
225
+ // Final Summary
226
+ // ═══════════════════════════════════════
227
+ console.log();
228
+ console.log(chalk.hex('#00ff88').bold(' ═══ SCAN COMPLETE ═══'));
229
+ console.log();
230
+ console.log(` ${chalk.dim('Duration:')} ${(duration / 1000).toFixed(1)}s`);
231
+ console.log(` ${chalk.dim('Modules:')} ${moduleLabel}`);
232
+ console.log(` ${chalk.dim('Agents:')} ${Object.keys(results.agents).length} agents executed`);
233
+
234
+ // Agent breakdown
235
+ for (const [name, agent] of Object.entries(results.agents)) {
236
+ const statusIcon = agent.status === 'done' ? chalk.hex('#00ff88')('✔') : chalk.red('✘');
237
+ console.log(` ${chalk.dim(' ' + name + ':')} ${statusIcon} ${agent.findingsCount} findings (${(agent.duration / 1000).toFixed(1)}s)`);
238
+ }
239
+
240
+ console.log();
241
+ const displaySummary = dedupSummary || summary;
242
+ const dedupStats = results.dedupStats;
243
+ if (dedupStats && dedupStats.duplicatesRemoved > 0) {
244
+ console.log(` ${chalk.dim('Findings:')} ${displaySummary.total} unique ${chalk.dim(`(from ${dedupStats.rawCount} raw, ${dedupStats.reductionPercent}% deduped)`)}`);
245
+ } else {
246
+ console.log(` ${chalk.dim('Findings:')} ${summary.total}`);
247
+ }
248
+ if (displaySummary.critical > 0) console.log(` ${chalk.red(' Critical:')} ${displaySummary.critical}`);
249
+ if (displaySummary.high > 0) console.log(` ${chalk.hex('#ff6d00')(' High:')} ${displaySummary.high}`);
250
+ if (displaySummary.medium > 0) console.log(` ${chalk.yellow(' Medium:')} ${displaySummary.medium}`);
251
+ if (displaySummary.low > 0) console.log(` ${chalk.blue(' Low:')} ${displaySummary.low}`);
252
+ if (displaySummary.info > 0) console.log(` ${chalk.gray(' Info:')} ${displaySummary.info}`);
253
+
254
+ // Correlations
255
+ if (results.correlations?.length > 0) {
256
+ console.log();
257
+ console.log(chalk.hex('#ff6d00').bold(' ═══ CORRELATIONS ═══'));
258
+ for (const c of results.correlations) {
259
+ console.log(` ${chalk.hex('#ff6d00')('⚡')} ${c.title}`);
260
+ }
261
+ }
262
+
263
+ console.log();
264
+
265
+ if (summary.critical > 0) {
266
+ console.log(chalk.red.bold(' ⚠ CRITICAL findings detected — immediate action required!'));
267
+ if (config.halt_on_critical) process.exit(1);
268
+ } else if (summary.high > 0) {
269
+ console.log(chalk.hex('#ff6d00')(' ⚠ HIGH severity findings detected — review recommended.'));
270
+ } else if (summary.total === 0) {
271
+ console.log(chalk.hex('#00ff88')(' ✔ No findings at the configured severity threshold. Clean scan!'));
272
+ }
273
+
274
+ console.log();
275
+ } catch (err) {
276
+ reportSpinner.fail('Report generation failed: ' + err.message);
277
+ logger.error('Report generation failed', err);
278
+ process.exit(1);
279
+ }
280
+ }
281
+
282
+ // ═══════════════════════════════════════════════
283
+ // Fix 7: Multi-target URL resolver
284
+ // ═══════════════════════════════════════════════
285
+
286
+ /**
287
+ * Resolve target URLs from either a primary URL, a comma-separated list (--targets),
288
+ * or a file path (one URL per line).
289
+ */
290
+ function _resolveTargets(primaryUrl, targetsOption) {
291
+ if (!targetsOption) return [primaryUrl];
292
+
293
+ // Check if it looks like a file path
294
+ if (fs.existsSync(targetsOption)) {
295
+ const lines = fs.readFileSync(targetsOption, 'utf-8')
296
+ .split('\n')
297
+ .map(l => l.trim())
298
+ .filter(l => l && l.startsWith('http'));
299
+ return lines.length > 0 ? lines : [primaryUrl];
300
+ }
301
+
302
+ // Comma-separated list
303
+ const targets = targetsOption.split(',').map(t => t.trim()).filter(t => t.startsWith('http'));
304
+ return targets.length > 0 ? targets : [primaryUrl];
305
+ }
306
+
307
+ // ═══════════════════════════════════════════════
308
+ // Commands
309
+ // ═══════════════════════════════════════════════
310
+
311
+ program
312
+ .command('scan')
313
+ .description('Run JAKU scan with selected modules (default: qa + security)')
314
+ .argument('<url>', 'Target URL to scan')
315
+ .option('-c, --config <path>', 'Path to jaku.config.json')
316
+ .option('-o, --output <dir>', 'Output directory for reports')
317
+ .option('-m, --modules <list>', 'Comma-separated modules to run (qa,security,ai,logic,api)', 'qa,security,ai,logic,api')
318
+ .option('-s, --severity <level>', 'Minimum severity threshold (critical|high|medium|low)', 'low')
319
+ .option('--targets <urls-or-file>', 'Comma-separated target URLs or path to a file with one URL per line (multi-target mode)')
320
+ .option('--json', 'Output JSON report')
321
+ .option('--html', 'Output HTML report')
322
+ .option('--max-pages <n>', 'Maximum pages to crawl', '50')
323
+ .option('--max-depth <n>', 'Maximum crawl depth', '5')
324
+ .option('--halt-on-critical', 'Abort scan immediately on critical finding')
325
+ .option('--webhook <url>', 'POST findings to webhook URL on completion')
326
+ .option('--prod-safe', 'Confirm authorization to scan production targets')
327
+ .option('--auth-strategy <type>', 'Auth strategy: auto|form|api|cookie (default: auto)')
328
+ .option('--login-url <url>', 'Login page URL for form-based auth')
329
+ .option('--username <user>', 'Username/email for authenticated scanning')
330
+ .option('--password <pass>', 'Password for authenticated scanning')
331
+ .option('-v, --verbose', 'Enable verbose logging')
332
+ .action(async (url, options) => {
333
+ const modules = options.modules.split(',').map(m => m.trim().toLowerCase());
334
+
335
+ // Fix 7: Multi-target support — parse --targets flag
336
+ const targets = _resolveTargets(url, options.targets);
337
+
338
+ if (targets.length === 1) {
339
+ await runScan(targets[0], options, modules);
340
+ } else {
341
+ console.log(chalk.hex('#00ff88').bold(`\n ═══ MULTI-TARGET SCAN: ${targets.length} targets ═══\n`));
342
+ for (let i = 0; i < targets.length; i++) {
343
+ const target = targets[i];
344
+ console.log(chalk.hex('#00ff88').bold(`\n ── Target ${i + 1}/${targets.length}: ${target} ──\n`));
345
+ await runScan(target, options, modules).catch(err => {
346
+ console.error(chalk.red(` ✘ Scan failed for ${target}: ${err.message}`));
347
+ });
348
+ }
349
+ console.log(chalk.hex('#00ff88').bold(`\n ═══ MULTI-TARGET SCAN COMPLETE ═══\n`));
350
+ }
351
+ });
352
+
353
+ program
354
+ .command('qa')
355
+ .description('Run Module 01 only: Quality Assurance & Functional Testing')
356
+ .argument('<url>', 'Target URL to scan')
357
+ .option('-c, --config <path>', 'Path to jaku.config.json')
358
+ .option('-o, --output <dir>', 'Output directory for reports')
359
+ .option('-s, --severity <level>', 'Severity threshold', 'low')
360
+ .option('--max-pages <n>', 'Maximum pages to crawl', '50')
361
+ .option('--max-depth <n>', 'Maximum crawl depth', '5')
362
+ .option('-v, --verbose', 'Enable verbose logging')
363
+ .action(async (url, options) => {
364
+ await runScan(url, options, ['qa']);
365
+ });
366
+
367
+ program
368
+ .command('security')
369
+ .description('Run Module 02 only: Security Vulnerability Scanning')
370
+ .argument('<url>', 'Target URL to scan')
371
+ .option('-c, --config <path>', 'Path to jaku.config.json')
372
+ .option('-o, --output <dir>', 'Output directory for reports')
373
+ .option('-s, --severity <level>', 'Severity threshold', 'low')
374
+ .option('--max-pages <n>', 'Maximum pages to crawl', '50')
375
+ .option('--max-depth <n>', 'Maximum crawl depth', '5')
376
+ .option('-v, --verbose', 'Enable verbose logging')
377
+ .action(async (url, options) => {
378
+ await runScan(url, options, ['security']);
379
+ });
380
+
381
+ program
382
+ .command('ai')
383
+ .description('Run Module 04 only: Prompt Injection & AI Abuse Detection')
384
+ .argument('<url>', 'Target URL to scan')
385
+ .option('-c, --config <path>', 'Path to jaku.config.json')
386
+ .option('-o, --output <dir>', 'Output directory for reports')
387
+ .option('-s, --severity <level>', 'Severity threshold', 'low')
388
+ .option('--max-pages <n>', 'Maximum pages to crawl', '50')
389
+ .option('--max-depth <n>', 'Maximum crawl depth', '5')
390
+ .option('-v, --verbose', 'Enable verbose logging')
391
+ .action(async (url, options) => {
392
+ await runScan(url, options, ['ai']);
393
+ });
394
+
395
+ program
396
+ .command('logic')
397
+ .description('Run Module 03 only: Business Logic Validation')
398
+ .argument('<url>', 'Target URL to scan')
399
+ .option('-c, --config <path>', 'Path to jaku.config.json')
400
+ .option('-o, --output <dir>', 'Output directory for reports')
401
+ .option('-s, --severity <level>', 'Severity threshold', 'low')
402
+ .option('--max-pages <n>', 'Maximum pages to crawl', '50')
403
+ .option('--max-depth <n>', 'Maximum crawl depth', '5')
404
+ .option('-v, --verbose', 'Enable verbose logging')
405
+ .action(async (url, options) => {
406
+ await runScan(url, options, ['logic']);
407
+ });
408
+
409
+ program
410
+ .command('api')
411
+ .description('Run Module 05 only: API & Auth Flow Verification')
412
+ .argument('<url>', 'Target URL to scan')
413
+ .option('-c, --config <path>', 'Path to jaku.config.json')
414
+ .option('-o, --output <dir>', 'Output directory for reports')
415
+ .option('-s, --severity <level>', 'Severity threshold', 'low')
416
+ .option('--max-pages <n>', 'Maximum pages to crawl', '50')
417
+ .option('--max-depth <n>', 'Maximum crawl depth', '5')
418
+ .option('-v, --verbose', 'Enable verbose logging')
419
+ .action(async (url, options) => {
420
+ await runScan(url, options, ['api']);
421
+ });
422
+
423
+ program.parse();
@@ -0,0 +1,171 @@
1
+ import { chromium } from 'playwright';
2
+ import { createFinding } from '../utils/finding.js';
3
+
4
+ /**
5
+ * AccessibilityChecker — Checks WCAG 2.2 compliance using axe-core.
6
+ *
7
+ * Categories:
8
+ * - Critical: keyboard trap, missing form labels, missing alt text on interactive elements
9
+ * - Serious: color contrast, focus visible, duplicate IDs
10
+ * - Moderate: language attribute, skip navigation, heading order
11
+ *
12
+ * Uses axe-core injected via Playwright for accurate real-browser analysis.
13
+ */
14
+ export class AccessibilityChecker {
15
+ // axe-core CDN version to inject
16
+ static AXE_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.9.1/axe.min.js';
17
+
18
+ // Severity mapping from axe-core impact levels
19
+ static SEVERITY_MAP = {
20
+ critical: 'critical',
21
+ serious: 'high',
22
+ moderate: 'medium',
23
+ minor: 'low',
24
+ };
25
+
26
+ // Categories to report (filter noise)
27
+ static INCLUDE_RULES = new Set([
28
+ 'image-alt', 'label', 'label-content-name-mismatch', 'input-button-name',
29
+ 'button-name', 'link-name', 'aria-required-attr', 'aria-valid-attr',
30
+ 'color-contrast', 'color-contrast-enhanced',
31
+ 'keyboard', 'focus-trap', 'focusable-disabled', 'focus-order-semantics',
32
+ 'duplicate-id', 'duplicate-id-active', 'duplicate-id-aria',
33
+ 'html-has-lang', 'html-lang-valid', 'document-title',
34
+ 'heading-order', 'bypass', 'landmark-one-main',
35
+ 'form-field-multiple-labels', 'autocomplete-valid',
36
+ 'scrollable-region-focusable', 'select-name', 'textarea-label',
37
+ ]);
38
+
39
+ constructor(config, logger) {
40
+ this.config = config;
41
+ this.logger = logger;
42
+ }
43
+
44
+ async check(surfaceInventory) {
45
+ const findings = [];
46
+ const pages = surfaceInventory.pages.filter(p => p.status < 400).slice(0, 15);
47
+
48
+ if (pages.length === 0) return findings;
49
+
50
+ const browser = await chromium.launch({ headless: true });
51
+
52
+ try {
53
+ for (const pageData of pages) {
54
+ const results = await this._runAxe(browser, pageData.url);
55
+ if (!results) continue;
56
+
57
+ for (const violation of results) {
58
+ // Skip rules not in our inclusion list (reduces noise)
59
+ if (!AccessibilityChecker.INCLUDE_RULES.has(violation.id)) continue;
60
+
61
+ const severity = AccessibilityChecker.SEVERITY_MAP[violation.impact] || 'low';
62
+ const affectedCount = violation.nodes?.length || 1;
63
+
64
+ findings.push(createFinding({
65
+ module: 'qa',
66
+ title: `Accessibility (WCAG 2.2): ${violation.help} on ${new URL(pageData.url).pathname}`,
67
+ severity,
68
+ affected_surface: pageData.url,
69
+ description: `${violation.description} This violates WCAG 2.2 success criterion ${violation.helpUrl ? `(see reference)` : violation.id}. ${affectedCount} element${affectedCount > 1 ? 's are' : ' is'} affected on this page.\n\n${violation.help}.`,
70
+ reproduction: [
71
+ `1. Open ${pageData.url}`,
72
+ '2. Run axe-core in DevTools: await axe.run()',
73
+ `3. Look for violation: "${violation.id}"`,
74
+ `4. Affected selectors: ${(violation.nodes || []).slice(0, 3).map(n => n.target?.[0] || 'unknown').join(', ')}`,
75
+ ],
76
+ evidence: JSON.stringify({
77
+ rule: violation.id,
78
+ impact: violation.impact,
79
+ affectedCount,
80
+ sampleNodes: (violation.nodes || []).slice(0, 2).map(n => ({
81
+ target: n.target,
82
+ html: n.html?.substring(0, 150),
83
+ failureSummary: n.failureSummary,
84
+ })),
85
+ }, null, 2).substring(0, 800),
86
+ remediation: violation.helpUrl
87
+ ? `See axe-core guidance: ${violation.helpUrl}`
88
+ : this._getGenericRemediation(violation.id),
89
+ references: [
90
+ 'https://www.w3.org/WAI/WCAG22/quickref/',
91
+ violation.helpUrl || 'https://dequeuniversity.com/rules/axe/',
92
+ ],
93
+ }));
94
+ }
95
+
96
+ this.logger?.debug?.(`Accessibility: ${pageData.url} — ${results.length} violations`);
97
+ }
98
+ } finally {
99
+ await browser.close();
100
+ }
101
+
102
+ this.logger?.info?.(`Accessibility Checker: found ${findings.length} issues`);
103
+ return findings;
104
+ }
105
+
106
+ async _runAxe(browser, url) {
107
+ const page = await browser.newPage({
108
+ viewport: { width: 1440, height: 900 },
109
+ });
110
+
111
+ try {
112
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
113
+ await page.waitForTimeout(1500);
114
+
115
+ // Inject axe-core
116
+ await page.addScriptTag({ url: AccessibilityChecker.AXE_CDN }).catch(async () => {
117
+ // Fallback: try local CDN or skip
118
+ const axeSource = await this._fetchAxeCore().catch(() => null);
119
+ if (axeSource) await page.addScriptTag({ content: axeSource });
120
+ });
121
+
122
+ await page.waitForTimeout(500);
123
+
124
+ // Run axe
125
+ const results = await page.evaluate(async () => {
126
+ if (typeof axe === 'undefined') return null;
127
+ const result = await axe.run(document, {
128
+ runOnly: {
129
+ type: 'tag',
130
+ values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'],
131
+ },
132
+ });
133
+ return result.violations;
134
+ });
135
+
136
+ return results;
137
+ } catch (err) {
138
+ this.logger?.debug?.(`Axe run failed for ${url}: ${err.message}`);
139
+ return null;
140
+ } finally {
141
+ await page.close();
142
+ }
143
+ }
144
+
145
+ async _fetchAxeCore() {
146
+ try {
147
+ const res = await fetch(AccessibilityChecker.AXE_CDN);
148
+ return await res.text();
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ _getGenericRemediation(ruleId) {
155
+ const remediations = {
156
+ 'image-alt': 'Add descriptive alt attributes to all <img> elements. Use alt="" for decorative images.',
157
+ 'label': 'Associate every form input with a visible <label> element using for/id pairing or aria-label.',
158
+ 'color-contrast': 'Ensure text has a contrast ratio of at least 4.5:1 (3:1 for large text) against its background. Use a contrast checker tool.',
159
+ 'keyboard': 'All interactive elements must be operable via keyboard alone. Test Tab, Enter, Space, Arrow keys.',
160
+ 'focus-trap': 'Never trap keyboard focus permanently in a component. Modal dialogs should trap focus but provide an escape path (Escape key, close button).',
161
+ 'duplicate-id': 'Each id attribute must be unique within the document. Duplicate IDs break ARIA relationships and cause accessibility failures.',
162
+ 'html-has-lang': 'Add a lang attribute to the <html> element to identify the page language (e.g., <html lang="en">).',
163
+ 'document-title': 'Every page must have a descriptive, unique <title> element.',
164
+ 'heading-order': 'Heading levels must not be skipped (e.g., h1 → h3 without h2). Maintain proper hierarchy.',
165
+ 'bypass': 'Provide a "Skip to main content" link as the first focusable element to allow keyboard users to bypass navigation.',
166
+ };
167
+ return remediations[ruleId] || 'Follow the WCAG 2.2 guidelines at https://www.w3.org/WAI/WCAG22/quickref/';
168
+ }
169
+ }
170
+
171
+ export default AccessibilityChecker;