palaryn 0.5.7 → 0.6.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/dist/src/billing/plan-enforcer.d.ts.map +1 -1
- package/dist/src/billing/plan-enforcer.js +0 -2
- package/dist/src/billing/plan-enforcer.js.map +1 -1
- package/dist/src/config/defaults.js +1 -1
- package/dist/src/config/defaults.js.map +1 -1
- package/dist/src/dlp/circuit-breaker.d.ts +44 -0
- package/dist/src/dlp/circuit-breaker.d.ts.map +1 -0
- package/dist/src/dlp/circuit-breaker.js +69 -0
- package/dist/src/dlp/circuit-breaker.js.map +1 -0
- package/dist/src/dlp/deberta-backend.d.ts +2 -0
- package/dist/src/dlp/deberta-backend.d.ts.map +1 -1
- package/dist/src/dlp/deberta-backend.js +21 -3
- package/dist/src/dlp/deberta-backend.js.map +1 -1
- package/dist/src/dlp/exfiltration-backend.d.ts.map +1 -1
- package/dist/src/dlp/exfiltration-backend.js +10 -0
- package/dist/src/dlp/exfiltration-backend.js.map +1 -1
- package/dist/src/dlp/index.d.ts +2 -0
- package/dist/src/dlp/index.d.ts.map +1 -1
- package/dist/src/dlp/index.js +5 -1
- package/dist/src/dlp/index.js.map +1 -1
- package/dist/src/dlp/llm-classifier.d.ts +8 -1
- package/dist/src/dlp/llm-classifier.d.ts.map +1 -1
- package/dist/src/dlp/llm-classifier.js +138 -61
- package/dist/src/dlp/llm-classifier.js.map +1 -1
- package/dist/src/dlp/multipart-extractor.d.ts +20 -0
- package/dist/src/dlp/multipart-extractor.d.ts.map +1 -0
- package/dist/src/dlp/multipart-extractor.js +60 -0
- package/dist/src/dlp/multipart-extractor.js.map +1 -0
- package/dist/src/dlp/navigation-instruction-backend.d.ts +6 -0
- package/dist/src/dlp/navigation-instruction-backend.d.ts.map +1 -0
- package/dist/src/dlp/navigation-instruction-backend.js +286 -0
- package/dist/src/dlp/navigation-instruction-backend.js.map +1 -0
- package/dist/src/dlp/nemo-backend.d.ts +2 -0
- package/dist/src/dlp/nemo-backend.d.ts.map +1 -1
- package/dist/src/dlp/nemo-backend.js +8 -0
- package/dist/src/dlp/nemo-backend.js.map +1 -1
- package/dist/src/dlp/prompt-injection-patterns.d.ts.map +1 -1
- package/dist/src/dlp/prompt-injection-patterns.js +36 -0
- package/dist/src/dlp/prompt-injection-patterns.js.map +1 -1
- package/dist/src/dlp/text-normalizer.d.ts +2 -15
- package/dist/src/dlp/text-normalizer.d.ts.map +1 -1
- package/dist/src/dlp/text-normalizer.js +34 -7
- package/dist/src/dlp/text-normalizer.js.map +1 -1
- package/dist/src/dlp/tool-patterns.d.ts +12 -0
- package/dist/src/dlp/tool-patterns.d.ts.map +1 -1
- package/dist/src/dlp/tool-patterns.js +61 -1
- package/dist/src/dlp/tool-patterns.js.map +1 -1
- package/dist/src/executor/filesystem-executor.d.ts +5 -5
- package/dist/src/executor/filesystem-executor.d.ts.map +1 -1
- package/dist/src/executor/filesystem-executor.js +43 -0
- package/dist/src/executor/filesystem-executor.js.map +1 -1
- package/dist/src/metrics/collector.d.ts +5 -0
- package/dist/src/metrics/collector.d.ts.map +1 -1
- package/dist/src/metrics/collector.js +14 -0
- package/dist/src/metrics/collector.js.map +1 -1
- package/dist/src/policy/engine.d.ts.map +1 -1
- package/dist/src/policy/engine.js +39 -3
- package/dist/src/policy/engine.js.map +1 -1
- package/dist/src/policy/opa-engine.d.ts.map +1 -1
- package/dist/src/policy/opa-engine.js +2 -1
- package/dist/src/policy/opa-engine.js.map +1 -1
- package/dist/src/server/app.d.ts.map +1 -1
- package/dist/src/server/app.js +17 -9
- package/dist/src/server/app.js.map +1 -1
- package/dist/src/server/gateway.d.ts +4 -0
- package/dist/src/server/gateway.d.ts.map +1 -1
- package/dist/src/server/gateway.js +146 -4
- package/dist/src/server/gateway.js.map +1 -1
- package/dist/src/types/config.d.ts +9 -0
- package/dist/src/types/config.d.ts.map +1 -1
- package/dist/src/types/policy.d.ts +4 -0
- package/dist/src/types/policy.d.ts.map +1 -1
- package/dist/src/types/tool-call.d.ts +4 -0
- package/dist/src/types/tool-call.d.ts.map +1 -1
- package/dist/tests/integration/navigation-chain.test.d.ts +9 -0
- package/dist/tests/integration/navigation-chain.test.d.ts.map +1 -0
- package/dist/tests/integration/navigation-chain.test.js +474 -0
- package/dist/tests/integration/navigation-chain.test.js.map +1 -0
- package/dist/tests/unit/adversarial-pipeline.test.js +173 -15
- package/dist/tests/unit/adversarial-pipeline.test.js.map +1 -1
- package/dist/tests/unit/cli.test.js +3 -7
- package/dist/tests/unit/cli.test.js.map +1 -1
- package/dist/tests/unit/filesystem-executor.test.js +88 -0
- package/dist/tests/unit/filesystem-executor.test.js.map +1 -1
- package/dist/tests/unit/multipart-extractor.test.d.ts +2 -0
- package/dist/tests/unit/multipart-extractor.test.d.ts.map +1 -0
- package/dist/tests/unit/multipart-extractor.test.js +118 -0
- package/dist/tests/unit/multipart-extractor.test.js.map +1 -0
- package/dist/tests/unit/navigation-instruction-backend.test.d.ts +8 -0
- package/dist/tests/unit/navigation-instruction-backend.test.d.ts.map +1 -0
- package/dist/tests/unit/navigation-instruction-backend.test.js +561 -0
- package/dist/tests/unit/navigation-instruction-backend.test.js.map +1 -0
- package/dist/tests/unit/policy-engine.test.js +314 -1
- package/dist/tests/unit/policy-engine.test.js.map +1 -1
- package/dist/tests/unit/prompt-injection-backend.test.js +1 -1
- package/dist/tests/unit/prompt-injection-backend.test.js.map +1 -1
- package/package.json +3 -2
- package/policy-packs/default.yaml +76 -0
- package/src/billing/plan-enforcer.ts +0 -2
- package/src/config/defaults.ts +1 -1
- package/src/dlp/circuit-breaker.ts +83 -0
- package/src/dlp/deberta-backend.ts +21 -3
- package/src/dlp/exfiltration-backend.ts +11 -0
- package/src/dlp/index.ts +2 -0
- package/src/dlp/llm-classifier.ts +148 -66
- package/src/dlp/multipart-extractor.ts +66 -0
- package/src/dlp/navigation-instruction-backend.ts +309 -0
- package/src/dlp/nemo-backend.ts +10 -0
- package/src/dlp/prompt-injection-patterns.ts +37 -0
- package/src/dlp/text-normalizer.ts +36 -7
- package/src/dlp/tool-patterns.ts +63 -0
- package/src/executor/filesystem-executor.ts +51 -0
- package/src/metrics/collector.ts +17 -0
- package/src/policy/engine.ts +39 -3
- package/src/policy/opa-engine.ts +2 -1
- package/src/server/app.ts +19 -10
- package/src/server/gateway.ts +155 -4
- package/src/types/config.ts +9 -0
- package/src/types/policy.ts +5 -0
- package/src/types/tool-call.ts +4 -0
package/src/dlp/nemo-backend.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
2
|
import { DLPBackend, DLPDetection } from './interfaces';
|
|
3
3
|
import { DLPSeverity } from '../types/tool-result';
|
|
4
|
+
import { CircuitBreaker } from './circuit-breaker';
|
|
4
5
|
|
|
5
6
|
export interface NemoGuardrailsConfig {
|
|
6
7
|
/** NeMo Guardrails API URL (e.g. 'http://nemo:8000'). */
|
|
@@ -25,15 +26,22 @@ export class NemoGuardrailsBackend implements DLPBackend {
|
|
|
25
26
|
|
|
26
27
|
private readonly apiUrl: string;
|
|
27
28
|
private readonly timeoutMs: number;
|
|
29
|
+
readonly circuitBreaker: CircuitBreaker;
|
|
28
30
|
|
|
29
31
|
constructor(config: NemoGuardrailsConfig) {
|
|
30
32
|
this.apiUrl = config.api_url.replace(/\/+$/, '');
|
|
31
33
|
this.timeoutMs = config.timeout_ms ?? 5000;
|
|
34
|
+
this.circuitBreaker = new CircuitBreaker({ name: 'nemo', failureThreshold: 5, resetTimeoutMs: 30_000 });
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
scanString(value: string): DLPDetection[] {
|
|
35
38
|
if (!value || value.length < 5) return [];
|
|
36
39
|
|
|
40
|
+
if (!this.circuitBreaker.allowRequest()) {
|
|
41
|
+
console.warn(`[NemoGuardrailsBackend] circuit OPEN — skipping external call`);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
try {
|
|
38
46
|
const payload = JSON.stringify({
|
|
39
47
|
messages: [{ role: 'user', content: value }],
|
|
@@ -53,8 +61,10 @@ export class NemoGuardrailsBackend implements DLPBackend {
|
|
|
53
61
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
54
62
|
});
|
|
55
63
|
|
|
64
|
+
this.circuitBreaker.recordSuccess();
|
|
56
65
|
return this.parseResponse(stdout, value);
|
|
57
66
|
} catch (err: unknown) {
|
|
67
|
+
this.circuitBreaker.recordFailure();
|
|
58
68
|
const message = err instanceof Error ? err.message : String(err);
|
|
59
69
|
console.warn(`[NemoGuardrailsBackend] scan failed: ${message}`);
|
|
60
70
|
return [];
|
|
@@ -401,6 +401,43 @@ export const PROMPT_INJECTION_PATTERNS: DLPPattern[] = [
|
|
|
401
401
|
pattern: /(?:you\s+have\s+)?no\s+(?:rules|limits|limitations|boundaries|restrictions|ethical\s+guidelines|safety\s+(?:measures|protocols|guidelines))/gi,
|
|
402
402
|
severity: 'medium',
|
|
403
403
|
},
|
|
404
|
+
|
|
405
|
+
// -----------------------------------------------------------------------
|
|
406
|
+
// Category 18: Policy/config self-modification (high)
|
|
407
|
+
// Detects instructions to modify the agent's own policy, config, or
|
|
408
|
+
// governance files — the most dangerous form of prompt injection as it
|
|
409
|
+
// can disable all security controls.
|
|
410
|
+
// -----------------------------------------------------------------------
|
|
411
|
+
{
|
|
412
|
+
name: 'prompt_injection_modify_policy',
|
|
413
|
+
pattern: /(?:modify|change|update|edit|overwrite|replace|rewrite|alter)\s+(?:the\s+|your\s+)?(?:policy|policies|policy[\s_-]*(?:pack|file|yaml|config|rules?))/gi,
|
|
414
|
+
severity: 'high',
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'prompt_injection_write_policy_file',
|
|
418
|
+
pattern: /(?:write|save|create|output|put|dump)\s+(?:this\s+|the\s+following\s+)?(?:to|into|in)\s+(?:the\s+)?(?:policy|config|yaml|yml|configuration)\s*(?:file|pack)?/gi,
|
|
419
|
+
severity: 'high',
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
name: 'prompt_injection_disable_security',
|
|
423
|
+
pattern: /(?:disable|turn\s+off|deactivate|remove|bypass|skip)\s+(?:all\s+)?(?:the\s+)?(?:security|DLP|firewall|policy|protection|enforcement|validation|rate[\s_-]*limit|budget[\s_-]*check|approval)/gi,
|
|
424
|
+
severity: 'high',
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
name: 'prompt_injection_allow_all_policy',
|
|
428
|
+
pattern: /(?:set|change|make|switch)\s+(?:the\s+)?(?:default\s+)?(?:policy|effect|rule)\s+(?:to\s+)?(?:allow[\s_-]*all|permissive|allow\s+everything)/gi,
|
|
429
|
+
severity: 'high',
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: 'prompt_injection_policy_pack_path',
|
|
433
|
+
pattern: /policy[\s_-]*packs?\/[^\s"']+\.ya?ml/gi,
|
|
434
|
+
severity: 'high',
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: 'prompt_injection_remove_rules',
|
|
438
|
+
pattern: /(?:remove|delete|clear|empty|wipe)\s+(?:all\s+)?(?:the\s+)?(?:policy\s+)?(?:rules?|restrictions?|blocklist|denylist|deny\s+rules?)/gi,
|
|
439
|
+
severity: 'high',
|
|
440
|
+
},
|
|
404
441
|
];
|
|
405
442
|
|
|
406
443
|
// ---------------------------------------------------------------------------
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* into canonical ASCII text before pattern matching.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
10
|
+
const punycode = require('punycode/');
|
|
11
|
+
|
|
9
12
|
// ---------------------------------------------------------------------------
|
|
10
13
|
// Zero-width character stripping
|
|
11
14
|
// ---------------------------------------------------------------------------
|
|
@@ -13,9 +16,10 @@
|
|
|
13
16
|
/** Regex matching zero-width and invisible Unicode characters.
|
|
14
17
|
* Comprehensive list covering: zero-width spaces/joiners, soft hyphen, BOM,
|
|
15
18
|
* directional marks/isolates, word joiners, invisible operators,
|
|
16
|
-
* combining grapheme joiner, Arabic letter mark,
|
|
19
|
+
* combining grapheme joiner, Arabic letter mark, deprecated formatting chars,
|
|
20
|
+
* and Unicode Tags block (U+E0001-U+E007F, deprecated language tags).
|
|
17
21
|
*/
|
|
18
|
-
export const ZERO_WIDTH_REGEX = /[\u200B\u200C\u200D\u00AD\uFEFF\u200E\u200F\u034F\u061C\u180E\u2060\u2061\u2062\u2063\u2064\u2066\u2067\u2068\u2069\u206A\u206B\u206C\u206D\u206E\u206F]/
|
|
22
|
+
export const ZERO_WIDTH_REGEX = /[\u200B\u200C\u200D\u00AD\uFEFF\u200E\u200F\u034F\u061C\u180E\u2060\u2061\u2062\u2063\u2064\u2066\u2067\u2068\u2069\u206A\u206B\u206C\u206D\u206E\u206F\u{E0001}-\u{E007F}]/gu;
|
|
19
23
|
|
|
20
24
|
// ---------------------------------------------------------------------------
|
|
21
25
|
// Homoglyph map (visually similar characters -> ASCII equivalents)
|
|
@@ -263,23 +267,39 @@ function stripEmojisBetweenLetters(input: string): string {
|
|
|
263
267
|
* Normalize text for bypass-resistant pattern matching.
|
|
264
268
|
*
|
|
265
269
|
* Applies transformations in order:
|
|
266
|
-
* 1. Strip zero-width / invisible Unicode characters
|
|
270
|
+
* 1. Strip zero-width / invisible Unicode characters (incl. Unicode Tags block)
|
|
267
271
|
* 2. Unicode NFKC normalization (collapses fullwidth, ligatures, etc.)
|
|
268
272
|
* 3. Decode HTML entities (named + numeric)
|
|
269
|
-
* 4. Decode URL percent-encoding
|
|
273
|
+
* 4. Decode URL percent-encoding (multi-pass, up to 3 iterations)
|
|
274
|
+
* 4.5. Decode Punycode domains (xn-- labels -> Unicode -> homoglyph collapse)
|
|
270
275
|
* 5. Collapse homoglyphs (Cyrillic/Greek lookalikes -> ASCII)
|
|
271
276
|
* 6. Collapse repeated whitespace to single space
|
|
272
277
|
*
|
|
273
278
|
* @param input - The raw text to normalize.
|
|
274
279
|
* @returns The normalized text suitable for pattern matching.
|
|
275
280
|
*/
|
|
281
|
+
/**
|
|
282
|
+
* Decode Punycode (xn--) domain labels to Unicode.
|
|
283
|
+
* Enables homoglyph normalization to collapse Punycode-encoded lookalike domains.
|
|
284
|
+
*/
|
|
285
|
+
function decodePunycodeDomains(input: string): string {
|
|
286
|
+
// Match xn-- labels in domain-like contexts (dot-separated labels)
|
|
287
|
+
return input.replace(/\bxn--[a-z0-9-]+(?:\.[a-z0-9-]+)*/gi, (domain) => {
|
|
288
|
+
try {
|
|
289
|
+
return punycode.toUnicode(domain);
|
|
290
|
+
} catch {
|
|
291
|
+
return domain; // Leave as-is if decode fails
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
276
296
|
export function normalizeText(input: string): string {
|
|
277
297
|
// Early exit for very short strings
|
|
278
298
|
if (input.length === 0) return input;
|
|
279
299
|
|
|
280
300
|
let text = input;
|
|
281
301
|
|
|
282
|
-
// 1. Strip zero-width characters
|
|
302
|
+
// 1. Strip zero-width characters (including Unicode Tags block U+E0001-E007F)
|
|
283
303
|
text = text.replace(ZERO_WIDTH_REGEX, '');
|
|
284
304
|
|
|
285
305
|
// 2. NFKC normalization (fullwidth -> ASCII, ligatures -> components, etc.)
|
|
@@ -296,8 +316,17 @@ export function normalizeText(input: string): string {
|
|
|
296
316
|
// 3. Decode HTML entities
|
|
297
317
|
text = decodeHTMLEntities(text);
|
|
298
318
|
|
|
299
|
-
// 4. Decode URL percent-encoding
|
|
300
|
-
|
|
319
|
+
// 4. Decode URL percent-encoding (multi-pass for layered encoding like %2569 -> %69 -> i)
|
|
320
|
+
let prevText: string;
|
|
321
|
+
let passes = 0;
|
|
322
|
+
do {
|
|
323
|
+
prevText = text;
|
|
324
|
+
text = decodeURLEncoding(text);
|
|
325
|
+
passes++;
|
|
326
|
+
} while (text !== prevText && passes < 3);
|
|
327
|
+
|
|
328
|
+
// 4.5 Decode Punycode domains (xn-- labels -> Unicode for homoglyph matching)
|
|
329
|
+
text = decodePunycodeDomains(text);
|
|
301
330
|
|
|
302
331
|
// 5. Collapse homoglyphs
|
|
303
332
|
text = text.replace(homoglyphRegex, (ch) => HOMOGLYPH_MAP[ch] || ch);
|
package/src/dlp/tool-patterns.ts
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
import { DLPPattern } from './patterns';
|
|
2
|
+
import { DLPBackend, DLPDetection } from './interfaces';
|
|
3
|
+
import { DLPSeverity } from '../types/tool-result';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DLP backend that scans for sensitive file paths in tool arguments.
|
|
7
|
+
* Detects references to policy packs, .env files, and governance config
|
|
8
|
+
* that could indicate a policy self-modification attack.
|
|
9
|
+
*/
|
|
10
|
+
export class SensitiveFileBackend implements DLPBackend {
|
|
11
|
+
readonly name = 'sensitive_file';
|
|
12
|
+
|
|
13
|
+
scanString(value: string): DLPDetection[] {
|
|
14
|
+
const detections: DLPDetection[] = [];
|
|
15
|
+
if (value.length < 4) return detections;
|
|
16
|
+
|
|
17
|
+
for (const pat of SENSITIVE_FILE_PATTERNS) {
|
|
18
|
+
pat.pattern.lastIndex = 0;
|
|
19
|
+
let m: RegExpExecArray | null;
|
|
20
|
+
while ((m = pat.pattern.exec(value)) !== null) {
|
|
21
|
+
detections.push({
|
|
22
|
+
pattern_name: pat.name,
|
|
23
|
+
severity: pat.severity as DLPSeverity,
|
|
24
|
+
match: m[0],
|
|
25
|
+
start: m.index,
|
|
26
|
+
end: m.index + m[0].length,
|
|
27
|
+
});
|
|
28
|
+
if (m[0].length === 0) {
|
|
29
|
+
pat.pattern.lastIndex++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
pat.pattern.lastIndex = 0;
|
|
33
|
+
}
|
|
34
|
+
return detections;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
2
37
|
|
|
3
38
|
// Shell injection patterns
|
|
4
39
|
export const SHELL_INJECTION_PATTERNS: DLPPattern[] = [
|
|
@@ -13,9 +48,30 @@ export const SHELL_INJECTION_PATTERNS: DLPPattern[] = [
|
|
|
13
48
|
// Path traversal patterns
|
|
14
49
|
export const PATH_TRAVERSAL_PATTERNS: DLPPattern[] = [
|
|
15
50
|
{ name: 'path_traversal', pattern: /\.\.\//g, severity: 'high' },
|
|
51
|
+
{ name: 'path_traversal_backslash', pattern: /\.\.\\/g, severity: 'high' },
|
|
16
52
|
{ name: 'path_traversal_encoded', pattern: /%2e%2e%2f/gi, severity: 'high' },
|
|
53
|
+
{ name: 'path_traversal_double_encoded', pattern: /%252e%252e/gi, severity: 'high' },
|
|
17
54
|
{ name: 'path_null_byte', pattern: /%00/g, severity: 'high' },
|
|
18
55
|
{ name: 'path_absolute_unix', pattern: /^\/(?:etc|proc|sys|dev|root|var\/log)\//g, severity: 'high' },
|
|
56
|
+
{ name: 'path_home_sensitive', pattern: /~\/\./g, severity: 'high' },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Sensitive file access patterns
|
|
60
|
+
export const SENSITIVE_FILE_PATTERNS: DLPPattern[] = [
|
|
61
|
+
{ name: 'sensitive_file_ssh', pattern: /\.ssh\/(?:id_rsa|id_ed25519|authorized_keys|known_hosts|config)/gi, severity: 'high' },
|
|
62
|
+
{ name: 'sensitive_file_aws', pattern: /\.aws\/(?:credentials|config)/gi, severity: 'high' },
|
|
63
|
+
{ name: 'sensitive_file_kube', pattern: /\.kube\/config/gi, severity: 'high' },
|
|
64
|
+
{ name: 'sensitive_file_terraform', pattern: /\.terraform\//gi, severity: 'high' },
|
|
65
|
+
{ name: 'sensitive_file_docker', pattern: /\.docker\/config\.json/gi, severity: 'high' },
|
|
66
|
+
{ name: 'sensitive_file_npmrc', pattern: /\.npmrc/gi, severity: 'medium' },
|
|
67
|
+
{ name: 'sensitive_file_gitconfig', pattern: /\.gitconfig/gi, severity: 'low' },
|
|
68
|
+
{ name: 'sensitive_file_shadow', pattern: /\/etc\/shadow/gi, severity: 'high' },
|
|
69
|
+
{ name: 'sensitive_file_shell_rc', pattern: /\.(?:bashrc|zshrc|profile|bash_history|zsh_history)/gi, severity: 'medium' },
|
|
70
|
+
// Palaryn governance / policy files — modification disables security controls
|
|
71
|
+
{ name: 'sensitive_file_policy_pack', pattern: /policy[\s_-]*packs?\/[^\s"']*\.ya?ml/gi, severity: 'high' },
|
|
72
|
+
{ name: 'sensitive_file_policy_yaml', pattern: /(?:^|[\\/])(?:policy|policies|rules)\.ya?ml/gi, severity: 'high' },
|
|
73
|
+
{ name: 'sensitive_file_env', pattern: /(?:^|[\\/\s"'])\.env(?:\.\w+)?(?=$|[\s"'\\/])/gi, severity: 'high' },
|
|
74
|
+
{ name: 'sensitive_file_palaryn_config', pattern: /palaryn[\s_-]*(?:config|settings)\.(?:ya?ml|json|toml)/gi, severity: 'high' },
|
|
19
75
|
];
|
|
20
76
|
|
|
21
77
|
// SQL injection patterns
|
|
@@ -27,9 +83,16 @@ export const SQL_INJECTION_PATTERNS: DLPPattern[] = [
|
|
|
27
83
|
{ name: 'sql_info_schema', pattern: /INFORMATION_SCHEMA/gi, severity: 'high' },
|
|
28
84
|
];
|
|
29
85
|
|
|
86
|
+
// Data exfiltration size check (body > 5KB with external URL)
|
|
87
|
+
export const DATA_EXFIL_PATTERNS: DLPPattern[] = [
|
|
88
|
+
{ name: 'shell_curl_wget', pattern: /\b(?:curl|wget)\s+https?:\/\//gi, severity: 'high' },
|
|
89
|
+
];
|
|
90
|
+
|
|
30
91
|
/** All tool-specific DLP patterns combined */
|
|
31
92
|
export const TOOL_DLP_PATTERNS: DLPPattern[] = [
|
|
32
93
|
...SHELL_INJECTION_PATTERNS,
|
|
33
94
|
...PATH_TRAVERSAL_PATTERNS,
|
|
95
|
+
...SENSITIVE_FILE_PATTERNS,
|
|
34
96
|
...SQL_INJECTION_PATTERNS,
|
|
97
|
+
...DATA_EXFIL_PATTERNS,
|
|
35
98
|
];
|
|
@@ -10,6 +10,24 @@ import { FilesystemExecutorConfig } from '../types/config';
|
|
|
10
10
|
* Handles tool calls with tool name `file.*` (e.g., file.read, file.write).
|
|
11
11
|
* All paths are resolved relative to and contained within base_dir.
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* Hardcoded protected path patterns that CANNOT be written to or deleted.
|
|
15
|
+
* This is a defense-in-depth measure against policy self-modification attacks
|
|
16
|
+
* where a prompt injection instructs the agent to overwrite governance files.
|
|
17
|
+
* These paths are blocked regardless of config, allowlist, or policy settings.
|
|
18
|
+
*/
|
|
19
|
+
const PROTECTED_PATH_PATTERNS = [
|
|
20
|
+
/policy[\s_-]*packs?\//i,
|
|
21
|
+
/\.env(?:\.\w+)?$/i,
|
|
22
|
+
/palaryn[\s_-]*config\./i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const PROTECTED_FILENAMES = [
|
|
26
|
+
'policy.yaml', 'policy.yml', 'policies.yaml', 'policies.yml',
|
|
27
|
+
'rules.yaml', 'rules.yml',
|
|
28
|
+
'.env', '.env.local', '.env.production', '.env.development',
|
|
29
|
+
];
|
|
30
|
+
|
|
13
31
|
export class FilesystemExecutor implements ToolExecutor {
|
|
14
32
|
private config: FilesystemExecutorConfig;
|
|
15
33
|
private resolvedBaseDir: string;
|
|
@@ -19,6 +37,29 @@ export class FilesystemExecutor implements ToolExecutor {
|
|
|
19
37
|
this.resolvedBaseDir = path.resolve(config.base_dir);
|
|
20
38
|
}
|
|
21
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Check if a file path targets a protected governance/config file.
|
|
42
|
+
* This is a hardcoded safeguard that cannot be disabled by configuration.
|
|
43
|
+
*/
|
|
44
|
+
private isProtectedPath(filePath: string): boolean {
|
|
45
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
46
|
+
const basename = path.basename(normalized).toLowerCase();
|
|
47
|
+
|
|
48
|
+
// Check against protected filenames
|
|
49
|
+
if (PROTECTED_FILENAMES.includes(basename)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check against protected path patterns
|
|
54
|
+
for (const pattern of PROTECTED_PATH_PATTERNS) {
|
|
55
|
+
if (pattern.test(normalized)) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
22
63
|
async execute(toolCall: ToolCall): Promise<ToolOutput> {
|
|
23
64
|
const action = this.resolveAction(toolCall);
|
|
24
65
|
|
|
@@ -102,6 +143,11 @@ export class FilesystemExecutor implements ToolExecutor {
|
|
|
102
143
|
if (!filePath || typeof filePath !== 'string') {
|
|
103
144
|
throw new Error('Missing or invalid "path" argument for file.write');
|
|
104
145
|
}
|
|
146
|
+
|
|
147
|
+
// Hardcoded protection: block writes to governance/policy/config files
|
|
148
|
+
if (this.isProtectedPath(filePath)) {
|
|
149
|
+
throw new Error(`Write denied: "${filePath}" is a protected governance/config file and cannot be modified by agent tool calls`);
|
|
150
|
+
}
|
|
105
151
|
if (content === undefined || content === null) {
|
|
106
152
|
throw new Error('Missing "content" argument for file.write');
|
|
107
153
|
}
|
|
@@ -138,6 +184,11 @@ export class FilesystemExecutor implements ToolExecutor {
|
|
|
138
184
|
throw new Error('Missing or invalid "path" argument for file.delete');
|
|
139
185
|
}
|
|
140
186
|
|
|
187
|
+
// Hardcoded protection: block deleting governance/policy/config files
|
|
188
|
+
if (this.isProtectedPath(filePath)) {
|
|
189
|
+
throw new Error(`Delete denied: "${filePath}" is a protected governance/config file and cannot be deleted by agent tool calls`);
|
|
190
|
+
}
|
|
191
|
+
|
|
141
192
|
const resolved = this.resolveSafePath(filePath);
|
|
142
193
|
|
|
143
194
|
const stat = await fs.stat(resolved);
|
package/src/metrics/collector.ts
CHANGED
|
@@ -31,6 +31,7 @@ export class GatewayMetrics {
|
|
|
31
31
|
// Histograms
|
|
32
32
|
private requestDuration: promClient.Histogram;
|
|
33
33
|
private costPerRequest: promClient.Histogram;
|
|
34
|
+
private policyEvaluationSeconds: promClient.Histogram;
|
|
34
35
|
|
|
35
36
|
// Gauge
|
|
36
37
|
private activeApprovals: promClient.Gauge;
|
|
@@ -69,6 +70,15 @@ export class GatewayMetrics {
|
|
|
69
70
|
registers: [this.registry],
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
// palaryn_policy_evaluation_seconds
|
|
74
|
+
this.policyEvaluationSeconds = new promClient.Histogram({
|
|
75
|
+
name: 'palaryn_policy_evaluation_seconds',
|
|
76
|
+
help: 'Time spent evaluating policy rules',
|
|
77
|
+
labelNames: ['engine'],
|
|
78
|
+
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0],
|
|
79
|
+
registers: [this.registry],
|
|
80
|
+
});
|
|
81
|
+
|
|
72
82
|
// palaryn_dlp_detections_total
|
|
73
83
|
this.dlpDetectionsTotal = new promClient.Counter({
|
|
74
84
|
name: 'palaryn_dlp_detections_total',
|
|
@@ -220,6 +230,13 @@ export class GatewayMetrics {
|
|
|
220
230
|
this.policyDecisionsTotal.inc({ decision, rule_id: ruleId });
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Record policy evaluation duration.
|
|
235
|
+
*/
|
|
236
|
+
recordPolicyEvaluation(engine: string, durationSeconds: number): void {
|
|
237
|
+
this.policyEvaluationSeconds.observe({ engine }, durationSeconds);
|
|
238
|
+
}
|
|
239
|
+
|
|
223
240
|
/**
|
|
224
241
|
* Record a DLP detection event.
|
|
225
242
|
*/
|
package/src/policy/engine.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as net from 'net';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
|
+
import { logger } from '../server/logger';
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
6
|
+
const punycode = require('punycode/');
|
|
4
7
|
import { ToolCall } from '../types/tool-call';
|
|
5
8
|
import {
|
|
6
9
|
PolicyDecision,
|
|
@@ -353,13 +356,13 @@ export class PolicyEngine {
|
|
|
353
356
|
}
|
|
354
357
|
// ReDoS protection: block patterns with nested quantifiers or excessive length
|
|
355
358
|
if (!PolicyEngine.isSafeRegex(pattern)) {
|
|
356
|
-
|
|
359
|
+
logger.warn('Rejecting potentially unsafe regex pattern (ReDoS risk)', { component: 'policy-engine', pattern: pattern.slice(0, 100) });
|
|
357
360
|
return null;
|
|
358
361
|
}
|
|
359
362
|
try {
|
|
360
363
|
re = new RegExp(pattern);
|
|
361
364
|
} catch (err) {
|
|
362
|
-
|
|
365
|
+
logger.warn('Invalid regex pattern', { component: 'policy-engine', pattern, error: err instanceof Error ? err.message : String(err) });
|
|
363
366
|
return null;
|
|
364
367
|
}
|
|
365
368
|
// Evict oldest entry if at capacity
|
|
@@ -775,6 +778,36 @@ export class PolicyEngine {
|
|
|
775
778
|
if (!conditions.provider_tool_types.includes(toolType as string)) return false;
|
|
776
779
|
}
|
|
777
780
|
|
|
781
|
+
// --- Referrer/provenance conditions ---
|
|
782
|
+
if (conditions.has_referrer !== undefined) {
|
|
783
|
+
const hasReferrer = !!toolCall.context?.referrer_url;
|
|
784
|
+
if (conditions.has_referrer !== hasReferrer) {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (conditions.referrer_domains && conditions.referrer_domains.length > 0) {
|
|
790
|
+
const referrerUrl = toolCall.context?.referrer_url;
|
|
791
|
+
if (!referrerUrl) {
|
|
792
|
+
// Rule requires specific referrer domain but no referrer is present — no match.
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
const referrerDomain = this.extractDomain(referrerUrl);
|
|
796
|
+
if (!referrerDomain || !this.isDomainInList(referrerDomain, conditions.referrer_domains)) {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (conditions.referrer_domains_blocklist && conditions.referrer_domains_blocklist.length > 0) {
|
|
802
|
+
const referrerUrl = toolCall.context?.referrer_url;
|
|
803
|
+
if (referrerUrl) {
|
|
804
|
+
const referrerDomain = this.extractDomain(referrerUrl);
|
|
805
|
+
if (referrerDomain && this.isDomainInList(referrerDomain, conditions.referrer_domains_blocklist)) {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
778
811
|
// --- DLP conditions (only evaluated when dlpContext is provided) ---
|
|
779
812
|
if (conditions.dlp_detected !== undefined) {
|
|
780
813
|
if (!dlpContext) return false; // DLP conditions require DLP context
|
|
@@ -812,7 +845,10 @@ export class PolicyEngine {
|
|
|
812
845
|
private extractDomain(url: string): string | null {
|
|
813
846
|
try {
|
|
814
847
|
const parsed = new URL(url);
|
|
815
|
-
|
|
848
|
+
let hostname = parsed.hostname.toLowerCase();
|
|
849
|
+
// Decode Punycode (xn--) domains to Unicode for consistent matching
|
|
850
|
+
try { hostname = punycode.toUnicode(hostname); } catch { /* leave as-is */ }
|
|
851
|
+
return hostname;
|
|
816
852
|
} catch {
|
|
817
853
|
return null;
|
|
818
854
|
}
|
package/src/policy/opa-engine.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as https from 'https';
|
|
|
3
3
|
import { URL } from 'url';
|
|
4
4
|
import { ToolCall } from '../types/tool-call';
|
|
5
5
|
import { PolicyDecision, PolicyEvalResult, PolicyTransformation } from '../types/policy';
|
|
6
|
+
import { logger } from '../server/logger';
|
|
6
7
|
import { OPAConfig } from '../types/config';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -79,7 +80,7 @@ export class OPAEngine {
|
|
|
79
80
|
throw new Error(`OPA server URL must not use private/reserved IP address in production: "${hostname}"`);
|
|
80
81
|
}
|
|
81
82
|
// In development, allow but warn
|
|
82
|
-
|
|
83
|
+
logger.warn('OPA server URL points to private address — blocked in production', { component: 'opa-engine', hostname });
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
// Normalize: remove trailing slashes for consistency
|
package/src/server/app.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { StripeClient, WebhookHandler, createWebhookRouter, createBillingRouter,
|
|
|
43
43
|
import { createPlanEnforcerMiddleware } from '../billing/plan-enforcer';
|
|
44
44
|
import { FilePersistedStores } from '../storage/file-persistence';
|
|
45
45
|
import { hashPassword } from '../auth/password';
|
|
46
|
-
import { log as devLog } from './logger';
|
|
46
|
+
import { log as devLog, logger } from './logger';
|
|
47
47
|
|
|
48
48
|
const MAX_TRACKED_IPS = 10000;
|
|
49
49
|
|
|
@@ -262,6 +262,12 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
262
262
|
gateway.setStores({ policyStore: saasStores.policyStore });
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Inject workspace store for plan enforcement inside the gateway pipeline
|
|
266
|
+
// (covers MCP bridge and proxy entry points, not just Express middleware)
|
|
267
|
+
if (saasStores.workspaceStore) {
|
|
268
|
+
gateway.setStores({ workspaceStore: saasStores.workspaceStore });
|
|
269
|
+
}
|
|
270
|
+
|
|
265
271
|
// Register noop executors for non-HTTP tools (e.g. pre-flight validation from Android)
|
|
266
272
|
gateway.registerExecutor('web_search', new NoopExecutor({ body: { validated: true, tool: 'web_search' } }));
|
|
267
273
|
gateway.registerExecutor('chat.completion', new NoopExecutor({ body: { validated: true, tool: 'chat.completion' } }));
|
|
@@ -500,10 +506,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
500
506
|
const rbacPolicyWrite = createRBACMiddleware(config.auth, 'policy:write');
|
|
501
507
|
|
|
502
508
|
// Admin routes (require auth + admin:full permission)
|
|
503
|
-
//
|
|
504
|
-
|
|
509
|
+
// Deprecated: use React SPA frontend instead (FRONTEND_ENABLED=true).
|
|
510
|
+
// Server-rendered admin is kept as fallback when frontend is disabled.
|
|
505
511
|
const rbacAdmin = createRBACMiddleware(config.auth, 'admin:full');
|
|
506
|
-
|
|
512
|
+
if (!config.frontend?.enabled) {
|
|
513
|
+
const adminRouter = createAdminRouter(gateway, config);
|
|
514
|
+
app.use('/admin', authMiddleware, rbacAdmin, adminRouter);
|
|
515
|
+
}
|
|
507
516
|
|
|
508
517
|
// Plan enforcer middleware — blocks calls when workspace exceeds plan limits
|
|
509
518
|
const planEnforcerMiddleware = createPlanEnforcerMiddleware({
|
|
@@ -568,13 +577,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
568
577
|
}
|
|
569
578
|
// S3: Never leak raw error messages to clients (may contain internal paths, IPs, secrets)
|
|
570
579
|
if (result.error) {
|
|
571
|
-
|
|
580
|
+
logger.error('Gateway error', { component: 'tool-execute', error: result.error });
|
|
572
581
|
result.error = 'Tool execution failed';
|
|
573
582
|
}
|
|
574
583
|
res.status(httpStatus).json(result);
|
|
575
584
|
} catch (err) {
|
|
576
585
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
577
|
-
|
|
586
|
+
logger.error('Execution error', { component: 'tool-execute', error: errorMessage });
|
|
578
587
|
sendError(res, 500, ErrorCode.TOOL_EXECUTION_ERROR, 'Tool execution failed');
|
|
579
588
|
}
|
|
580
589
|
});
|
|
@@ -635,13 +644,13 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
635
644
|
headers: proxyResult.headers,
|
|
636
645
|
}, pre);
|
|
637
646
|
} catch (postErr) {
|
|
638
|
-
|
|
647
|
+
logger.error('Post-execute failed', { component: 'stream-proxy', error: postErr instanceof Error ? postErr.message : postErr });
|
|
639
648
|
// Don't fail the response — it's already sent for streaming
|
|
640
649
|
}
|
|
641
650
|
|
|
642
651
|
} catch (err) {
|
|
643
652
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
644
|
-
|
|
653
|
+
logger.error('Execution error', { component: 'stream-proxy', error: errorMessage });
|
|
645
654
|
if (!res.headersSent) {
|
|
646
655
|
sendError(res, 502, ErrorCode.TOOL_EXECUTION_ERROR, 'Stream proxy failed');
|
|
647
656
|
} else {
|
|
@@ -684,7 +693,7 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
684
693
|
}
|
|
685
694
|
} catch (err) {
|
|
686
695
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
687
|
-
|
|
696
|
+
logger.error('Approval error', { component: 'tool-approve', error: errorMessage });
|
|
688
697
|
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Approval processing failed');
|
|
689
698
|
}
|
|
690
699
|
});
|
|
@@ -751,7 +760,7 @@ export function createApp(config: GatewayConfig, externalSaaSStores?: Partial<Sa
|
|
|
751
760
|
res.json({ status: 'ok' });
|
|
752
761
|
} catch (err) {
|
|
753
762
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
754
|
-
|
|
763
|
+
logger.error('Usage report error', { component: 'usage-report', error: errorMessage });
|
|
755
764
|
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Usage reporting failed');
|
|
756
765
|
}
|
|
757
766
|
});
|