@veraxhq/verax 0.1.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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/bin/verax.js +452 -0
  4. package/package.json +57 -0
  5. package/src/verax/detect/comparison.js +69 -0
  6. package/src/verax/detect/confidence-engine.js +498 -0
  7. package/src/verax/detect/evidence-validator.js +33 -0
  8. package/src/verax/detect/expectation-model.js +204 -0
  9. package/src/verax/detect/findings-writer.js +31 -0
  10. package/src/verax/detect/index.js +397 -0
  11. package/src/verax/detect/skip-classifier.js +202 -0
  12. package/src/verax/flow/flow-engine.js +265 -0
  13. package/src/verax/flow/flow-spec.js +145 -0
  14. package/src/verax/flow/redaction.js +74 -0
  15. package/src/verax/index.js +97 -0
  16. package/src/verax/learn/action-contract-extractor.js +281 -0
  17. package/src/verax/learn/ast-contract-extractor.js +255 -0
  18. package/src/verax/learn/index.js +18 -0
  19. package/src/verax/learn/manifest-writer.js +97 -0
  20. package/src/verax/learn/project-detector.js +87 -0
  21. package/src/verax/learn/react-router-extractor.js +73 -0
  22. package/src/verax/learn/route-extractor.js +122 -0
  23. package/src/verax/learn/route-validator.js +215 -0
  24. package/src/verax/learn/source-instrumenter.js +214 -0
  25. package/src/verax/learn/static-extractor.js +222 -0
  26. package/src/verax/learn/truth-assessor.js +96 -0
  27. package/src/verax/learn/ts-contract-resolver.js +395 -0
  28. package/src/verax/observe/browser.js +22 -0
  29. package/src/verax/observe/console-sensor.js +166 -0
  30. package/src/verax/observe/dom-signature.js +23 -0
  31. package/src/verax/observe/domain-boundary.js +38 -0
  32. package/src/verax/observe/evidence-capture.js +5 -0
  33. package/src/verax/observe/human-driver.js +376 -0
  34. package/src/verax/observe/index.js +67 -0
  35. package/src/verax/observe/interaction-discovery.js +269 -0
  36. package/src/verax/observe/interaction-runner.js +410 -0
  37. package/src/verax/observe/network-sensor.js +173 -0
  38. package/src/verax/observe/selector-generator.js +74 -0
  39. package/src/verax/observe/settle.js +155 -0
  40. package/src/verax/observe/state-ui-sensor.js +200 -0
  41. package/src/verax/observe/traces-writer.js +82 -0
  42. package/src/verax/observe/ui-signal-sensor.js +197 -0
  43. package/src/verax/resolve-workspace-root.js +173 -0
  44. package/src/verax/scan-summary-writer.js +41 -0
  45. package/src/verax/shared/artifact-manager.js +139 -0
  46. package/src/verax/shared/caching.js +104 -0
  47. package/src/verax/shared/expectation-proof.js +4 -0
  48. package/src/verax/shared/redaction.js +227 -0
  49. package/src/verax/shared/retry-policy.js +89 -0
  50. package/src/verax/shared/timing-metrics.js +44 -0
@@ -0,0 +1,376 @@
1
+ /**
2
+ * WAVE 2: Human Behavior Driver
3
+ * Realistic interaction execution with scrolling, form filling, and budgeting
4
+ */
5
+
6
+ import { waitForSettle } from './settle.js';
7
+
8
+ export class HumanBehaviorDriver {
9
+ constructor(options = {}) {
10
+ this.maxScrollSteps = options.maxScrollSteps || 5;
11
+ this.interactionBudgetPerPage = options.interactionBudgetPerPage || 50;
12
+ this.scrollPauseMs = options.scrollPauseMs || 500;
13
+ }
14
+
15
+ /**
16
+ * Discover all interactive elements on current page with scrolling.
17
+ * Returns stable set of unique elements after scroll-and-rediscover passes.
18
+ */
19
+ async discoverInteractionsWithScroll(page) {
20
+ const discovered = new Map(); // key: selector, value: element data
21
+
22
+ // Initial discovery before any scrolling
23
+ await this.discoverElements(page, discovered);
24
+
25
+ // Progressive scrolling discovery
26
+ const viewportHeight = await page.evaluate(() => window.innerHeight);
27
+ const maxScrollDistance = await page.evaluate(() => document.documentElement.scrollHeight);
28
+
29
+ for (let step = 0; step < this.maxScrollSteps; step++) {
30
+ // Calculate scroll position (0%, 25%, 50%, 75%, 100%)
31
+ const scrollPercent = (step + 1) / this.maxScrollSteps;
32
+ const targetScroll = Math.min(maxScrollDistance, scrollPercent * maxScrollDistance);
33
+
34
+ await page.evaluate((scroll) => window.scrollTo(0, scroll), targetScroll);
35
+ await page.waitForTimeout(this.scrollPauseMs);
36
+
37
+ // Rediscover after scroll
38
+ await this.discoverElements(page, discovered);
39
+ }
40
+
41
+ // Return to top
42
+ await page.evaluate(() => window.scrollTo(0, 0));
43
+
44
+ return Array.from(discovered.values());
45
+ }
46
+
47
+ /**
48
+ * Discover interactive elements and merge into stable map.
49
+ * Handles: links, buttons, forms, role=button elements
50
+ */
51
+ async discoverElements(page, discovered) {
52
+ const elements = await page.evaluate(() => {
53
+ const result = [];
54
+
55
+ // Links
56
+ document.querySelectorAll('a[href]').forEach((el) => {
57
+ if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
58
+ result.push({
59
+ type: 'link',
60
+ selector: generateSelector(el),
61
+ href: el.getAttribute('href'),
62
+ text: el.textContent.trim().slice(0, 100),
63
+ visible: true
64
+ });
65
+ }
66
+ });
67
+
68
+ // Buttons
69
+ document.querySelectorAll('button').forEach((el) => {
70
+ if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
71
+ result.push({
72
+ type: 'button',
73
+ selector: generateSelector(el),
74
+ text: el.textContent.trim().slice(0, 100),
75
+ visible: true
76
+ });
77
+ }
78
+ });
79
+
80
+ // Form inputs
81
+ document.querySelectorAll('input[type="submit"], input[type="button"]').forEach((el) => {
82
+ if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
83
+ result.push({
84
+ type: 'button',
85
+ selector: generateSelector(el),
86
+ text: el.value || el.getAttribute('title') || 'Submit',
87
+ visible: true
88
+ });
89
+ }
90
+ });
91
+
92
+ // Role=button elements
93
+ document.querySelectorAll('[role="button"]').forEach((el) => {
94
+ if (isVisible(el) && !el.hasAttribute('data-skip-verify')) {
95
+ result.push({
96
+ type: 'button',
97
+ selector: generateSelector(el),
98
+ text: el.textContent.trim().slice(0, 100),
99
+ visible: true
100
+ });
101
+ }
102
+ });
103
+
104
+ return result;
105
+
106
+ // Helper: Check if element is visible
107
+ function isVisible(el) {
108
+ if (!el.offsetParent) return false;
109
+ const style = window.getComputedStyle(el);
110
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
111
+ return true;
112
+ }
113
+
114
+ // Helper: Generate stable selector
115
+ function generateSelector(el) {
116
+ // Try ID first
117
+ if (el.id) return `#${el.id}`;
118
+
119
+ // Try data attributes
120
+ if (el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
121
+
122
+ // Use CSS selector generation
123
+ const path = [];
124
+ let current = el;
125
+ while (current && current !== document.documentElement) {
126
+ let selector = current.tagName.toLowerCase();
127
+ if (current.id) {
128
+ selector += `#${current.id}`;
129
+ path.unshift(selector);
130
+ break;
131
+ }
132
+
133
+ // Add class if present
134
+ if (current.className) {
135
+ const classes = current.className
136
+ .split(' ')
137
+ .filter((c) => c && !c.startsWith('__'))
138
+ .join('.');
139
+ if (classes) selector += `.${classes}`;
140
+ }
141
+
142
+ // Add nth-child for uniqueness
143
+ let sibling = current;
144
+ let index = 0;
145
+ while (sibling) {
146
+ if (sibling.tagName === current.tagName) index++;
147
+ sibling = sibling.previousElementSibling;
148
+ }
149
+ if (index > 0) selector += `:nth-of-type(${index})`;
150
+
151
+ path.unshift(selector);
152
+ current = current.parentElement;
153
+ }
154
+
155
+ return path.join(' > ');
156
+ }
157
+ });
158
+
159
+ // Merge into stable map (deduplicate by selector)
160
+ for (const el of elements) {
161
+ if (!discovered.has(el.selector)) {
162
+ discovered.set(el.selector, el);
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Select interactions according to budget with deterministic prioritization.
169
+ * Returns { selected, skipped, budget }
170
+ */
171
+ selectByBudget(discovered) {
172
+ const selected = [];
173
+ const skipped = [];
174
+
175
+ // Sort deterministically: by priority category, then text/href for stability
176
+ const sorted = discovered.sort((a, b) => {
177
+ const priorityA = getPriority(a);
178
+ const priorityB = getPriority(b);
179
+ if (priorityA !== priorityB) return priorityA - priorityB;
180
+
181
+ const textA = (a.text || a.href || '').toLowerCase();
182
+ const textB = (b.text || b.href || '').toLowerCase();
183
+ return textA.localeCompare(textB);
184
+ });
185
+
186
+ // Allocate budget across categories
187
+ const categories = { link: [], button: [], form: [] };
188
+ for (const el of sorted) {
189
+ const cat = el.type === 'link' ? 'link' : 'button';
190
+ categories[cat].push(el);
191
+ }
192
+
193
+ // Distribute budget: 40% links, 40% buttons, 20% forms
194
+ const linkBudget = Math.floor(this.interactionBudgetPerPage * 0.4);
195
+ const buttonBudget = Math.floor(this.interactionBudgetPerPage * 0.4);
196
+ const formBudget = Math.floor(this.interactionBudgetPerPage * 0.2);
197
+
198
+ for (let i = 0; i < categories.link.length; i++) {
199
+ if (i < linkBudget) selected.push(categories.link[i]);
200
+ else skipped.push(categories.link[i]);
201
+ }
202
+
203
+ for (let i = 0; i < categories.button.length; i++) {
204
+ if (i < buttonBudget) selected.push(categories.button[i]);
205
+ else skipped.push(categories.button[i]);
206
+ }
207
+
208
+ return {
209
+ selected,
210
+ skipped,
211
+ discoveredCount: discovered.length,
212
+ selectedCount: selected.length,
213
+ skippedDueToBudgetCount: skipped.length,
214
+ budgetUsed: selected.length,
215
+ budgetAvailable: this.interactionBudgetPerPage
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Fill and submit a form with realistic dummy data.
221
+ * Respects safety rules: skips payment, checkout, delete, etc.
222
+ */
223
+ async fillAndSubmitForm(page, formSelector) {
224
+ // Check safety rules first
225
+ const isDangerous = await page.evaluate((sel) => {
226
+ const form = document.querySelector(sel);
227
+ if (!form) return true;
228
+
229
+ const text = form.textContent.toLowerCase();
230
+ const dangerousKeywords = ['pay', 'checkout', 'delete', 'remove', 'unsubscribe', 'billing', 'credit card'];
231
+ if (dangerousKeywords.some((kw) => text.includes(kw))) return true;
232
+
233
+ // Check buttons
234
+ const buttons = form.querySelectorAll('button, input[type="submit"]');
235
+ for (const btn of buttons) {
236
+ const btnText = btn.textContent.toLowerCase();
237
+ if (dangerousKeywords.some((kw) => btnText.includes(kw))) return true;
238
+ }
239
+
240
+ return false;
241
+ }, formSelector);
242
+
243
+ if (isDangerous) {
244
+ return { success: false, reason: 'DANGEROUS_FORM_SKIPPED' };
245
+ }
246
+
247
+ // Fill required fields
248
+ const filled = await page.evaluate((sel) => {
249
+ const form = document.querySelector(sel);
250
+ if (!form) return { filled: [], errors: [] };
251
+
252
+ const filled = [];
253
+ const errors = [];
254
+
255
+ // Find all input elements
256
+ const inputs = form.querySelectorAll('input, textarea, select');
257
+
258
+ for (const input of inputs) {
259
+ if (input.disabled || input.readOnly) continue;
260
+ if (input.type === 'hidden' || input.type === 'submit' || input.type === 'button') continue;
261
+
262
+ try {
263
+ if (input.tagName === 'SELECT') {
264
+ // Choose first non-empty option
265
+ const options = input.querySelectorAll('option');
266
+ if (options.length > 1) {
267
+ input.value = options[1].value;
268
+ input.dispatchEvent(new Event('change', { bubbles: true }));
269
+ filled.push({ name: input.name || input.id || 'select', value: input.value });
270
+ }
271
+ } else if (input.type === 'checkbox' || input.type === 'radio') {
272
+ // Choose first option
273
+ input.checked = true;
274
+ input.dispatchEvent(new Event('change', { bubbles: true }));
275
+ filled.push({ name: input.name || input.id || 'checkbox', value: 'checked' });
276
+ } else if (input.type === 'email') {
277
+ input.value = 'test@example.com';
278
+ input.dispatchEvent(new Event('change', { bubbles: true }));
279
+ filled.push({ name: input.name || input.id || 'email', value: 'test@example.com' });
280
+ } else if (input.type === 'tel') {
281
+ input.value = '+1-555-0123';
282
+ input.dispatchEvent(new Event('change', { bubbles: true }));
283
+ filled.push({ name: input.name || input.id || 'tel', value: '+1-555-0123' });
284
+ } else if (input.type === 'url') {
285
+ input.value = 'https://example.com';
286
+ input.dispatchEvent(new Event('change', { bubbles: true }));
287
+ filled.push({ name: input.name || input.id || 'url', value: 'https://example.com' });
288
+ } else if (input.tagName === 'TEXTAREA') {
289
+ input.value = 'Test message';
290
+ input.dispatchEvent(new Event('change', { bubbles: true }));
291
+ filled.push({ name: input.name || input.id || 'textarea', value: 'Test message' });
292
+ } else if (input.type === 'text' || input.type === '') {
293
+ // Guess based on name/label
294
+ const name = (input.name || input.id || '').toLowerCase();
295
+ let value = 'John Doe';
296
+ if (name.includes('email')) value = 'test@example.com';
297
+ else if (name.includes('name')) value = 'John Doe';
298
+ else if (name.includes('phone') || name.includes('tel')) value = '+1-555-0123';
299
+ else if (name.includes('address') || name.includes('street')) value = '123 Main St';
300
+ else if (name.includes('city')) value = 'Anytown';
301
+ else if (name.includes('state') || name.includes('province')) value = 'CA';
302
+ else if (name.includes('zip') || name.includes('postal')) value = '12345';
303
+
304
+ input.value = value;
305
+ input.dispatchEvent(new Event('change', { bubbles: true }));
306
+ filled.push({ name: input.name || input.id || 'text', value });
307
+ }
308
+ } catch (err) {
309
+ errors.push({ name: input.name || input.id, error: err.message });
310
+ }
311
+ }
312
+
313
+ return { filled, errors };
314
+ }, formSelector);
315
+
316
+ // Submit form
317
+ const submitResult = await page.evaluate((sel) => {
318
+ const form = document.querySelector(sel);
319
+ if (!form) return { submitted: false, reason: 'FORM_NOT_FOUND' };
320
+
321
+ // Find submit button
322
+ let submitBtn = form.querySelector('button[type="submit"], input[type="submit"]');
323
+
324
+ if (!submitBtn) {
325
+ // Try click form submit
326
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
327
+ return { submitted: true, method: 'form_submit_event' };
328
+ }
329
+
330
+ // Click submit button
331
+ submitBtn.click();
332
+ return { submitted: true, method: 'button_click' };
333
+ }, formSelector);
334
+
335
+ // Wait for navigation or settle
336
+ try {
337
+ await Promise.race([
338
+ page.waitForNavigation({ waitUntil: 'networkidle', timeout: 5000 }).catch(() => {}),
339
+ page.waitForTimeout(2000)
340
+ ]);
341
+ } catch {
342
+ // Navigation may not happen, that's okay
343
+ }
344
+
345
+ await waitForSettle(page, { timeoutMs: 10000 });
346
+
347
+ return {
348
+ success: true,
349
+ filled: filled.filled,
350
+ submitted: submitResult.submitted,
351
+ method: submitResult.method
352
+ };
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Get deterministic priority for interaction.
358
+ * Lower number = higher priority
359
+ */
360
+ function getPriority(element) {
361
+ const text = (element.text || element.href || '').toLowerCase();
362
+
363
+ // Primary navigation
364
+ if (text.includes('home') || text.includes('/')) return 0;
365
+ if (text.includes('about')) return 1;
366
+ if (text.includes('contact')) return 2;
367
+ if (text.includes('service') || text.includes('product')) return 3;
368
+ if (text.includes('blog') || text.includes('news')) return 4;
369
+
370
+ // Footer/secondary
371
+ if (text.includes('footer') || text.includes('copyright')) return 100;
372
+ if (text.includes('privacy') || text.includes('terms')) return 101;
373
+
374
+ // Default
375
+ return 50;
376
+ }
@@ -0,0 +1,67 @@
1
+ import { resolve, dirname } from 'path';
2
+ import { mkdirSync } from 'fs';
3
+ import { createBrowser, navigateToUrl, closeBrowser } from './browser.js';
4
+ import { discoverInteractions } from './interaction-discovery.js';
5
+ import { captureScreenshot } from './evidence-capture.js';
6
+ import { runInteraction } from './interaction-runner.js';
7
+ import { writeTraces } from './traces-writer.js';
8
+ import { getBaseOrigin } from './domain-boundary.js';
9
+
10
+ const MAX_SCAN_DURATION_MS = 60000;
11
+
12
+ export async function observe(url, manifestPath = null, artifactPaths = null) {
13
+ const { browser, page } = await createBrowser();
14
+ const startTime = Date.now();
15
+ const baseOrigin = getBaseOrigin(url);
16
+
17
+ try {
18
+ await navigateToUrl(page, url);
19
+
20
+ const projectDir = manifestPath ? dirname(dirname(dirname(manifestPath))) : process.cwd();
21
+ let screenshotsDir;
22
+ if (artifactPaths) {
23
+ screenshotsDir = resolve(artifactPaths.evidence, 'screenshots');
24
+ } else {
25
+ const observeDir = resolve(projectDir, '.veraxverax', 'observe');
26
+ screenshotsDir = resolve(observeDir, 'screenshots');
27
+ }
28
+ mkdirSync(screenshotsDir, { recursive: true });
29
+
30
+ const timestamp = Date.now();
31
+ const initialScreenshot = resolve(screenshotsDir, `initial-${timestamp}.png`);
32
+ await captureScreenshot(page, initialScreenshot);
33
+
34
+ const { interactions, coverage } = await discoverInteractions(page, baseOrigin);
35
+ const traces = [];
36
+ const observeWarnings = [];
37
+ if (coverage && coverage.capped) {
38
+ observeWarnings.push({
39
+ code: 'INTERACTIONS_CAPPED',
40
+ message: 'Interaction discovery reached the cap (30). Scan coverage is incomplete.'
41
+ });
42
+ }
43
+
44
+ for (let i = 0; i < interactions.length; i++) {
45
+ if (Date.now() - startTime > MAX_SCAN_DURATION_MS) {
46
+ break;
47
+ }
48
+
49
+ const trace = await runInteraction(page, interactions[i], timestamp, i, screenshotsDir, baseOrigin, startTime, MAX_SCAN_DURATION_MS);
50
+ if (trace) {
51
+ traces.push(trace);
52
+ }
53
+ }
54
+
55
+ const observation = writeTraces(projectDir, url, traces, coverage, observeWarnings, artifactPaths);
56
+
57
+ await closeBrowser(browser);
58
+
59
+ return {
60
+ ...observation,
61
+ screenshotsDir: screenshotsDir
62
+ };
63
+ } catch (error) {
64
+ await closeBrowser(browser);
65
+ throw error;
66
+ }
67
+ }