@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.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/bin/verax.js +452 -0
- package/package.json +57 -0
- package/src/verax/detect/comparison.js +69 -0
- package/src/verax/detect/confidence-engine.js +498 -0
- package/src/verax/detect/evidence-validator.js +33 -0
- package/src/verax/detect/expectation-model.js +204 -0
- package/src/verax/detect/findings-writer.js +31 -0
- package/src/verax/detect/index.js +397 -0
- package/src/verax/detect/skip-classifier.js +202 -0
- package/src/verax/flow/flow-engine.js +265 -0
- package/src/verax/flow/flow-spec.js +145 -0
- package/src/verax/flow/redaction.js +74 -0
- package/src/verax/index.js +97 -0
- package/src/verax/learn/action-contract-extractor.js +281 -0
- package/src/verax/learn/ast-contract-extractor.js +255 -0
- package/src/verax/learn/index.js +18 -0
- package/src/verax/learn/manifest-writer.js +97 -0
- package/src/verax/learn/project-detector.js +87 -0
- package/src/verax/learn/react-router-extractor.js +73 -0
- package/src/verax/learn/route-extractor.js +122 -0
- package/src/verax/learn/route-validator.js +215 -0
- package/src/verax/learn/source-instrumenter.js +214 -0
- package/src/verax/learn/static-extractor.js +222 -0
- package/src/verax/learn/truth-assessor.js +96 -0
- package/src/verax/learn/ts-contract-resolver.js +395 -0
- package/src/verax/observe/browser.js +22 -0
- package/src/verax/observe/console-sensor.js +166 -0
- package/src/verax/observe/dom-signature.js +23 -0
- package/src/verax/observe/domain-boundary.js +38 -0
- package/src/verax/observe/evidence-capture.js +5 -0
- package/src/verax/observe/human-driver.js +376 -0
- package/src/verax/observe/index.js +67 -0
- package/src/verax/observe/interaction-discovery.js +269 -0
- package/src/verax/observe/interaction-runner.js +410 -0
- package/src/verax/observe/network-sensor.js +173 -0
- package/src/verax/observe/selector-generator.js +74 -0
- package/src/verax/observe/settle.js +155 -0
- package/src/verax/observe/state-ui-sensor.js +200 -0
- package/src/verax/observe/traces-writer.js +82 -0
- package/src/verax/observe/ui-signal-sensor.js +197 -0
- package/src/verax/resolve-workspace-root.js +173 -0
- package/src/verax/scan-summary-writer.js +41 -0
- package/src/verax/shared/artifact-manager.js +139 -0
- package/src/verax/shared/caching.js +104 -0
- package/src/verax/shared/expectation-proof.js +4 -0
- package/src/verax/shared/redaction.js +227 -0
- package/src/verax/shared/retry-policy.js +89 -0
- package/src/verax/shared/timing-metrics.js +44 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { getUrlPath } from './evidence-validator.js';
|
|
2
|
+
|
|
3
|
+
const MAX_EXAMPLES = 10;
|
|
4
|
+
|
|
5
|
+
export function classifySkipReason(manifest, interaction, beforeUrl, validation = null) {
|
|
6
|
+
const skipReasons = {
|
|
7
|
+
NO_EXPECTATION: {
|
|
8
|
+
code: 'NO_EXPECTATION',
|
|
9
|
+
message: 'No applicable expectations found for this interaction type or context'
|
|
10
|
+
},
|
|
11
|
+
AMBIGUOUS_MATCH: {
|
|
12
|
+
code: 'AMBIGUOUS_MATCH',
|
|
13
|
+
message: 'Multiple expectations could match; conservative approach requires single clear match'
|
|
14
|
+
},
|
|
15
|
+
SELECTOR_MISMATCH: {
|
|
16
|
+
code: 'SELECTOR_MISMATCH',
|
|
17
|
+
message: 'Expectations exist but selector mismatch and no safe fallback match'
|
|
18
|
+
},
|
|
19
|
+
WEAK_EXPECTATION_DROPPED: {
|
|
20
|
+
code: 'WEAK_EXPECTATION_DROPPED',
|
|
21
|
+
message: 'Expectation derived from route that was validated as unreachable'
|
|
22
|
+
},
|
|
23
|
+
UNSUPPORTED_INTERACTION: {
|
|
24
|
+
code: 'UNSUPPORTED_INTERACTION',
|
|
25
|
+
message: 'Interaction type not supported for expectation derivation'
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const beforePath = getUrlPath(beforeUrl);
|
|
30
|
+
const normalizedBefore = beforePath ? beforePath.replace(/\/$/, '') || '/' : null;
|
|
31
|
+
|
|
32
|
+
let hasStaticExpectations = false;
|
|
33
|
+
let hasRouteExpectations = false;
|
|
34
|
+
let hasMatchingStaticExpectations = false;
|
|
35
|
+
let selectorMismatchCount = 0;
|
|
36
|
+
let matchingRoutes = [];
|
|
37
|
+
let unreachableRoutes = new Set();
|
|
38
|
+
|
|
39
|
+
if (validation && validation.details) {
|
|
40
|
+
for (const detail of validation.details) {
|
|
41
|
+
if (detail.status === 'UNREACHABLE') {
|
|
42
|
+
unreachableRoutes.add(detail.path);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (manifest.staticExpectations && manifest.staticExpectations.length > 0) {
|
|
48
|
+
hasStaticExpectations = true;
|
|
49
|
+
for (const expectation of manifest.staticExpectations) {
|
|
50
|
+
if (normalizedBefore && expectation.fromPath.replace(/\/$/, '') || '/' === normalizedBefore) {
|
|
51
|
+
if (expectation.type === 'navigation' && (interaction.type === 'link' || interaction.type === 'button')) {
|
|
52
|
+
hasMatchingStaticExpectations = true;
|
|
53
|
+
const selectorHint = expectation.evidence.selectorHint || '';
|
|
54
|
+
const interactionSelector = interaction.selector || '';
|
|
55
|
+
|
|
56
|
+
if (selectorHint && interactionSelector) {
|
|
57
|
+
const normalizedSelectorHint = selectorHint.replace(/[\[\]()]/g, '');
|
|
58
|
+
const normalizedInteractionSelector = interactionSelector.replace(/[\[\]()]/g, '');
|
|
59
|
+
|
|
60
|
+
if (selectorHint === interactionSelector ||
|
|
61
|
+
selectorHint.includes(interactionSelector) ||
|
|
62
|
+
interactionSelector.includes(normalizedSelectorHint) ||
|
|
63
|
+
normalizedSelectorHint === normalizedInteractionSelector) {
|
|
64
|
+
return null;
|
|
65
|
+
} else {
|
|
66
|
+
selectorMismatchCount++;
|
|
67
|
+
}
|
|
68
|
+
} else if (!selectorHint && !interactionSelector) {
|
|
69
|
+
return null;
|
|
70
|
+
} else {
|
|
71
|
+
selectorMismatchCount++;
|
|
72
|
+
}
|
|
73
|
+
} else if (expectation.type === 'form_submission' && interaction.type === 'form') {
|
|
74
|
+
hasMatchingStaticExpectations = true;
|
|
75
|
+
const selectorHint = expectation.evidence.selectorHint || '';
|
|
76
|
+
const interactionSelector = interaction.selector || '';
|
|
77
|
+
|
|
78
|
+
if (selectorHint && interactionSelector) {
|
|
79
|
+
const normalizedSelectorHint = selectorHint.replace(/[\[\]()]/g, '');
|
|
80
|
+
const normalizedInteractionSelector = interactionSelector.replace(/[\[\]()]/g, '');
|
|
81
|
+
|
|
82
|
+
if (selectorHint === interactionSelector ||
|
|
83
|
+
selectorHint.includes(interactionSelector) ||
|
|
84
|
+
interactionSelector.includes(normalizedSelectorHint) ||
|
|
85
|
+
normalizedSelectorHint === normalizedInteractionSelector) {
|
|
86
|
+
return null;
|
|
87
|
+
} else {
|
|
88
|
+
selectorMismatchCount++;
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
selectorMismatchCount++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (manifest.routes && manifest.routes.length > 0) {
|
|
99
|
+
hasRouteExpectations = true;
|
|
100
|
+
for (const route of manifest.routes) {
|
|
101
|
+
if (!route.public) continue;
|
|
102
|
+
if (unreachableRoutes.has(route.path)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (interaction.type === 'link') {
|
|
107
|
+
const label = (interaction.label || '').toLowerCase().trim();
|
|
108
|
+
const routePath = route.path.toLowerCase();
|
|
109
|
+
const routeName = routePath.split('/').pop() || 'home';
|
|
110
|
+
|
|
111
|
+
if (label.includes(routeName) || routeName.includes(label)) {
|
|
112
|
+
matchingRoutes.push(route.path);
|
|
113
|
+
}
|
|
114
|
+
} else if (interaction.type === 'button' || interaction.type === 'form') {
|
|
115
|
+
const label = (interaction.label || '').toLowerCase().trim();
|
|
116
|
+
const routePath = route.path.toLowerCase();
|
|
117
|
+
const routeName = routePath.split('/').pop() || 'home';
|
|
118
|
+
|
|
119
|
+
const navigationKeywords = ['go', 'navigate', 'next', 'continue', 'submit', 'save'];
|
|
120
|
+
const hasNavKeyword = navigationKeywords.some(keyword => label.includes(keyword));
|
|
121
|
+
|
|
122
|
+
if (hasNavKeyword || label.includes(routeName) || routeName.includes(label)) {
|
|
123
|
+
matchingRoutes.push(route.path);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (hasMatchingStaticExpectations && selectorMismatchCount > 0) {
|
|
130
|
+
return skipReasons.SELECTOR_MISMATCH;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (matchingRoutes.length > 1) {
|
|
134
|
+
return skipReasons.AMBIGUOUS_MATCH;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (matchingRoutes.length === 1 && unreachableRoutes.has(matchingRoutes[0])) {
|
|
138
|
+
return skipReasons.WEAK_EXPECTATION_DROPPED;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (interaction.type === 'button' && !hasStaticExpectations && !hasRouteExpectations) {
|
|
142
|
+
const label = (interaction.label || '').toLowerCase().trim();
|
|
143
|
+
const navigationKeywords = ['go', 'navigate', 'next', 'continue', 'submit', 'save'];
|
|
144
|
+
const hasNavKeyword = navigationKeywords.some(keyword => label.includes(keyword));
|
|
145
|
+
|
|
146
|
+
if (!hasNavKeyword) {
|
|
147
|
+
return skipReasons.UNSUPPORTED_INTERACTION;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!hasStaticExpectations && !hasRouteExpectations) {
|
|
152
|
+
return skipReasons.NO_EXPECTATION;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (hasStaticExpectations && !hasMatchingStaticExpectations && !hasRouteExpectations) {
|
|
156
|
+
return skipReasons.NO_EXPECTATION;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (hasRouteExpectations && matchingRoutes.length === 0) {
|
|
160
|
+
return skipReasons.NO_EXPECTATION;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return skipReasons.NO_EXPECTATION;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function collectSkipReasons(skips) {
|
|
167
|
+
const reasonCounts = new Map();
|
|
168
|
+
const examples = [];
|
|
169
|
+
|
|
170
|
+
for (const skip of skips) {
|
|
171
|
+
const code = skip.code;
|
|
172
|
+
reasonCounts.set(code, (reasonCounts.get(code) || 0) + 1);
|
|
173
|
+
|
|
174
|
+
if (examples.length < MAX_EXAMPLES) {
|
|
175
|
+
examples.push({
|
|
176
|
+
interaction: {
|
|
177
|
+
type: skip.interaction.type,
|
|
178
|
+
selector: skip.interaction.selector ? skip.interaction.selector.substring(0, 100) : '',
|
|
179
|
+
label: skip.interaction.label ? skip.interaction.label.substring(0, 100) : ''
|
|
180
|
+
},
|
|
181
|
+
code: code,
|
|
182
|
+
message: skip.message
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const reasons = Array.from(reasonCounts.entries())
|
|
188
|
+
.map(([code, count]) => ({ code, count }))
|
|
189
|
+
.sort((a, b) => {
|
|
190
|
+
if (b.count !== a.count) {
|
|
191
|
+
return b.count - a.count;
|
|
192
|
+
}
|
|
193
|
+
return a.code.localeCompare(b.code);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
total: skips.length,
|
|
198
|
+
reasons: reasons,
|
|
199
|
+
examples: examples
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 7 — Flow Engine
|
|
3
|
+
*
|
|
4
|
+
* Executes deterministic multi-step flows with strict safety gates.
|
|
5
|
+
* - No guessing: explicit selectors and expected outcomes only
|
|
6
|
+
* - Safety gates: denyKeywords, allowlist enforcement
|
|
7
|
+
* - Per-step sensor capture: network, console, UI signals
|
|
8
|
+
* - Deterministic: uses waitForSettle after each interactive step
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { waitForSettle } from '../observe/settle.js';
|
|
12
|
+
import { resolveSecrets, extractSecretValues } from './flow-spec.js';
|
|
13
|
+
import { redactObject } from './redaction.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Execute a flow on a Playwright page.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} page - Playwright page
|
|
19
|
+
* @param {Object} spec - Validated flow spec
|
|
20
|
+
* @param {Object} sensors - Sensor collectors {network, console, etc.}
|
|
21
|
+
* @returns {Promise<Object>} - {success, findings, stepResults}
|
|
22
|
+
*/
|
|
23
|
+
export async function executeFlow(page, spec, sensors = {}) {
|
|
24
|
+
const secretValues = extractSecretValues(spec.secrets);
|
|
25
|
+
const stepResults = [];
|
|
26
|
+
const findings = [];
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
for (let idx = 0; idx < spec.steps.length; idx++) {
|
|
30
|
+
const step = spec.steps[idx];
|
|
31
|
+
const stepResult = await executeStep(page, step, idx, spec, sensors, secretValues);
|
|
32
|
+
|
|
33
|
+
stepResults.push(stepResult);
|
|
34
|
+
|
|
35
|
+
if (!stepResult.success) {
|
|
36
|
+
findings.push({
|
|
37
|
+
type: stepResult.findingType,
|
|
38
|
+
stepIndex: idx,
|
|
39
|
+
stepType: step.type,
|
|
40
|
+
reason: stepResult.reason,
|
|
41
|
+
evidence: redactObject(stepResult.evidence, secretValues)
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Stop on first failure
|
|
45
|
+
if (step.failureMode !== 'continue') {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
findings.push({
|
|
52
|
+
type: 'flow_step_failed',
|
|
53
|
+
stepIndex: -1,
|
|
54
|
+
reason: `Unexpected error: ${err.message}`,
|
|
55
|
+
evidence: { error: err.toString() }
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
success: findings.length === 0,
|
|
61
|
+
findings,
|
|
62
|
+
stepResults: stepResults.map(r => redactObject(r, secretValues))
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function executeStep(page, step, idx, spec, sensors, secretValues) {
|
|
67
|
+
const baseResult = {
|
|
68
|
+
stepIndex: idx,
|
|
69
|
+
type: step.type,
|
|
70
|
+
success: false,
|
|
71
|
+
reason: '',
|
|
72
|
+
evidence: {}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
switch (step.type) {
|
|
77
|
+
case 'goto':
|
|
78
|
+
return await stepGoto(page, step, baseResult, spec);
|
|
79
|
+
|
|
80
|
+
case 'fill':
|
|
81
|
+
return await stepFill(page, step, baseResult, spec);
|
|
82
|
+
|
|
83
|
+
case 'click':
|
|
84
|
+
return await stepClick(page, step, baseResult, spec);
|
|
85
|
+
|
|
86
|
+
case 'expect':
|
|
87
|
+
return await stepExpect(page, step, baseResult);
|
|
88
|
+
|
|
89
|
+
default:
|
|
90
|
+
baseResult.reason = `Unknown step type: ${step.type}`;
|
|
91
|
+
baseResult.findingType = 'flow_step_failed';
|
|
92
|
+
return baseResult;
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
baseResult.reason = err.message;
|
|
96
|
+
baseResult.findingType = 'flow_step_failed';
|
|
97
|
+
baseResult.evidence = { error: err.toString() };
|
|
98
|
+
return baseResult;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function stepGoto(page, step, result, spec) {
|
|
103
|
+
const url = new URL(step.url, spec.baseUrl).toString();
|
|
104
|
+
|
|
105
|
+
// Check allowlist
|
|
106
|
+
try {
|
|
107
|
+
const urlObj = new URL(url);
|
|
108
|
+
const domainAllowed = spec.allowlist.domains.length === 0 ||
|
|
109
|
+
spec.allowlist.domains.some(d => urlObj.hostname.includes(d) || urlObj.hostname === d);
|
|
110
|
+
|
|
111
|
+
if (!domainAllowed) {
|
|
112
|
+
result.reason = `Domain ${urlObj.hostname} not in allowlist`;
|
|
113
|
+
result.findingType = 'unexpected_navigation';
|
|
114
|
+
result.evidence = { url, allowedDomains: spec.allowlist.domains };
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pathAllowed = spec.allowlist.pathsPrefix.length === 0 ||
|
|
119
|
+
spec.allowlist.pathsPrefix.some(prefix => urlObj.pathname.startsWith(prefix));
|
|
120
|
+
|
|
121
|
+
if (!pathAllowed) {
|
|
122
|
+
result.reason = `Path ${urlObj.pathname} does not match allowlist prefixes`;
|
|
123
|
+
result.findingType = 'unexpected_navigation';
|
|
124
|
+
result.evidence = { url, allowedPrefixes: spec.allowlist.pathsPrefix };
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
result.reason = `Invalid URL: ${url}`;
|
|
129
|
+
result.findingType = 'flow_step_failed';
|
|
130
|
+
result.evidence = { url, error: err.message };
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await page.goto(url, { waitUntil: 'networkidle' });
|
|
135
|
+
await waitForSettle(page, { timeoutMs: 3000, settleMs: 500 });
|
|
136
|
+
|
|
137
|
+
result.success = true;
|
|
138
|
+
result.evidence = { url };
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function stepFill(page, step, result, spec) {
|
|
143
|
+
const selector = step.selector;
|
|
144
|
+
const resolvedValue = resolveSecrets(step.value, spec.secrets);
|
|
145
|
+
|
|
146
|
+
// Check if element exists
|
|
147
|
+
const element = await page.$(selector);
|
|
148
|
+
if (!element) {
|
|
149
|
+
result.reason = `Selector not found: ${selector}`;
|
|
150
|
+
result.findingType = 'flow_step_failed';
|
|
151
|
+
result.evidence = { selector };
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fill the field
|
|
156
|
+
await element.fill(resolvedValue);
|
|
157
|
+
await waitForSettle(page, { timeoutMs: 2000, settleMs: 300 });
|
|
158
|
+
|
|
159
|
+
result.success = true;
|
|
160
|
+
result.evidence = { selector };
|
|
161
|
+
// NOTE: resolved value NOT stored in evidence for security
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function stepClick(page, step, result, spec) {
|
|
166
|
+
const selector = step.selector;
|
|
167
|
+
|
|
168
|
+
// Check if element exists
|
|
169
|
+
const element = await page.$(selector);
|
|
170
|
+
if (!element) {
|
|
171
|
+
result.reason = `Selector not found: ${selector}`;
|
|
172
|
+
result.findingType = 'flow_step_failed';
|
|
173
|
+
result.evidence = { selector };
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check for deny keywords in element text/aria-label
|
|
178
|
+
// Use textContent instead of innerText for better compatibility
|
|
179
|
+
let elementText = await page.evaluate(el => el.textContent || '', element);
|
|
180
|
+
let elementAriaLabel = '';
|
|
181
|
+
let elementValue = '';
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
elementAriaLabel = await element.getAttribute('aria-label') || '';
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// Ignore
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
elementValue = await element.getAttribute('value') || '';
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// Ignore
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const fullContent = `${elementText} ${elementAriaLabel} ${elementValue}`.toLowerCase();
|
|
196
|
+
|
|
197
|
+
for (const keyword of spec.denyKeywords) {
|
|
198
|
+
if (fullContent.includes(keyword.toLowerCase())) {
|
|
199
|
+
result.reason = `Click blocked by safety gate: denyKeyword "${keyword}" found in element`;
|
|
200
|
+
result.findingType = 'blocked_by_safety_gate';
|
|
201
|
+
result.evidence = { selector, keyword, elementText: '***REDACTED***' };
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Click the element
|
|
207
|
+
await element.click();
|
|
208
|
+
await waitForSettle(page, { timeoutMs: 3000, settleMs: 500 });
|
|
209
|
+
|
|
210
|
+
result.success = true;
|
|
211
|
+
result.evidence = { selector };
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function stepExpect(page, step, result) {
|
|
216
|
+
try {
|
|
217
|
+
if (step.kind === 'selector') {
|
|
218
|
+
const element = await page.$(step.selector);
|
|
219
|
+
if (!element) {
|
|
220
|
+
result.reason = `Expected selector not found: ${step.selector}`;
|
|
221
|
+
result.findingType = 'flow_step_failed';
|
|
222
|
+
result.evidence = { selector: step.selector };
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
result.success = true;
|
|
226
|
+
result.evidence = { selector: step.selector };
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (step.kind === 'url') {
|
|
231
|
+
const url = page.url();
|
|
232
|
+
if (!url.includes(step.prefix)) {
|
|
233
|
+
result.reason = `Expected URL prefix not found. Current: ${url}, Expected prefix: ${step.prefix}`;
|
|
234
|
+
result.findingType = 'flow_step_failed';
|
|
235
|
+
result.evidence = { currentUrl: url, expectedPrefix: step.prefix };
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
result.success = true;
|
|
239
|
+
result.evidence = { url, expectedPrefix: step.prefix };
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (step.kind === 'route') {
|
|
244
|
+
const url = new URL(page.url());
|
|
245
|
+
if (url.pathname !== step.path) {
|
|
246
|
+
result.reason = `Expected route not matched. Current: ${url.pathname}, Expected: ${step.path}`;
|
|
247
|
+
result.findingType = 'flow_step_failed';
|
|
248
|
+
result.evidence = { currentPath: url.pathname, expectedPath: step.path };
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
result.success = true;
|
|
252
|
+
result.evidence = { path: url.pathname };
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
result.reason = err.message;
|
|
257
|
+
result.findingType = 'flow_step_failed';
|
|
258
|
+
result.evidence = { error: err.toString() };
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
result.reason = `Unknown expect kind: ${step.kind}`;
|
|
263
|
+
result.findingType = 'flow_step_failed';
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 7 — Flow Specification Parser & Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates and normalizes flow spec JSON.
|
|
5
|
+
* No guessing: explicit selectors, URLs, and step definitions only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ALLOWED_STEP_TYPES = ['goto', 'fill', 'click', 'expect'];
|
|
9
|
+
const ALLOWED_EXPECT_KINDS = ['selector', 'url', 'route'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate and normalize a flow spec.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} spec - Flow spec object
|
|
15
|
+
* @returns {Object} - Normalized spec
|
|
16
|
+
* @throws {Error} - On validation failure
|
|
17
|
+
*/
|
|
18
|
+
export function validateFlowSpec(spec) {
|
|
19
|
+
if (!spec || typeof spec !== 'object') {
|
|
20
|
+
throw new Error('Flow spec must be a valid JSON object');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!spec.name || typeof spec.name !== 'string') {
|
|
24
|
+
throw new Error('Flow spec must have a "name" property');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!spec.baseUrl || typeof spec.baseUrl !== 'string') {
|
|
28
|
+
throw new Error('Flow spec must have a "baseUrl" property');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!Array.isArray(spec.steps) || spec.steps.length === 0) {
|
|
32
|
+
throw new Error('Flow spec must have a "steps" array with at least one step');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate allowlist
|
|
36
|
+
const allowlist = spec.allowlist || { domains: [], pathsPrefix: ['/'] };
|
|
37
|
+
if (!Array.isArray(allowlist.domains)) {
|
|
38
|
+
allowlist.domains = [];
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(allowlist.pathsPrefix)) {
|
|
41
|
+
allowlist.pathsPrefix = ['/'];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Validate denyKeywords
|
|
45
|
+
const denyKeywords = spec.denyKeywords || [];
|
|
46
|
+
if (!Array.isArray(denyKeywords)) {
|
|
47
|
+
throw new Error('denyKeywords must be an array');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validate steps
|
|
51
|
+
const steps = spec.steps.map((step, idx) => {
|
|
52
|
+
if (!step.type || !ALLOWED_STEP_TYPES.includes(step.type)) {
|
|
53
|
+
throw new Error(`Step ${idx}: type must be one of ${ALLOWED_STEP_TYPES.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (step.type === 'goto') {
|
|
57
|
+
if (!step.url || typeof step.url !== 'string') {
|
|
58
|
+
throw new Error(`Step ${idx}: goto requires url`);
|
|
59
|
+
}
|
|
60
|
+
return step;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (step.type === 'fill') {
|
|
64
|
+
if (!step.selector || typeof step.selector !== 'string') {
|
|
65
|
+
throw new Error(`Step ${idx}: fill requires selector`);
|
|
66
|
+
}
|
|
67
|
+
if (step.value === undefined) {
|
|
68
|
+
throw new Error(`Step ${idx}: fill requires value`);
|
|
69
|
+
}
|
|
70
|
+
return step;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (step.type === 'click') {
|
|
74
|
+
if (!step.selector || typeof step.selector !== 'string') {
|
|
75
|
+
throw new Error(`Step ${idx}: click requires selector`);
|
|
76
|
+
}
|
|
77
|
+
return step;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (step.type === 'expect') {
|
|
81
|
+
if (!step.kind || !ALLOWED_EXPECT_KINDS.includes(step.kind)) {
|
|
82
|
+
throw new Error(`Step ${idx}: expect requires kind one of ${ALLOWED_EXPECT_KINDS.join(', ')}`);
|
|
83
|
+
}
|
|
84
|
+
if (step.kind === 'selector' && !step.selector) {
|
|
85
|
+
throw new Error(`Step ${idx}: expect selector requires selector`);
|
|
86
|
+
}
|
|
87
|
+
if (step.kind === 'url' && !step.prefix) {
|
|
88
|
+
throw new Error(`Step ${idx}: expect url requires prefix`);
|
|
89
|
+
}
|
|
90
|
+
if (step.kind === 'route' && !step.path) {
|
|
91
|
+
throw new Error(`Step ${idx}: expect route requires path`);
|
|
92
|
+
}
|
|
93
|
+
return step;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return step;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
name: spec.name,
|
|
101
|
+
baseUrl: spec.baseUrl,
|
|
102
|
+
allowlist,
|
|
103
|
+
denyKeywords,
|
|
104
|
+
secrets: spec.secrets || {},
|
|
105
|
+
steps
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve environment variable references like $ENV:VERAX_USER_EMAIL.
|
|
111
|
+
* Returns actual value or throws if not found.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} value - String containing $ENV:VARNAME references
|
|
114
|
+
* @param {Object} secrets - Map of secret keys to env var names
|
|
115
|
+
* @returns {string} - Resolved value
|
|
116
|
+
*/
|
|
117
|
+
export function resolveSecrets(value, secrets = {}) {
|
|
118
|
+
if (typeof value !== 'string') return value;
|
|
119
|
+
|
|
120
|
+
// Replace $ENV:VARNAME with actual env var value
|
|
121
|
+
return value.replace(/\$ENV:([A-Z_][A-Z0-9_]*)/g, (match, varName) => {
|
|
122
|
+
const envValue = process.env[varName];
|
|
123
|
+
if (!envValue) {
|
|
124
|
+
throw new Error(`Environment variable not found: ${varName}`);
|
|
125
|
+
}
|
|
126
|
+
return envValue;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extract all secret values from a resolved context for redaction.
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} secrets - Map of secret keys to env var names
|
|
134
|
+
* @returns {Set} - Set of actual secret values to redact
|
|
135
|
+
*/
|
|
136
|
+
export function extractSecretValues(secrets = {}) {
|
|
137
|
+
const values = new Set();
|
|
138
|
+
for (const envVar of Object.values(secrets)) {
|
|
139
|
+
const value = process.env[envVar];
|
|
140
|
+
if (value) {
|
|
141
|
+
values.add(value);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return values;
|
|
145
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wave 7 — Redaction Utility
|
|
3
|
+
*
|
|
4
|
+
* Ensures secrets are never exposed in artifacts, logs, or traces.
|
|
5
|
+
* Replaces resolved secret values with "***REDACTED***".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Redact secrets from a string.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} str - Input string
|
|
12
|
+
* @param {Set} secretValues - Set of actual secret values to replace
|
|
13
|
+
* @returns {string} - Redacted string
|
|
14
|
+
*/
|
|
15
|
+
export function redactString(str, secretValues = new Set()) {
|
|
16
|
+
if (typeof str !== 'string' || secretValues.size === 0) {
|
|
17
|
+
return str;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let result = str;
|
|
21
|
+
for (const secret of secretValues) {
|
|
22
|
+
if (secret && typeof secret === 'string') {
|
|
23
|
+
// Use global replace to catch all occurrences
|
|
24
|
+
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
result = result.replace(new RegExp(escaped, 'g'), '***REDACTED***');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Redact secrets from a JavaScript object recursively.
|
|
33
|
+
*
|
|
34
|
+
* @param {*} obj - Object to redact
|
|
35
|
+
* @param {Set} secretValues - Set of secret values to replace
|
|
36
|
+
* @returns {*} - Redacted object (deep copy)
|
|
37
|
+
*/
|
|
38
|
+
export function redactObject(obj, secretValues = new Set()) {
|
|
39
|
+
if (secretValues.size === 0) {
|
|
40
|
+
return obj;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof obj === 'string') {
|
|
44
|
+
return redactString(obj, secretValues);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(obj)) {
|
|
48
|
+
return obj.map(item => redactObject(item, secretValues));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (obj !== null && typeof obj === 'object') {
|
|
52
|
+
const redacted = {};
|
|
53
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
54
|
+
redacted[key] = redactObject(value, secretValues);
|
|
55
|
+
}
|
|
56
|
+
return redacted;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return obj;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Redact secrets from JSON string.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} jsonStr - JSON string
|
|
66
|
+
* @param {Set} secretValues - Set of secret values
|
|
67
|
+
* @returns {string} - Redacted JSON string
|
|
68
|
+
*/
|
|
69
|
+
export function redactJSON(jsonStr, secretValues = new Set()) {
|
|
70
|
+
if (typeof jsonStr !== 'string' || secretValues.size === 0) {
|
|
71
|
+
return jsonStr;
|
|
72
|
+
}
|
|
73
|
+
return redactString(jsonStr, secretValues);
|
|
74
|
+
}
|