@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.
Files changed (175) hide show
  1. package/README.md +4 -0
  2. package/dist/api-auth.d.ts +31 -0
  3. package/dist/api-auth.d.ts.map +1 -0
  4. package/dist/api-auth.js +105 -0
  5. package/dist/api-auth.js.map +1 -0
  6. package/dist/api-auth.test.d.ts +2 -0
  7. package/dist/api-auth.test.d.ts.map +1 -0
  8. package/dist/api-auth.test.js +89 -0
  9. package/dist/api-auth.test.js.map +1 -0
  10. package/dist/api.d.ts +8 -7
  11. package/dist/api.d.ts.map +1 -1
  12. package/dist/api.js +141 -134
  13. package/dist/api.js.map +1 -1
  14. package/dist/cli.d.ts +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +107 -14
  17. package/dist/cli.js.map +1 -1
  18. package/dist/cli.test.d.ts +2 -0
  19. package/dist/cli.test.d.ts.map +1 -0
  20. package/dist/cli.test.js +68 -0
  21. package/dist/cli.test.js.map +1 -0
  22. package/dist/config.d.ts +30 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +50 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/discovery-win.d.ts +4 -0
  27. package/dist/discovery-win.d.ts.map +1 -0
  28. package/dist/discovery-win.js +153 -0
  29. package/dist/discovery-win.js.map +1 -0
  30. package/dist/discovery.d.ts.map +1 -1
  31. package/dist/discovery.js +23 -97
  32. package/dist/discovery.js.map +1 -1
  33. package/dist/discovery.test.js +18 -109
  34. package/dist/discovery.test.js.map +1 -1
  35. package/dist/enforcement/file-monitor.d.ts +9 -0
  36. package/dist/enforcement/file-monitor.d.ts.map +1 -1
  37. package/dist/enforcement/file-monitor.js +9 -2
  38. package/dist/enforcement/file-monitor.js.map +1 -1
  39. package/dist/enforcement/network-monitor.d.ts.map +1 -1
  40. package/dist/enforcement/network-monitor.js +350 -9
  41. package/dist/enforcement/network-monitor.js.map +1 -1
  42. package/dist/enforcement/network-monitor.test.d.ts +2 -0
  43. package/dist/enforcement/network-monitor.test.d.ts.map +1 -0
  44. package/dist/enforcement/network-monitor.test.js +52 -0
  45. package/dist/enforcement/network-monitor.test.js.map +1 -0
  46. package/dist/enforcement/policy-executor.d.ts +24 -1
  47. package/dist/enforcement/policy-executor.d.ts.map +1 -1
  48. package/dist/enforcement/policy-executor.js +213 -69
  49. package/dist/enforcement/policy-executor.js.map +1 -1
  50. package/dist/enforcement/policy-executor.test.d.ts +2 -0
  51. package/dist/enforcement/policy-executor.test.d.ts.map +1 -0
  52. package/dist/enforcement/policy-executor.test.js +46 -0
  53. package/dist/enforcement/policy-executor.test.js.map +1 -0
  54. package/dist/enforcement/target-validator.d.ts +37 -0
  55. package/dist/enforcement/target-validator.d.ts.map +1 -0
  56. package/dist/enforcement/target-validator.js +0 -0
  57. package/dist/enforcement/target-validator.js.map +1 -0
  58. package/dist/enforcement/target-validator.test.d.ts +2 -0
  59. package/dist/enforcement/target-validator.test.d.ts.map +1 -0
  60. package/dist/enforcement/target-validator.test.js +103 -0
  61. package/dist/enforcement/target-validator.test.js.map +1 -0
  62. package/dist/http-client.d.ts +35 -0
  63. package/dist/http-client.d.ts.map +1 -0
  64. package/dist/http-client.js +168 -0
  65. package/dist/http-client.js.map +1 -0
  66. package/dist/http-client.test.d.ts +2 -0
  67. package/dist/http-client.test.d.ts.map +1 -0
  68. package/dist/http-client.test.js +172 -0
  69. package/dist/http-client.test.js.map +1 -0
  70. package/dist/index.js +189 -113
  71. package/dist/index.js.map +1 -1
  72. package/dist/launcher.d.ts +33 -0
  73. package/dist/launcher.d.ts.map +1 -0
  74. package/dist/launcher.js +425 -0
  75. package/dist/launcher.js.map +1 -0
  76. package/dist/launcher.test.d.ts +2 -0
  77. package/dist/launcher.test.d.ts.map +1 -0
  78. package/dist/launcher.test.js +109 -0
  79. package/dist/launcher.test.js.map +1 -0
  80. package/dist/proxy/cert-manager.d.ts +24 -0
  81. package/dist/proxy/cert-manager.d.ts.map +1 -0
  82. package/dist/proxy/cert-manager.js +117 -0
  83. package/dist/proxy/cert-manager.js.map +1 -0
  84. package/dist/proxy/cert-manager.test.d.ts +2 -0
  85. package/dist/proxy/cert-manager.test.d.ts.map +1 -0
  86. package/dist/proxy/cert-manager.test.js +70 -0
  87. package/dist/proxy/cert-manager.test.js.map +1 -0
  88. package/dist/proxy/index.d.ts +61 -0
  89. package/dist/proxy/index.d.ts.map +1 -0
  90. package/dist/proxy/index.js +74 -0
  91. package/dist/proxy/index.js.map +1 -0
  92. package/dist/proxy/policy-enforcer.d.ts +30 -0
  93. package/dist/proxy/policy-enforcer.d.ts.map +1 -0
  94. package/dist/proxy/policy-enforcer.js +143 -0
  95. package/dist/proxy/policy-enforcer.js.map +1 -0
  96. package/dist/proxy/proxy-server.d.ts +42 -0
  97. package/dist/proxy/proxy-server.d.ts.map +1 -0
  98. package/dist/proxy/proxy-server.js +652 -0
  99. package/dist/proxy/proxy-server.js.map +1 -0
  100. package/dist/proxy/redaction-engine.d.ts +4 -0
  101. package/dist/proxy/redaction-engine.d.ts.map +1 -0
  102. package/dist/proxy/redaction-engine.js +50 -0
  103. package/dist/proxy/redaction-engine.js.map +1 -0
  104. package/dist/proxy/response-redaction.test.d.ts +2 -0
  105. package/dist/proxy/response-redaction.test.d.ts.map +1 -0
  106. package/dist/proxy/response-redaction.test.js +125 -0
  107. package/dist/proxy/response-redaction.test.js.map +1 -0
  108. package/dist/proxy/threat-engine.d.ts +22 -0
  109. package/dist/proxy/threat-engine.d.ts.map +1 -0
  110. package/dist/proxy/threat-engine.js +291 -0
  111. package/dist/proxy/threat-engine.js.map +1 -0
  112. package/dist/proxy/threat-engine.test.d.ts +2 -0
  113. package/dist/proxy/threat-engine.test.d.ts.map +1 -0
  114. package/dist/proxy/threat-engine.test.js +27 -0
  115. package/dist/proxy/threat-engine.test.js.map +1 -0
  116. package/dist/redirect/env-injector.d.ts +72 -0
  117. package/dist/redirect/env-injector.d.ts.map +1 -0
  118. package/dist/redirect/env-injector.js +177 -0
  119. package/dist/redirect/env-injector.js.map +1 -0
  120. package/dist/redirect/env-injector.test.d.ts +2 -0
  121. package/dist/redirect/env-injector.test.d.ts.map +1 -0
  122. package/dist/redirect/env-injector.test.js +91 -0
  123. package/dist/redirect/env-injector.test.js.map +1 -0
  124. package/dist/redirect/index.d.ts +3 -0
  125. package/dist/redirect/index.d.ts.map +1 -0
  126. package/dist/redirect/index.js +8 -0
  127. package/dist/redirect/index.js.map +1 -0
  128. package/dist/redirect/platform-redirect.d.ts +42 -0
  129. package/dist/redirect/platform-redirect.d.ts.map +1 -0
  130. package/dist/redirect/platform-redirect.js +229 -0
  131. package/dist/redirect/platform-redirect.js.map +1 -0
  132. package/dist/redirect/platform-redirect.test.d.ts +2 -0
  133. package/dist/redirect/platform-redirect.test.d.ts.map +1 -0
  134. package/dist/redirect/platform-redirect.test.js +76 -0
  135. package/dist/redirect/platform-redirect.test.js.map +1 -0
  136. package/dist/sandbox/index.d.ts +23 -2
  137. package/dist/sandbox/index.d.ts.map +1 -1
  138. package/dist/sandbox/index.js +24 -7
  139. package/dist/sandbox/index.js.map +1 -1
  140. package/dist/sandbox/linux-sandbox.d.ts +13 -2
  141. package/dist/sandbox/linux-sandbox.d.ts.map +1 -1
  142. package/dist/sandbox/linux-sandbox.js +61 -27
  143. package/dist/sandbox/linux-sandbox.js.map +1 -1
  144. package/dist/sandbox/macos-sandbox.d.ts +15 -4
  145. package/dist/sandbox/macos-sandbox.d.ts.map +1 -1
  146. package/dist/sandbox/macos-sandbox.js +36 -18
  147. package/dist/sandbox/macos-sandbox.js.map +1 -1
  148. package/dist/sandbox/sandbox-result.test.d.ts +2 -0
  149. package/dist/sandbox/sandbox-result.test.d.ts.map +1 -0
  150. package/dist/sandbox/sandbox-result.test.js +87 -0
  151. package/dist/sandbox/sandbox-result.test.js.map +1 -0
  152. package/dist/sandbox/windows-sandbox.d.ts +34 -0
  153. package/dist/sandbox/windows-sandbox.d.ts.map +1 -0
  154. package/dist/sandbox/windows-sandbox.js +161 -0
  155. package/dist/sandbox/windows-sandbox.js.map +1 -0
  156. package/dist/setup.d.ts.map +1 -1
  157. package/dist/setup.js +33 -43
  158. package/dist/setup.js.map +1 -1
  159. package/dist/skill-authz/skill-evaluator.d.ts +30 -0
  160. package/dist/skill-authz/skill-evaluator.d.ts.map +1 -1
  161. package/dist/skill-authz/skill-evaluator.js +161 -30
  162. package/dist/skill-authz/skill-evaluator.js.map +1 -1
  163. package/dist/skill-authz/skill-evaluator.test.d.ts +2 -0
  164. package/dist/skill-authz/skill-evaluator.test.d.ts.map +1 -0
  165. package/dist/skill-authz/skill-evaluator.test.js +127 -0
  166. package/dist/skill-authz/skill-evaluator.test.js.map +1 -0
  167. package/dist/telemetry.d.ts.map +1 -1
  168. package/dist/telemetry.js +16 -44
  169. package/dist/telemetry.js.map +1 -1
  170. package/dist/types.d.ts +48 -105
  171. package/dist/types.d.ts.map +1 -1
  172. package/dist/types.js +34 -1
  173. package/dist/types.js.map +1 -1
  174. package/package.json +7 -3
  175. 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