@odavl/guardian 0.1.0-rc1
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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/guardian.js +690 -0
- package/flows/example-login-flow.json +36 -0
- package/flows/example-signup-flow.json +44 -0
- package/guardian-contract-v1.md +149 -0
- package/guardian.config.json +54 -0
- package/guardian.policy.json +12 -0
- package/guardian.profile.docs.yaml +18 -0
- package/guardian.profile.ecommerce.yaml +17 -0
- package/guardian.profile.marketing.yaml +18 -0
- package/guardian.profile.saas.yaml +21 -0
- package/package.json +69 -0
- package/policies/enterprise.json +12 -0
- package/policies/saas.json +12 -0
- package/policies/startup.json +12 -0
- package/src/guardian/attempt-engine.js +454 -0
- package/src/guardian/attempt-registry.js +227 -0
- package/src/guardian/attempt-reporter.js +507 -0
- package/src/guardian/attempt.js +227 -0
- package/src/guardian/auto-attempt-builder.js +283 -0
- package/src/guardian/baseline-reporter.js +143 -0
- package/src/guardian/baseline-storage.js +285 -0
- package/src/guardian/baseline.js +492 -0
- package/src/guardian/behavioral-signals.js +261 -0
- package/src/guardian/breakage-intelligence.js +223 -0
- package/src/guardian/browser.js +92 -0
- package/src/guardian/cli-summary.js +141 -0
- package/src/guardian/crawler.js +142 -0
- package/src/guardian/discovery-engine.js +661 -0
- package/src/guardian/enhanced-html-reporter.js +305 -0
- package/src/guardian/failure-taxonomy.js +169 -0
- package/src/guardian/flow-executor.js +374 -0
- package/src/guardian/flow-registry.js +67 -0
- package/src/guardian/html-reporter.js +414 -0
- package/src/guardian/index.js +218 -0
- package/src/guardian/init-command.js +139 -0
- package/src/guardian/junit-reporter.js +264 -0
- package/src/guardian/market-criticality.js +335 -0
- package/src/guardian/market-reporter.js +305 -0
- package/src/guardian/network-trace.js +178 -0
- package/src/guardian/policy.js +357 -0
- package/src/guardian/preset-loader.js +148 -0
- package/src/guardian/reality.js +547 -0
- package/src/guardian/reporter.js +181 -0
- package/src/guardian/root-cause-analysis.js +171 -0
- package/src/guardian/safety.js +248 -0
- package/src/guardian/scan-presets.js +60 -0
- package/src/guardian/screenshot.js +152 -0
- package/src/guardian/sitemap.js +225 -0
- package/src/guardian/snapshot-schema.js +266 -0
- package/src/guardian/snapshot.js +327 -0
- package/src/guardian/validators.js +323 -0
- package/src/guardian/visual-diff.js +247 -0
- package/src/guardian/webhook.js +206 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class GuardianReporter {
|
|
5
|
+
/**
|
|
6
|
+
* Prepare artifacts directory for the current run
|
|
7
|
+
* @param {string} artifactsDir - Base artifacts directory
|
|
8
|
+
* @returns {object} { runDir, runId }
|
|
9
|
+
*/
|
|
10
|
+
prepareArtifactsDir(artifactsDir) {
|
|
11
|
+
const now = new Date();
|
|
12
|
+
const dateStr = now.toISOString()
|
|
13
|
+
.replace(/[:\-]/g, '')
|
|
14
|
+
.substring(0, 15)
|
|
15
|
+
.replace('T', '-');
|
|
16
|
+
const runId = `run-${dateStr}`;
|
|
17
|
+
|
|
18
|
+
const runDir = path.join(artifactsDir, runId);
|
|
19
|
+
if (!fs.existsSync(runDir)) {
|
|
20
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { runDir, runId };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
createReport(crawlResult, baseUrl) {
|
|
27
|
+
const { visited, totalDiscovered, totalVisited } = crawlResult;
|
|
28
|
+
|
|
29
|
+
const coverage = totalDiscovered > 0
|
|
30
|
+
? parseFloat(((totalVisited / totalDiscovered) * 100).toFixed(2))
|
|
31
|
+
: 0;
|
|
32
|
+
|
|
33
|
+
// Calculate confidence
|
|
34
|
+
let confidenceLevel = 'LOW';
|
|
35
|
+
if (coverage >= 85) {
|
|
36
|
+
confidenceLevel = 'HIGH';
|
|
37
|
+
} else if (coverage >= 60) {
|
|
38
|
+
confidenceLevel = 'MEDIUM';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Calculate decision
|
|
42
|
+
let decision = 'READY';
|
|
43
|
+
if (coverage < 30) {
|
|
44
|
+
decision = 'DO_NOT_LAUNCH';
|
|
45
|
+
} else if (coverage < 60) {
|
|
46
|
+
decision = 'INSUFFICIENT_CONFIDENCE';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check for critical errors (server errors only, not 404s)
|
|
50
|
+
const failedPages = visited.filter(p => p.status && p.status >= 500);
|
|
51
|
+
if (failedPages.length > 0) {
|
|
52
|
+
decision = 'DO_NOT_LAUNCH';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reasons = [];
|
|
56
|
+
if (coverage < 30) reasons.push(`Low coverage (${coverage}%)`);
|
|
57
|
+
if (failedPages.length > 0) reasons.push(`${failedPages.length} pages failed to load`);
|
|
58
|
+
if (coverage >= 60) reasons.push(`Coverage is ${coverage}%`);
|
|
59
|
+
if (failedPages.length === 0 && coverage >= 60) reasons.push('All visited pages loaded successfully');
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
version: 'mvp-0.1',
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
baseUrl: baseUrl,
|
|
65
|
+
summary: {
|
|
66
|
+
visitedPages: totalVisited,
|
|
67
|
+
discoveredPages: totalDiscovered,
|
|
68
|
+
coverage: coverage,
|
|
69
|
+
failedPages: failedPages.length
|
|
70
|
+
},
|
|
71
|
+
confidence: {
|
|
72
|
+
level: confidenceLevel,
|
|
73
|
+
reasoning: `Coverage is ${coverage}% with ${failedPages.length} failed pages`
|
|
74
|
+
},
|
|
75
|
+
finalJudgment: {
|
|
76
|
+
decision: decision,
|
|
77
|
+
reasons: reasons.length > 0 ? reasons : ['All checks passed']
|
|
78
|
+
},
|
|
79
|
+
pages: visited.map((p, i) => ({
|
|
80
|
+
index: i + 1,
|
|
81
|
+
url: p.url,
|
|
82
|
+
status: p.status,
|
|
83
|
+
links: p.linkCount,
|
|
84
|
+
depth: p.depth,
|
|
85
|
+
error: p.error || null
|
|
86
|
+
}))
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create report from flow execution result
|
|
92
|
+
* @param {object} flowResult - Flow execution result
|
|
93
|
+
* @param {string} baseUrl - Base URL
|
|
94
|
+
* @returns {object} Report object
|
|
95
|
+
*/
|
|
96
|
+
createFlowReport(flowResult, baseUrl) {
|
|
97
|
+
const { flowId, flowName, success, stepsExecuted, stepsTotal, failedStep, error } = flowResult;
|
|
98
|
+
|
|
99
|
+
const coverage = stepsTotal > 0
|
|
100
|
+
? parseFloat(((stepsExecuted / stepsTotal) * 100).toFixed(2))
|
|
101
|
+
: 0;
|
|
102
|
+
|
|
103
|
+
// For flows: success = READY, failure = DO_NOT_LAUNCH
|
|
104
|
+
const decision = success ? 'READY' : 'DO_NOT_LAUNCH';
|
|
105
|
+
const confidenceLevel = success ? 'HIGH' : 'LOW';
|
|
106
|
+
|
|
107
|
+
const reasons = [];
|
|
108
|
+
if (success) {
|
|
109
|
+
reasons.push(`Flow "${flowName}" completed successfully`);
|
|
110
|
+
reasons.push(`All ${stepsTotal} steps executed`);
|
|
111
|
+
} else {
|
|
112
|
+
reasons.push(`Flow "${flowName}" failed at step ${failedStep}`);
|
|
113
|
+
reasons.push(`Error: ${error}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
version: 'mvp-0.2',
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
baseUrl: baseUrl,
|
|
120
|
+
mode: 'flow',
|
|
121
|
+
flow: {
|
|
122
|
+
id: flowId,
|
|
123
|
+
name: flowName,
|
|
124
|
+
stepsTotal: stepsTotal,
|
|
125
|
+
stepsExecuted: stepsExecuted,
|
|
126
|
+
failedStep: failedStep || null,
|
|
127
|
+
error: error || null
|
|
128
|
+
},
|
|
129
|
+
summary: {
|
|
130
|
+
visitedPages: stepsExecuted,
|
|
131
|
+
discoveredPages: stepsTotal,
|
|
132
|
+
coverage: coverage,
|
|
133
|
+
failedPages: success ? 0 : 1
|
|
134
|
+
},
|
|
135
|
+
confidence: {
|
|
136
|
+
level: confidenceLevel,
|
|
137
|
+
reasoning: success
|
|
138
|
+
? `Flow completed successfully (${stepsExecuted}/${stepsTotal} steps)`
|
|
139
|
+
: `Flow failed at step ${failedStep}: ${error}`
|
|
140
|
+
},
|
|
141
|
+
finalJudgment: {
|
|
142
|
+
decision: decision,
|
|
143
|
+
reasons: reasons
|
|
144
|
+
},
|
|
145
|
+
pages: flowResult.screenshots ? flowResult.screenshots.map((screenshot, i) => ({
|
|
146
|
+
index: i + 1,
|
|
147
|
+
url: `${baseUrl} (Flow step ${i + 1})`,
|
|
148
|
+
status: 200,
|
|
149
|
+
links: 0,
|
|
150
|
+
screenshot: screenshot
|
|
151
|
+
})) : []
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
saveReport(report, artifactsDir) {
|
|
156
|
+
// Create run directory
|
|
157
|
+
const now = new Date();
|
|
158
|
+
const dateStr = now.toISOString()
|
|
159
|
+
.replace(/[:\-]/g, '')
|
|
160
|
+
.substring(0, 15)
|
|
161
|
+
.replace('T', '-');
|
|
162
|
+
const runId = `run-${dateStr}`;
|
|
163
|
+
|
|
164
|
+
const runDir = path.join(artifactsDir, runId);
|
|
165
|
+
if (!fs.existsSync(runDir)) {
|
|
166
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Save report.json
|
|
170
|
+
const reportPath = path.join(runDir, 'report.json');
|
|
171
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
runId: runId,
|
|
175
|
+
runDir: runDir,
|
|
176
|
+
reportPath: reportPath
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { GuardianReporter };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4 — Root-Cause Analysis
|
|
3
|
+
* Deterministically derive hints from failure evidence
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { BREAK_TYPES } = require('./failure-taxonomy');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract hints from step execution failure
|
|
10
|
+
* @param {Object} step - { type, target, status, error, duration }
|
|
11
|
+
* @returns {string[]} Array of hint strings
|
|
12
|
+
*/
|
|
13
|
+
function hintsFromStep(step) {
|
|
14
|
+
const hints = [];
|
|
15
|
+
|
|
16
|
+
if (!step) return hints;
|
|
17
|
+
|
|
18
|
+
const error = (step.error || '').toLowerCase();
|
|
19
|
+
const type = step.type || '';
|
|
20
|
+
|
|
21
|
+
// Navigation
|
|
22
|
+
if (type === 'navigate' && error.includes('timeout')) {
|
|
23
|
+
hints.push('Server not responding or slow to load');
|
|
24
|
+
} else if (type === 'navigate' && error.includes('failed')) {
|
|
25
|
+
hints.push('Navigation failed; verify base URL is correct');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Click failures
|
|
29
|
+
if (type === 'click') {
|
|
30
|
+
if (error.includes('selector') || error.includes('found')) {
|
|
31
|
+
hints.push(`Element selector not found: ${step.target}`);
|
|
32
|
+
} else if (error.includes('visible')) {
|
|
33
|
+
hints.push(`Element not visible or clickable: ${step.target}`);
|
|
34
|
+
} else if (error.includes('timeout')) {
|
|
35
|
+
hints.push(`Timeout clicking ${step.target}; page may be loading slowly`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Type failures
|
|
40
|
+
if (type === 'type') {
|
|
41
|
+
if (error.includes('selector') || error.includes('found')) {
|
|
42
|
+
hints.push(`Input field not found: ${step.target}`);
|
|
43
|
+
} else if (error.includes('timeout')) {
|
|
44
|
+
hints.push(`Timeout typing into ${step.target}; page lag detected`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// WaitFor failures
|
|
49
|
+
if (type === 'waitFor') {
|
|
50
|
+
if (error.includes('timeout')) {
|
|
51
|
+
hints.push(`Success element never appeared: ${step.target}`);
|
|
52
|
+
hints.push('Submission may have silently failed or redirected');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Submit failures
|
|
57
|
+
if (type === 'submit') {
|
|
58
|
+
hints.push('Form submission did not complete; check form validation');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return hints;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract hints from validator results
|
|
66
|
+
* @param {Array} validators - Array of { type, status, message, evidence }
|
|
67
|
+
* @returns {string[]} Array of hint strings
|
|
68
|
+
*/
|
|
69
|
+
function hintsFromValidators(validators) {
|
|
70
|
+
const hints = [];
|
|
71
|
+
|
|
72
|
+
if (!Array.isArray(validators)) return hints;
|
|
73
|
+
|
|
74
|
+
for (const v of validators) {
|
|
75
|
+
if (v.status === 'FAIL') {
|
|
76
|
+
if (v.type === 'elementVisible') {
|
|
77
|
+
hints.push(`Expected element not visible: ${v.evidence?.selector}`);
|
|
78
|
+
} else if (v.type === 'pageContainsAnyText') {
|
|
79
|
+
hints.push(`Page text check failed; expected one of: ${v.evidence?.searchTerms?.join(', ')}`);
|
|
80
|
+
} else if (v.type === 'elementContainsText') {
|
|
81
|
+
hints.push(`Element text mismatch; expected: ${v.evidence?.expectedText}`);
|
|
82
|
+
} else if (v.type === 'htmlLangAttribute') {
|
|
83
|
+
hints.push(`HTML lang attribute unexpected; expected: ${v.evidence?.expected}`);
|
|
84
|
+
} else if (v.type === 'urlIncludes' || v.type === 'urlMatches') {
|
|
85
|
+
hints.push(`URL does not match expected pattern: ${v.evidence?.pattern || v.evidence?.expected}`);
|
|
86
|
+
} else {
|
|
87
|
+
hints.push(`Validator failed: ${v.type}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return hints;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract hints from friction signals
|
|
97
|
+
* @param {Array} signals - Array of { id, description, threshold, observedValue }
|
|
98
|
+
* @returns {string[]} Array of hint strings
|
|
99
|
+
*/
|
|
100
|
+
function hintsFromFriction(signals) {
|
|
101
|
+
const hints = [];
|
|
102
|
+
|
|
103
|
+
if (!Array.isArray(signals)) return hints;
|
|
104
|
+
|
|
105
|
+
for (const sig of signals) {
|
|
106
|
+
if (sig.id === 'slow_step_execution') {
|
|
107
|
+
hints.push(`Step took ${sig.observedValue}ms (threshold ${sig.threshold}ms); network or page lag`);
|
|
108
|
+
} else if (sig.id === 'multiple_retries_required') {
|
|
109
|
+
hints.push(`Step needed ${sig.observedValue} retries; interaction unreliable`);
|
|
110
|
+
} else if (sig.id === 'slow_total_duration') {
|
|
111
|
+
hints.push(`Total attempt took ${sig.observedValue}ms; slow server response`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return hints;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Derive root-cause hints from complete failure evidence
|
|
120
|
+
* @param {Object} failure - Attempt or flow result
|
|
121
|
+
* @param {string} breakType - BREAK_TYPE
|
|
122
|
+
* @returns {Object} { hints: string[], primaryHint: string }
|
|
123
|
+
*/
|
|
124
|
+
function deriveRootCauseHints(failure, breakType) {
|
|
125
|
+
const allHints = new Set();
|
|
126
|
+
|
|
127
|
+
// Step-level evidence
|
|
128
|
+
if (failure.steps && Array.isArray(failure.steps)) {
|
|
129
|
+
const failedStep = failure.steps.find(s => s.status === 'failed');
|
|
130
|
+
if (failedStep) {
|
|
131
|
+
hintsFromStep(failedStep).forEach(h => allHints.add(h));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Validator evidence
|
|
136
|
+
if (failure.validators) {
|
|
137
|
+
hintsFromValidators(failure.validators).forEach(h => allHints.add(h));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Friction signals
|
|
141
|
+
if (failure.friction && failure.friction.signals) {
|
|
142
|
+
hintsFromFriction(failure.friction.signals).forEach(h => allHints.add(h));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fallback hints by break type
|
|
146
|
+
if (allHints.size === 0) {
|
|
147
|
+
const fallbacks = {
|
|
148
|
+
[BREAK_TYPES.NAVIGATION]: 'Server unreachable or incorrect URL',
|
|
149
|
+
[BREAK_TYPES.SUBMISSION]: 'Form submission did not complete',
|
|
150
|
+
[BREAK_TYPES.VALIDATION]: 'Success validation did not pass',
|
|
151
|
+
[BREAK_TYPES.TIMEOUT]: 'Step timed out; slow response or missing element',
|
|
152
|
+
[BREAK_TYPES.VISUAL]: 'Expected element not found on page',
|
|
153
|
+
[BREAK_TYPES.CONSOLE]: 'Console error prevented progress',
|
|
154
|
+
[BREAK_TYPES.NETWORK]: 'Network error or lost connection'
|
|
155
|
+
};
|
|
156
|
+
allHints.add(fallbacks[breakType] || 'Unknown failure');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const hints = Array.from(allHints);
|
|
160
|
+
return {
|
|
161
|
+
hints,
|
|
162
|
+
primaryHint: hints[0] || 'Unknown failure'
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
deriveRootCauseHints,
|
|
168
|
+
hintsFromStep,
|
|
169
|
+
hintsFromValidators,
|
|
170
|
+
hintsFromFriction
|
|
171
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian Safety Guards Module
|
|
3
|
+
* Prevents destructive or dangerous actions during testing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class GuardianSafety {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
// URL patterns to avoid (logout, delete, admin, etc.)
|
|
9
|
+
this.denyUrlPatterns = options.denyUrlPatterns || [
|
|
10
|
+
'logout',
|
|
11
|
+
'signout',
|
|
12
|
+
'sign-out',
|
|
13
|
+
'log-out',
|
|
14
|
+
'delete',
|
|
15
|
+
'remove',
|
|
16
|
+
'destroy',
|
|
17
|
+
'admin',
|
|
18
|
+
'settings',
|
|
19
|
+
'account/close',
|
|
20
|
+
'account/delete',
|
|
21
|
+
'unsubscribe',
|
|
22
|
+
'cancel',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// CSS selectors to avoid clicking
|
|
26
|
+
this.denySelectors = options.denySelectors || [
|
|
27
|
+
'[data-danger]',
|
|
28
|
+
'[data-destructive]',
|
|
29
|
+
'.btn-delete',
|
|
30
|
+
'.btn-danger',
|
|
31
|
+
'.delete-button',
|
|
32
|
+
'button[type="reset"]',
|
|
33
|
+
'a[href*="logout"]',
|
|
34
|
+
'a[href*="delete"]',
|
|
35
|
+
'a[href*="remove"]',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// Form submissions require explicit permission
|
|
39
|
+
this.blockFormSubmitsByDefault = options.blockFormSubmitsByDefault !== false;
|
|
40
|
+
|
|
41
|
+
// Payment-related actions require explicit permission
|
|
42
|
+
this.blockPaymentsByDefault = options.blockPaymentsByDefault !== false;
|
|
43
|
+
|
|
44
|
+
// Payment-related keywords
|
|
45
|
+
this.paymentKeywords = [
|
|
46
|
+
'payment',
|
|
47
|
+
'checkout',
|
|
48
|
+
'purchase',
|
|
49
|
+
'buy',
|
|
50
|
+
'pay',
|
|
51
|
+
'card',
|
|
52
|
+
'billing',
|
|
53
|
+
'stripe',
|
|
54
|
+
'paypal',
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if URL is safe to visit
|
|
60
|
+
* @param {string} url - URL to check
|
|
61
|
+
* @returns {object} { safe: boolean, reason: string }
|
|
62
|
+
*/
|
|
63
|
+
isUrlSafe(url) {
|
|
64
|
+
try {
|
|
65
|
+
const urlLower = url.toLowerCase();
|
|
66
|
+
|
|
67
|
+
for (const pattern of this.denyUrlPatterns) {
|
|
68
|
+
if (urlLower.includes(pattern.toLowerCase())) {
|
|
69
|
+
return {
|
|
70
|
+
safe: false,
|
|
71
|
+
reason: `URL contains blocked pattern: "${pattern}"`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { safe: true, reason: null };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return {
|
|
79
|
+
safe: false,
|
|
80
|
+
reason: `Invalid URL: ${error.message}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if selector is safe to click
|
|
87
|
+
* @param {string} selector - CSS selector
|
|
88
|
+
* @returns {object} { safe: boolean, reason: string }
|
|
89
|
+
*/
|
|
90
|
+
isSelectorSafe(selector) {
|
|
91
|
+
try {
|
|
92
|
+
const selectorLower = selector.toLowerCase();
|
|
93
|
+
|
|
94
|
+
for (const denyPattern of this.denySelectors) {
|
|
95
|
+
if (selectorLower.includes(denyPattern.toLowerCase())) {
|
|
96
|
+
return {
|
|
97
|
+
safe: false,
|
|
98
|
+
reason: `Selector matches blocked pattern: "${denyPattern}"`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { safe: true, reason: null };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return {
|
|
106
|
+
safe: false,
|
|
107
|
+
reason: `Invalid selector: ${error.message}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if element text suggests dangerous action
|
|
114
|
+
* @param {string} text - Element text content
|
|
115
|
+
* @returns {object} { safe: boolean, reason: string }
|
|
116
|
+
*/
|
|
117
|
+
isTextSafe(text) {
|
|
118
|
+
if (!text) {
|
|
119
|
+
return { safe: true, reason: null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const textLower = text.toLowerCase().trim();
|
|
123
|
+
const dangerousWords = [
|
|
124
|
+
'logout',
|
|
125
|
+
'log out',
|
|
126
|
+
'sign out',
|
|
127
|
+
'delete',
|
|
128
|
+
'remove',
|
|
129
|
+
'destroy',
|
|
130
|
+
'cancel account',
|
|
131
|
+
'close account',
|
|
132
|
+
'unsubscribe',
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
for (const word of dangerousWords) {
|
|
136
|
+
if (textLower.includes(word)) {
|
|
137
|
+
return {
|
|
138
|
+
safe: false,
|
|
139
|
+
reason: `Text contains dangerous word: "${word}"`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { safe: true, reason: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if action involves payment
|
|
149
|
+
* @param {string} context - Context (URL, selector, or text)
|
|
150
|
+
* @returns {boolean} True if payment-related
|
|
151
|
+
*/
|
|
152
|
+
isPaymentRelated(context) {
|
|
153
|
+
if (!context) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const contextLower = context.toLowerCase();
|
|
158
|
+
|
|
159
|
+
for (const keyword of this.paymentKeywords) {
|
|
160
|
+
if (contextLower.includes(keyword)) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if form submission is safe
|
|
170
|
+
* @param {string} formAction - Form action URL or selector
|
|
171
|
+
* @param {object} formData - Form data being submitted
|
|
172
|
+
* @returns {object} { safe: boolean, reason: string }
|
|
173
|
+
*/
|
|
174
|
+
isFormSubmitSafe(formAction, formData = {}) {
|
|
175
|
+
// Check if form submissions are globally blocked
|
|
176
|
+
if (this.blockFormSubmitsByDefault) {
|
|
177
|
+
return {
|
|
178
|
+
safe: false,
|
|
179
|
+
reason: 'Form submissions are blocked by default (safety guard)',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check if form action URL is safe
|
|
184
|
+
if (formAction) {
|
|
185
|
+
const urlCheck = this.isUrlSafe(formAction);
|
|
186
|
+
if (!urlCheck.safe) {
|
|
187
|
+
return urlCheck;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check if payment-related
|
|
191
|
+
if (this.blockPaymentsByDefault && this.isPaymentRelated(formAction)) {
|
|
192
|
+
return {
|
|
193
|
+
safe: false,
|
|
194
|
+
reason: 'Form submission appears payment-related (blocked by safety guard)',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check form data for sensitive fields
|
|
200
|
+
const formDataStr = JSON.stringify(formData).toLowerCase();
|
|
201
|
+
if (this.blockPaymentsByDefault && this.isPaymentRelated(formDataStr)) {
|
|
202
|
+
return {
|
|
203
|
+
safe: false,
|
|
204
|
+
reason: 'Form data contains payment-related fields (blocked by safety guard)',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { safe: true, reason: null };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Filter URLs to remove unsafe ones
|
|
213
|
+
* @param {string[]} urls - Array of URLs
|
|
214
|
+
* @returns {object} { safe: string[], blocked: Array<{url, reason}> }
|
|
215
|
+
*/
|
|
216
|
+
filterUrls(urls) {
|
|
217
|
+
const safe = [];
|
|
218
|
+
const blocked = [];
|
|
219
|
+
|
|
220
|
+
for (const url of urls) {
|
|
221
|
+
const check = this.isUrlSafe(url);
|
|
222
|
+
if (check.safe) {
|
|
223
|
+
safe.push(url);
|
|
224
|
+
} else {
|
|
225
|
+
blocked.push({ url, reason: check.reason });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { safe, blocked };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get safety summary (how many URLs/actions were blocked)
|
|
234
|
+
* @param {object} stats - Statistics object
|
|
235
|
+
* @returns {object} Safety summary
|
|
236
|
+
*/
|
|
237
|
+
getSummary(stats = {}) {
|
|
238
|
+
return {
|
|
239
|
+
urlsBlocked: stats.urlsBlocked || 0,
|
|
240
|
+
selectorsBlocked: stats.selectorsBlocked || 0,
|
|
241
|
+
formsBlocked: stats.formsBlocked || 0,
|
|
242
|
+
totalBlocked: (stats.urlsBlocked || 0) + (stats.selectorsBlocked || 0) + (stats.formsBlocked || 0),
|
|
243
|
+
safetyEnabled: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = GuardianSafety;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan Presets (Phase 6)
|
|
3
|
+
* Opinionated defaults for one-command scans
|
|
4
|
+
* Deterministic mappings: attempts, flows, policy thresholds.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { getDefaultAttemptIds } = require('./attempt-registry');
|
|
8
|
+
const { getDefaultFlowIds } = require('./flow-registry');
|
|
9
|
+
|
|
10
|
+
function resolveScanPreset(name = 'landing') {
|
|
11
|
+
const preset = (name || '').toLowerCase();
|
|
12
|
+
|
|
13
|
+
// Defaults: curated attempts + curated flows
|
|
14
|
+
const defaults = {
|
|
15
|
+
attempts: getDefaultAttemptIds(),
|
|
16
|
+
flows: getDefaultFlowIds(),
|
|
17
|
+
policy: null
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
switch (preset) {
|
|
21
|
+
case 'landing':
|
|
22
|
+
return {
|
|
23
|
+
attempts: ['contact_form', 'language_switch', 'newsletter_signup'],
|
|
24
|
+
flows: [], // focus on landing conversion, flows optional
|
|
25
|
+
policy: {
|
|
26
|
+
// lenient warnings, strict criticals
|
|
27
|
+
failOnSeverity: 'CRITICAL',
|
|
28
|
+
maxWarnings: 999,
|
|
29
|
+
visualGates: { CRITICAL: 0, WARNING: 999, maxDiffPercent: 25 }
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
case 'saas':
|
|
33
|
+
return {
|
|
34
|
+
attempts: ['language_switch', 'contact_form', 'newsletter_signup'],
|
|
35
|
+
flows: ['signup_flow', 'login_flow'],
|
|
36
|
+
policy: {
|
|
37
|
+
failOnSeverity: 'CRITICAL',
|
|
38
|
+
maxWarnings: 1,
|
|
39
|
+
failOnNewRegression: true,
|
|
40
|
+
visualGates: { CRITICAL: 0, WARNING: 5, maxDiffPercent: 20 }
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
case 'shop':
|
|
44
|
+
case 'ecommerce':
|
|
45
|
+
return {
|
|
46
|
+
attempts: ['language_switch', 'contact_form', 'newsletter_signup'],
|
|
47
|
+
flows: ['checkout_flow'],
|
|
48
|
+
policy: {
|
|
49
|
+
failOnSeverity: 'CRITICAL',
|
|
50
|
+
maxWarnings: 0,
|
|
51
|
+
failOnNewRegression: true,
|
|
52
|
+
visualGates: { CRITICAL: 0, WARNING: 0, maxDiffPercent: 15 }
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
default:
|
|
56
|
+
return defaults;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { resolveScanPreset };
|