@neurosec/sentry 1.0.19 → 1.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/README.md +4 -0
- package/dist/api-auth.d.ts +31 -0
- package/dist/api-auth.d.ts.map +1 -0
- package/dist/api-auth.js +105 -0
- package/dist/api-auth.js.map +1 -0
- package/dist/api-auth.test.d.ts +2 -0
- package/dist/api-auth.test.d.ts.map +1 -0
- package/dist/api-auth.test.js +89 -0
- package/dist/api-auth.test.js.map +1 -0
- package/dist/api.d.ts +8 -7
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +141 -134
- package/dist/api.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +107 -14
- package/dist/cli.js.map +1 -1
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +68 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +50 -1
- package/dist/config.js.map +1 -1
- package/dist/discovery-win.d.ts +4 -0
- package/dist/discovery-win.d.ts.map +1 -0
- package/dist/discovery-win.js +153 -0
- package/dist/discovery-win.js.map +1 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +23 -97
- package/dist/discovery.js.map +1 -1
- package/dist/discovery.test.js +18 -109
- package/dist/discovery.test.js.map +1 -1
- package/dist/enforcement/file-monitor.d.ts +9 -0
- package/dist/enforcement/file-monitor.d.ts.map +1 -1
- package/dist/enforcement/file-monitor.js +9 -2
- package/dist/enforcement/file-monitor.js.map +1 -1
- package/dist/enforcement/network-monitor.d.ts.map +1 -1
- package/dist/enforcement/network-monitor.js +350 -9
- package/dist/enforcement/network-monitor.js.map +1 -1
- package/dist/enforcement/network-monitor.test.d.ts +2 -0
- package/dist/enforcement/network-monitor.test.d.ts.map +1 -0
- package/dist/enforcement/network-monitor.test.js +52 -0
- package/dist/enforcement/network-monitor.test.js.map +1 -0
- package/dist/enforcement/policy-executor.d.ts +24 -1
- package/dist/enforcement/policy-executor.d.ts.map +1 -1
- package/dist/enforcement/policy-executor.js +213 -69
- package/dist/enforcement/policy-executor.js.map +1 -1
- package/dist/enforcement/policy-executor.test.d.ts +2 -0
- package/dist/enforcement/policy-executor.test.d.ts.map +1 -0
- package/dist/enforcement/policy-executor.test.js +46 -0
- package/dist/enforcement/policy-executor.test.js.map +1 -0
- package/dist/enforcement/target-validator.d.ts +37 -0
- package/dist/enforcement/target-validator.d.ts.map +1 -0
- package/dist/enforcement/target-validator.js +0 -0
- package/dist/enforcement/target-validator.js.map +1 -0
- package/dist/enforcement/target-validator.test.d.ts +2 -0
- package/dist/enforcement/target-validator.test.d.ts.map +1 -0
- package/dist/enforcement/target-validator.test.js +103 -0
- package/dist/enforcement/target-validator.test.js.map +1 -0
- package/dist/http-client.d.ts +35 -0
- package/dist/http-client.d.ts.map +1 -0
- package/dist/http-client.js +168 -0
- package/dist/http-client.js.map +1 -0
- package/dist/http-client.test.d.ts +2 -0
- package/dist/http-client.test.d.ts.map +1 -0
- package/dist/http-client.test.js +172 -0
- package/dist/http-client.test.js.map +1 -0
- package/dist/index.js +189 -113
- package/dist/index.js.map +1 -1
- package/dist/launcher.d.ts +33 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +425 -0
- package/dist/launcher.js.map +1 -0
- package/dist/launcher.test.d.ts +2 -0
- package/dist/launcher.test.d.ts.map +1 -0
- package/dist/launcher.test.js +109 -0
- package/dist/launcher.test.js.map +1 -0
- package/dist/proxy/cert-manager.d.ts +24 -0
- package/dist/proxy/cert-manager.d.ts.map +1 -0
- package/dist/proxy/cert-manager.js +117 -0
- package/dist/proxy/cert-manager.js.map +1 -0
- package/dist/proxy/cert-manager.test.d.ts +2 -0
- package/dist/proxy/cert-manager.test.d.ts.map +1 -0
- package/dist/proxy/cert-manager.test.js +70 -0
- package/dist/proxy/cert-manager.test.js.map +1 -0
- package/dist/proxy/index.d.ts +61 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +74 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/proxy/policy-enforcer.d.ts +30 -0
- package/dist/proxy/policy-enforcer.d.ts.map +1 -0
- package/dist/proxy/policy-enforcer.js +143 -0
- package/dist/proxy/policy-enforcer.js.map +1 -0
- package/dist/proxy/proxy-server.d.ts +42 -0
- package/dist/proxy/proxy-server.d.ts.map +1 -0
- package/dist/proxy/proxy-server.js +652 -0
- package/dist/proxy/proxy-server.js.map +1 -0
- package/dist/proxy/redaction-engine.d.ts +4 -0
- package/dist/proxy/redaction-engine.d.ts.map +1 -0
- package/dist/proxy/redaction-engine.js +50 -0
- package/dist/proxy/redaction-engine.js.map +1 -0
- package/dist/proxy/response-redaction.test.d.ts +2 -0
- package/dist/proxy/response-redaction.test.d.ts.map +1 -0
- package/dist/proxy/response-redaction.test.js +125 -0
- package/dist/proxy/response-redaction.test.js.map +1 -0
- package/dist/proxy/threat-engine.d.ts +22 -0
- package/dist/proxy/threat-engine.d.ts.map +1 -0
- package/dist/proxy/threat-engine.js +291 -0
- package/dist/proxy/threat-engine.js.map +1 -0
- package/dist/proxy/threat-engine.test.d.ts +2 -0
- package/dist/proxy/threat-engine.test.d.ts.map +1 -0
- package/dist/proxy/threat-engine.test.js +27 -0
- package/dist/proxy/threat-engine.test.js.map +1 -0
- package/dist/redirect/env-injector.d.ts +72 -0
- package/dist/redirect/env-injector.d.ts.map +1 -0
- package/dist/redirect/env-injector.js +177 -0
- package/dist/redirect/env-injector.js.map +1 -0
- package/dist/redirect/env-injector.test.d.ts +2 -0
- package/dist/redirect/env-injector.test.d.ts.map +1 -0
- package/dist/redirect/env-injector.test.js +91 -0
- package/dist/redirect/env-injector.test.js.map +1 -0
- package/dist/redirect/index.d.ts +3 -0
- package/dist/redirect/index.d.ts.map +1 -0
- package/dist/redirect/index.js +8 -0
- package/dist/redirect/index.js.map +1 -0
- package/dist/redirect/platform-redirect.d.ts +42 -0
- package/dist/redirect/platform-redirect.d.ts.map +1 -0
- package/dist/redirect/platform-redirect.js +229 -0
- package/dist/redirect/platform-redirect.js.map +1 -0
- package/dist/redirect/platform-redirect.test.d.ts +2 -0
- package/dist/redirect/platform-redirect.test.d.ts.map +1 -0
- package/dist/redirect/platform-redirect.test.js +76 -0
- package/dist/redirect/platform-redirect.test.js.map +1 -0
- package/dist/sandbox/index.d.ts +23 -2
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +24 -7
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/linux-sandbox.d.ts +13 -2
- package/dist/sandbox/linux-sandbox.d.ts.map +1 -1
- package/dist/sandbox/linux-sandbox.js +61 -27
- package/dist/sandbox/linux-sandbox.js.map +1 -1
- package/dist/sandbox/macos-sandbox.d.ts +15 -4
- package/dist/sandbox/macos-sandbox.d.ts.map +1 -1
- package/dist/sandbox/macos-sandbox.js +36 -18
- package/dist/sandbox/macos-sandbox.js.map +1 -1
- package/dist/sandbox/sandbox-result.test.d.ts +2 -0
- package/dist/sandbox/sandbox-result.test.d.ts.map +1 -0
- package/dist/sandbox/sandbox-result.test.js +87 -0
- package/dist/sandbox/sandbox-result.test.js.map +1 -0
- package/dist/sandbox/windows-sandbox.d.ts +34 -0
- package/dist/sandbox/windows-sandbox.d.ts.map +1 -0
- package/dist/sandbox/windows-sandbox.js +161 -0
- package/dist/sandbox/windows-sandbox.js.map +1 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +33 -43
- package/dist/setup.js.map +1 -1
- package/dist/skill-authz/skill-evaluator.d.ts +30 -0
- package/dist/skill-authz/skill-evaluator.d.ts.map +1 -1
- package/dist/skill-authz/skill-evaluator.js +161 -30
- package/dist/skill-authz/skill-evaluator.js.map +1 -1
- package/dist/skill-authz/skill-evaluator.test.d.ts +2 -0
- package/dist/skill-authz/skill-evaluator.test.d.ts.map +1 -0
- package/dist/skill-authz/skill-evaluator.test.js +127 -0
- package/dist/skill-authz/skill-evaluator.test.js.map +1 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +16 -44
- package/dist/telemetry.js.map +1 -1
- package/dist/types.d.ts +48 -105
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +34 -1
- package/dist/types.js.map +1 -1
- package/package.json +7 -3
- package/scripts/install-sentry-windows.ps1 +217 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.LLMProxyServer = void 0;
|
|
7
|
+
const http_1 = __importDefault(require("http"));
|
|
8
|
+
const https_1 = __importDefault(require("https"));
|
|
9
|
+
const url_1 = __importDefault(require("url"));
|
|
10
|
+
const uuid_1 = require("uuid");
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const types_1 = require("../types");
|
|
13
|
+
const cert_manager_1 = require("./cert-manager");
|
|
14
|
+
const logger_1 = require("../logger");
|
|
15
|
+
class LLMProxyServer {
|
|
16
|
+
constructor(config, enforcer) {
|
|
17
|
+
this.server = null;
|
|
18
|
+
this.httpsServer = null;
|
|
19
|
+
this.activeRequests = new Map();
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.enforcer = enforcer;
|
|
22
|
+
}
|
|
23
|
+
async start() {
|
|
24
|
+
if (this.server)
|
|
25
|
+
return;
|
|
26
|
+
this.server = http_1.default.createServer((req, res) => {
|
|
27
|
+
this.handleRequest(req, res).catch(err => {
|
|
28
|
+
logger_1.logger.error('Proxy request handler error', { err: err.message });
|
|
29
|
+
if (!res.headersSent) {
|
|
30
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
31
|
+
res.end(JSON.stringify({ error: 'Proxy error' }));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
this.server.once('error', reject);
|
|
37
|
+
this.server.listen(this.config.port, this.config.bindAddress, () => resolve());
|
|
38
|
+
});
|
|
39
|
+
if (this.config.interceptHttps) {
|
|
40
|
+
try {
|
|
41
|
+
// Auto-provision a local CA on first start so HTTPS interception
|
|
42
|
+
// works out of the box instead of being a passthrough default (S-C8).
|
|
43
|
+
const certInfo = (0, cert_manager_1.ensureProxyCertificate)({
|
|
44
|
+
certPath: this.config.certPath,
|
|
45
|
+
keyPath: this.config.keyPath,
|
|
46
|
+
});
|
|
47
|
+
const cert = fs_1.default.readFileSync(certInfo.certPath, 'utf8');
|
|
48
|
+
const key = fs_1.default.readFileSync(certInfo.keyPath, 'utf8');
|
|
49
|
+
this.httpsServer = https_1.default.createServer({ cert, key }, (req, res) => {
|
|
50
|
+
this.handleRequest(req, res).catch(err => {
|
|
51
|
+
logger_1.logger.error('HTTPS proxy handler error', { err: err.message });
|
|
52
|
+
if (!res.headersSent) {
|
|
53
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
54
|
+
res.end(JSON.stringify({ error: 'Proxy error' }));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
this.httpsServer.once('error', reject);
|
|
60
|
+
this.httpsServer.listen(this.config.port + 1, this.config.bindAddress, () => resolve());
|
|
61
|
+
});
|
|
62
|
+
logger_1.logger.info('HTTPS proxy listening', {
|
|
63
|
+
port: this.config.port + 1,
|
|
64
|
+
fingerprintSha256: certInfo.fingerprintSha256,
|
|
65
|
+
generated: certInfo.generated,
|
|
66
|
+
notAfter: certInfo.notAfter,
|
|
67
|
+
trustHint: 'Set NODE_EXTRA_CA_CERTS=' + certInfo.certPath + ' on agent hosts',
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
logger_1.logger.warn('HTTPS proxy not started (cert provisioning failed)', {
|
|
72
|
+
certPath: this.config.certPath,
|
|
73
|
+
err: err.message,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
logger_1.logger.info('LLM Proxy server started', {
|
|
78
|
+
port: this.config.port,
|
|
79
|
+
bind: this.config.bindAddress,
|
|
80
|
+
upstreamTimeoutMs: this.config.upstreamTimeoutMs,
|
|
81
|
+
maxBufferMb: this.config.maxBufferSizeMb,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async stop() {
|
|
85
|
+
if (this.httpsServer) {
|
|
86
|
+
await new Promise(resolve => this.httpsServer?.close(() => resolve()));
|
|
87
|
+
this.httpsServer = null;
|
|
88
|
+
}
|
|
89
|
+
if (this.server) {
|
|
90
|
+
await new Promise(resolve => this.server?.close(() => resolve()));
|
|
91
|
+
this.server = null;
|
|
92
|
+
}
|
|
93
|
+
logger_1.logger.info('LLM Proxy server stopped');
|
|
94
|
+
}
|
|
95
|
+
async handleRequest(clientReq, clientRes) {
|
|
96
|
+
const requestId = (0, uuid_1.v4)();
|
|
97
|
+
const startTime = Date.now();
|
|
98
|
+
if (this.activeRequests.size >= (this.config.maxConcurrentRequests ?? 100)) {
|
|
99
|
+
clientRes.writeHead(503, { 'Content-Type': 'application/json' });
|
|
100
|
+
clientRes.end(JSON.stringify({
|
|
101
|
+
success: false,
|
|
102
|
+
error: { code: 'PROXY_BUSY', message: 'Local Sentry proxy concurrency limit reached' },
|
|
103
|
+
}));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const cleanup = () => {
|
|
107
|
+
this.activeRequests.delete(requestId);
|
|
108
|
+
};
|
|
109
|
+
clientRes.once('finish', cleanup);
|
|
110
|
+
clientRes.once('close', cleanup);
|
|
111
|
+
clientReq.once('aborted', cleanup);
|
|
112
|
+
this.activeRequests.set(requestId, {
|
|
113
|
+
id: requestId,
|
|
114
|
+
method: clientReq.method ?? 'GET',
|
|
115
|
+
path: clientReq.url ?? '/',
|
|
116
|
+
host: String(clientReq.headers['host'] ?? 'unknown'),
|
|
117
|
+
headers: {},
|
|
118
|
+
body: '',
|
|
119
|
+
startTime,
|
|
120
|
+
});
|
|
121
|
+
// Collect the request body
|
|
122
|
+
const body = await this.collectBody(clientReq, this.config.maxBufferSizeMb * 1024 * 1024);
|
|
123
|
+
const targetUrl = clientReq.url ?? '/';
|
|
124
|
+
const targetHost = clientReq.headers['host'] ?? 'unknown';
|
|
125
|
+
const method = clientReq.method ?? 'GET';
|
|
126
|
+
this.activeRequests.set(requestId, {
|
|
127
|
+
id: requestId,
|
|
128
|
+
method,
|
|
129
|
+
path: targetUrl,
|
|
130
|
+
host: String(targetHost),
|
|
131
|
+
headers: this.filterHeaders(clientReq.headers),
|
|
132
|
+
body,
|
|
133
|
+
startTime,
|
|
134
|
+
});
|
|
135
|
+
logger_1.logger.debug('Proxy request received', {
|
|
136
|
+
id: requestId,
|
|
137
|
+
method,
|
|
138
|
+
path: targetUrl,
|
|
139
|
+
host: targetHost,
|
|
140
|
+
size: body.length,
|
|
141
|
+
});
|
|
142
|
+
// Detect the LLM provider
|
|
143
|
+
const providerMatch = (0, types_1.detectProviderFromRequest)(targetUrl, targetHost);
|
|
144
|
+
// Determine if this is an LLM API call we should intercept
|
|
145
|
+
const isLLMCall = this.isLLMRequest(targetUrl, method);
|
|
146
|
+
if (isLLMCall) {
|
|
147
|
+
// Enforce input policy on the request body
|
|
148
|
+
const requestDecision = this.enforcer.enforce(body, 'input');
|
|
149
|
+
if (requestDecision.action === 'block') {
|
|
150
|
+
logger_1.logger.warn('Request blocked by NeuroShield policy', {
|
|
151
|
+
id: requestId,
|
|
152
|
+
reason: requestDecision.reason,
|
|
153
|
+
threats: requestDecision.threats.length,
|
|
154
|
+
});
|
|
155
|
+
clientRes.writeHead(403, { 'Content-Type': 'application/json' });
|
|
156
|
+
clientRes.end(JSON.stringify({
|
|
157
|
+
success: false,
|
|
158
|
+
error: {
|
|
159
|
+
code: 'FIREWALL_BLOCKED',
|
|
160
|
+
message: 'Request blocked by NeuroShield policy',
|
|
161
|
+
details: {
|
|
162
|
+
reason: requestDecision.reason,
|
|
163
|
+
threats: requestDecision.threats.map(t => ({ type: t.type, severity: t.severity })),
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
}));
|
|
167
|
+
this.recordProxyEvent(requestId, method, targetUrl, targetHost, body, 403, { 'content-type': 'application/json' }, JSON.stringify({ blocked: true }), requestDecision, 'request', Date.now() - startTime);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Determine the upstream target
|
|
172
|
+
const upstream = this.resolveUpstream(targetUrl, targetHost, providerMatch);
|
|
173
|
+
// Forward the request
|
|
174
|
+
try {
|
|
175
|
+
await this.forwardRequest(clientReq, clientRes, {
|
|
176
|
+
id: requestId,
|
|
177
|
+
method,
|
|
178
|
+
path: targetUrl,
|
|
179
|
+
host: targetHost,
|
|
180
|
+
headers: this.filterHeaders(clientReq.headers),
|
|
181
|
+
body,
|
|
182
|
+
startTime,
|
|
183
|
+
}, upstream, isLLMCall);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
logger_1.logger.error('Upstream request failed', {
|
|
187
|
+
id: requestId,
|
|
188
|
+
upstream: upstream?.hostname ?? 'unknown',
|
|
189
|
+
err: err.message,
|
|
190
|
+
});
|
|
191
|
+
if (!clientRes.headersSent) {
|
|
192
|
+
clientRes.writeHead(502, { 'Content-Type': 'application/json' });
|
|
193
|
+
clientRes.end(JSON.stringify({
|
|
194
|
+
success: false,
|
|
195
|
+
error: { code: 'UPSTREAM_ERROR', message: 'Failed to reach LLM provider' },
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async forwardRequest(clientReq, clientRes, pending, upstream, enforceOutput) {
|
|
201
|
+
if (!upstream) {
|
|
202
|
+
// No upstream determined: return error
|
|
203
|
+
clientRes.writeHead(502, { 'Content-Type': 'application/json' });
|
|
204
|
+
clientRes.end(JSON.stringify({
|
|
205
|
+
success: false,
|
|
206
|
+
error: { code: 'NO_UPSTREAM', message: 'Could not determine upstream LLM provider' },
|
|
207
|
+
}));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// For non-LLM traffic, just proxy through without inspection
|
|
211
|
+
if (!enforceOutput) {
|
|
212
|
+
const options = {
|
|
213
|
+
hostname: upstream.hostname,
|
|
214
|
+
port: upstream.port,
|
|
215
|
+
path: upstream.path,
|
|
216
|
+
method: pending.method,
|
|
217
|
+
headers: pending.headers,
|
|
218
|
+
timeout: this.config.upstreamTimeoutMs,
|
|
219
|
+
};
|
|
220
|
+
const proxyReq = (upstream.protocol === 'https:' ? https_1.default : http_1.default).request(options, (upstreamRes) => {
|
|
221
|
+
clientRes.writeHead(upstreamRes.statusCode ?? 200, upstreamRes.headers);
|
|
222
|
+
upstreamRes.pipe(clientRes);
|
|
223
|
+
});
|
|
224
|
+
proxyReq.on('error', (err) => {
|
|
225
|
+
logger_1.logger.error('Proxy forward error', { err: err.message });
|
|
226
|
+
if (!clientRes.headersSent) {
|
|
227
|
+
clientRes.writeHead(502);
|
|
228
|
+
clientRes.end();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
proxyReq.write(pending.body);
|
|
232
|
+
proxyReq.end();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// For LLM traffic: forward, buffer response, enforce output policy
|
|
236
|
+
const options = {
|
|
237
|
+
hostname: upstream.hostname,
|
|
238
|
+
port: upstream.port,
|
|
239
|
+
path: upstream.path,
|
|
240
|
+
method: pending.method,
|
|
241
|
+
headers: { ...pending.headers, 'host': upstream.hostname },
|
|
242
|
+
timeout: this.config.upstreamTimeoutMs,
|
|
243
|
+
};
|
|
244
|
+
const proxyReq = (upstream.protocol === 'https:' ? https_1.default : http_1.default).request(options, async (upstreamRes) => {
|
|
245
|
+
const contentType = Array.isArray(upstreamRes.headers['content-type'])
|
|
246
|
+
? upstreamRes.headers['content-type'][0]
|
|
247
|
+
: upstreamRes.headers['content-type'] ?? '';
|
|
248
|
+
if (/text\/event-stream/i.test(contentType)) {
|
|
249
|
+
await this.forwardStreamingResponse(clientRes, upstreamRes, pending);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const chunks = [];
|
|
253
|
+
let totalSize = 0;
|
|
254
|
+
const maxSize = this.config.maxBufferSizeMb * 1024 * 1024;
|
|
255
|
+
upstreamRes.on('data', (chunk) => {
|
|
256
|
+
totalSize += chunk.length;
|
|
257
|
+
if (totalSize <= maxSize) {
|
|
258
|
+
chunks.push(chunk);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
upstreamRes.on('end', () => {
|
|
262
|
+
const responseBody = Buffer.concat(chunks).toString('utf8');
|
|
263
|
+
const elapsed = Date.now() - pending.startTime;
|
|
264
|
+
// Enforce output policy
|
|
265
|
+
const responseDecision = this.enforcer.enforce(responseBody, 'output');
|
|
266
|
+
if (responseDecision.action === 'block') {
|
|
267
|
+
logger_1.logger.warn('Response blocked by NeuroShield policy', {
|
|
268
|
+
id: pending.id,
|
|
269
|
+
reason: responseDecision.reason,
|
|
270
|
+
threats: responseDecision.threats.length,
|
|
271
|
+
});
|
|
272
|
+
clientRes.writeHead(403, { 'Content-Type': 'application/json' });
|
|
273
|
+
clientRes.end(JSON.stringify({
|
|
274
|
+
success: false,
|
|
275
|
+
error: {
|
|
276
|
+
code: 'FIREWALL_BLOCKED_RESPONSE',
|
|
277
|
+
message: 'Response blocked by NeuroShield policy',
|
|
278
|
+
details: {
|
|
279
|
+
reason: responseDecision.reason,
|
|
280
|
+
threats: responseDecision.threats.map(t => ({ type: t.type, severity: t.severity })),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
const responseToSend = responseDecision.action === 'redact'
|
|
287
|
+
? this.applyRedactionToResponse(responseBody, responseDecision)
|
|
288
|
+
: responseBody;
|
|
289
|
+
const responseHeaders = { ...upstreamRes.headers };
|
|
290
|
+
delete responseHeaders['content-length'];
|
|
291
|
+
delete responseHeaders['transfer-encoding'];
|
|
292
|
+
clientRes.writeHead(upstreamRes.statusCode ?? 200, responseHeaders);
|
|
293
|
+
clientRes.end(responseToSend);
|
|
294
|
+
}
|
|
295
|
+
this.recordProxyEvent(pending.id, pending.method, pending.path, pending.host, pending.body, upstreamRes.statusCode ?? 200, upstreamRes.headers, responseBody, responseDecision, 'response', elapsed);
|
|
296
|
+
});
|
|
297
|
+
upstreamRes.on('error', (err) => {
|
|
298
|
+
logger_1.logger.error('Upstream response error', { err: err.message });
|
|
299
|
+
if (!clientRes.headersSent) {
|
|
300
|
+
clientRes.writeHead(502);
|
|
301
|
+
clientRes.end();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
proxyReq.on('error', (err) => {
|
|
306
|
+
logger_1.logger.error('Upstream request error', { err: err.message });
|
|
307
|
+
if (!clientRes.headersSent) {
|
|
308
|
+
clientRes.writeHead(502, { 'Content-Type': 'application/json' });
|
|
309
|
+
clientRes.end(JSON.stringify({ error: 'Upstream connection failed' }));
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
proxyReq.on('timeout', () => {
|
|
313
|
+
proxyReq.destroy();
|
|
314
|
+
if (!clientRes.headersSent) {
|
|
315
|
+
clientRes.writeHead(504, { 'Content-Type': 'application/json' });
|
|
316
|
+
clientRes.end(JSON.stringify({ error: 'Upstream timeout' }));
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
proxyReq.write(pending.body);
|
|
320
|
+
proxyReq.end();
|
|
321
|
+
}
|
|
322
|
+
async forwardStreamingResponse(clientRes, upstreamRes, pending) {
|
|
323
|
+
const responseHeaders = { ...upstreamRes.headers };
|
|
324
|
+
delete responseHeaders['content-length'];
|
|
325
|
+
let started = false;
|
|
326
|
+
let trailing = '';
|
|
327
|
+
let rawResponseBody = '';
|
|
328
|
+
let finalDecision = this.allowDecision();
|
|
329
|
+
const writeHeaders = () => {
|
|
330
|
+
if (started) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
clientRes.writeHead(upstreamRes.statusCode ?? 200, responseHeaders);
|
|
334
|
+
started = true;
|
|
335
|
+
};
|
|
336
|
+
const handleLine = (line) => {
|
|
337
|
+
const rewritten = this.rewriteStreamingSseLine(line);
|
|
338
|
+
if (rewritten.decision.action === 'block') {
|
|
339
|
+
finalDecision = rewritten.decision;
|
|
340
|
+
if (!started) {
|
|
341
|
+
clientRes.writeHead(403, { 'Content-Type': 'application/json' });
|
|
342
|
+
clientRes.end(JSON.stringify({
|
|
343
|
+
success: false,
|
|
344
|
+
error: {
|
|
345
|
+
code: 'FIREWALL_BLOCKED_RESPONSE',
|
|
346
|
+
message: 'Streaming response blocked by NeuroShield policy',
|
|
347
|
+
details: {
|
|
348
|
+
reason: rewritten.decision.reason,
|
|
349
|
+
threats: rewritten.decision.threats.map(t => ({ type: t.type, severity: t.severity })),
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
clientRes.write(`event: error\ndata: ${JSON.stringify({ error: rewritten.decision.reason })}\n\n`);
|
|
356
|
+
clientRes.end();
|
|
357
|
+
}
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
if (rewritten.decision.action !== 'allow') {
|
|
361
|
+
finalDecision = rewritten.decision;
|
|
362
|
+
}
|
|
363
|
+
writeHeaders();
|
|
364
|
+
clientRes.write(`${rewritten.line}\n`);
|
|
365
|
+
return true;
|
|
366
|
+
};
|
|
367
|
+
await new Promise((resolve) => {
|
|
368
|
+
upstreamRes.on('data', (chunk) => {
|
|
369
|
+
if (clientRes.writableEnded) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const text = chunk.toString('utf8');
|
|
373
|
+
rawResponseBody += text;
|
|
374
|
+
trailing += text;
|
|
375
|
+
while (true) {
|
|
376
|
+
const newlineIndex = trailing.indexOf('\n');
|
|
377
|
+
if (newlineIndex === -1) {
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
const line = trailing.slice(0, newlineIndex);
|
|
381
|
+
trailing = trailing.slice(newlineIndex + 1);
|
|
382
|
+
if (!handleLine(line)) {
|
|
383
|
+
upstreamRes.destroy();
|
|
384
|
+
resolve();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
upstreamRes.on('end', () => {
|
|
390
|
+
if (!clientRes.writableEnded && trailing.length > 0) {
|
|
391
|
+
handleLine(trailing);
|
|
392
|
+
}
|
|
393
|
+
if (!clientRes.writableEnded) {
|
|
394
|
+
writeHeaders();
|
|
395
|
+
clientRes.end();
|
|
396
|
+
}
|
|
397
|
+
this.recordProxyEvent(pending.id, pending.method, pending.path, pending.host, pending.body, upstreamRes.statusCode ?? 200, upstreamRes.headers, rawResponseBody, finalDecision, 'response', Date.now() - pending.startTime);
|
|
398
|
+
resolve();
|
|
399
|
+
});
|
|
400
|
+
upstreamRes.on('error', (err) => {
|
|
401
|
+
logger_1.logger.error('Upstream streaming response error', { err: err.message });
|
|
402
|
+
if (!clientRes.headersSent) {
|
|
403
|
+
clientRes.writeHead(502, { 'Content-Type': 'application/json' });
|
|
404
|
+
clientRes.end(JSON.stringify({ error: 'Upstream streaming failed' }));
|
|
405
|
+
}
|
|
406
|
+
else if (!clientRes.writableEnded) {
|
|
407
|
+
clientRes.end();
|
|
408
|
+
}
|
|
409
|
+
resolve();
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
rewriteStreamingSseLine(line) {
|
|
414
|
+
const match = line.match(/^data:\s*(.*)$/);
|
|
415
|
+
if (!match) {
|
|
416
|
+
return { line, decision: this.allowDecision() };
|
|
417
|
+
}
|
|
418
|
+
const payload = match[1];
|
|
419
|
+
if (!payload || payload === '[DONE]') {
|
|
420
|
+
return { line, decision: this.allowDecision() };
|
|
421
|
+
}
|
|
422
|
+
const decision = this.enforcer.enforce(payload, 'output');
|
|
423
|
+
if (decision.action !== 'redact') {
|
|
424
|
+
return { line, decision };
|
|
425
|
+
}
|
|
426
|
+
const redactor = this.enforcer.getRedactionEngine();
|
|
427
|
+
const threatTypes = decision.threats.map((threat) => threat.type);
|
|
428
|
+
try {
|
|
429
|
+
const parsed = JSON.parse(payload);
|
|
430
|
+
this._walkAndRedactJson(parsed, redactor, threatTypes);
|
|
431
|
+
return { line: `data: ${JSON.stringify(parsed)}`, decision };
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return { line: `data: ${redactor.redact(payload, threatTypes)}`, decision };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
allowDecision() {
|
|
438
|
+
return {
|
|
439
|
+
action: 'allow',
|
|
440
|
+
reason: 'No policy matched',
|
|
441
|
+
triggeredRules: [],
|
|
442
|
+
threats: [],
|
|
443
|
+
riskScore: 0,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
isLLMRequest(path, method) {
|
|
447
|
+
if (method !== 'POST')
|
|
448
|
+
return false;
|
|
449
|
+
const lower = path.toLowerCase();
|
|
450
|
+
// OpenAI-compatible chat completions
|
|
451
|
+
if (lower.includes('/v1/chat/completions') || lower.includes('/chat/completions'))
|
|
452
|
+
return true;
|
|
453
|
+
if (lower.includes('/v1/completions') || lower.includes('/completions'))
|
|
454
|
+
return true;
|
|
455
|
+
if (lower.includes('/v1/embeddings') || lower.includes('/embeddings'))
|
|
456
|
+
return true;
|
|
457
|
+
// Anthropic
|
|
458
|
+
if (lower.includes('/v1/messages'))
|
|
459
|
+
return true;
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
resolveUpstream(path, host, providerMatch) {
|
|
463
|
+
// If client specified X-NeuroShield-Upstream header, use that
|
|
464
|
+
// Otherwise derive from host or known provider
|
|
465
|
+
if (providerMatch) {
|
|
466
|
+
const parsed = url_1.default.parse(providerMatch.provider.baseUrl);
|
|
467
|
+
const port = parsed.protocol === 'https:' ? 443 : 80;
|
|
468
|
+
return {
|
|
469
|
+
hostname: parsed.hostname ?? 'api.openai.com',
|
|
470
|
+
port,
|
|
471
|
+
protocol: parsed.protocol ?? 'https:',
|
|
472
|
+
path,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
// If the host looks like a known provider, route there
|
|
476
|
+
for (const [, provider] of Object.entries(types_1.KNOWN_PROVIDERS)) {
|
|
477
|
+
const parsed = url_1.default.parse(provider.baseUrl);
|
|
478
|
+
if (host.includes(parsed.hostname ?? '') || host === (parsed.hostname ?? '')) {
|
|
479
|
+
return {
|
|
480
|
+
hostname: parsed.hostname ?? host,
|
|
481
|
+
port: parsed.protocol === 'https:' ? 443 : 80,
|
|
482
|
+
protocol: parsed.protocol ?? 'https:',
|
|
483
|
+
path,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Default: assume it's an OpenAI-compatible endpoint and route based on host
|
|
488
|
+
return {
|
|
489
|
+
hostname: host.split(':')[0],
|
|
490
|
+
port: 443,
|
|
491
|
+
protocol: 'https:',
|
|
492
|
+
path,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
collectBody(req, maxBytes) {
|
|
496
|
+
return new Promise((resolve) => {
|
|
497
|
+
const chunks = [];
|
|
498
|
+
let total = 0;
|
|
499
|
+
req.on('data', (chunk) => {
|
|
500
|
+
total += chunk.length;
|
|
501
|
+
if (total <= maxBytes) {
|
|
502
|
+
chunks.push(chunk);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
req.on('end', () => {
|
|
506
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
507
|
+
});
|
|
508
|
+
req.on('error', () => {
|
|
509
|
+
resolve('');
|
|
510
|
+
});
|
|
511
|
+
// Safety timeout
|
|
512
|
+
setTimeout(() => resolve(Buffer.concat(chunks).toString('utf8')), 10000);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
filterHeaders(headers) {
|
|
516
|
+
const filtered = {};
|
|
517
|
+
const skipHeaders = new Set([
|
|
518
|
+
'host', 'connection', 'transfer-encoding', 'content-length',
|
|
519
|
+
'proxy-connection', 'keep-alive', 'upgrade',
|
|
520
|
+
]);
|
|
521
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
522
|
+
if (!skipHeaders.has(key.toLowerCase()) && value !== undefined) {
|
|
523
|
+
filtered[key] = Array.isArray(value) ? value.join(', ') : value;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return filtered;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Apply RedactionEngine to a response body while preserving structure.
|
|
530
|
+
*
|
|
531
|
+
* For chat-completion / messages JSON responses we walk into known textual
|
|
532
|
+
* fields (`choices[].message.content`, `choices[].delta.content`, top-level
|
|
533
|
+
* `content[].text`, Anthropic-style `content[]` blocks) and redact those
|
|
534
|
+
* strings only. For SSE streams (text/event-stream) we redact each `data:`
|
|
535
|
+
* line's JSON payload in place. For anything else we redact the raw body.
|
|
536
|
+
*
|
|
537
|
+
* The previous TODO that returned the body unchanged caused REDACT decisions
|
|
538
|
+
* to silently pass through unredacted (S-C6).
|
|
539
|
+
*/
|
|
540
|
+
applyRedactionToResponse(body, decision) {
|
|
541
|
+
const threatTypes = decision.threats.map((t) => t.type);
|
|
542
|
+
const redactor = this.enforcer.getRedactionEngine();
|
|
543
|
+
// SSE: rewrite each `data: {…}` line's JSON in place; preserve framing.
|
|
544
|
+
if (body.includes('data:')) {
|
|
545
|
+
const lines = body.split('\n');
|
|
546
|
+
const rewritten = lines.map((line) => {
|
|
547
|
+
const m = line.match(/^data:\s*(.*)$/);
|
|
548
|
+
if (!m)
|
|
549
|
+
return line;
|
|
550
|
+
const payload = m[1];
|
|
551
|
+
if (!payload || payload === '[DONE]')
|
|
552
|
+
return line;
|
|
553
|
+
try {
|
|
554
|
+
const parsed = JSON.parse(payload);
|
|
555
|
+
this._walkAndRedactJson(parsed, redactor, threatTypes);
|
|
556
|
+
return `data: ${JSON.stringify(parsed)}`;
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return line; // not JSON; leave untouched
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
return rewritten.join('\n');
|
|
563
|
+
}
|
|
564
|
+
// Single JSON response
|
|
565
|
+
if (body.trim().startsWith('{')) {
|
|
566
|
+
try {
|
|
567
|
+
const parsed = JSON.parse(body);
|
|
568
|
+
this._walkAndRedactJson(parsed, redactor, threatTypes);
|
|
569
|
+
return JSON.stringify(parsed);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
// fall through to raw
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return redactor.redact(body, threatTypes);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Walk a parsed JSON tree and replace text content in known LLM response
|
|
579
|
+
* shapes with redacted versions. We modify in place to preserve the rest
|
|
580
|
+
* of the object structure (function_call, tool_calls, finish_reason, etc.).
|
|
581
|
+
*/
|
|
582
|
+
_walkAndRedactJson(node, redactor, threatTypes) {
|
|
583
|
+
if (!node || typeof node !== 'object')
|
|
584
|
+
return;
|
|
585
|
+
const obj = node;
|
|
586
|
+
// OpenAI: choices[].message.content / choices[].delta.content
|
|
587
|
+
if (Array.isArray(obj.choices)) {
|
|
588
|
+
for (const choice of obj.choices) {
|
|
589
|
+
if (!choice || typeof choice !== 'object')
|
|
590
|
+
continue;
|
|
591
|
+
const message = choice.message;
|
|
592
|
+
if (message && typeof message.content === 'string') {
|
|
593
|
+
message.content = redactor.redact(message.content, threatTypes);
|
|
594
|
+
}
|
|
595
|
+
const messageFunctionCall = message?.function_call;
|
|
596
|
+
if (messageFunctionCall && typeof messageFunctionCall.arguments === 'string') {
|
|
597
|
+
messageFunctionCall.arguments = redactor.redact(messageFunctionCall.arguments, threatTypes);
|
|
598
|
+
}
|
|
599
|
+
const messageToolCalls = message?.tool_calls;
|
|
600
|
+
if (Array.isArray(messageToolCalls)) {
|
|
601
|
+
for (const toolCall of messageToolCalls) {
|
|
602
|
+
const fn = toolCall?.function;
|
|
603
|
+
if (fn && typeof fn.arguments === 'string') {
|
|
604
|
+
fn.arguments = redactor.redact(fn.arguments, threatTypes);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const delta = choice.delta;
|
|
609
|
+
if (delta && typeof delta.content === 'string') {
|
|
610
|
+
delta.content = redactor.redact(delta.content, threatTypes);
|
|
611
|
+
}
|
|
612
|
+
const deltaFunctionCall = delta?.function_call;
|
|
613
|
+
if (deltaFunctionCall && typeof deltaFunctionCall.arguments === 'string') {
|
|
614
|
+
deltaFunctionCall.arguments = redactor.redact(deltaFunctionCall.arguments, threatTypes);
|
|
615
|
+
}
|
|
616
|
+
const deltaToolCalls = delta?.tool_calls;
|
|
617
|
+
if (Array.isArray(deltaToolCalls)) {
|
|
618
|
+
for (const toolCall of deltaToolCalls) {
|
|
619
|
+
const fn = toolCall?.function;
|
|
620
|
+
if (fn && typeof fn.arguments === 'string') {
|
|
621
|
+
fn.arguments = redactor.redact(fn.arguments, threatTypes);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Anthropic / generic: top-level content[].text
|
|
628
|
+
if (Array.isArray(obj.content)) {
|
|
629
|
+
for (const block of obj.content) {
|
|
630
|
+
if (block && typeof block === 'object' && typeof block.text === 'string') {
|
|
631
|
+
block.text = redactor.redact(block.text, threatTypes);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Top-level text field
|
|
636
|
+
if (typeof obj.text === 'string') {
|
|
637
|
+
obj.text = redactor.redact(obj.text, threatTypes);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
recordProxyEvent(_id, _method, _path, _host, _reqBody, _statusCode, _resHeaders, _resBody, _decision, _direction, _latencyMs) {
|
|
641
|
+
logger_1.logger.debug('Proxy event', {
|
|
642
|
+
id: _id,
|
|
643
|
+
direction: _direction,
|
|
644
|
+
action: _decision.action,
|
|
645
|
+
threats: _decision.threats.length,
|
|
646
|
+
latencyMs: _latencyMs,
|
|
647
|
+
statusCode: _statusCode,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
exports.LLMProxyServer = LLMProxyServer;
|
|
652
|
+
//# sourceMappingURL=proxy-server.js.map
|