@purplesquirrel/guardrails-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +21 -0
- package/.github/workflows/ci.yml +36 -0
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/TROUBLESHOOTING.md +299 -0
- package/package.json +26 -0
- package/src/audit/AuditLogger.js +340 -0
- package/src/engine/GuardrailsEngine.js +305 -0
- package/src/filters/OutputFilter.js +296 -0
- package/src/policies/PolicyEngine.js +337 -0
- package/src/validators/InputValidator.js +291 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OutputFilter - Filters and redacts sensitive information from outputs
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - PII redaction
|
|
6
|
+
* - Secret/credential filtering
|
|
7
|
+
* - Content policy enforcement
|
|
8
|
+
* - Custom pattern masking
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export class OutputFilter {
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
|
|
15
|
+
// Sensitive data patterns with redaction masks
|
|
16
|
+
this.sensitivePatterns = [
|
|
17
|
+
{
|
|
18
|
+
name: 'SSN',
|
|
19
|
+
pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
20
|
+
mask: '[SSN REDACTED]',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'CreditCard',
|
|
24
|
+
pattern: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b/g,
|
|
25
|
+
mask: '[CARD REDACTED]',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'Email',
|
|
29
|
+
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
|
|
30
|
+
mask: '[EMAIL REDACTED]',
|
|
31
|
+
partialMask: (match) => {
|
|
32
|
+
const [local, domain] = match.split('@');
|
|
33
|
+
return `${local[0]}***@${domain}`;
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Phone',
|
|
38
|
+
pattern: /\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
|
|
39
|
+
mask: '[PHONE REDACTED]',
|
|
40
|
+
partialMask: (match) => match.replace(/\d(?=\d{4})/g, '*'),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'IPAddress',
|
|
44
|
+
pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
|
45
|
+
mask: '[IP REDACTED]',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'APIKey',
|
|
49
|
+
pattern: /\b(?:sk-|pk-|api[_-]?key[_-]?)[a-zA-Z0-9]{20,}\b/gi,
|
|
50
|
+
mask: '[API_KEY REDACTED]',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'AWSAccessKey',
|
|
54
|
+
pattern: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
55
|
+
mask: '[AWS_KEY REDACTED]',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'AWSSecretKey',
|
|
59
|
+
pattern: /\b[A-Za-z0-9/+=]{40}\b/g,
|
|
60
|
+
mask: '[SECRET REDACTED]',
|
|
61
|
+
context: /aws|secret|key/i, // Only match if context suggests it's a secret
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'JWTToken',
|
|
65
|
+
pattern: /\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/g,
|
|
66
|
+
mask: '[JWT REDACTED]',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'Password',
|
|
70
|
+
pattern: /(?:password|passwd|pwd|secret)\s*[:=]\s*['"]?[^\s'"]+['"]?/gi,
|
|
71
|
+
mask: '[PASSWORD REDACTED]',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'PrivateKey',
|
|
75
|
+
pattern: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----[\s\S]*?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----/g,
|
|
76
|
+
mask: '[PRIVATE_KEY REDACTED]',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'ConnectionString',
|
|
80
|
+
pattern: /(?:mongodb|postgres|mysql|redis|amqp):\/\/[^\s]+/gi,
|
|
81
|
+
mask: '[CONNECTION_STRING REDACTED]',
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
// Harmful content patterns
|
|
86
|
+
this.harmfulPatterns = [
|
|
87
|
+
{
|
|
88
|
+
name: 'MaliciousCode',
|
|
89
|
+
pattern: /(?:rm\s+-rf|format\s+c:|del\s+\/[fqs]|shutdown|reboot|kill\s+-9)/gi,
|
|
90
|
+
action: 'block',
|
|
91
|
+
message: 'Potentially harmful command detected',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'SQLInjection',
|
|
95
|
+
pattern: /(?:UNION\s+SELECT|DROP\s+TABLE|DELETE\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET)/gi,
|
|
96
|
+
action: 'warn',
|
|
97
|
+
message: 'SQL injection pattern detected',
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// Custom redaction patterns from config
|
|
102
|
+
this.customPatterns = config.sensitiveDataPatterns || [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Filter response content
|
|
107
|
+
* @param {Object|string} response - The response to filter
|
|
108
|
+
* @param {Object} context - Additional context
|
|
109
|
+
* @returns {Object} - Filter result
|
|
110
|
+
*/
|
|
111
|
+
async filter(response, context = {}) {
|
|
112
|
+
const redactions = [];
|
|
113
|
+
let modified = false;
|
|
114
|
+
|
|
115
|
+
// Extract and process text content
|
|
116
|
+
let filteredResponse;
|
|
117
|
+
if (typeof response === 'string') {
|
|
118
|
+
const result = this.filterText(response);
|
|
119
|
+
filteredResponse = result.text;
|
|
120
|
+
redactions.push(...result.redactions);
|
|
121
|
+
modified = result.modified;
|
|
122
|
+
} else {
|
|
123
|
+
filteredResponse = await this.filterObject(response, redactions);
|
|
124
|
+
modified = redactions.length > 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check for harmful content
|
|
128
|
+
const textContent = typeof filteredResponse === 'string'
|
|
129
|
+
? filteredResponse
|
|
130
|
+
: JSON.stringify(filteredResponse);
|
|
131
|
+
|
|
132
|
+
const harmfulCheck = this.checkHarmfulContent(textContent);
|
|
133
|
+
if (harmfulCheck.found) {
|
|
134
|
+
redactions.push(...harmfulCheck.findings.map(f => ({
|
|
135
|
+
type: 'HARMFUL_CONTENT',
|
|
136
|
+
name: f.name,
|
|
137
|
+
action: f.action,
|
|
138
|
+
message: f.message,
|
|
139
|
+
})));
|
|
140
|
+
|
|
141
|
+
// Block if any harmful pattern has action='block'
|
|
142
|
+
if (harmfulCheck.findings.some(f => f.action === 'block')) {
|
|
143
|
+
return {
|
|
144
|
+
response: null,
|
|
145
|
+
modified: true,
|
|
146
|
+
blocked: true,
|
|
147
|
+
redactions,
|
|
148
|
+
message: 'Response blocked due to harmful content',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
response: filteredResponse,
|
|
155
|
+
modified,
|
|
156
|
+
blocked: false,
|
|
157
|
+
redactions,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Filter text content
|
|
163
|
+
*/
|
|
164
|
+
filterText(text) {
|
|
165
|
+
let filtered = text;
|
|
166
|
+
const redactions = [];
|
|
167
|
+
let modified = false;
|
|
168
|
+
|
|
169
|
+
// Apply sensitive data patterns
|
|
170
|
+
for (const pattern of [...this.sensitivePatterns, ...this.customPatterns]) {
|
|
171
|
+
const matches = filtered.match(pattern.pattern);
|
|
172
|
+
if (matches) {
|
|
173
|
+
for (const match of matches) {
|
|
174
|
+
// Check context if specified
|
|
175
|
+
if (pattern.context && !pattern.context.test(filtered)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const replacement = this.config.partialRedaction && pattern.partialMask
|
|
180
|
+
? pattern.partialMask(match)
|
|
181
|
+
: pattern.mask;
|
|
182
|
+
|
|
183
|
+
filtered = filtered.replace(match, replacement);
|
|
184
|
+
redactions.push({
|
|
185
|
+
type: 'SENSITIVE_DATA',
|
|
186
|
+
name: pattern.name,
|
|
187
|
+
originalLength: match.length,
|
|
188
|
+
});
|
|
189
|
+
modified = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { text: filtered, redactions, modified };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Filter object recursively
|
|
199
|
+
*/
|
|
200
|
+
async filterObject(obj, redactions = []) {
|
|
201
|
+
if (obj === null || obj === undefined) {
|
|
202
|
+
return obj;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (typeof obj === 'string') {
|
|
206
|
+
const result = this.filterText(obj);
|
|
207
|
+
redactions.push(...result.redactions);
|
|
208
|
+
return result.text;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (Array.isArray(obj)) {
|
|
212
|
+
return Promise.all(obj.map(item => this.filterObject(item, redactions)));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof obj === 'object') {
|
|
216
|
+
const filtered = {};
|
|
217
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
218
|
+
// Check if key itself is sensitive
|
|
219
|
+
const sensitiveKeys = ['password', 'secret', 'apiKey', 'token', 'credential', 'auth'];
|
|
220
|
+
const isSensitiveKey = sensitiveKeys.some(k =>
|
|
221
|
+
key.toLowerCase().includes(k.toLowerCase())
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
if (isSensitiveKey && typeof value === 'string') {
|
|
225
|
+
filtered[key] = '[REDACTED]';
|
|
226
|
+
redactions.push({
|
|
227
|
+
type: 'SENSITIVE_KEY',
|
|
228
|
+
name: key,
|
|
229
|
+
});
|
|
230
|
+
} else {
|
|
231
|
+
filtered[key] = await this.filterObject(value, redactions);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return filtered;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return obj;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check for harmful content
|
|
242
|
+
*/
|
|
243
|
+
checkHarmfulContent(text) {
|
|
244
|
+
const findings = [];
|
|
245
|
+
|
|
246
|
+
for (const pattern of this.harmfulPatterns) {
|
|
247
|
+
if (pattern.pattern.test(text)) {
|
|
248
|
+
findings.push({
|
|
249
|
+
name: pattern.name,
|
|
250
|
+
action: pattern.action,
|
|
251
|
+
message: pattern.message,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
found: findings.length > 0,
|
|
258
|
+
findings,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Update configuration
|
|
264
|
+
*/
|
|
265
|
+
updateConfig(config) {
|
|
266
|
+
this.config = { ...this.config, ...config };
|
|
267
|
+
if (config.sensitiveDataPatterns) {
|
|
268
|
+
this.customPatterns = config.sensitiveDataPatterns;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Add a custom sensitive pattern
|
|
274
|
+
*/
|
|
275
|
+
addSensitivePattern(name, pattern, mask) {
|
|
276
|
+
this.sensitivePatterns.push({
|
|
277
|
+
name,
|
|
278
|
+
pattern: typeof pattern === 'string' ? new RegExp(pattern, 'g') : pattern,
|
|
279
|
+
mask,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Add a custom harmful pattern
|
|
285
|
+
*/
|
|
286
|
+
addHarmfulPattern(name, pattern, action = 'warn', message = '') {
|
|
287
|
+
this.harmfulPatterns.push({
|
|
288
|
+
name,
|
|
289
|
+
pattern: typeof pattern === 'string' ? new RegExp(pattern, 'gi') : pattern,
|
|
290
|
+
action,
|
|
291
|
+
message: message || `${name} pattern detected`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export default OutputFilter;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PolicyEngine - Enforces security and usage policies
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Role-based access control
|
|
6
|
+
* - Resource access policies
|
|
7
|
+
* - Time-based restrictions
|
|
8
|
+
* - Quota management
|
|
9
|
+
* - Custom rule evaluation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class PolicyEngine {
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.policies = new Map();
|
|
16
|
+
this.quotas = new Map();
|
|
17
|
+
|
|
18
|
+
// Initialize default policies
|
|
19
|
+
this.initializeDefaultPolicies();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initialize default security policies
|
|
24
|
+
*/
|
|
25
|
+
initializeDefaultPolicies() {
|
|
26
|
+
// Default: Block file system access to sensitive paths
|
|
27
|
+
this.addPolicy({
|
|
28
|
+
id: 'block-sensitive-paths',
|
|
29
|
+
name: 'Block Sensitive File Paths',
|
|
30
|
+
description: 'Prevents access to sensitive system paths',
|
|
31
|
+
enabled: true,
|
|
32
|
+
priority: 100,
|
|
33
|
+
condition: (request, context) => {
|
|
34
|
+
const text = JSON.stringify(request).toLowerCase();
|
|
35
|
+
const sensitivePaths = [
|
|
36
|
+
'/etc/passwd', '/etc/shadow', '/etc/hosts',
|
|
37
|
+
'.ssh/', '.aws/', '.env',
|
|
38
|
+
'id_rsa', 'credentials',
|
|
39
|
+
'/var/log/', '/proc/', '/sys/',
|
|
40
|
+
];
|
|
41
|
+
return sensitivePaths.some(path => text.includes(path));
|
|
42
|
+
},
|
|
43
|
+
action: 'deny',
|
|
44
|
+
message: 'Access to sensitive system paths is not allowed',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Default: Block potentially dangerous tool calls
|
|
48
|
+
this.addPolicy({
|
|
49
|
+
id: 'block-dangerous-tools',
|
|
50
|
+
name: 'Block Dangerous Tool Calls',
|
|
51
|
+
description: 'Prevents execution of potentially dangerous operations',
|
|
52
|
+
enabled: true,
|
|
53
|
+
priority: 90,
|
|
54
|
+
condition: (request, context) => {
|
|
55
|
+
const toolName = request.name || request.tool || '';
|
|
56
|
+
const dangerousTools = [
|
|
57
|
+
'shell', 'exec', 'system', 'spawn',
|
|
58
|
+
'eval', 'run_command', 'execute',
|
|
59
|
+
];
|
|
60
|
+
return dangerousTools.some(t =>
|
|
61
|
+
toolName.toLowerCase().includes(t)
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
action: 'deny',
|
|
65
|
+
message: 'Execution of shell commands requires explicit authorization',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Default: Require authentication for sensitive operations
|
|
69
|
+
this.addPolicy({
|
|
70
|
+
id: 'require-auth-sensitive',
|
|
71
|
+
name: 'Require Auth for Sensitive Operations',
|
|
72
|
+
description: 'Requires authentication for sensitive operations',
|
|
73
|
+
enabled: true,
|
|
74
|
+
priority: 80,
|
|
75
|
+
condition: (request, context) => {
|
|
76
|
+
const sensitiveOps = ['delete', 'remove', 'drop', 'truncate', 'destroy'];
|
|
77
|
+
const text = JSON.stringify(request).toLowerCase();
|
|
78
|
+
const isSensitive = sensitiveOps.some(op => text.includes(op));
|
|
79
|
+
return isSensitive && !context.authenticated;
|
|
80
|
+
},
|
|
81
|
+
action: 'deny',
|
|
82
|
+
message: 'Authentication required for sensitive operations',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Default: Rate limit by user
|
|
86
|
+
this.addPolicy({
|
|
87
|
+
id: 'user-quota',
|
|
88
|
+
name: 'User Request Quota',
|
|
89
|
+
description: 'Enforces per-user request quotas',
|
|
90
|
+
enabled: true,
|
|
91
|
+
priority: 70,
|
|
92
|
+
condition: (request, context) => {
|
|
93
|
+
if (!context.userId) return false;
|
|
94
|
+
const quota = this.getQuota(context.userId);
|
|
95
|
+
return quota.used >= quota.limit;
|
|
96
|
+
},
|
|
97
|
+
action: 'deny',
|
|
98
|
+
message: 'User quota exceeded',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Default: Block during maintenance windows
|
|
102
|
+
this.addPolicy({
|
|
103
|
+
id: 'maintenance-window',
|
|
104
|
+
name: 'Maintenance Window',
|
|
105
|
+
description: 'Blocks requests during maintenance windows',
|
|
106
|
+
enabled: false, // Disabled by default
|
|
107
|
+
priority: 200,
|
|
108
|
+
condition: (request, context) => {
|
|
109
|
+
const maintenanceWindows = this.config.maintenanceWindows || [];
|
|
110
|
+
const now = new Date();
|
|
111
|
+
return maintenanceWindows.some(window => {
|
|
112
|
+
const start = new Date(window.start);
|
|
113
|
+
const end = new Date(window.end);
|
|
114
|
+
return now >= start && now <= end;
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
action: 'deny',
|
|
118
|
+
message: 'System is under maintenance',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Default: Allow-list for external URLs
|
|
122
|
+
this.addPolicy({
|
|
123
|
+
id: 'url-allowlist',
|
|
124
|
+
name: 'URL Allow List',
|
|
125
|
+
description: 'Only allows access to approved external URLs',
|
|
126
|
+
enabled: false, // Disabled by default
|
|
127
|
+
priority: 60,
|
|
128
|
+
condition: (request, context) => {
|
|
129
|
+
const allowedDomains = this.config.allowedDomains || [];
|
|
130
|
+
if (allowedDomains.length === 0) return false;
|
|
131
|
+
|
|
132
|
+
const urlPattern = /https?:\/\/([^\/\s]+)/gi;
|
|
133
|
+
const text = JSON.stringify(request);
|
|
134
|
+
const matches = text.matchAll(urlPattern);
|
|
135
|
+
|
|
136
|
+
for (const match of matches) {
|
|
137
|
+
const domain = match[1].toLowerCase();
|
|
138
|
+
const isAllowed = allowedDomains.some(allowed =>
|
|
139
|
+
domain === allowed || domain.endsWith('.' + allowed)
|
|
140
|
+
);
|
|
141
|
+
if (!isAllowed) return true; // Block if not in allowlist
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
},
|
|
145
|
+
action: 'deny',
|
|
146
|
+
message: 'External URL not in allowed list',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Evaluate all policies against a request
|
|
152
|
+
* @param {Object} request - The request to evaluate
|
|
153
|
+
* @param {Object} context - Additional context
|
|
154
|
+
* @returns {Object} - Evaluation result
|
|
155
|
+
*/
|
|
156
|
+
async evaluate(request, context = {}) {
|
|
157
|
+
// Get enabled policies sorted by priority (higher first)
|
|
158
|
+
const enabledPolicies = Array.from(this.policies.values())
|
|
159
|
+
.filter(p => p.enabled)
|
|
160
|
+
.sort((a, b) => b.priority - a.priority);
|
|
161
|
+
|
|
162
|
+
for (const policy of enabledPolicies) {
|
|
163
|
+
try {
|
|
164
|
+
const conditionResult = await Promise.resolve(
|
|
165
|
+
policy.condition(request, context)
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (conditionResult) {
|
|
169
|
+
// Policy condition matched
|
|
170
|
+
if (policy.action === 'deny') {
|
|
171
|
+
return {
|
|
172
|
+
allowed: false,
|
|
173
|
+
reason: policy.message,
|
|
174
|
+
violatedPolicy: {
|
|
175
|
+
id: policy.id,
|
|
176
|
+
name: policy.name,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
} else if (policy.action === 'warn') {
|
|
180
|
+
// Log warning but allow
|
|
181
|
+
console.warn(`Policy warning [${policy.id}]: ${policy.message}`);
|
|
182
|
+
} else if (policy.action === 'modify') {
|
|
183
|
+
// Allow policy to modify request
|
|
184
|
+
if (policy.modify) {
|
|
185
|
+
request = await Promise.resolve(policy.modify(request, context));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error(`Policy evaluation error [${policy.id}]:`, error.message);
|
|
191
|
+
// Fail closed on policy evaluation errors
|
|
192
|
+
if (this.config.failClosed !== false) {
|
|
193
|
+
return {
|
|
194
|
+
allowed: false,
|
|
195
|
+
reason: 'Policy evaluation error',
|
|
196
|
+
error: error.message,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { allowed: true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Add a policy
|
|
207
|
+
*/
|
|
208
|
+
addPolicy(policy) {
|
|
209
|
+
if (!policy.id) {
|
|
210
|
+
policy.id = `policy_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
211
|
+
}
|
|
212
|
+
this.policies.set(policy.id, {
|
|
213
|
+
enabled: true,
|
|
214
|
+
priority: 50,
|
|
215
|
+
action: 'deny',
|
|
216
|
+
...policy,
|
|
217
|
+
});
|
|
218
|
+
return policy.id;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Remove a policy
|
|
223
|
+
*/
|
|
224
|
+
removePolicy(policyId) {
|
|
225
|
+
return this.policies.delete(policyId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Enable/disable a policy
|
|
230
|
+
*/
|
|
231
|
+
setEnabled(policyId, enabled) {
|
|
232
|
+
const policy = this.policies.get(policyId);
|
|
233
|
+
if (policy) {
|
|
234
|
+
policy.enabled = enabled;
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get all policies
|
|
242
|
+
*/
|
|
243
|
+
getPolicies() {
|
|
244
|
+
return Array.from(this.policies.values());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get policy by ID
|
|
249
|
+
*/
|
|
250
|
+
getPolicy(policyId) {
|
|
251
|
+
return this.policies.get(policyId);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Update configuration
|
|
256
|
+
*/
|
|
257
|
+
updateConfig(config) {
|
|
258
|
+
this.config = { ...this.config, ...config };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Set quota for a user
|
|
263
|
+
*/
|
|
264
|
+
setQuota(userId, limit) {
|
|
265
|
+
this.quotas.set(userId, { limit, used: 0, resetAt: Date.now() + 86400000 });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get quota for a user
|
|
270
|
+
*/
|
|
271
|
+
getQuota(userId) {
|
|
272
|
+
if (!this.quotas.has(userId)) {
|
|
273
|
+
this.setQuota(userId, this.config.defaultQuota || 1000);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const quota = this.quotas.get(userId);
|
|
277
|
+
|
|
278
|
+
// Reset quota if period expired
|
|
279
|
+
if (Date.now() > quota.resetAt) {
|
|
280
|
+
quota.used = 0;
|
|
281
|
+
quota.resetAt = Date.now() + 86400000;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return quota;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Increment quota usage
|
|
289
|
+
*/
|
|
290
|
+
incrementQuota(userId, amount = 1) {
|
|
291
|
+
const quota = this.getQuota(userId);
|
|
292
|
+
quota.used += amount;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Create a policy from a simple rule definition
|
|
297
|
+
*/
|
|
298
|
+
createSimplePolicy(rule) {
|
|
299
|
+
const { id, name, description, pattern, action = 'deny', message, field = 'all' } = rule;
|
|
300
|
+
|
|
301
|
+
return this.addPolicy({
|
|
302
|
+
id,
|
|
303
|
+
name,
|
|
304
|
+
description,
|
|
305
|
+
enabled: true,
|
|
306
|
+
priority: 50,
|
|
307
|
+
action,
|
|
308
|
+
message: message || `Rule ${name} violated`,
|
|
309
|
+
condition: (request, context) => {
|
|
310
|
+
let text;
|
|
311
|
+
if (field === 'all') {
|
|
312
|
+
text = JSON.stringify(request);
|
|
313
|
+
} else {
|
|
314
|
+
text = request[field] || '';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
|
|
318
|
+
return regex.test(text);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Load policies from JSON configuration
|
|
325
|
+
*/
|
|
326
|
+
loadPolicies(policiesConfig) {
|
|
327
|
+
for (const config of policiesConfig) {
|
|
328
|
+
if (config.type === 'simple') {
|
|
329
|
+
this.createSimplePolicy(config);
|
|
330
|
+
} else {
|
|
331
|
+
this.addPolicy(config);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export default PolicyEngine;
|