jaku.sh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +636 -0
  3. package/action.yml +264 -0
  4. package/bin/jaku +2 -0
  5. package/package.json +62 -0
  6. package/src/agents/ai-agent.js +175 -0
  7. package/src/agents/api-agent.js +95 -0
  8. package/src/agents/base-agent.js +158 -0
  9. package/src/agents/crawl-agent.js +175 -0
  10. package/src/agents/event-bus.js +59 -0
  11. package/src/agents/findings-ledger.js +410 -0
  12. package/src/agents/logic-agent.js +144 -0
  13. package/src/agents/orchestrator.js +323 -0
  14. package/src/agents/qa-agent.js +149 -0
  15. package/src/agents/security-agent.js +211 -0
  16. package/src/cli.js +423 -0
  17. package/src/core/accessibility-checker.js +171 -0
  18. package/src/core/ai/ai-endpoint-detector.js +227 -0
  19. package/src/core/ai/guardrail-prober.js +362 -0
  20. package/src/core/ai/indirect-injector.js +106 -0
  21. package/src/core/ai/jailbreak-tester.js +212 -0
  22. package/src/core/ai/model-dos-tester.js +174 -0
  23. package/src/core/ai/model-fingerprinter.js +246 -0
  24. package/src/core/ai/multi-turn-attacker.js +297 -0
  25. package/src/core/ai/output-analyzer.js +182 -0
  26. package/src/core/ai/prompt-injector.js +543 -0
  27. package/src/core/ai/system-prompt-extractor.js +244 -0
  28. package/src/core/api/api-key-auditor.js +266 -0
  29. package/src/core/api/auth-flow-tester.js +430 -0
  30. package/src/core/api/cors-ws-tester.js +263 -0
  31. package/src/core/api/graphql-tester.js +287 -0
  32. package/src/core/api/oauth-prober.js +343 -0
  33. package/src/core/auth-manager.js +902 -0
  34. package/src/core/broken-flow-detector.js +207 -0
  35. package/src/core/browser-manager.js +119 -0
  36. package/src/core/console-monitor.js +111 -0
  37. package/src/core/crawler.js +430 -0
  38. package/src/core/csr-waiter.js +410 -0
  39. package/src/core/form-validator.js +240 -0
  40. package/src/core/logic/abuse-pattern-scanner.js +291 -0
  41. package/src/core/logic/access-boundary-tester.js +448 -0
  42. package/src/core/logic/business-rule-inferrer.js +196 -0
  43. package/src/core/logic/graphql-auditor.js +298 -0
  44. package/src/core/logic/parameter-polluter.js +212 -0
  45. package/src/core/logic/pricing-exploiter.js +299 -0
  46. package/src/core/logic/race-condition-detector.js +222 -0
  47. package/src/core/logic/workflow-enforcer.js +284 -0
  48. package/src/core/performance-checker.js +204 -0
  49. package/src/core/responsive-checker.js +228 -0
  50. package/src/core/security/cors-prober.js +150 -0
  51. package/src/core/security/csrf-prober.js +217 -0
  52. package/src/core/security/dependency-auditor.js +182 -0
  53. package/src/core/security/file-upload-tester.js +340 -0
  54. package/src/core/security/header-analyzer.js +324 -0
  55. package/src/core/security/infra-scanner.js +391 -0
  56. package/src/core/security/path-traversal.js +112 -0
  57. package/src/core/security/prototype-pollution.js +147 -0
  58. package/src/core/security/secret-detector.js +517 -0
  59. package/src/core/security/sqli-prober.js +257 -0
  60. package/src/core/security/tls-checker.js +223 -0
  61. package/src/core/security/xss-scanner.js +225 -0
  62. package/src/core/test-generator.js +339 -0
  63. package/src/core/test-runner.js +398 -0
  64. package/src/reporting/diff-reporter.js +172 -0
  65. package/src/reporting/report-generator.js +408 -0
  66. package/src/reporting/sarif-generator.js +190 -0
  67. package/src/utils/config.js +57 -0
  68. package/src/utils/finding.js +67 -0
  69. package/src/utils/logger.js +50 -0
@@ -0,0 +1,448 @@
1
+ import { createFinding } from '../../utils/finding.js';
2
+
3
+ /**
4
+ * AccessBoundaryTester — Tests access control boundaries.
5
+ *
6
+ * Probes:
7
+ * - Horizontal IDOR (access other users' resources by changing ID)
8
+ * - UUID/GUID enumeration (sequential UUID prediction and fuzzing)
9
+ * - JWT sub-claim manipulation (change user_id in JWT payload)
10
+ * - Cross-tenant access (change org_id, tenant_id, workspace_id)
11
+ * - Vertical escalation (access admin from unprivileged context)
12
+ * - Guest access (perform auth-required actions without login)
13
+ * - Free-to-paid bypass (access premium features without subscription)
14
+ */
15
+ export class AccessBoundaryTester {
16
+ constructor(logger) {
17
+ this.logger = logger;
18
+
19
+ // Common ID parameter names
20
+ this.ID_PARAMS = ['id', 'user_id', 'userId', 'uid', 'account_id', 'accountId',
21
+ 'profile_id', 'order_id', 'orderId', 'item_id', 'itemId'];
22
+
23
+ // Admin/privileged paths to test
24
+ this.ADMIN_PATHS = [
25
+ '/admin', '/admin/', '/admin/dashboard', '/admin/users',
26
+ '/admin/settings', '/admin/config', '/manage', '/manage/users',
27
+ '/internal', '/internal/api', '/api/admin', '/api/admin/users',
28
+ '/api/internal', '/settings/admin', '/dashboard/admin',
29
+ ];
30
+
31
+ // Premium/paid feature paths
32
+ this.PREMIUM_PATHS = [
33
+ '/premium', '/pro', '/enterprise', '/features/premium',
34
+ '/api/premium', '/api/pro', '/upgrade', '/api/export',
35
+ '/api/analytics', '/api/reports/advanced', '/api/bulk',
36
+ ];
37
+ }
38
+
39
+ /**
40
+ * Test access boundaries against discovered surfaces.
41
+ */
42
+ async test(businessContext, surfaceInventory) {
43
+ const findings = [];
44
+
45
+ this.logger?.info?.('Access Boundary Tester: starting tests');
46
+
47
+ // 1. Test vertical escalation (admin access)
48
+ const verticalFindings = await this._testVerticalEscalation(surfaceInventory);
49
+ findings.push(...verticalFindings);
50
+
51
+ // 2. Test IDOR on API endpoints (numeric IDs)
52
+ const idorFindings = await this._testIDOR(businessContext, surfaceInventory);
53
+ findings.push(...idorFindings);
54
+
55
+ // 3. Test UUID-based IDOR
56
+ const uuidFindings = await this._testUUIDIDOR(surfaceInventory);
57
+ findings.push(...uuidFindings);
58
+
59
+ // 4. Test cross-tenant access
60
+ const crossTenantFindings = await this._testCrossTenantAccess(surfaceInventory);
61
+ findings.push(...crossTenantFindings);
62
+
63
+ // 5. Test free-to-paid bypass
64
+ const premiumFindings = await this._testPremiumBypass(surfaceInventory);
65
+ findings.push(...premiumFindings);
66
+
67
+ // 6. Test guest access to authenticated routes
68
+ const guestFindings = await this._testGuestAccess(businessContext);
69
+ findings.push(...guestFindings);
70
+
71
+ this.logger?.info?.(`Access Boundary Tester: found ${findings.length} issues`);
72
+ return findings;
73
+ }
74
+
75
+ /**
76
+ * Test vertical privilege escalation — can unprivileged users access admin?
77
+ */
78
+ async _testVerticalEscalation(surfaceInventory) {
79
+ const findings = [];
80
+ const baseUrl = this._getBaseUrl(surfaceInventory);
81
+ if (!baseUrl) return findings;
82
+
83
+ for (const path of this.ADMIN_PATHS) {
84
+ try {
85
+ const url = new URL(path, baseUrl).href;
86
+ const controller = new AbortController();
87
+ const timeout = setTimeout(() => controller.abort(), 5000);
88
+
89
+ const response = await fetch(url, {
90
+ method: 'GET',
91
+ redirect: 'manual',
92
+ signal: controller.signal,
93
+ });
94
+ clearTimeout(timeout);
95
+
96
+ // If admin page is accessible (200) without auth, that's a finding
97
+ if (response.status === 200) {
98
+ const text = await response.text();
99
+ // Verify it's actually admin content, not a generic 200
100
+ if (this._isAdminContent(text)) {
101
+ findings.push(createFinding({
102
+ module: 'logic',
103
+ title: 'Vertical Privilege Escalation: Admin Accessible',
104
+ severity: 'critical',
105
+ affected_surface: url,
106
+ description: `Admin endpoint ${path} is accessible without authentication. An unauthenticated user can access administrative functionality.`,
107
+ reproduction: [
108
+ `1. Open ${url} in a private/incognito browser`,
109
+ `2. Admin page loads without login requirement`,
110
+ ],
111
+ evidence: `URL: ${url}\nStatus: ${response.status}\nContent indicators: admin content detected`,
112
+ remediation: 'Implement authentication and authorization checks on all admin endpoints. Use middleware to verify user role before granting access. Return 401/403 for unauthorized requests.',
113
+ }));
114
+ }
115
+ }
116
+ } catch {
117
+ continue;
118
+ }
119
+ }
120
+
121
+ return findings;
122
+ }
123
+
124
+ /**
125
+ * Test IDOR — can resource IDs be manipulated to access other users' data?
126
+ * Handles numeric IDs with enumeration.
127
+ */
128
+ async _testIDOR(businessContext, surfaceInventory) {
129
+ const findings = [];
130
+ const apis = surfaceInventory.apis || [];
131
+
132
+ for (const api of apis) {
133
+ const url = api.url || api;
134
+
135
+ // Check if URL contains numeric IDs that could be enumerated
136
+ const idMatch = url.match(/\/(\d+)(\/|$|\?)/);
137
+ if (!idMatch) continue;
138
+
139
+ const originalId = idMatch[1];
140
+ const testIds = [
141
+ String(parseInt(originalId) + 1),
142
+ String(parseInt(originalId) - 1),
143
+ String(parseInt(originalId) + 100),
144
+ '1',
145
+ '0',
146
+ '-1',
147
+ ];
148
+
149
+ for (const testId of testIds) {
150
+ if (testId === originalId) continue;
151
+
152
+ const tamperedUrl = url.replace(`/${originalId}`, `/${testId}`);
153
+ try {
154
+ const controller = new AbortController();
155
+ const timeout = setTimeout(() => controller.abort(), 5000);
156
+
157
+ const response = await fetch(tamperedUrl, {
158
+ method: 'GET',
159
+ signal: controller.signal,
160
+ });
161
+ clearTimeout(timeout);
162
+
163
+ if (response.ok) {
164
+ const text = await response.text();
165
+ if (text.length > 50 && !this._isGenericResponse(text)) {
166
+ findings.push(createFinding({
167
+ module: 'logic',
168
+ title: 'IDOR: Insecure Direct Object Reference (Numeric ID)',
169
+ severity: 'high',
170
+ affected_surface: tamperedUrl,
171
+ description: `Changing the resource ID from ${originalId} to ${testId} in ${url} returned data without authorization check. An attacker can enumerate IDs to access other users' data.`,
172
+ reproduction: [
173
+ `1. Original URL: ${url}`,
174
+ `2. Change ID ${originalId} to ${testId}: ${tamperedUrl}`,
175
+ `3. Server returns data for the different resource`,
176
+ ],
177
+ evidence: `Original ID: ${originalId}\nTest ID: ${testId}\nResponse status: ${response.status}`,
178
+ remediation: 'Implement authorization checks that verify the requesting user owns the resource. Use UUIDs instead of sequential IDs. Always validate resource ownership server-side.',
179
+ }));
180
+ break;
181
+ }
182
+ }
183
+ } catch { continue; }
184
+ }
185
+ }
186
+
187
+ return findings;
188
+ }
189
+
190
+ /**
191
+ * Test UUID-based IDOR — swap or fuzz UUIDs in API paths.
192
+ */
193
+ async _testUUIDIDOR(surfaceInventory) {
194
+ const findings = [];
195
+ const apis = surfaceInventory.apis || [];
196
+
197
+ const uuidRegex = /\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\/|$|\?)/i;
198
+
199
+ for (const api of apis) {
200
+ const url = api.url || api;
201
+ const uuidMatch = url.match(uuidRegex);
202
+ if (!uuidMatch) continue;
203
+
204
+ const originalUUID = uuidMatch[1];
205
+
206
+ // Test with known-invalid UUIDs that might reveal different behavior
207
+ const testUUIDs = [
208
+ '00000000-0000-0000-0000-000000000000', // nil UUID
209
+ '00000000-0000-0000-0000-000000000001', // sequential
210
+ 'ffffffff-ffff-ffff-ffff-ffffffffffff', // max UUID
211
+ ];
212
+
213
+ for (const testUUID of testUUIDs) {
214
+ const tamperedUrl = url.replace(originalUUID, testUUID);
215
+ try {
216
+ const controller = new AbortController();
217
+ const timeout = setTimeout(() => controller.abort(), 5000);
218
+
219
+ const [originalResp, tamperedResp] = await Promise.all([
220
+ fetch(url, { method: 'GET', signal: controller.signal }),
221
+ fetch(tamperedUrl, { method: 'GET', signal: controller.signal }),
222
+ ]);
223
+ clearTimeout(timeout);
224
+
225
+ if (tamperedResp.ok) {
226
+ const text = await tamperedResp.text();
227
+ if (text.length > 50 && !this._isGenericResponse(text)) {
228
+ findings.push(createFinding({
229
+ module: 'logic',
230
+ title: 'IDOR: UUID-Based Object Reference Vulnerable to Enumeration',
231
+ severity: 'high',
232
+ affected_surface: tamperedUrl,
233
+ description: `Swapping the UUID ${originalUUID} with ${testUUID} in ${url} returned a non-error response. If the application doesn't verify UUID ownership, an attacker can access arbitrary resources. UUIDs are only safe against enumeration when ownership is enforced server-side.`,
234
+ reproduction: [
235
+ `1. Original URL: ${url}`,
236
+ `2. Swap UUID: ${tamperedUrl}`,
237
+ `3. Server returned status ${tamperedResp.status} with ${text.length} bytes`,
238
+ ],
239
+ evidence: `Original: ${originalUUID}\nTest: ${testUUID}\nResponse: ${tamperedResp.status} (${text.length} bytes)`,
240
+ remediation: 'Never rely on UUIDs alone for access control — they prevent enumeration but not targeted sharing. Always verify the requesting user is the owner of the resource identified by the UUID, server-side on every request.',
241
+ }));
242
+ break;
243
+ }
244
+ }
245
+ } catch { continue; }
246
+ }
247
+ }
248
+
249
+ return findings;
250
+ }
251
+
252
+ /**
253
+ * Test cross-tenant access by manipulating tenant/org/workspace IDs.
254
+ */
255
+ async _testCrossTenantAccess(surfaceInventory) {
256
+ const findings = [];
257
+
258
+ const TENANT_PARAMS = [
259
+ 'org_id', 'tenant_id', 'workspace_id', 'organization_id',
260
+ 'company_id', 'account_id', 'team_id', 'site_id',
261
+ ];
262
+
263
+ for (const page of surfaceInventory.pages) {
264
+ if (!page.url || page.status >= 400) continue;
265
+ const url = new URL(page.url);
266
+ const params = [...url.searchParams.entries()];
267
+
268
+ for (const [key, value] of params) {
269
+ const isOrgParam = TENANT_PARAMS.some(p => key.toLowerCase().includes(p.replace('_id', '')));
270
+ if (!isOrgParam) continue;
271
+
272
+ // Try to access a different tenant's data
273
+ const testValues = [
274
+ String(parseInt(value) + 1) || '2',
275
+ String(parseInt(value) - 1) || '0',
276
+ '1',
277
+ ];
278
+
279
+ const baseResponse = await fetch(page.url).catch(() => null);
280
+ if (!baseResponse?.ok) continue;
281
+ const baseText = await baseResponse.text();
282
+
283
+ for (const testValue of testValues) {
284
+ if (testValue === value) continue;
285
+ const testUrl = new URL(page.url);
286
+ testUrl.searchParams.set(key, testValue);
287
+
288
+ try {
289
+ const response = await fetch(testUrl.toString(), { method: 'GET' });
290
+ if (!response.ok) continue;
291
+
292
+ const text = await response.text();
293
+ // Different response length with different tenant ID = potential cross-tenant access
294
+ const lengthRatio = text.length / (baseText.length || 1);
295
+ if (text.length > 100 && lengthRatio > 0.5 && !this._isGenericResponse(text)) {
296
+ findings.push(createFinding({
297
+ module: 'logic',
298
+ title: `Cross-Tenant Access: ${key} parameter not enforcing tenant isolation`,
299
+ severity: 'critical',
300
+ affected_surface: page.url,
301
+ description: `Changing the ${key} parameter from ${value} to ${testValue} in ${page.url} returned data without authorization check. This indicates the server is not enforcing tenant isolation — an attacker from one tenant can access another tenant's data.`,
302
+ reproduction: [
303
+ `1. Authenticated as tenant ${value}, access: ${page.url}`,
304
+ `2. Change ${key} to ${testValue}: ${testUrl.toString()}`,
305
+ `3. Server returns data belonging to tenant ${testValue}`,
306
+ ],
307
+ evidence: `Tenant param: ${key}\nOriginal value: ${value}\nTest value: ${testValue}\nResponse: ${response.status} (${text.length} bytes)`,
308
+ remediation: 'All database queries must include a mandatory tenant_id filter derived from the authenticated session, never from user-supplied input. Use row-level security (RLS) in the database. Never trust client-provided tenant/org IDs for data access decisions.',
309
+ references: ['CWE-284', 'OWASP API Security Top 10 – API1: Broken Object Level Authorization'],
310
+ }));
311
+ break;
312
+ }
313
+ } catch { continue; }
314
+ }
315
+ }
316
+ }
317
+
318
+ return findings;
319
+ }
320
+
321
+ /**
322
+ * Test premium/paid feature bypass.
323
+ */
324
+ async _testPremiumBypass(surfaceInventory) {
325
+ const findings = [];
326
+ const baseUrl = this._getBaseUrl(surfaceInventory);
327
+ if (!baseUrl) return findings;
328
+
329
+ for (const path of this.PREMIUM_PATHS) {
330
+ try {
331
+ const url = new URL(path, baseUrl).href;
332
+ const controller = new AbortController();
333
+ const timeout = setTimeout(() => controller.abort(), 5000);
334
+
335
+ const response = await fetch(url, {
336
+ method: 'GET',
337
+ redirect: 'manual',
338
+ signal: controller.signal,
339
+ });
340
+ clearTimeout(timeout);
341
+
342
+ if (response.status === 200) {
343
+ const text = await response.text();
344
+ if (text.length > 100 && !this._isGenericResponse(text)) {
345
+ findings.push(createFinding({
346
+ module: 'logic',
347
+ title: 'Access Bypass: Premium Feature Accessible',
348
+ severity: 'high',
349
+ affected_surface: url,
350
+ description: `Premium endpoint ${path} is accessible without a paid subscription. Unauthenticated users can access features intended for paying customers.`,
351
+ reproduction: [
352
+ `1. Open ${url} without authentication`,
353
+ `2. Premium content is accessible`,
354
+ ],
355
+ evidence: `URL: ${url}\nStatus: ${response.status}`,
356
+ remediation: 'Verify subscription status server-side before serving premium content. Implement tier-based access control in middleware.',
357
+ }));
358
+ }
359
+ }
360
+ } catch {
361
+ continue;
362
+ }
363
+ }
364
+
365
+ return findings;
366
+ }
367
+
368
+ /**
369
+ * Test guest access to authenticated routes.
370
+ */
371
+ async _testGuestAccess(businessContext) {
372
+ const findings = [];
373
+ const authSurfaces = businessContext.domains?.auth || [];
374
+
375
+ // Look for API endpoints in auth domain that should require login
376
+ const apiEndpoints = businessContext.apiEndpoints?.auth || [];
377
+
378
+ for (const endpoint of apiEndpoints) {
379
+ // Skip login/register endpoints (these should be accessible)
380
+ if (/login|signin|register|signup|forgot|reset/i.test(endpoint.url)) continue;
381
+
382
+ try {
383
+ const controller = new AbortController();
384
+ const timeout = setTimeout(() => controller.abort(), 5000);
385
+
386
+ const response = await fetch(endpoint.url, {
387
+ method: endpoint.method || 'GET',
388
+ signal: controller.signal,
389
+ });
390
+ clearTimeout(timeout);
391
+
392
+ if (response.ok) {
393
+ const text = await response.text();
394
+ if (this._containsSensitiveData(text)) {
395
+ findings.push(createFinding({
396
+ module: 'logic',
397
+ title: 'Guest Access: Authenticated Endpoint Exposed',
398
+ severity: 'high',
399
+ affected_surface: endpoint.url,
400
+ description: `Authenticated endpoint ${endpoint.url} returns sensitive data without requiring authentication token/session.`,
401
+ reproduction: [
402
+ `1. Send ${endpoint.method} to ${endpoint.url} without auth headers`,
403
+ `2. Server returns sensitive data`,
404
+ ],
405
+ evidence: `URL: ${endpoint.url}\nMethod: ${endpoint.method}\nStatus: ${response.status}`,
406
+ remediation: 'Require authentication tokens on all protected endpoints. Return 401 for unauthenticated requests. Never rely on client-side route guards alone.',
407
+ }));
408
+ }
409
+ }
410
+ } catch {
411
+ continue;
412
+ }
413
+ }
414
+
415
+ return findings;
416
+ }
417
+
418
+ _getBaseUrl(surfaceInventory) {
419
+ const pages = surfaceInventory.pages || [];
420
+ if (pages.length === 0) return null;
421
+ const firstUrl = pages[0].url || pages[0];
422
+ try {
423
+ const parsed = new URL(firstUrl);
424
+ return `${parsed.protocol}//${parsed.host}`;
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
429
+
430
+ _isAdminContent(text) {
431
+ return /admin|dashboard|manage|settings|users|configuration/i.test(text) &&
432
+ text.length > 200;
433
+ }
434
+
435
+ _isGenericResponse(text) {
436
+ return /not found|404|403|unauthorized|forbidden|error/i.test(text) &&
437
+ text.length < 500;
438
+ }
439
+
440
+ _containsSensitiveData(text) {
441
+ return /@[\w.-]+\.\w+/.test(text) || // email
442
+ /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/.test(text) || // phone
443
+ /"(password|token|secret|key)":/i.test(text) || // sensitive fields
444
+ (text.length > 200 && /"(id|user|name|email)":/i.test(text)); // user data
445
+ }
446
+ }
447
+
448
+ export default AccessBoundaryTester;
@@ -0,0 +1,196 @@
1
+ import { createFinding } from '../../utils/finding.js';
2
+
3
+ /**
4
+ * BusinessRuleInferrer — Infers business rules from the surface inventory.
5
+ *
6
+ * Categorizes discovered surfaces into business domains:
7
+ * - Payments (cart, checkout, pricing, billing)
8
+ * - Auth (login, register, roles, admin)
9
+ * - Subscriptions (plans, upgrade, downgrade, cancel)
10
+ * - Inventory (products, stock, quantity, orders)
11
+ * - Referrals (invite, refer, rewards, points)
12
+ * - Workflows (multi-step forms, wizards, onboarding)
13
+ */
14
+ export class BusinessRuleInferrer {
15
+ constructor(logger) {
16
+ this.logger = logger;
17
+
18
+ this.DOMAIN_PATTERNS = {
19
+ payments: {
20
+ urlPatterns: [
21
+ /\/cart/i, /\/checkout/i, /\/pay/i, /\/billing/i,
22
+ /\/pricing/i, /\/purchase/i, /\/order/i, /\/invoice/i,
23
+ /\/coupon/i, /\/discount/i, /\/promo/i, /\/gift/i,
24
+ ],
25
+ formIndicators: ['price', 'amount', 'quantity', 'coupon', 'card', 'payment', 'total', 'subtotal'],
26
+ },
27
+ auth: {
28
+ urlPatterns: [
29
+ /\/login/i, /\/signin/i, /\/register/i, /\/signup/i,
30
+ /\/admin/i, /\/dashboard/i, /\/account/i, /\/profile/i,
31
+ /\/role/i, /\/permission/i, /\/auth/i, /\/oauth/i,
32
+ ],
33
+ formIndicators: ['username', 'password', 'email', 'role', 'token'],
34
+ },
35
+ subscriptions: {
36
+ urlPatterns: [
37
+ /\/subscri/i, /\/plan/i, /\/upgrade/i, /\/downgrade/i,
38
+ /\/cancel/i, /\/trial/i, /\/premium/i, /\/tier/i,
39
+ /\/membership/i, /\/renew/i,
40
+ ],
41
+ formIndicators: ['plan', 'subscription', 'tier', 'billing_cycle'],
42
+ },
43
+ inventory: {
44
+ urlPatterns: [
45
+ /\/product/i, /\/item/i, /\/stock/i, /\/inventory/i,
46
+ /\/catalog/i, /\/shop/i, /\/store/i, /\/add-to-cart/i,
47
+ /\/wishlist/i, /\/quantity/i,
48
+ ],
49
+ formIndicators: ['quantity', 'stock', 'sku', 'product_id', 'item_id'],
50
+ },
51
+ referrals: {
52
+ urlPatterns: [
53
+ /\/refer/i, /\/invite/i, /\/reward/i, /\/points/i,
54
+ /\/bonus/i, /\/affiliate/i, /\/earn/i, /\/redeem/i,
55
+ ],
56
+ formIndicators: ['referral_code', 'invite_code', 'points', 'reward'],
57
+ },
58
+ workflows: {
59
+ urlPatterns: [
60
+ /\/step[_-]?\d/i, /\/wizard/i, /\/onboard/i, /\/setup/i,
61
+ /\/verify/i, /\/confirm/i, /\/review/i, /\/submit/i,
62
+ /\/complete/i, /\/finalize/i,
63
+ ],
64
+ formIndicators: ['step', 'next', 'previous', 'progress', 'stage'],
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Infer business context from the surface inventory.
71
+ */
72
+ infer(surfaceInventory) {
73
+ const context = {
74
+ domains: {},
75
+ multiStepFlows: [],
76
+ roleGatedPages: [],
77
+ pricingSurfaces: [],
78
+ apiEndpoints: {},
79
+ };
80
+
81
+ const pages = surfaceInventory.pages || [];
82
+ const forms = surfaceInventory.forms || [];
83
+ const apis = surfaceInventory.apis || [];
84
+
85
+ // 1. Categorize pages by domain
86
+ for (const page of pages) {
87
+ const url = page.url || page;
88
+ for (const [domain, config] of Object.entries(this.DOMAIN_PATTERNS)) {
89
+ if (config.urlPatterns.some(p => p.test(url))) {
90
+ if (!context.domains[domain]) context.domains[domain] = [];
91
+ context.domains[domain].push({ type: 'page', url, domain });
92
+ }
93
+ }
94
+ }
95
+
96
+ // 2. Categorize APIs by domain
97
+ for (const api of apis) {
98
+ const url = api.url || api;
99
+ for (const [domain, config] of Object.entries(this.DOMAIN_PATTERNS)) {
100
+ if (config.urlPatterns.some(p => p.test(url))) {
101
+ if (!context.domains[domain]) context.domains[domain] = [];
102
+ context.domains[domain].push({
103
+ type: 'api',
104
+ url,
105
+ method: api.method || 'GET',
106
+ domain,
107
+ });
108
+ if (!context.apiEndpoints[domain]) context.apiEndpoints[domain] = [];
109
+ context.apiEndpoints[domain].push({ url, method: api.method || 'GET' });
110
+ }
111
+ }
112
+ }
113
+
114
+ // 3. Categorize forms by field names
115
+ for (const form of forms) {
116
+ const fields = (form.fields || []).map(f => (f.name || f.id || '').toLowerCase());
117
+ for (const [domain, config] of Object.entries(this.DOMAIN_PATTERNS)) {
118
+ const hasIndicator = config.formIndicators.some(ind =>
119
+ fields.some(f => f.includes(ind))
120
+ );
121
+ if (hasIndicator) {
122
+ if (!context.domains[domain]) context.domains[domain] = [];
123
+ context.domains[domain].push({
124
+ type: 'form',
125
+ url: form.action || form.pageUrl,
126
+ pageUrl: form.pageUrl,
127
+ fields: form.fields,
128
+ domain,
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ // 4. Detect multi-step flows
135
+ context.multiStepFlows = this._detectMultiStepFlows(pages);
136
+
137
+ // 5. Detect role-gated pages
138
+ context.roleGatedPages = this._detectRoleGatedPages(pages);
139
+
140
+ // 6. Identify pricing surfaces
141
+ context.pricingSurfaces = [
142
+ ...(context.domains.payments || []),
143
+ ...(context.domains.subscriptions || []),
144
+ ];
145
+
146
+ // Summary
147
+ const activeDomains = Object.entries(context.domains)
148
+ .filter(([, items]) => items.length > 0)
149
+ .map(([domain, items]) => `${domain}(${items.length})`);
150
+
151
+ this.logger?.info?.(`Business Rule Inferrer: detected domains: ${activeDomains.join(', ') || 'none'}`);
152
+ this.logger?.info?.(` Multi-step flows: ${context.multiStepFlows.length}`);
153
+ this.logger?.info?.(` Role-gated pages: ${context.roleGatedPages.length}`);
154
+ this.logger?.info?.(` Pricing surfaces: ${context.pricingSurfaces.length}`);
155
+
156
+ return context;
157
+ }
158
+
159
+ /**
160
+ * Detect multi-step flows (pages with step indicators in URLs).
161
+ */
162
+ _detectMultiStepFlows(pages) {
163
+ const flows = [];
164
+ const stepPages = pages.filter(p => {
165
+ const url = p.url || p;
166
+ return /step[_-]?\d|\/\d+\/?$/i.test(url) ||
167
+ /wizard|onboard|setup/i.test(url);
168
+ });
169
+
170
+ if (stepPages.length >= 2) {
171
+ flows.push({
172
+ type: 'multi_step',
173
+ pages: stepPages.map(p => p.url || p),
174
+ stepCount: stepPages.length,
175
+ });
176
+ }
177
+
178
+ return flows;
179
+ }
180
+
181
+ /**
182
+ * Detect role-gated pages (admin, dashboard, etc.).
183
+ */
184
+ _detectRoleGatedPages(pages) {
185
+ return pages.filter(p => {
186
+ const url = p.url || p;
187
+ return /\/admin|\/dashboard|\/manage|\/settings|\/internal/i.test(url);
188
+ }).map(p => ({
189
+ url: p.url || p,
190
+ status: p.status,
191
+ redirected: p.redirectedTo || null,
192
+ }));
193
+ }
194
+ }
195
+
196
+ export default BusinessRuleInferrer;