@openguardrails/gateway 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.
Files changed (41) hide show
  1. package/dist/config.d.ts +13 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +100 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/handlers/anthropic.d.ts +12 -0
  6. package/dist/handlers/anthropic.d.ts.map +1 -0
  7. package/dist/handlers/anthropic.js +150 -0
  8. package/dist/handlers/anthropic.js.map +1 -0
  9. package/dist/handlers/gemini.d.ts +12 -0
  10. package/dist/handlers/gemini.d.ts.map +1 -0
  11. package/dist/handlers/gemini.js +80 -0
  12. package/dist/handlers/gemini.js.map +1 -0
  13. package/dist/handlers/openai.d.ts +13 -0
  14. package/dist/handlers/openai.d.ts.map +1 -0
  15. package/dist/handlers/openai.js +145 -0
  16. package/dist/handlers/openai.js.map +1 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +136 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/restorer.d.ts +21 -0
  22. package/dist/restorer.d.ts.map +1 -0
  23. package/dist/restorer.js +91 -0
  24. package/dist/restorer.js.map +1 -0
  25. package/dist/sanitizer.d.ts +17 -0
  26. package/dist/sanitizer.d.ts.map +1 -0
  27. package/dist/sanitizer.js +226 -0
  28. package/dist/sanitizer.js.map +1 -0
  29. package/dist/types.d.ts +35 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/types.js +5 -0
  32. package/dist/types.js.map +1 -0
  33. package/package.json +55 -0
  34. package/src/config.ts +122 -0
  35. package/src/handlers/anthropic.ts +195 -0
  36. package/src/handlers/gemini.ts +99 -0
  37. package/src/handlers/openai.ts +188 -0
  38. package/src/index.ts +159 -0
  39. package/src/restorer.ts +101 -0
  40. package/src/sanitizer.ts +278 -0
  41. package/src/types.ts +43 -0
package/src/index.ts ADDED
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenGuardrails AI Security Gateway
4
+ *
5
+ * Local HTTP proxy that intercepts LLM API calls, sanitizes sensitive data
6
+ * before sending to providers, and restores it in responses.
7
+ * Supports Anthropic, OpenAI, and Gemini protocols.
8
+ */
9
+
10
+ import { createServer } from "node:http";
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ import { loadConfig, validateConfig } from "./config.js";
13
+ import type { GatewayConfig } from "./types.js";
14
+ import { handleAnthropicRequest } from "./handlers/anthropic.js";
15
+ import { handleOpenAIRequest } from "./handlers/openai.js";
16
+ import { handleGeminiRequest } from "./handlers/gemini.js";
17
+
18
+ const GATEWAY_MODE = process.env.GATEWAY_MODE || "selfhosted";
19
+
20
+ let config: GatewayConfig;
21
+
22
+ /**
23
+ * Main request handler
24
+ */
25
+ async function handleRequest(
26
+ req: IncomingMessage,
27
+ res: ServerResponse,
28
+ ): Promise<void> {
29
+ const { method, url } = req;
30
+
31
+ // Log request
32
+ console.log(`[ai-security-gateway] ${method} ${url}`);
33
+
34
+ // CORS headers (for browser-based clients)
35
+ res.setHeader("Access-Control-Allow-Origin", "*");
36
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
37
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, anthropic-version");
38
+
39
+ // Handle OPTIONS for CORS preflight
40
+ if (method === "OPTIONS") {
41
+ res.writeHead(204);
42
+ res.end();
43
+ return;
44
+ }
45
+
46
+ // Health check (allow GET)
47
+ if (url === "/health") {
48
+ res.writeHead(200, { "Content-Type": "application/json" });
49
+ res.end(JSON.stringify({ status: "ok", version: "1.0.0" }));
50
+ return;
51
+ }
52
+
53
+ // Only allow POST for API endpoints
54
+ if (method !== "POST") {
55
+ res.writeHead(405, { "Content-Type": "application/json" });
56
+ res.end(JSON.stringify({ error: "Method not allowed" }));
57
+ return;
58
+ }
59
+
60
+ // Route to appropriate handler
61
+ try {
62
+ if (url === "/v1/messages") {
63
+ // Anthropic Messages API
64
+ await handleAnthropicRequest(req, res, config);
65
+ } else if (url === "/v1/chat/completions") {
66
+ // OpenAI Chat Completions API
67
+ await handleOpenAIRequest(req, res, config);
68
+ } else if (url?.match(/^\/v1\/models\/(.+):generateContent$/)) {
69
+ // Gemini API
70
+ const match = url.match(/^\/v1\/models\/(.+):generateContent$/);
71
+ const modelName = match?.[1];
72
+ if (modelName) {
73
+ await handleGeminiRequest(req, res, config, modelName);
74
+ } else {
75
+ res.writeHead(404, { "Content-Type": "application/json" });
76
+ res.end(JSON.stringify({ error: "Model name required" }));
77
+ }
78
+ } else {
79
+ // Unknown endpoint
80
+ res.writeHead(404, { "Content-Type": "application/json" });
81
+ res.end(JSON.stringify({ error: "Not found", url }));
82
+ }
83
+ } catch (error) {
84
+ console.error("[ai-security-gateway] Request handler error:", error);
85
+ res.writeHead(500, { "Content-Type": "application/json" });
86
+ res.end(
87
+ JSON.stringify({
88
+ error: "Internal server error",
89
+ message: error instanceof Error ? error.message : String(error),
90
+ }),
91
+ );
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Start gateway server
97
+ */
98
+ export function startGateway(configPath?: string): void {
99
+ try {
100
+ // Load and validate configuration
101
+ config = loadConfig(configPath);
102
+ validateConfig(config);
103
+
104
+ console.log("[ai-security-gateway] Configuration loaded:");
105
+ console.log(` Mode: ${GATEWAY_MODE}`);
106
+ console.log(` Port: ${config.port}`);
107
+ console.log(
108
+ ` Backends: ${Object.keys(config.backends).join(", ")}`,
109
+ );
110
+
111
+ // Create HTTP server
112
+ const server = createServer(handleRequest);
113
+
114
+ // Start listening
115
+ server.listen(config.port, "127.0.0.1", () => {
116
+ console.log(
117
+ `[ai-security-gateway] Server listening on http://127.0.0.1:${config.port}`,
118
+ );
119
+ console.log("[ai-security-gateway] Ready to proxy requests");
120
+ console.log("");
121
+ console.log("Endpoints:");
122
+ console.log(` POST http://127.0.0.1:${config.port}/v1/messages - Anthropic`);
123
+ console.log(` POST http://127.0.0.1:${config.port}/v1/chat/completions - OpenAI`);
124
+ console.log(` POST http://127.0.0.1:${config.port}/v1/models/:model:generateContent - Gemini`);
125
+ console.log(` GET http://127.0.0.1:${config.port}/health - Health check`);
126
+ });
127
+
128
+ // Handle shutdown
129
+ process.on("SIGINT", () => {
130
+ console.log("\n[ai-security-gateway] Shutting down...");
131
+ server.close(() => {
132
+ console.log("[ai-security-gateway] Server stopped");
133
+ process.exit(0);
134
+ });
135
+ });
136
+
137
+ process.on("SIGTERM", () => {
138
+ console.log("\n[ai-security-gateway] Shutting down...");
139
+ server.close(() => {
140
+ console.log("[ai-security-gateway] Server stopped");
141
+ process.exit(0);
142
+ });
143
+ });
144
+ } catch (error) {
145
+ console.error("[ai-security-gateway] Failed to start:", error);
146
+ process.exit(1);
147
+ }
148
+ }
149
+
150
+ // Re-export for programmatic use
151
+ export { sanitize, sanitizeMessages } from "./sanitizer.js";
152
+ export { restore, restoreJSON, restoreSSELine } from "./restorer.js";
153
+ export type { GatewayConfig, MappingTable, SanitizeResult, EntityMatch } from "./types.js";
154
+
155
+ // Start if run directly
156
+ if (import.meta.url === `file://${process.argv[1]}`) {
157
+ const configPath = process.argv[2];
158
+ startGateway(configPath);
159
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * AI Security Gateway - Content restorer
3
+ *
4
+ * Restores sanitized placeholders back to original values using the mapping table.
5
+ */
6
+
7
+ import type { MappingTable } from "./types.js";
8
+
9
+ /**
10
+ * Restore placeholders in a string
11
+ */
12
+ function restoreText(text: string, mappingTable: MappingTable): string {
13
+ let restored = text;
14
+
15
+ // Sort placeholders by length descending to handle nested cases
16
+ const placeholders = Array.from(mappingTable.keys()).sort(
17
+ (a, b) => b.length - a.length,
18
+ );
19
+
20
+ for (const placeholder of placeholders) {
21
+ const originalValue = mappingTable.get(placeholder)!;
22
+ // Use split/join for safe replacement (handles special regex chars)
23
+ restored = restored.split(placeholder).join(originalValue);
24
+ }
25
+
26
+ return restored;
27
+ }
28
+
29
+ /**
30
+ * Recursively restore any value (string, object, array)
31
+ */
32
+ function restoreValue(value: any, mappingTable: MappingTable): any {
33
+ // String: restore placeholders
34
+ if (typeof value === "string") {
35
+ return restoreText(value, mappingTable);
36
+ }
37
+
38
+ // Array: restore each element
39
+ if (Array.isArray(value)) {
40
+ return value.map((item) => restoreValue(item, mappingTable));
41
+ }
42
+
43
+ // Object: restore each property
44
+ if (value !== null && typeof value === "object") {
45
+ const restored: any = {};
46
+ for (const [key, val] of Object.entries(value)) {
47
+ restored[key] = restoreValue(val, mappingTable);
48
+ }
49
+ return restored;
50
+ }
51
+
52
+ // Primitives: return as-is
53
+ return value;
54
+ }
55
+
56
+ /**
57
+ * Restore any content (object, array, string) using the mapping table
58
+ */
59
+ export function restore(content: any, mappingTable: MappingTable): any {
60
+ if (mappingTable.size === 0) return content;
61
+ return restoreValue(content, mappingTable);
62
+ }
63
+
64
+ /**
65
+ * Restore a JSON string
66
+ * Useful for SSE streaming where each chunk is a JSON string
67
+ */
68
+ export function restoreJSON(jsonString: string, mappingTable: MappingTable): string {
69
+ if (mappingTable.size === 0) return jsonString;
70
+
71
+ try {
72
+ // Try to parse as JSON first
73
+ const parsed = JSON.parse(jsonString);
74
+ const restored = restore(parsed, mappingTable);
75
+ return JSON.stringify(restored);
76
+ } catch {
77
+ // If not valid JSON, treat as plain text
78
+ return restoreText(jsonString, mappingTable);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Restore SSE data line (for streaming responses)
84
+ * Format: "data: {...}\n"
85
+ */
86
+ export function restoreSSELine(line: string, mappingTable: MappingTable): string {
87
+ if (mappingTable.size === 0) return line;
88
+ if (!line.startsWith("data: ")) return line;
89
+
90
+ const dataContent = line.slice(6); // Remove "data: " prefix
91
+ if (dataContent === "[DONE]") return line;
92
+
93
+ try {
94
+ const parsed = JSON.parse(dataContent);
95
+ const restored = restore(parsed, mappingTable);
96
+ return `data: ${JSON.stringify(restored)}\n`;
97
+ } catch {
98
+ // Fallback to text restoration
99
+ return `data: ${restoreText(dataContent, mappingTable)}\n`;
100
+ }
101
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * AI Security Gateway - Content sanitizer
3
+ *
4
+ * Recursively processes message structures, replaces sensitive data with
5
+ * numbered placeholders, and returns a mapping table for restoration.
6
+ */
7
+
8
+ import type { SanitizeResult, MappingTable, EntityMatch } from "./types.js";
9
+
10
+ // =============================================================================
11
+ // Entity Definitions
12
+ // =============================================================================
13
+
14
+ type Entity = {
15
+ category: string;
16
+ categoryKey: string; // Used for numbered placeholders: __email_1__, __email_2__
17
+ pattern: RegExp;
18
+ };
19
+
20
+ const ENTITIES: Entity[] = [
21
+ // URLs (must come before email to avoid partial matches)
22
+ {
23
+ category: "URL",
24
+ categoryKey: "url",
25
+ pattern: /https?:\/\/[^\s<>"{}|\\^`\[\]]+/g,
26
+ },
27
+ // Email
28
+ {
29
+ category: "EMAIL",
30
+ categoryKey: "email",
31
+ pattern: /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g,
32
+ },
33
+ // Credit Card (4 groups of 4 digits)
34
+ {
35
+ category: "CREDIT_CARD",
36
+ categoryKey: "credit_card",
37
+ pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
38
+ },
39
+ // Bank Card (Chinese format: 16-19 digits)
40
+ {
41
+ category: "BANK_CARD",
42
+ categoryKey: "bank_card",
43
+ pattern: /\b\d{16,19}\b/g,
44
+ },
45
+ // SSN (###-##-####)
46
+ {
47
+ category: "SSN",
48
+ categoryKey: "ssn",
49
+ pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
50
+ },
51
+ // IBAN
52
+ {
53
+ category: "IBAN",
54
+ categoryKey: "iban",
55
+ pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}[A-Z0-9]{0,16}\b/g,
56
+ },
57
+ // IP Address
58
+ {
59
+ category: "IP_ADDRESS",
60
+ categoryKey: "ip",
61
+ pattern: /\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g,
62
+ },
63
+ // Phone numbers (US/intl formats, including +86-xxx-xxxx-xxxx)
64
+ {
65
+ category: "PHONE",
66
+ categoryKey: "phone",
67
+ pattern: /[+]?[0-9]{1,3}?[-\s.]?[(]?[0-9]{3}[)]?[-\s.][0-9]{3,4}[-\s.][0-9]{4,6}\b/g,
68
+ },
69
+ ];
70
+
71
+ // Known secret prefixes
72
+ const SECRET_PREFIXES = [
73
+ "sk-",
74
+ "sk_",
75
+ "pk_",
76
+ "ghp_",
77
+ "AKIA",
78
+ "xox",
79
+ "SG.",
80
+ "hf_",
81
+ "api-",
82
+ "token-",
83
+ "secret-",
84
+ ];
85
+
86
+ const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9\-_.~+/]+=*/g;
87
+
88
+ const SECRET_PREFIX_PATTERN = new RegExp(
89
+ `(?:${SECRET_PREFIXES.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})[A-Za-z0-9\\-_.~+/]{8,}=*`,
90
+ "g",
91
+ );
92
+
93
+ // =============================================================================
94
+ // Shannon Entropy
95
+ // =============================================================================
96
+
97
+ function shannonEntropy(s: string): number {
98
+ if (s.length === 0) return 0;
99
+ const freq = new Map<string, number>();
100
+ for (const ch of s) {
101
+ freq.set(ch, (freq.get(ch) ?? 0) + 1);
102
+ }
103
+ let entropy = 0;
104
+ for (const count of freq.values()) {
105
+ const p = count / s.length;
106
+ entropy -= p * Math.log2(p);
107
+ }
108
+ return entropy;
109
+ }
110
+
111
+ // =============================================================================
112
+ // Match Collection
113
+ // =============================================================================
114
+
115
+ function collectMatches(content: string): EntityMatch[] {
116
+ const matches: EntityMatch[] = [];
117
+
118
+ // Regex-based entities
119
+ for (const entity of ENTITIES) {
120
+ entity.pattern.lastIndex = 0;
121
+ let m: RegExpExecArray | null;
122
+ while ((m = entity.pattern.exec(content)) !== null) {
123
+ matches.push({
124
+ originalText: m[0],
125
+ category: entity.categoryKey,
126
+ placeholder: "", // Will be set later with numbering
127
+ });
128
+ }
129
+ }
130
+
131
+ // Secret prefixes
132
+ SECRET_PREFIX_PATTERN.lastIndex = 0;
133
+ let m: RegExpExecArray | null;
134
+ while ((m = SECRET_PREFIX_PATTERN.exec(content)) !== null) {
135
+ matches.push({
136
+ originalText: m[0],
137
+ category: "secret",
138
+ placeholder: "",
139
+ });
140
+ }
141
+
142
+ // Bearer tokens
143
+ BEARER_PATTERN.lastIndex = 0;
144
+ while ((m = BEARER_PATTERN.exec(content)) !== null) {
145
+ matches.push({
146
+ originalText: m[0],
147
+ category: "secret",
148
+ placeholder: "",
149
+ });
150
+ }
151
+
152
+ // High-entropy tokens
153
+ const tokenPattern = /\b[A-Za-z0-9\-_.~+/]{20,}={0,3}\b/g;
154
+ tokenPattern.lastIndex = 0;
155
+ while ((m = tokenPattern.exec(content)) !== null) {
156
+ const token = m[0];
157
+ if (matches.some((existing) => existing.originalText === token)) continue;
158
+ if (/^[a-z]+$/.test(token)) continue;
159
+ if (shannonEntropy(token) >= 4.0) {
160
+ matches.push({
161
+ originalText: token,
162
+ category: "secret",
163
+ placeholder: "",
164
+ });
165
+ }
166
+ }
167
+
168
+ return matches;
169
+ }
170
+
171
+ // =============================================================================
172
+ // Text Sanitization
173
+ // =============================================================================
174
+
175
+ function sanitizeText(
176
+ text: string,
177
+ mappingTable: MappingTable,
178
+ categoryCounters: Map<string, number>,
179
+ ): string {
180
+ const matches = collectMatches(text);
181
+ if (matches.length === 0) return text;
182
+
183
+ // Deduplicate by original text
184
+ const unique = new Map<string, EntityMatch>();
185
+ for (const match of matches) {
186
+ if (!unique.has(match.originalText)) {
187
+ unique.set(match.originalText, match);
188
+ }
189
+ }
190
+
191
+ // Sort by length descending
192
+ const sorted = [...unique.values()].sort(
193
+ (a, b) => b.originalText.length - a.originalText.length,
194
+ );
195
+
196
+ // Replace and build mapping table
197
+ let sanitized = text;
198
+ for (const match of sorted) {
199
+ // Generate numbered placeholder
200
+ const counter = (categoryCounters.get(match.category) ?? 0) + 1;
201
+ categoryCounters.set(match.category, counter);
202
+ const placeholder = `__${match.category}_${counter}__`;
203
+
204
+ // Replace all occurrences
205
+ const parts = sanitized.split(match.originalText);
206
+ if (parts.length > 1) {
207
+ sanitized = parts.join(placeholder);
208
+ mappingTable.set(placeholder, match.originalText);
209
+ }
210
+ }
211
+
212
+ return sanitized;
213
+ }
214
+
215
+ // =============================================================================
216
+ // Recursive Sanitization
217
+ // =============================================================================
218
+
219
+ /**
220
+ * Recursively sanitize any value (string, object, array)
221
+ */
222
+ function sanitizeValue(
223
+ value: any,
224
+ mappingTable: MappingTable,
225
+ categoryCounters: Map<string, number>,
226
+ ): any {
227
+ // String: sanitize directly
228
+ if (typeof value === "string") {
229
+ return sanitizeText(value, mappingTable, categoryCounters);
230
+ }
231
+
232
+ // Array: sanitize each element
233
+ if (Array.isArray(value)) {
234
+ return value.map((item) =>
235
+ sanitizeValue(item, mappingTable, categoryCounters),
236
+ );
237
+ }
238
+
239
+ // Object: sanitize each property
240
+ if (value !== null && typeof value === "object") {
241
+ const sanitized: any = {};
242
+ for (const [key, val] of Object.entries(value)) {
243
+ sanitized[key] = sanitizeValue(val, mappingTable, categoryCounters);
244
+ }
245
+ return sanitized;
246
+ }
247
+
248
+ // Primitives: return as-is
249
+ return value;
250
+ }
251
+
252
+ // =============================================================================
253
+ // Public API
254
+ // =============================================================================
255
+
256
+ /**
257
+ * Sanitize any content (messages array, object, string)
258
+ * Returns sanitized content and mapping table for restoration
259
+ */
260
+ export function sanitize(content: any): SanitizeResult {
261
+ const mappingTable: MappingTable = new Map();
262
+ const categoryCounters = new Map<string, number>();
263
+
264
+ const sanitized = sanitizeValue(content, mappingTable, categoryCounters);
265
+
266
+ return {
267
+ sanitized,
268
+ mappingTable,
269
+ redactionCount: mappingTable.size,
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Sanitize messages array (common case for LLM APIs)
275
+ */
276
+ export function sanitizeMessages(messages: any[]): SanitizeResult {
277
+ return sanitize(messages);
278
+ }
package/src/types.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * AI Security Gateway types
3
+ */
4
+
5
+ // Mapping from placeholder to original value
6
+ export type MappingTable = Map<string, string>;
7
+
8
+ // Result of sanitization with mapping table
9
+ export type SanitizeResult = {
10
+ sanitized: any; // Sanitized content (same structure as input)
11
+ mappingTable: MappingTable; // placeholder -> original value
12
+ redactionCount: number; // Total redactions made
13
+ };
14
+
15
+ // Gateway configuration
16
+ export type GatewayConfig = {
17
+ port: number;
18
+ backends: {
19
+ anthropic?: {
20
+ baseUrl: string;
21
+ apiKey: string;
22
+ };
23
+ openai?: {
24
+ baseUrl: string;
25
+ apiKey: string;
26
+ };
27
+ gemini?: {
28
+ baseUrl: string;
29
+ apiKey: string;
30
+ };
31
+ };
32
+ // Optional: route specific paths to specific backends
33
+ routing?: {
34
+ [path: string]: keyof GatewayConfig["backends"];
35
+ };
36
+ };
37
+
38
+ // Entity match
39
+ export type EntityMatch = {
40
+ originalText: string;
41
+ category: string;
42
+ placeholder: string;
43
+ };