mcp-rubber-duck 1.8.0 → 1.9.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/CHANGELOG.md +8 -0
- package/README.md +158 -1
- package/audit-ci.json +2 -1
- package/dist/config/config.d.ts +2 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +144 -1
- package/dist/config/config.js.map +1 -1
- package/dist/config/types.d.ts +1084 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +59 -0
- package/dist/config/types.js.map +1 -1
- package/dist/guardrails/context.d.ts +10 -0
- package/dist/guardrails/context.d.ts.map +1 -0
- package/dist/guardrails/context.js +35 -0
- package/dist/guardrails/context.js.map +1 -0
- package/dist/guardrails/errors.d.ts +26 -0
- package/dist/guardrails/errors.d.ts.map +1 -0
- package/dist/guardrails/errors.js +42 -0
- package/dist/guardrails/errors.js.map +1 -0
- package/dist/guardrails/index.d.ts +6 -0
- package/dist/guardrails/index.d.ts.map +1 -0
- package/dist/guardrails/index.js +11 -0
- package/dist/guardrails/index.js.map +1 -0
- package/dist/guardrails/plugins/base-plugin.d.ts +35 -0
- package/dist/guardrails/plugins/base-plugin.d.ts.map +1 -0
- package/dist/guardrails/plugins/base-plugin.js +70 -0
- package/dist/guardrails/plugins/base-plugin.js.map +1 -0
- package/dist/guardrails/plugins/index.d.ts +6 -0
- package/dist/guardrails/plugins/index.d.ts.map +1 -0
- package/dist/guardrails/plugins/index.js +6 -0
- package/dist/guardrails/plugins/index.js.map +1 -0
- package/dist/guardrails/plugins/pattern-blocker.d.ts +27 -0
- package/dist/guardrails/plugins/pattern-blocker.d.ts.map +1 -0
- package/dist/guardrails/plugins/pattern-blocker.js +140 -0
- package/dist/guardrails/plugins/pattern-blocker.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.d.ts +40 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.js +134 -0
- package/dist/guardrails/plugins/pii-redactor/detectors.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/index.d.ts +28 -0
- package/dist/guardrails/plugins/pii-redactor/index.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/index.js +157 -0
- package/dist/guardrails/plugins/pii-redactor/index.js.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts +33 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.d.ts.map +1 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js +70 -0
- package/dist/guardrails/plugins/pii-redactor/pseudonymizer.js.map +1 -0
- package/dist/guardrails/plugins/rate-limiter.d.ts +28 -0
- package/dist/guardrails/plugins/rate-limiter.d.ts.map +1 -0
- package/dist/guardrails/plugins/rate-limiter.js +91 -0
- package/dist/guardrails/plugins/rate-limiter.js.map +1 -0
- package/dist/guardrails/plugins/token-limiter.d.ts +30 -0
- package/dist/guardrails/plugins/token-limiter.d.ts.map +1 -0
- package/dist/guardrails/plugins/token-limiter.js +98 -0
- package/dist/guardrails/plugins/token-limiter.js.map +1 -0
- package/dist/guardrails/service.d.ts +38 -0
- package/dist/guardrails/service.d.ts.map +1 -0
- package/dist/guardrails/service.js +183 -0
- package/dist/guardrails/service.js.map +1 -0
- package/dist/guardrails/types.d.ts +96 -0
- package/dist/guardrails/types.d.ts.map +1 -0
- package/dist/guardrails/types.js +2 -0
- package/dist/guardrails/types.js.map +1 -0
- package/dist/providers/duck-provider-enhanced.d.ts +2 -1
- package/dist/providers/duck-provider-enhanced.d.ts.map +1 -1
- package/dist/providers/duck-provider-enhanced.js +55 -6
- package/dist/providers/duck-provider-enhanced.js.map +1 -1
- package/dist/providers/enhanced-manager.d.ts +2 -1
- package/dist/providers/enhanced-manager.d.ts.map +1 -1
- package/dist/providers/enhanced-manager.js +3 -3
- package/dist/providers/enhanced-manager.js.map +1 -1
- package/dist/providers/manager.d.ts +3 -1
- package/dist/providers/manager.d.ts.map +1 -1
- package/dist/providers/manager.js +4 -2
- package/dist/providers/manager.js.map +1 -1
- package/dist/providers/provider.d.ts +3 -1
- package/dist/providers/provider.d.ts.map +1 -1
- package/dist/providers/provider.js +43 -3
- package/dist/providers/provider.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +28 -6
- package/dist/server.js.map +1 -1
- package/dist/services/function-bridge.d.ts +3 -1
- package/dist/services/function-bridge.d.ts.map +1 -1
- package/dist/services/function-bridge.js +40 -1
- package/dist/services/function-bridge.js.map +1 -1
- package/package.json +1 -1
- package/src/config/config.ts +187 -1
- package/src/config/types.ts +73 -0
- package/src/guardrails/context.ts +37 -0
- package/src/guardrails/errors.ts +46 -0
- package/src/guardrails/index.ts +20 -0
- package/src/guardrails/plugins/base-plugin.ts +103 -0
- package/src/guardrails/plugins/index.ts +5 -0
- package/src/guardrails/plugins/pattern-blocker.ts +190 -0
- package/src/guardrails/plugins/pii-redactor/detectors.ts +200 -0
- package/src/guardrails/plugins/pii-redactor/index.ts +203 -0
- package/src/guardrails/plugins/pii-redactor/pseudonymizer.ts +91 -0
- package/src/guardrails/plugins/rate-limiter.ts +142 -0
- package/src/guardrails/plugins/token-limiter.ts +155 -0
- package/src/guardrails/service.ts +209 -0
- package/src/guardrails/types.ts +120 -0
- package/src/providers/duck-provider-enhanced.ts +76 -7
- package/src/providers/enhanced-manager.ts +5 -3
- package/src/providers/manager.ts +6 -3
- package/src/providers/provider.ts +57 -6
- package/src/server.ts +32 -6
- package/src/services/function-bridge.ts +53 -2
- package/tests/guardrails/config.test.ts +267 -0
- package/tests/guardrails/errors.test.ts +109 -0
- package/tests/guardrails/plugins/pattern-blocker.test.ts +309 -0
- package/tests/guardrails/plugins/pii-redactor.test.ts +1004 -0
- package/tests/guardrails/plugins/rate-limiter.test.ts +310 -0
- package/tests/guardrails/plugins/token-limiter.test.ts +216 -0
- package/tests/guardrails/service.test.ts +911 -0
- package/tests/mcp-bridge.test.ts +248 -0
- package/tests/providers.test.ts +739 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { BaseGuardrailPlugin } from './base-plugin.js';
|
|
2
|
+
import { GuardrailPhase, GuardrailContext, GuardrailResult } from '../types.js';
|
|
3
|
+
import { PatternBlockerConfig } from '../../config/types.js';
|
|
4
|
+
|
|
5
|
+
interface PatternMatch {
|
|
6
|
+
pattern: string;
|
|
7
|
+
isRegex: boolean;
|
|
8
|
+
matchedText: string;
|
|
9
|
+
position: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pattern blocker plugin - blocks or warns on specific patterns
|
|
14
|
+
*/
|
|
15
|
+
export class PatternBlockerPlugin extends BaseGuardrailPlugin {
|
|
16
|
+
name = 'pattern_blocker';
|
|
17
|
+
phases: GuardrailPhase[] = ['pre_request', 'pre_tool_input'];
|
|
18
|
+
|
|
19
|
+
private blockedPatterns: string[] = [];
|
|
20
|
+
private blockedPatternsRegex: RegExp[] = [];
|
|
21
|
+
private caseSensitive: boolean = false;
|
|
22
|
+
private actionOnMatch: 'block' | 'warn' | 'redact' = 'block';
|
|
23
|
+
|
|
24
|
+
async initialize(config: Record<string, unknown>): Promise<void> {
|
|
25
|
+
await super.initialize(config);
|
|
26
|
+
|
|
27
|
+
const typedConfig = config as Partial<PatternBlockerConfig>;
|
|
28
|
+
this.blockedPatterns = typedConfig.blocked_patterns ?? [];
|
|
29
|
+
this.caseSensitive = typedConfig.case_sensitive ?? false;
|
|
30
|
+
this.actionOnMatch = typedConfig.action_on_match ?? 'block';
|
|
31
|
+
this.priority = typedConfig.priority ?? 30;
|
|
32
|
+
|
|
33
|
+
// Compile regex patterns
|
|
34
|
+
this.blockedPatternsRegex = [];
|
|
35
|
+
for (const pattern of typedConfig.blocked_patterns_regex ?? []) {
|
|
36
|
+
try {
|
|
37
|
+
const flags = this.caseSensitive ? 'g' : 'gi';
|
|
38
|
+
this.blockedPatternsRegex.push(new RegExp(pattern, flags));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Invalid regex, skip it
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
execute(phase: GuardrailPhase, context: GuardrailContext): Promise<GuardrailResult> {
|
|
46
|
+
if (!this.phases.includes(phase)) {
|
|
47
|
+
return Promise.resolve(this.allow(context));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Get text to check based on phase
|
|
51
|
+
let textToCheck: string;
|
|
52
|
+
let fieldName: string;
|
|
53
|
+
|
|
54
|
+
if (phase === 'pre_request') {
|
|
55
|
+
textToCheck = context.prompt || '';
|
|
56
|
+
fieldName = 'prompt';
|
|
57
|
+
} else if (phase === 'pre_tool_input') {
|
|
58
|
+
textToCheck = JSON.stringify(context.toolArgs || {});
|
|
59
|
+
fieldName = 'toolArgs';
|
|
60
|
+
} else {
|
|
61
|
+
return Promise.resolve(this.allow(context));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Find matches
|
|
65
|
+
const matches = this.findMatches(textToCheck);
|
|
66
|
+
|
|
67
|
+
if (matches.length === 0) {
|
|
68
|
+
return Promise.resolve(this.allow(context));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle matches based on action
|
|
72
|
+
const matchSummary = matches.map((m) => m.pattern).join(', ');
|
|
73
|
+
|
|
74
|
+
if (this.actionOnMatch === 'block') {
|
|
75
|
+
this.addViolation(
|
|
76
|
+
context,
|
|
77
|
+
phase,
|
|
78
|
+
'blocked_pattern',
|
|
79
|
+
'error',
|
|
80
|
+
`Blocked patterns found: ${matchSummary}`,
|
|
81
|
+
{ matches: matches.map((m) => ({ pattern: m.pattern, position: m.position })) }
|
|
82
|
+
);
|
|
83
|
+
return Promise.resolve(this.block(context, `Blocked pattern detected: ${matchSummary}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (this.actionOnMatch === 'warn') {
|
|
87
|
+
this.addViolation(
|
|
88
|
+
context,
|
|
89
|
+
phase,
|
|
90
|
+
'blocked_pattern_warning',
|
|
91
|
+
'warning',
|
|
92
|
+
`Suspicious patterns found: ${matchSummary}`,
|
|
93
|
+
{ matches: matches.map((m) => ({ pattern: m.pattern, position: m.position })) }
|
|
94
|
+
);
|
|
95
|
+
return Promise.resolve(this.allow(context));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.actionOnMatch === 'redact') {
|
|
99
|
+
// Redact matches from text
|
|
100
|
+
let redactedText = textToCheck;
|
|
101
|
+
for (const match of matches) {
|
|
102
|
+
redactedText = redactedText.replace(
|
|
103
|
+
match.matchedText,
|
|
104
|
+
'[REDACTED]'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.addModification(
|
|
109
|
+
context,
|
|
110
|
+
phase,
|
|
111
|
+
fieldName,
|
|
112
|
+
`Redacted ${matches.length} blocked patterns`,
|
|
113
|
+
textToCheck,
|
|
114
|
+
redactedText
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Update context
|
|
118
|
+
if (phase === 'pre_request') {
|
|
119
|
+
context.prompt = redactedText;
|
|
120
|
+
// Also update last message if present (create new object to avoid mutating original)
|
|
121
|
+
if (context.messages.length > 0) {
|
|
122
|
+
const lastIndex = context.messages.length - 1;
|
|
123
|
+
context.messages[lastIndex] = {
|
|
124
|
+
...context.messages[lastIndex],
|
|
125
|
+
content: redactedText,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
} else if (phase === 'pre_tool_input') {
|
|
129
|
+
try {
|
|
130
|
+
context.toolArgs = JSON.parse(redactedText) as Record<string, unknown>;
|
|
131
|
+
} catch {
|
|
132
|
+
// If parse fails, leave as is
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Promise.resolve(this.modify(context));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return Promise.resolve(this.allow(context));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Find all pattern matches in text
|
|
144
|
+
*/
|
|
145
|
+
private findMatches(text: string): PatternMatch[] {
|
|
146
|
+
const matches: PatternMatch[] = [];
|
|
147
|
+
const searchText = this.caseSensitive ? text : text.toLowerCase();
|
|
148
|
+
|
|
149
|
+
// Check simple string patterns
|
|
150
|
+
for (const pattern of this.blockedPatterns) {
|
|
151
|
+
const searchPattern = this.caseSensitive ? pattern : pattern.toLowerCase();
|
|
152
|
+
let position = searchText.indexOf(searchPattern);
|
|
153
|
+
while (position !== -1) {
|
|
154
|
+
matches.push({
|
|
155
|
+
pattern,
|
|
156
|
+
isRegex: false,
|
|
157
|
+
matchedText: text.substring(position, position + pattern.length),
|
|
158
|
+
position,
|
|
159
|
+
});
|
|
160
|
+
position = searchText.indexOf(searchPattern, position + 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check regex patterns
|
|
165
|
+
for (const regex of this.blockedPatternsRegex) {
|
|
166
|
+
regex.lastIndex = 0; // Reset regex state
|
|
167
|
+
let match;
|
|
168
|
+
while ((match = regex.exec(text)) !== null) {
|
|
169
|
+
matches.push({
|
|
170
|
+
pattern: regex.source,
|
|
171
|
+
isRegex: true,
|
|
172
|
+
matchedText: match[0],
|
|
173
|
+
position: match.index,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return matches;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get configured patterns (for testing)
|
|
183
|
+
*/
|
|
184
|
+
getPatterns(): { simple: string[]; regex: string[] } {
|
|
185
|
+
return {
|
|
186
|
+
simple: [...this.blockedPatterns],
|
|
187
|
+
regex: this.blockedPatternsRegex.map((r) => r.source),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
export type PIIType =
|
|
2
|
+
| 'email'
|
|
3
|
+
| 'phone'
|
|
4
|
+
| 'ssn'
|
|
5
|
+
| 'api_key'
|
|
6
|
+
| 'credit_card'
|
|
7
|
+
| 'ip_address'
|
|
8
|
+
| 'custom';
|
|
9
|
+
|
|
10
|
+
export interface PIIDetection {
|
|
11
|
+
type: PIIType;
|
|
12
|
+
value: string;
|
|
13
|
+
startIndex: number;
|
|
14
|
+
endIndex: number;
|
|
15
|
+
confidence: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PIIDetectorConfig {
|
|
19
|
+
detectEmails: boolean;
|
|
20
|
+
detectPhones: boolean;
|
|
21
|
+
detectSSN: boolean;
|
|
22
|
+
detectAPIKeys: boolean;
|
|
23
|
+
detectCreditCards: boolean;
|
|
24
|
+
detectIPAddresses: boolean;
|
|
25
|
+
customPatterns: Array<{ name: string; pattern: string; placeholder: string }>;
|
|
26
|
+
allowlist: string[];
|
|
27
|
+
allowlistDomains: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PII detector using regex patterns
|
|
32
|
+
*/
|
|
33
|
+
export class PIIDetector {
|
|
34
|
+
private patterns: Map<PIIType, RegExp> = new Map();
|
|
35
|
+
private allowlist: Set<string>;
|
|
36
|
+
private allowlistDomains: Set<string>;
|
|
37
|
+
private customPatterns: Array<{ name: string; regex: RegExp; placeholder: string }> = [];
|
|
38
|
+
|
|
39
|
+
constructor(config: PIIDetectorConfig) {
|
|
40
|
+
this.allowlist = new Set(config.allowlist.map((a) => a.toLowerCase()));
|
|
41
|
+
this.allowlistDomains = new Set(config.allowlistDomains.map((d) => d.toLowerCase()));
|
|
42
|
+
|
|
43
|
+
// Initialize built-in patterns
|
|
44
|
+
if (config.detectEmails) {
|
|
45
|
+
this.patterns.set(
|
|
46
|
+
'email',
|
|
47
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (config.detectPhones) {
|
|
52
|
+
// Handles multiple phone formats: US, international, with/without country code
|
|
53
|
+
this.patterns.set(
|
|
54
|
+
'phone',
|
|
55
|
+
/\b(?:\+?1[-.\s]?)?\(?[0-9]{3}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (config.detectSSN) {
|
|
60
|
+
// US SSN format: XXX-XX-XXXX or XXXXXXXXX
|
|
61
|
+
this.patterns.set(
|
|
62
|
+
'ssn',
|
|
63
|
+
/\b[0-9]{3}[-\s]?[0-9]{2}[-\s]?[0-9]{4}\b/g
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config.detectAPIKeys) {
|
|
68
|
+
// Common API key patterns
|
|
69
|
+
this.patterns.set(
|
|
70
|
+
'api_key',
|
|
71
|
+
/\b(sk-[a-zA-Z0-9]{20,}|gsk_[a-zA-Z0-9]{20,}|api[_-]?key[_-]?[a-zA-Z0-9]{16,})\b/gi
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (config.detectCreditCards) {
|
|
76
|
+
// Credit card patterns (Visa, Mastercard, Amex, Discover)
|
|
77
|
+
// Simplified - doesn't validate Luhn, just matches format
|
|
78
|
+
this.patterns.set(
|
|
79
|
+
'credit_card',
|
|
80
|
+
/\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
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (config.detectIPAddresses) {
|
|
85
|
+
// IPv4 addresses
|
|
86
|
+
this.patterns.set(
|
|
87
|
+
'ip_address',
|
|
88
|
+
/\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Custom patterns
|
|
93
|
+
for (const custom of config.customPatterns) {
|
|
94
|
+
try {
|
|
95
|
+
this.customPatterns.push({
|
|
96
|
+
name: custom.name,
|
|
97
|
+
regex: new RegExp(custom.pattern, 'g'),
|
|
98
|
+
placeholder: custom.placeholder,
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
// Invalid regex, skip it
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Detect PII in text
|
|
108
|
+
*/
|
|
109
|
+
detect(text: string): PIIDetection[] {
|
|
110
|
+
const detections: PIIDetection[] = [];
|
|
111
|
+
|
|
112
|
+
// Check built-in patterns
|
|
113
|
+
for (const [type, pattern] of this.patterns) {
|
|
114
|
+
pattern.lastIndex = 0; // Reset regex state
|
|
115
|
+
let match;
|
|
116
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
117
|
+
const value = match[0];
|
|
118
|
+
|
|
119
|
+
// Check allowlist
|
|
120
|
+
if (this.isAllowlisted(value, type)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
detections.push({
|
|
125
|
+
type,
|
|
126
|
+
value,
|
|
127
|
+
startIndex: match.index,
|
|
128
|
+
endIndex: match.index + value.length,
|
|
129
|
+
confidence: this.calculateConfidence(type, value),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check custom patterns
|
|
135
|
+
for (const custom of this.customPatterns) {
|
|
136
|
+
custom.regex.lastIndex = 0;
|
|
137
|
+
let match;
|
|
138
|
+
while ((match = custom.regex.exec(text)) !== null) {
|
|
139
|
+
const value = match[0];
|
|
140
|
+
|
|
141
|
+
if (this.allowlist.has(value.toLowerCase())) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
detections.push({
|
|
146
|
+
type: 'custom',
|
|
147
|
+
value,
|
|
148
|
+
startIndex: match.index,
|
|
149
|
+
endIndex: match.index + value.length,
|
|
150
|
+
confidence: 0.9,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Sort by position (for consistent pseudonymization)
|
|
156
|
+
return detections.sort((a, b) => a.startIndex - b.startIndex);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private isAllowlisted(value: string, type: PIIType): boolean {
|
|
160
|
+
const lowerValue = value.toLowerCase();
|
|
161
|
+
|
|
162
|
+
if (this.allowlist.has(lowerValue)) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// For emails, check domain allowlist
|
|
167
|
+
if (type === 'email') {
|
|
168
|
+
const domain = lowerValue.split('@')[1];
|
|
169
|
+
if (domain && this.allowlistDomains.has(domain)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private calculateConfidence(type: PIIType, value: string): number {
|
|
178
|
+
// Simple confidence scoring based on format strictness
|
|
179
|
+
switch (type) {
|
|
180
|
+
case 'ssn':
|
|
181
|
+
return 0.95; // High confidence for strict format
|
|
182
|
+
case 'credit_card':
|
|
183
|
+
return 0.95; // High confidence for strict format
|
|
184
|
+
case 'email':
|
|
185
|
+
return 0.9;
|
|
186
|
+
case 'phone':
|
|
187
|
+
return 0.85;
|
|
188
|
+
case 'api_key':
|
|
189
|
+
// Higher confidence for longer keys or known prefixes
|
|
190
|
+
if (value.startsWith('sk-') || value.startsWith('gsk_')) {
|
|
191
|
+
return 0.95;
|
|
192
|
+
}
|
|
193
|
+
return 0.7; // Lower confidence due to possible false positives
|
|
194
|
+
case 'ip_address':
|
|
195
|
+
return 0.8;
|
|
196
|
+
default:
|
|
197
|
+
return 0.8;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { BaseGuardrailPlugin } from '../base-plugin.js';
|
|
2
|
+
import { GuardrailPhase, GuardrailContext, GuardrailResult } from '../../types.js';
|
|
3
|
+
import { PIIRedactorConfig } from '../../../config/types.js';
|
|
4
|
+
import { PIIDetector, PIIDetectorConfig } from './detectors.js';
|
|
5
|
+
import { Pseudonymizer } from './pseudonymizer.js';
|
|
6
|
+
import { logger } from '../../../utils/logger.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PII Redactor plugin - detects and redacts sensitive data
|
|
10
|
+
*/
|
|
11
|
+
export class PIIRedactorPlugin extends BaseGuardrailPlugin {
|
|
12
|
+
name = 'pii_redactor';
|
|
13
|
+
phases: GuardrailPhase[] = ['pre_request', 'post_response', 'pre_tool_input', 'post_tool_output'];
|
|
14
|
+
|
|
15
|
+
private detector!: PIIDetector;
|
|
16
|
+
private pseudonymizer!: Pseudonymizer;
|
|
17
|
+
private restoreOnResponse: boolean = false;
|
|
18
|
+
private logDetections: boolean = true;
|
|
19
|
+
|
|
20
|
+
async initialize(config: Record<string, unknown>): Promise<void> {
|
|
21
|
+
await super.initialize(config);
|
|
22
|
+
|
|
23
|
+
const typedConfig = config as Partial<PIIRedactorConfig>;
|
|
24
|
+
|
|
25
|
+
const detectorConfig: PIIDetectorConfig = {
|
|
26
|
+
detectEmails: typedConfig.detect_emails ?? true,
|
|
27
|
+
detectPhones: typedConfig.detect_phones ?? true,
|
|
28
|
+
detectSSN: typedConfig.detect_ssn ?? true,
|
|
29
|
+
detectAPIKeys: typedConfig.detect_api_keys ?? true,
|
|
30
|
+
detectCreditCards: typedConfig.detect_credit_cards ?? true,
|
|
31
|
+
detectIPAddresses: typedConfig.detect_ip_addresses ?? false,
|
|
32
|
+
customPatterns: typedConfig.custom_patterns ?? [],
|
|
33
|
+
allowlist: typedConfig.allowlist ?? [],
|
|
34
|
+
allowlistDomains: typedConfig.allowlist_domains ?? [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
this.detector = new PIIDetector(detectorConfig);
|
|
38
|
+
this.pseudonymizer = new Pseudonymizer();
|
|
39
|
+
this.restoreOnResponse = typedConfig.restore_on_response ?? false;
|
|
40
|
+
this.logDetections = typedConfig.log_detections ?? true;
|
|
41
|
+
this.priority = typedConfig.priority ?? 25;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async execute(phase: GuardrailPhase, context: GuardrailContext): Promise<GuardrailResult> {
|
|
45
|
+
switch (phase) {
|
|
46
|
+
case 'pre_request':
|
|
47
|
+
case 'pre_tool_input':
|
|
48
|
+
return this.redactPII(context, phase);
|
|
49
|
+
|
|
50
|
+
case 'post_response':
|
|
51
|
+
case 'post_tool_output':
|
|
52
|
+
if (this.restoreOnResponse) {
|
|
53
|
+
return this.restorePII(context, phase);
|
|
54
|
+
}
|
|
55
|
+
return this.allow(context);
|
|
56
|
+
|
|
57
|
+
default:
|
|
58
|
+
return this.allow(context);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private redactPII(
|
|
63
|
+
context: GuardrailContext,
|
|
64
|
+
phase: GuardrailPhase
|
|
65
|
+
): Promise<GuardrailResult> {
|
|
66
|
+
let textToScan: string;
|
|
67
|
+
let field: string;
|
|
68
|
+
|
|
69
|
+
if (phase === 'pre_request') {
|
|
70
|
+
textToScan = context.prompt || '';
|
|
71
|
+
field = 'prompt';
|
|
72
|
+
} else {
|
|
73
|
+
textToScan = JSON.stringify(context.toolArgs || {});
|
|
74
|
+
field = 'toolArgs';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!textToScan) {
|
|
78
|
+
return Promise.resolve(this.allow(context));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const detections = this.detector.detect(textToScan);
|
|
82
|
+
|
|
83
|
+
if (detections.length === 0) {
|
|
84
|
+
return Promise.resolve(this.allow(context));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Log detections
|
|
88
|
+
if (this.logDetections) {
|
|
89
|
+
logger.info(`PII detected in ${field}:`, {
|
|
90
|
+
requestId: context.requestId,
|
|
91
|
+
types: [...new Set(detections.map((d) => d.type))],
|
|
92
|
+
count: detections.length,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Pseudonymize
|
|
97
|
+
const { text: redactedText, mappings } = this.pseudonymizer.pseudonymize(
|
|
98
|
+
textToScan,
|
|
99
|
+
detections
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Store mappings for potential restoration
|
|
103
|
+
context.metadata.set('pii_mappings', mappings);
|
|
104
|
+
|
|
105
|
+
// Record modification
|
|
106
|
+
this.addModification(
|
|
107
|
+
context,
|
|
108
|
+
phase,
|
|
109
|
+
field,
|
|
110
|
+
`Redacted ${detections.length} PII items: ${[...new Set(detections.map((d) => d.type))].join(', ')}`,
|
|
111
|
+
undefined, // Don't store original (contains PII)
|
|
112
|
+
undefined // Don't store new (would expose placeholder patterns)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Apply modification
|
|
116
|
+
if (phase === 'pre_request') {
|
|
117
|
+
context.prompt = redactedText;
|
|
118
|
+
// Also update the last message if present
|
|
119
|
+
if (context.messages.length > 0) {
|
|
120
|
+
const lastIndex = context.messages.length - 1;
|
|
121
|
+
context.messages[lastIndex] = {
|
|
122
|
+
...context.messages[lastIndex],
|
|
123
|
+
content: redactedText,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
try {
|
|
128
|
+
context.toolArgs = JSON.parse(redactedText) as Record<string, unknown>;
|
|
129
|
+
} catch {
|
|
130
|
+
// If parse fails, store as string
|
|
131
|
+
context.toolArgs = { _redacted: redactedText };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return Promise.resolve(this.modify(context));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private restorePII(
|
|
139
|
+
context: GuardrailContext,
|
|
140
|
+
phase: GuardrailPhase
|
|
141
|
+
): Promise<GuardrailResult> {
|
|
142
|
+
const mappings = context.metadata.get('pii_mappings') as Map<string, string> | undefined;
|
|
143
|
+
|
|
144
|
+
if (!mappings || mappings.size === 0) {
|
|
145
|
+
return Promise.resolve(this.allow(context));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let textToRestore: string;
|
|
149
|
+
|
|
150
|
+
if (phase === 'post_response') {
|
|
151
|
+
textToRestore = context.response || '';
|
|
152
|
+
} else {
|
|
153
|
+
textToRestore =
|
|
154
|
+
typeof context.toolResult === 'string'
|
|
155
|
+
? context.toolResult
|
|
156
|
+
: JSON.stringify(context.toolResult);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!textToRestore) {
|
|
160
|
+
return Promise.resolve(this.allow(context));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const restoredText = this.pseudonymizer.restore(textToRestore, mappings);
|
|
164
|
+
|
|
165
|
+
// Only modify if something changed
|
|
166
|
+
if (restoredText === textToRestore) {
|
|
167
|
+
return Promise.resolve(this.allow(context));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.addModification(
|
|
171
|
+
context,
|
|
172
|
+
phase,
|
|
173
|
+
phase === 'post_response' ? 'response' : 'toolResult',
|
|
174
|
+
`Restored ${mappings.size} PII placeholders`
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (phase === 'post_response') {
|
|
178
|
+
context.response = restoredText;
|
|
179
|
+
} else {
|
|
180
|
+
try {
|
|
181
|
+
context.toolResult = JSON.parse(restoredText) as unknown;
|
|
182
|
+
} catch {
|
|
183
|
+
context.toolResult = restoredText;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return Promise.resolve(this.modify(context));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get detector for testing
|
|
192
|
+
*/
|
|
193
|
+
getDetector(): PIIDetector {
|
|
194
|
+
return this.detector;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get pseudonymizer for testing
|
|
199
|
+
*/
|
|
200
|
+
getPseudonymizer(): Pseudonymizer {
|
|
201
|
+
return this.pseudonymizer;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { PIIDetection, PIIType } from './detectors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pseudonymizer - replaces PII with numbered placeholders
|
|
5
|
+
* and supports optional restoration
|
|
6
|
+
*/
|
|
7
|
+
export class Pseudonymizer {
|
|
8
|
+
private counters: Map<PIIType, number> = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pseudonymize text by replacing PII with placeholders
|
|
12
|
+
* Returns the modified text and a mapping for restoration
|
|
13
|
+
*/
|
|
14
|
+
pseudonymize(
|
|
15
|
+
text: string,
|
|
16
|
+
detections: PIIDetection[]
|
|
17
|
+
): { text: string; mappings: Map<string, string> } {
|
|
18
|
+
const mappings = new Map<string, string>();
|
|
19
|
+
let result = text;
|
|
20
|
+
let offset = 0;
|
|
21
|
+
|
|
22
|
+
// Reset counters for consistent numbering
|
|
23
|
+
this.counters.clear();
|
|
24
|
+
|
|
25
|
+
for (const detection of detections) {
|
|
26
|
+
const placeholder = this.generatePlaceholder(detection.type);
|
|
27
|
+
mappings.set(placeholder, detection.value);
|
|
28
|
+
|
|
29
|
+
// Replace in text (accounting for previous replacements)
|
|
30
|
+
const start = detection.startIndex + offset;
|
|
31
|
+
const end = detection.endIndex + offset;
|
|
32
|
+
result = result.substring(0, start) + placeholder + result.substring(end);
|
|
33
|
+
|
|
34
|
+
// Adjust offset for next replacement
|
|
35
|
+
offset += placeholder.length - detection.value.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { text: result, mappings };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Restore placeholders in text with original values
|
|
43
|
+
*/
|
|
44
|
+
restore(text: string, mappings: Map<string, string>): string {
|
|
45
|
+
let result = text;
|
|
46
|
+
|
|
47
|
+
for (const [placeholder, original] of mappings) {
|
|
48
|
+
// Use global replace to handle multiple occurrences
|
|
49
|
+
result = result.replace(
|
|
50
|
+
new RegExp(this.escapeRegex(placeholder), 'g'),
|
|
51
|
+
original
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate a placeholder for a PII type
|
|
60
|
+
*/
|
|
61
|
+
private generatePlaceholder(type: PIIType): string {
|
|
62
|
+
const count = (this.counters.get(type) || 0) + 1;
|
|
63
|
+
this.counters.set(type, count);
|
|
64
|
+
|
|
65
|
+
const typeLabels: Record<PIIType, string> = {
|
|
66
|
+
email: 'EMAIL',
|
|
67
|
+
phone: 'PHONE',
|
|
68
|
+
ssn: 'SSN',
|
|
69
|
+
api_key: 'API_KEY',
|
|
70
|
+
credit_card: 'CARD',
|
|
71
|
+
ip_address: 'IP',
|
|
72
|
+
custom: 'REDACTED',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return `[${typeLabels[type]}_${count}]`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Escape special regex characters in a string
|
|
80
|
+
*/
|
|
81
|
+
private escapeRegex(str: string): string {
|
|
82
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reset counters (for testing)
|
|
87
|
+
*/
|
|
88
|
+
reset(): void {
|
|
89
|
+
this.counters.clear();
|
|
90
|
+
}
|
|
91
|
+
}
|