@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,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian Network Trace Module
|
|
3
|
+
* Captures network activity (HAR files) and browser traces
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
class GuardianNetworkTrace {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.enableHAR = options.enableHAR !== false; // Enable HAR by default
|
|
12
|
+
this.enableTrace = options.enableTrace !== false; // Enable trace by default
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start HAR recording for a browser context
|
|
17
|
+
* @param {BrowserContext} context - Playwright browser context
|
|
18
|
+
* @param {string} artifactsDir - Directory to save HAR
|
|
19
|
+
* @returns {Promise<string|null>} Path to HAR file
|
|
20
|
+
*/
|
|
21
|
+
async startHAR(context, artifactsDir) {
|
|
22
|
+
if (!this.enableHAR) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const harPath = path.join(artifactsDir, 'network.har');
|
|
28
|
+
|
|
29
|
+
// Note: HAR recording must be started when creating context
|
|
30
|
+
// This method returns the path where HAR will be saved
|
|
31
|
+
return harPath;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`❌ Failed to prepare HAR recording: ${error.message}`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stop HAR recording (Playwright handles this automatically on context.close())
|
|
40
|
+
* @param {BrowserContext} context - Playwright browser context
|
|
41
|
+
* @returns {Promise<boolean>} Success status
|
|
42
|
+
*/
|
|
43
|
+
async stopHAR(context) {
|
|
44
|
+
// HAR is saved automatically when context closes
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Start browser trace recording
|
|
50
|
+
* @param {BrowserContext} context - Playwright browser context
|
|
51
|
+
* @param {string} artifactsDir - Directory to save trace
|
|
52
|
+
* @returns {Promise<string|null>} Path where trace will be saved
|
|
53
|
+
*/
|
|
54
|
+
async startTrace(context, artifactsDir) {
|
|
55
|
+
if (!this.enableTrace) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const tracePath = path.join(artifactsDir, 'trace.zip');
|
|
61
|
+
|
|
62
|
+
await context.tracing.start({
|
|
63
|
+
screenshots: true,
|
|
64
|
+
snapshots: true,
|
|
65
|
+
sources: false, // Don't include source code
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return tracePath;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`❌ Failed to start trace: ${error.message}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Stop browser trace recording and save
|
|
77
|
+
* @param {BrowserContext} context - Playwright browser context
|
|
78
|
+
* @param {string} tracePath - Where to save trace file
|
|
79
|
+
* @returns {Promise<boolean>} Success status
|
|
80
|
+
*/
|
|
81
|
+
async stopTrace(context, tracePath) {
|
|
82
|
+
if (!this.enableTrace || !tracePath) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await context.tracing.stop({ path: tracePath });
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`❌ Failed to stop trace: ${error.message}`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate HAR file exists and is valid JSON
|
|
97
|
+
* @param {string} harPath - Path to HAR file
|
|
98
|
+
* @returns {boolean} True if valid
|
|
99
|
+
*/
|
|
100
|
+
validateHAR(harPath) {
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(harPath)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const content = fs.readFileSync(harPath, 'utf8');
|
|
107
|
+
const har = JSON.parse(content);
|
|
108
|
+
|
|
109
|
+
// Basic HAR structure validation
|
|
110
|
+
return har.log && har.log.entries && Array.isArray(har.log.entries);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate trace file exists and has reasonable size
|
|
118
|
+
* @param {string} tracePath - Path to trace file
|
|
119
|
+
* @returns {boolean} True if valid
|
|
120
|
+
*/
|
|
121
|
+
validateTrace(tracePath) {
|
|
122
|
+
try {
|
|
123
|
+
if (!fs.existsSync(tracePath)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stats = fs.statSync(tracePath);
|
|
128
|
+
// Trace should be at least 10KB
|
|
129
|
+
return stats.size > 10240;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get HAR statistics (request count, failed requests, etc.)
|
|
137
|
+
* @param {string} harPath - Path to HAR file
|
|
138
|
+
* @returns {object|null} HAR statistics
|
|
139
|
+
*/
|
|
140
|
+
getHARStats(harPath) {
|
|
141
|
+
try {
|
|
142
|
+
if (!this.validateHAR(harPath)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const content = fs.readFileSync(harPath, 'utf8');
|
|
147
|
+
const har = JSON.parse(content);
|
|
148
|
+
const entries = har.log.entries;
|
|
149
|
+
|
|
150
|
+
const stats = {
|
|
151
|
+
totalRequests: entries.length,
|
|
152
|
+
failedRequests: entries.filter(e => e.response && e.response.status >= 400).length,
|
|
153
|
+
requestsByType: {},
|
|
154
|
+
totalSize: 0,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Count by content type
|
|
158
|
+
entries.forEach(entry => {
|
|
159
|
+
const mimeType = entry.response?.content?.mimeType || 'unknown';
|
|
160
|
+
const baseType = mimeType.split(';')[0].split('/')[0]; // e.g., 'text', 'image'
|
|
161
|
+
|
|
162
|
+
stats.requestsByType[baseType] = (stats.requestsByType[baseType] || 0) + 1;
|
|
163
|
+
|
|
164
|
+
// Sum up sizes
|
|
165
|
+
if (entry.response?.content?.size) {
|
|
166
|
+
stats.totalSize += entry.response.content.size;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return stats;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error(`❌ Failed to parse HAR stats: ${error.message}`);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = GuardianNetworkTrace;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian Policy Evaluation
|
|
3
|
+
*
|
|
4
|
+
* Deterministic threshold-based gating for CI/CD pipelines.
|
|
5
|
+
* - Evaluate snapshot against policy thresholds
|
|
6
|
+
* - Determine exit code (success/warn/fail)
|
|
7
|
+
* - Support baseline regression detection
|
|
8
|
+
* - Domain-aware gates for Phase 4 (REVENUE/TRUST critical failures)
|
|
9
|
+
*
|
|
10
|
+
* NO AI. Pure deterministic logic.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { aggregateIntelligence } = require('./breakage-intelligence');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} GuardianPolicy
|
|
19
|
+
* @property {string} [failOnSeverity='CRITICAL'] - Severity level that triggers exit 1 (CRITICAL|WARNING|INFO)
|
|
20
|
+
* @property {number} [maxWarnings=0] - Max WARNING count before fail
|
|
21
|
+
* @property {number} [maxInfo=999] - Max INFO count before fail
|
|
22
|
+
* @property {number} [maxTotalRisk=999] - Max total risks before fail
|
|
23
|
+
* @property {boolean} [failOnNewRegression=true] - Fail if baseline regression detected
|
|
24
|
+
* @property {boolean} [failOnSoftFailures=false] - Fail if any soft failures detected
|
|
25
|
+
* @property {number} [softFailureThreshold=5] - Max soft failures before fail
|
|
26
|
+
* @property {boolean} [requireBaseline=false] - Require baseline to exist
|
|
27
|
+
* @property {Object} [domainGates] - Domain-aware gates (Phase 4). Ex: { REVENUE: { CRITICAL: 0, WARNING: 3 }, TRUST: { CRITICAL: 0 } }
|
|
28
|
+
* @property {Object} [visualGates] - Phase 5: Visual regression gates. Ex: { CRITICAL: 0, WARNING: 999, maxDiffPercent: 25 }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load policy from file or return defaults
|
|
33
|
+
*/
|
|
34
|
+
function loadPolicy(policyPath = null) {
|
|
35
|
+
const defaultPolicy = {
|
|
36
|
+
failOnSeverity: 'CRITICAL',
|
|
37
|
+
maxWarnings: 0,
|
|
38
|
+
maxInfo: 999,
|
|
39
|
+
maxTotalRisk: 999,
|
|
40
|
+
failOnNewRegression: true,
|
|
41
|
+
failOnSoftFailures: false,
|
|
42
|
+
softFailureThreshold: 5,
|
|
43
|
+
requireBaseline: false,
|
|
44
|
+
domainGates: {
|
|
45
|
+
// Phase 4: Fail on any CRITICAL in REVENUE or TRUST domains
|
|
46
|
+
REVENUE: { CRITICAL: 0, WARNING: 999 },
|
|
47
|
+
TRUST: { CRITICAL: 0, WARNING: 999 }
|
|
48
|
+
},
|
|
49
|
+
// Phase 5: Visual regression gates
|
|
50
|
+
visualGates: {
|
|
51
|
+
CRITICAL: 0, // Fail if any CRITICAL visual diffs
|
|
52
|
+
WARNING: 999, // Warn if more than 999 WARNING visual diffs
|
|
53
|
+
maxDiffPercent: 25 // Fail if visual change > 25% of page
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (!policyPath) {
|
|
58
|
+
// Try to find guardian.policy.json in current directory or .odavl-guardian/
|
|
59
|
+
const candidates = [
|
|
60
|
+
'guardian.policy.json',
|
|
61
|
+
'.odavl-guardian/policy.json',
|
|
62
|
+
'.odavl-guardian/guardian.policy.json'
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
if (fs.existsSync(candidate)) {
|
|
67
|
+
policyPath = candidate;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If no policy file found, use defaults
|
|
74
|
+
if (!policyPath || !fs.existsSync(policyPath)) {
|
|
75
|
+
return defaultPolicy;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const json = fs.readFileSync(policyPath, 'utf8');
|
|
80
|
+
const loaded = JSON.parse(json);
|
|
81
|
+
return { ...defaultPolicy, ...loaded };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.warn(`⚠️ Failed to load policy from ${policyPath}: ${e.message}`);
|
|
84
|
+
console.warn(' Using default policy');
|
|
85
|
+
return defaultPolicy;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Evaluate snapshot against policy
|
|
91
|
+
* Returns { passed: boolean, exitCode: 0|1|2, reasons: string[], summary: string }
|
|
92
|
+
*/
|
|
93
|
+
function evaluatePolicy(snapshot, policy) {
|
|
94
|
+
const effectivePolicy = policy || loadPolicy();
|
|
95
|
+
const reasons = [];
|
|
96
|
+
let exitCode = 0;
|
|
97
|
+
|
|
98
|
+
// Extract market impact summary (Phase 3)
|
|
99
|
+
const marketImpact = snapshot.marketImpactSummary || {};
|
|
100
|
+
const criticalCount = marketImpact.countsBySeverity?.CRITICAL || 0;
|
|
101
|
+
const warningCount = marketImpact.countsBySeverity?.WARNING || 0;
|
|
102
|
+
const infoCount = marketImpact.countsBySeverity?.INFO || 0;
|
|
103
|
+
const totalRisk = marketImpact.totalRiskCount || 0;
|
|
104
|
+
|
|
105
|
+
// Extract soft failures (Phase 2)
|
|
106
|
+
const softFailureCount = snapshot.attempts?.reduce((sum, attempt) => {
|
|
107
|
+
return sum + (attempt.softFailureCount || 0);
|
|
108
|
+
}, 0) || 0;
|
|
109
|
+
|
|
110
|
+
// Phase 4: Check domain gates if intelligence available
|
|
111
|
+
if (!exitCode && effectivePolicy.domainGates && snapshot.intelligence) {
|
|
112
|
+
const intelligence = snapshot.intelligence;
|
|
113
|
+
const domainFailures = intelligence.byDomain || {};
|
|
114
|
+
|
|
115
|
+
for (const [domain, gates] of Object.entries(effectivePolicy.domainGates)) {
|
|
116
|
+
const domainFailure = domainFailures[domain] || { failures: [] };
|
|
117
|
+
|
|
118
|
+
// Check CRITICAL gate
|
|
119
|
+
if (gates.CRITICAL !== undefined) {
|
|
120
|
+
const criticalInDomain = domainFailure.failures?.filter(f => f.severity === 'CRITICAL').length || 0;
|
|
121
|
+
if (criticalInDomain > gates.CRITICAL) {
|
|
122
|
+
reasons.push(`Domain ${domain}: ${criticalInDomain} CRITICAL failure(s) exceed gate limit of ${gates.CRITICAL}`);
|
|
123
|
+
exitCode = 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check WARNING gate
|
|
128
|
+
if (!exitCode && gates.WARNING !== undefined) {
|
|
129
|
+
const warningInDomain = domainFailure.failures?.filter(f => f.severity === 'WARNING').length || 0;
|
|
130
|
+
if (warningInDomain > gates.WARNING) {
|
|
131
|
+
reasons.push(`Domain ${domain}: ${warningInDomain} WARNING failure(s) exceed gate limit of ${gates.WARNING}`);
|
|
132
|
+
exitCode = 2;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Phase 5: Check visual regression gates if configured
|
|
139
|
+
if (!exitCode && effectivePolicy.visualGates && snapshot.intelligence) {
|
|
140
|
+
const intelligence = snapshot.intelligence;
|
|
141
|
+
const visualFailures = intelligence.failures?.filter(f => f.breakType === 'VISUAL') || [];
|
|
142
|
+
const visualCritical = visualFailures.filter(f => f.severity === 'CRITICAL').length || 0;
|
|
143
|
+
const visualWarning = visualFailures.filter(f => f.severity === 'WARNING').length || 0;
|
|
144
|
+
const maxDiffPercent = Math.max(...visualFailures.map(f => f.visualDiff?.percentChange || 0));
|
|
145
|
+
|
|
146
|
+
// Check CRITICAL visual diffs
|
|
147
|
+
if (effectivePolicy.visualGates.CRITICAL !== undefined) {
|
|
148
|
+
if (visualCritical > effectivePolicy.visualGates.CRITICAL) {
|
|
149
|
+
reasons.push(`Visual regression: ${visualCritical} CRITICAL diff(s) exceed gate limit of ${effectivePolicy.visualGates.CRITICAL}`);
|
|
150
|
+
exitCode = 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check WARNING visual diffs
|
|
155
|
+
if (!exitCode && effectivePolicy.visualGates.WARNING !== undefined) {
|
|
156
|
+
if (visualWarning > effectivePolicy.visualGates.WARNING) {
|
|
157
|
+
reasons.push(`Visual regression: ${visualWarning} WARNING diff(s) exceed gate limit of ${effectivePolicy.visualGates.WARNING}`);
|
|
158
|
+
exitCode = 2;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check max diff percent
|
|
163
|
+
if (!exitCode && effectivePolicy.visualGates.maxDiffPercent !== undefined) {
|
|
164
|
+
if (maxDiffPercent > effectivePolicy.visualGates.maxDiffPercent) {
|
|
165
|
+
reasons.push(`Visual regression: ${maxDiffPercent.toFixed(1)}% diff exceeds max threshold of ${effectivePolicy.visualGates.maxDiffPercent}%`);
|
|
166
|
+
exitCode = 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Evaluate CRITICAL severity (always exit 1 if present)
|
|
172
|
+
if (effectivePolicy.failOnSeverity === 'CRITICAL' && criticalCount > 0) {
|
|
173
|
+
reasons.push(`${criticalCount} CRITICAL risk(s) detected (policy: failOnSeverity=CRITICAL)`);
|
|
174
|
+
exitCode = 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Evaluate WARNING severity
|
|
178
|
+
if (effectivePolicy.failOnSeverity === 'WARNING' && warningCount > 0) {
|
|
179
|
+
reasons.push(`${warningCount} WARNING risk(s) detected (policy: failOnSeverity=WARNING)`);
|
|
180
|
+
exitCode = 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Evaluate max warnings
|
|
184
|
+
if (!exitCode && warningCount > effectivePolicy.maxWarnings) {
|
|
185
|
+
reasons.push(`${warningCount} WARNING(s) exceed limit of ${effectivePolicy.maxWarnings}`);
|
|
186
|
+
exitCode = 2;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Evaluate max info
|
|
190
|
+
if (!exitCode && infoCount > effectivePolicy.maxInfo) {
|
|
191
|
+
reasons.push(`${infoCount} INFO(s) exceed limit of ${effectivePolicy.maxInfo}`);
|
|
192
|
+
exitCode = 2;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Evaluate total risk
|
|
196
|
+
if (!exitCode && totalRisk > effectivePolicy.maxTotalRisk) {
|
|
197
|
+
reasons.push(`${totalRisk} total risk(s) exceed limit of ${effectivePolicy.maxTotalRisk}`);
|
|
198
|
+
exitCode = 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Evaluate baseline regression
|
|
202
|
+
if (!exitCode && effectivePolicy.failOnNewRegression) {
|
|
203
|
+
const baseline = snapshot.baseline || {};
|
|
204
|
+
const diff = baseline.diff || {};
|
|
205
|
+
|
|
206
|
+
if (diff.regressions && Object.keys(diff.regressions).length > 0) {
|
|
207
|
+
const regCount = Object.keys(diff.regressions).length;
|
|
208
|
+
reasons.push(`${regCount} baseline regression(s) detected (policy: failOnNewRegression=true)`);
|
|
209
|
+
exitCode = 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Evaluate soft failures
|
|
214
|
+
if (!exitCode && effectivePolicy.failOnSoftFailures && softFailureCount > 0) {
|
|
215
|
+
reasons.push(`${softFailureCount} soft failure(s) detected (policy: failOnSoftFailures=true)`);
|
|
216
|
+
exitCode = 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Evaluate soft failure threshold
|
|
220
|
+
if (!exitCode && softFailureCount > effectivePolicy.softFailureThreshold) {
|
|
221
|
+
reasons.push(`${softFailureCount} soft failure(s) exceed threshold of ${effectivePolicy.softFailureThreshold}`);
|
|
222
|
+
exitCode = 2;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Evaluate baseline requirement
|
|
226
|
+
if (!exitCode && effectivePolicy.requireBaseline) {
|
|
227
|
+
const baseline = snapshot.baseline || {};
|
|
228
|
+
if (!baseline.baselineFound && !baseline.baselineCreatedThisRun) {
|
|
229
|
+
reasons.push('Baseline required but not found (policy: requireBaseline=true)');
|
|
230
|
+
exitCode = 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Build summary
|
|
235
|
+
const summary =
|
|
236
|
+
exitCode === 0
|
|
237
|
+
? '✅ Policy evaluation PASSED'
|
|
238
|
+
: exitCode === 1
|
|
239
|
+
? '❌ Policy evaluation FAILED (exit code 1)'
|
|
240
|
+
: '⚠️ Policy evaluation WARNING (exit code 2)';
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
passed: exitCode === 0,
|
|
244
|
+
exitCode,
|
|
245
|
+
reasons,
|
|
246
|
+
summary,
|
|
247
|
+
counts: {
|
|
248
|
+
critical: criticalCount,
|
|
249
|
+
warning: warningCount,
|
|
250
|
+
info: infoCount,
|
|
251
|
+
softFailures: softFailureCount,
|
|
252
|
+
totalRisk
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Format policy evaluation results for CLI output
|
|
259
|
+
*/
|
|
260
|
+
function formatPolicyOutput(evaluation) {
|
|
261
|
+
let output = '\n' + '━'.repeat(60) + '\n';
|
|
262
|
+
output += '🛡️ Policy Evaluation\n';
|
|
263
|
+
output += '━'.repeat(60) + '\n\n';
|
|
264
|
+
|
|
265
|
+
output += `${evaluation.summary}\n`;
|
|
266
|
+
|
|
267
|
+
if (evaluation.reasons.length > 0) {
|
|
268
|
+
output += '\nFailure reasons:\n';
|
|
269
|
+
evaluation.reasons.forEach(r => {
|
|
270
|
+
output += ` ❌ ${r}\n`;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
output += `\nRisk counts:\n`;
|
|
275
|
+
output += ` 🔴 CRITICAL: ${evaluation.counts.critical}\n`;
|
|
276
|
+
output += ` 🟡 WARNING: ${evaluation.counts.warning}\n`;
|
|
277
|
+
output += ` 🔵 INFO: ${evaluation.counts.info}\n`;
|
|
278
|
+
output += ` 🐛 Soft Failures: ${evaluation.counts.softFailures}\n`;
|
|
279
|
+
output += ` 📊 Total Risks: ${evaluation.counts.totalRisk}\n`;
|
|
280
|
+
|
|
281
|
+
output += `\nExit Code: ${evaluation.exitCode}\n`;
|
|
282
|
+
output += '━'.repeat(60) + '\n';
|
|
283
|
+
|
|
284
|
+
return output;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Create a default policy file
|
|
289
|
+
*/
|
|
290
|
+
function createDefaultPolicyFile(outputPath = 'guardian.policy.json') {
|
|
291
|
+
const defaultPolicy = {
|
|
292
|
+
failOnSeverity: 'CRITICAL',
|
|
293
|
+
maxWarnings: 0,
|
|
294
|
+
maxInfo: 999,
|
|
295
|
+
maxTotalRisk: 999,
|
|
296
|
+
failOnNewRegression: true,
|
|
297
|
+
failOnSoftFailures: false,
|
|
298
|
+
softFailureThreshold: 5,
|
|
299
|
+
requireBaseline: false
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
fs.writeFileSync(
|
|
303
|
+
outputPath,
|
|
304
|
+
JSON.stringify(defaultPolicy, null, 2),
|
|
305
|
+
'utf8'
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
return outputPath;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Validate policy object structure
|
|
313
|
+
*/
|
|
314
|
+
function validatePolicy(policy) {
|
|
315
|
+
const errors = [];
|
|
316
|
+
|
|
317
|
+
if (!policy || typeof policy !== 'object') {
|
|
318
|
+
return {
|
|
319
|
+
valid: false,
|
|
320
|
+
errors: ['Policy must be an object']
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const severityValues = ['CRITICAL', 'WARNING', 'INFO'];
|
|
325
|
+
if (policy.failOnSeverity && !severityValues.includes(policy.failOnSeverity)) {
|
|
326
|
+
errors.push(`failOnSeverity must be one of: ${severityValues.join(', ')}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (typeof policy.maxWarnings !== 'number' || policy.maxWarnings < 0) {
|
|
330
|
+
errors.push('maxWarnings must be a non-negative number');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (typeof policy.maxInfo !== 'number' || policy.maxInfo < 0) {
|
|
334
|
+
errors.push('maxInfo must be a non-negative number');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (typeof policy.maxTotalRisk !== 'number' || policy.maxTotalRisk < 0) {
|
|
338
|
+
errors.push('maxTotalRisk must be a non-negative number');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof policy.failOnNewRegression !== 'boolean') {
|
|
342
|
+
errors.push('failOnNewRegression must be a boolean');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
valid: errors.length === 0,
|
|
347
|
+
errors
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = {
|
|
352
|
+
loadPolicy,
|
|
353
|
+
evaluatePolicy,
|
|
354
|
+
formatPolicyOutput,
|
|
355
|
+
createDefaultPolicyFile,
|
|
356
|
+
validatePolicy
|
|
357
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preset Policy Loader
|
|
3
|
+
*
|
|
4
|
+
* Load preset policies (startup, saas, enterprise) for easy adoption.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load preset policy by name
|
|
12
|
+
* @param {string} presetName - 'startup', 'saas', or 'enterprise'
|
|
13
|
+
* @returns {object|null} Policy object or null if not found
|
|
14
|
+
*/
|
|
15
|
+
function loadPreset(presetName) {
|
|
16
|
+
const validPresets = ['startup', 'saas', 'enterprise'];
|
|
17
|
+
|
|
18
|
+
if (!presetName || !validPresets.includes(presetName.toLowerCase())) {
|
|
19
|
+
console.warn(`⚠️ Invalid preset: ${presetName}. Valid presets: ${validPresets.join(', ')}`);
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const presetPath = path.join(__dirname, '../../policies', `${presetName.toLowerCase()}.json`);
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(presetPath)) {
|
|
26
|
+
console.error(`⚠️ Preset file not found: ${presetPath}`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const content = fs.readFileSync(presetPath, 'utf-8');
|
|
32
|
+
const policy = JSON.parse(content);
|
|
33
|
+
|
|
34
|
+
console.log(`✅ Loaded preset: ${policy.name || presetName}`);
|
|
35
|
+
if (policy.description) {
|
|
36
|
+
console.log(` ${policy.description}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return policy;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`⚠️ Failed to load preset ${presetName}: ${error.message}`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse policy option (file path or preset:name)
|
|
48
|
+
* @param {string} policyOption - File path or 'preset:name'
|
|
49
|
+
* @returns {object|null} Policy object or null
|
|
50
|
+
*/
|
|
51
|
+
function parsePolicyOption(policyOption) {
|
|
52
|
+
// Guard null/undefined
|
|
53
|
+
if (policyOption == null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// If a policy object was passed directly, keep legacy behavior (no implicit acceptance)
|
|
58
|
+
// to avoid changing defaults. Only handle string-like inputs safely.
|
|
59
|
+
const optionStr = typeof policyOption === 'string'
|
|
60
|
+
? policyOption.trim()
|
|
61
|
+
: String(policyOption).trim();
|
|
62
|
+
|
|
63
|
+
if (optionStr.length === 0) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check if it's a preset
|
|
68
|
+
if (optionStr.startsWith('preset:')) {
|
|
69
|
+
const presetName = optionStr.substring(7); // Remove 'preset:' prefix
|
|
70
|
+
return loadPreset(presetName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Otherwise, treat as file path
|
|
74
|
+
if (fs.existsSync(optionStr)) {
|
|
75
|
+
try {
|
|
76
|
+
const content = fs.readFileSync(optionStr, 'utf-8');
|
|
77
|
+
const policy = JSON.parse(content);
|
|
78
|
+
console.log(`✅ Loaded policy from: ${optionStr}`);
|
|
79
|
+
return policy;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`⚠️ Failed to load policy from ${optionStr}: ${error.message}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.warn(`⚠️ Policy file not found: ${optionStr}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* List available presets
|
|
92
|
+
* @returns {array} Array of preset info objects
|
|
93
|
+
*/
|
|
94
|
+
function listPresets() {
|
|
95
|
+
const presetsDir = path.join(__dirname, '../../policies');
|
|
96
|
+
|
|
97
|
+
if (!fs.existsSync(presetsDir)) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const presets = [];
|
|
102
|
+
const files = fs.readdirSync(presetsDir);
|
|
103
|
+
|
|
104
|
+
files.forEach(file => {
|
|
105
|
+
if (file.endsWith('.json')) {
|
|
106
|
+
try {
|
|
107
|
+
const content = fs.readFileSync(path.join(presetsDir, file), 'utf-8');
|
|
108
|
+
const policy = JSON.parse(content);
|
|
109
|
+
presets.push({
|
|
110
|
+
name: file.replace('.json', ''),
|
|
111
|
+
displayName: policy.name || file,
|
|
112
|
+
description: policy.description || 'No description',
|
|
113
|
+
policy
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Skip invalid files
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return presets;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Print preset list to console
|
|
126
|
+
*/
|
|
127
|
+
function printPresets() {
|
|
128
|
+
const presets = listPresets();
|
|
129
|
+
|
|
130
|
+
if (presets.length === 0) {
|
|
131
|
+
console.log('No presets found.');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log('\n📋 Available Policy Presets:\n');
|
|
136
|
+
presets.forEach(preset => {
|
|
137
|
+
console.log(` • ${preset.name}`);
|
|
138
|
+
console.log(` ${preset.description}`);
|
|
139
|
+
console.log(` Usage: --policy preset:${preset.name}\n`);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
loadPreset,
|
|
145
|
+
parsePolicyOption,
|
|
146
|
+
listPresets,
|
|
147
|
+
printPresets
|
|
148
|
+
};
|