securenow 6.0.1 → 6.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 (87) hide show
  1. package/CONSUMING-APPS-GUIDE.md +455 -0
  2. package/NPM_README.md +2029 -0
  3. package/README.md +297 -40
  4. package/SKILL-API.md +634 -0
  5. package/SKILL-CLI.md +454 -0
  6. package/cidr.js +83 -0
  7. package/cli/apps.js +585 -0
  8. package/cli/auth.js +280 -0
  9. package/cli/client.js +115 -0
  10. package/cli/config.js +173 -0
  11. package/cli/diagnostics.js +387 -0
  12. package/cli/firewall.js +100 -0
  13. package/cli/fp.js +638 -0
  14. package/cli/init.js +201 -0
  15. package/cli/monitor.js +440 -0
  16. package/cli/run.js +148 -0
  17. package/cli/security.js +980 -0
  18. package/cli/ui.js +386 -0
  19. package/cli/utils.js +127 -0
  20. package/cli.js +466 -455
  21. package/console-instrumentation.js +147 -136
  22. package/docs/ALL-FRAMEWORKS-QUICKSTART.md +1377 -455
  23. package/docs/API-KEYS-GUIDE.md +233 -0
  24. package/docs/ARCHITECTURE.md +3 -3
  25. package/docs/AUTO-BODY-CAPTURE.md +1 -1
  26. package/docs/AUTO-SETUP-SUMMARY.md +331 -0
  27. package/docs/AUTO-SETUP.md +4 -4
  28. package/docs/AUTOMATIC-IP-CAPTURE.md +5 -5
  29. package/docs/BODY-CAPTURE-FIX.md +261 -0
  30. package/docs/BODY-CAPTURE-QUICKSTART.md +2 -2
  31. package/docs/CHANGELOG-NEXTJS.md +1 -35
  32. package/docs/COMPLETION-REPORT.md +408 -0
  33. package/docs/CUSTOMER-GUIDE.md +16 -16
  34. package/docs/EASIEST-SETUP.md +5 -5
  35. package/docs/ENVIRONMENT-VARIABLES.md +880 -652
  36. package/docs/EXPRESS-BODY-CAPTURE.md +13 -12
  37. package/docs/EXPRESS-SETUP-GUIDE.md +719 -720
  38. package/docs/FINAL-SOLUTION.md +335 -0
  39. package/docs/FIREWALL-GUIDE.md +426 -0
  40. package/docs/IMPLEMENTATION-SUMMARY.md +410 -0
  41. package/docs/INDEX.md +22 -4
  42. package/docs/LOGGING-GUIDE.md +701 -708
  43. package/docs/LOGGING-QUICKSTART.md +234 -255
  44. package/docs/NEXTJS-BODY-CAPTURE-COMPARISON.md +323 -0
  45. package/docs/NEXTJS-BODY-CAPTURE.md +2 -2
  46. package/docs/NEXTJS-GUIDE.md +14 -14
  47. package/docs/NEXTJS-QUICKSTART.md +1 -1
  48. package/docs/NEXTJS-SETUP-COMPLETE.md +795 -0
  49. package/docs/NEXTJS-WRAPPER-APPROACH.md +1 -1
  50. package/docs/NUXT-GUIDE.md +166 -0
  51. package/docs/QUICKSTART-BODY-CAPTURE.md +2 -2
  52. package/docs/REDACTION-EXAMPLES.md +1 -1
  53. package/docs/REQUEST-BODY-CAPTURE.md +19 -10
  54. package/docs/SOLUTION-SUMMARY.md +312 -0
  55. package/docs/VERCEL-OTEL-MIGRATION.md +3 -3
  56. package/examples/README.md +6 -6
  57. package/examples/instrumentation-with-auto-capture.ts +1 -1
  58. package/examples/nextjs-env-example.txt +2 -2
  59. package/examples/nextjs-instrumentation.js +1 -1
  60. package/examples/nextjs-instrumentation.ts +1 -1
  61. package/examples/nextjs-with-logging-example.md +6 -6
  62. package/examples/nextjs-with-options.ts +1 -1
  63. package/examples/test-nextjs-setup.js +1 -1
  64. package/firewall-cloud.js +212 -0
  65. package/firewall-iptables.js +139 -0
  66. package/firewall-only.js +38 -0
  67. package/firewall-tcp.js +74 -0
  68. package/firewall.js +720 -0
  69. package/free-trial-banner.js +174 -0
  70. package/nextjs-auto-capture.js +199 -207
  71. package/nextjs-middleware.js +186 -181
  72. package/nextjs-webpack-config.js +88 -53
  73. package/nextjs-wrapper.js +158 -158
  74. package/nextjs.d.ts +1 -1
  75. package/nextjs.js +224 -198
  76. package/nuxt-server-plugin.mjs +423 -0
  77. package/nuxt.d.ts +60 -0
  78. package/nuxt.mjs +75 -0
  79. package/package.json +67 -45
  80. package/postinstall.js +6 -6
  81. package/register.d.ts +1 -1
  82. package/register.js +39 -4
  83. package/resolve-ip.js +77 -0
  84. package/tracing.d.ts +2 -1
  85. package/tracing.js +333 -31
  86. package/web-vite.mjs +239 -156
  87. package/LICENSE +0 -15
package/tracing.js CHANGED
@@ -1,7 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Preload with: NODE_OPTIONS="-r securenow/register"
4
+ * Preload with: node --require securenow/register app.js
5
+ *
6
+ * Works for both CJS and ESM apps. On Node >=20.6 the ESM loader hook is
7
+ * auto-registered via module.register() — no --import flag needed.
8
+ * On Node 18 with "type": "module", add the hook manually:
9
+ * node --import @opentelemetry/instrumentation/hook.mjs --require securenow/register app.js
5
10
  *
6
11
  * Env:
7
12
  * SECURENOW_APPID=logical-name # or OTEL_SERVICE_NAME=logical-name
@@ -11,6 +16,7 @@
11
16
  * OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=... # full traces URL
12
17
  * OTEL_EXPORTER_OTLP_HEADERS="k=v,k2=v2"
13
18
  * SECURENOW_DISABLE_INSTRUMENTATIONS="pkg1,pkg2"
19
+ * SECURENOW_CAPTURE_MULTIPART=1 # capture multipart/form-data fields & file metadata (streaming, no file content buffered)
14
20
  * OTEL_LOG_LEVEL=info|debug
15
21
  * SECURENOW_TEST_SPAN=1
16
22
  *
@@ -18,15 +24,15 @@
18
24
  * SECURENOW_STRICT=1 -> if no appid/name is provided in cluster, exit(1) so PM2 restarts the worker
19
25
  */
20
26
 
21
- const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api');
27
+ const { diag, DiagConsoleLogger, DiagLogLevel, context, trace } = require('@opentelemetry/api');
22
28
  const { NodeSDK } = require('@opentelemetry/sdk-node');
23
29
  const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
24
30
  const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
25
31
  const { LoggerProvider, BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
26
- const { logs: apiLogs } = require('@opentelemetry/api-logs');
27
32
  const { Resource } = require('@opentelemetry/resources');
28
33
  const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
29
34
  const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
35
+ const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb');
30
36
  const { v4: uuidv4 } = require('uuid');
31
37
 
32
38
  const env = k => process.env[k] ?? process.env[k.toUpperCase()] ?? process.env[k.toLowerCase()];
@@ -47,6 +53,10 @@ const DEFAULT_SENSITIVE_FIELDS = [
47
53
  'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
48
54
  ];
49
55
 
56
+ function escapeRegex(str) {
57
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ }
59
+
50
60
  /**
51
61
  * Redact sensitive fields from an object
52
62
  */
@@ -55,7 +65,7 @@ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
55
65
 
56
66
  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
57
67
 
58
- for (const key in redacted) {
68
+ for (const key of Object.keys(redacted)) {
59
69
  const lowerKey = key.toLowerCase();
60
70
 
61
71
  if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
@@ -78,10 +88,10 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
78
88
 
79
89
  // Redact sensitive fields in GraphQL arguments and variables
80
90
  sensitiveFields.forEach(field => {
81
- // Match patterns: field: "value" or field: 'value' or field:"value"
91
+ const escaped = escapeRegex(field);
82
92
  const patterns = [
83
- new RegExp(`(${field}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
84
- new RegExp(`(${field}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
93
+ new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
94
+ new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
85
95
  ];
86
96
 
87
97
  patterns.forEach(pattern => {
@@ -98,13 +108,162 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
98
108
  return redacted;
99
109
  }
100
110
 
111
+ // -------- Multipart streaming parser --------
112
+ // Streams through the request without buffering file content.
113
+ // Only part headers and text-field values are kept in memory,
114
+ // so memory stays bounded (~few KB) regardless of upload size.
115
+
116
+ function extractBoundary(contentType) {
117
+ const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i);
118
+ return match ? (match[1] || match[2]) : null;
119
+ }
120
+
121
+ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFieldSize, onComplete) {
122
+ const boundary = extractBoundary(contentType);
123
+ if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return; }
124
+
125
+ const result = { fields: Object.create(null), files: [] };
126
+ let totalSize = 0;
127
+ let buf = Buffer.alloc(0);
128
+
129
+ const MAX_PARTS = 100;
130
+ let partCount = 0;
131
+
132
+ const FIRST_DELIM = Buffer.from('--' + boundary);
133
+ const DELIM = Buffer.from('\r\n--' + boundary);
134
+ const HDR_END = Buffer.from('\r\n\r\n');
135
+
136
+ let initialized = false;
137
+ let inHeaders = true;
138
+ let isFile = false;
139
+ let fldName = '';
140
+ let fName = '';
141
+ let pCT = '';
142
+ let bodyBytes = 0;
143
+ let textVal = '';
144
+
145
+ function flushPart() {
146
+ if (!fldName || fldName === '__proto__' || fldName === 'constructor' || fldName === 'prototype') return;
147
+ if (isFile) {
148
+ result.files.push({ field: fldName, filename: fName, contentType: pCT || 'unknown', size: bodyBytes });
149
+ } else {
150
+ const lower = fldName.toLowerCase();
151
+ const redact = sensitiveFields.some(f => lower.includes(f.toLowerCase()));
152
+ result.fields[fldName] = redact ? '[REDACTED]' : textVal.substring(0, maxTextFieldSize);
153
+ }
154
+ fldName = ''; bodyBytes = 0; textVal = ''; partCount++;
155
+ }
156
+
157
+ function drain() {
158
+ if (!initialized) {
159
+ const i = buf.indexOf(FIRST_DELIM);
160
+ if (i === -1) {
161
+ if (buf.length > FIRST_DELIM.length + 4) buf = buf.slice(buf.length - FIRST_DELIM.length - 4);
162
+ return;
163
+ }
164
+ buf = buf.slice(i + FIRST_DELIM.length);
165
+ initialized = true;
166
+ inHeaders = true;
167
+ }
168
+
169
+ let guard = 200;
170
+ while (buf.length > 0 && guard-- > 0 && partCount < MAX_PARTS) {
171
+ if (inHeaders) {
172
+ if (buf.length >= 2 && buf[0] === 0x2D && buf[1] === 0x2D) { buf = Buffer.alloc(0); return; }
173
+ if (buf.length >= 2 && buf[0] === 0x0D && buf[1] === 0x0A) { buf = buf.slice(2); continue; }
174
+
175
+ const hi = buf.indexOf(HDR_END);
176
+ if (hi === -1) return;
177
+
178
+ const hdr = buf.slice(0, hi).toString('latin1');
179
+ buf = buf.slice(hi + 4);
180
+
181
+ const nm = hdr.match(/name="([^"]+)"/);
182
+ const fn = hdr.match(/filename="([^"]*)"/);
183
+ const ct = hdr.match(/Content-Type:\s*(.+)/i);
184
+ fldName = nm ? nm[1] : '';
185
+ fName = fn ? fn[1] : '';
186
+ pCT = ct ? ct[1].trim() : '';
187
+ isFile = !!fn;
188
+ bodyBytes = 0;
189
+ textVal = '';
190
+ inHeaders = false;
191
+ }
192
+
193
+ const di = buf.indexOf(DELIM);
194
+ if (di === -1) {
195
+ const safe = Math.max(0, buf.length - DELIM.length - 2);
196
+ if (safe > 0) {
197
+ bodyBytes += safe;
198
+ if (!isFile && textVal.length < maxTextFieldSize) {
199
+ textVal += buf.slice(0, safe).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
200
+ }
201
+ buf = buf.slice(safe);
202
+ }
203
+ return;
204
+ }
205
+
206
+ bodyBytes += di;
207
+ if (!isFile && textVal.length < maxTextFieldSize) {
208
+ textVal += buf.slice(0, di).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
209
+ }
210
+ flushPart();
211
+ buf = buf.slice(di + DELIM.length);
212
+ inHeaders = true;
213
+ }
214
+ }
215
+
216
+ request.on('data', (chunk) => {
217
+ totalSize += chunk.length;
218
+ buf = Buffer.concat([buf, chunk]);
219
+ drain();
220
+ });
221
+
222
+ request.on('end', () => {
223
+ try {
224
+ if (!inHeaders && fldName) {
225
+ bodyBytes += buf.length;
226
+ if (!isFile) textVal += buf.toString('utf8').substring(0, maxTextFieldSize - textVal.length);
227
+ flushPart();
228
+ }
229
+ onComplete({ parsed: result, totalSize });
230
+ } catch (e) {
231
+ onComplete({ error: 'PARSE_ERROR' });
232
+ }
233
+ });
234
+ }
235
+
236
+ // -------- ESM detection --------
237
+ // register.js auto-registers the hook via module.register() on Node >=20.6.
238
+ // This warning only fires if BOTH --import AND module.register() were skipped
239
+ // (e.g. Node 18, or require('securenow/tracing') called directly without register.js).
240
+ (() => {
241
+ try {
242
+ const fs = require('fs');
243
+ const path = require('path');
244
+ const pkgPath = path.resolve(process.cwd(), 'package.json');
245
+ if (fs.existsSync(pkgPath)) {
246
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
247
+ if (pkg.type === 'module') {
248
+ const execArgv = process.execArgv.join(' ');
249
+ const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
250
+ const hasModuleRegister = typeof require('node:module').register === 'function';
251
+ if (!hasCliHook && !hasModuleRegister) {
252
+ console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
253
+ console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
254
+ }
255
+ }
256
+ }
257
+ } catch (_) {}
258
+ })();
259
+
101
260
  // -------- diagnostics --------
261
+ const diagLevel = (env('OTEL_LOG_LEVEL') || '').toLowerCase();
102
262
  (() => {
103
- const L = (env('OTEL_LOG_LEVEL') || '').toLowerCase();
104
- const level = L === 'debug' ? DiagLogLevel.DEBUG :
105
- L === 'info' ? DiagLogLevel.INFO :
106
- L === 'warn' ? DiagLogLevel.WARN :
107
- L === 'error' ? DiagLogLevel.ERROR : DiagLogLevel.NONE;
263
+ const level = diagLevel === 'debug' ? DiagLogLevel.DEBUG :
264
+ diagLevel === 'info' ? DiagLogLevel.INFO :
265
+ diagLevel === 'warn' ? DiagLogLevel.WARN :
266
+ diagLevel === 'error' ? DiagLogLevel.ERROR : DiagLogLevel.NONE;
108
267
  diag.setLogger(new DiagConsoleLogger(), level);
109
268
  console.log('[securenow] preload loaded pid=%d', process.pid);
110
269
  })();
@@ -161,21 +320,44 @@ for (const n of (env('SECURENOW_DISABLE_INSTRUMENTATIONS') || '').split(',').map
161
320
 
162
321
  // -------- Body Capture Configuration --------
163
322
  const captureBody = String(env('SECURENOW_CAPTURE_BODY')) === '1' || String(env('SECURENOW_CAPTURE_BODY')).toLowerCase() === 'true';
164
- const maxBodySize = parseInt(env('SECURENOW_MAX_BODY_SIZE') || '10240'); // 10KB default
323
+ const maxBodySize = Math.max(1024, parseInt(env('SECURENOW_MAX_BODY_SIZE'), 10) || 10240);
165
324
  const customSensitiveFields = (env('SECURENOW_SENSITIVE_FIELDS') || '').split(',').map(s => s.trim()).filter(Boolean);
166
325
  const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
167
326
 
327
+ const captureMultipart = String(env('SECURENOW_CAPTURE_MULTIPART')) === '1' || String(env('SECURENOW_CAPTURE_MULTIPART')).toLowerCase() === 'true';
328
+
329
+ // -------- Trusted proxy IP resolution --------
330
+ const { resolveClientIp, isFromTrustedProxy, LOOPBACK_RE } = require('./resolve-ip');
331
+
168
332
  // Configure HTTP instrumentation with body capture
169
333
  const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
170
334
  const httpInstrumentation = new HttpInstrumentation({
171
335
  requestHook: (span, request) => {
172
336
  try {
173
- if (captureBody && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
337
+ const clientIp = resolveClientIp(request);
338
+ if (clientIp) {
339
+ span.setAttribute('http.client_ip', clientIp);
340
+ }
341
+
342
+ if (request.headers) {
343
+ const SKIP_HEADERS = new Set(['cookie', 'authorization', 'proxy-authorization', 'set-cookie', 'x-api-key', 'x-auth-token']);
344
+ const safe = {};
345
+ for (const [k, v] of Object.entries(request.headers)) {
346
+ if (SKIP_HEADERS.has(k.toLowerCase())) { safe[k] = '[REDACTED]'; continue; }
347
+ safe[k] = typeof v === 'string' ? v.substring(0, 500) : String(v);
348
+ }
349
+ const serialized = JSON.stringify(safe);
350
+ if (serialized.length <= 8192) {
351
+ span.setAttribute('http.request.headers', serialized);
352
+ }
353
+ }
354
+
355
+ if ((captureBody || captureMultipart) && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
174
356
  const contentType = request.headers['content-type'] || '';
175
357
 
176
- if (contentType.includes('application/json') ||
358
+ if (captureBody && (contentType.includes('application/json') ||
177
359
  contentType.includes('application/graphql') ||
178
- contentType.includes('application/x-www-form-urlencoded')) {
360
+ contentType.includes('application/x-www-form-urlencoded'))) {
179
361
 
180
362
  let body = '';
181
363
  const chunks = [];
@@ -223,9 +405,9 @@ const httpInstrumentation = new HttpInstrumentation({
223
405
  });
224
406
  }
225
407
  } catch (e) {
226
- // Parse error: capture as-is (truncated)
227
- span.setAttribute('http.request.body', body.substring(0, 1000));
408
+ span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
228
409
  span.setAttribute('http.request.body.parse_error', true);
410
+ span.setAttribute('http.request.body.size', size);
229
411
  }
230
412
  } else if (size > maxBodySize) {
231
413
  span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
@@ -233,10 +415,38 @@ const httpInstrumentation = new HttpInstrumentation({
233
415
  }
234
416
  });
235
417
  } else if (contentType.includes('multipart/form-data')) {
236
- // Multipart is NOT captured
237
- span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
238
- span.setAttribute('http.request.body.type', 'multipart');
239
- span.setAttribute('http.request.body.note', 'File uploads not captured by design');
418
+ if (captureMultipart) {
419
+ collectMultipartMeta(request, contentType, allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
420
+ try {
421
+ if (error === 'BOUNDARY_NOT_FOUND') {
422
+ span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
423
+ span.setAttribute('http.request.body.type', 'multipart');
424
+ return;
425
+ }
426
+ if (error) {
427
+ span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
428
+ span.setAttribute('http.request.body.type', 'multipart');
429
+ span.setAttribute('http.request.body.parse_error', true);
430
+ return;
431
+ }
432
+ span.setAttributes({
433
+ 'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
434
+ 'http.request.body.type': 'multipart',
435
+ 'http.request.body.size': totalSize,
436
+ 'http.request.body.fields_count': Object.keys(parsed.fields).length,
437
+ 'http.request.body.files_count': parsed.files.length,
438
+ });
439
+ } catch (e) {
440
+ span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
441
+ span.setAttribute('http.request.body.type', 'multipart');
442
+ span.setAttribute('http.request.body.parse_error', true);
443
+ }
444
+ });
445
+ } else {
446
+ span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
447
+ span.setAttribute('http.request.body.type', 'multipart');
448
+ span.setAttribute('http.request.body.note', 'Set SECURENOW_CAPTURE_MULTIPART=1 to enable');
449
+ }
240
450
  }
241
451
  }
242
452
  } catch (error) {
@@ -246,7 +456,7 @@ const httpInstrumentation = new HttpInstrumentation({
246
456
  });
247
457
 
248
458
  // -------- Logging Configuration --------
249
- const loggingEnabled = String(env('SECURENOW_LOGGING_ENABLED')) !== '0' && String(env('SECURENOW_LOGGING_ENABLED')).toLowerCase() !== 'false';
459
+ const loggingEnabled = String(env('SECURENOW_LOGGING_ENABLED')) === '1' || String(env('SECURENOW_LOGGING_ENABLED')).toLowerCase() === 'true';
250
460
 
251
461
  // Create shared resource for both traces and logs
252
462
  const sharedResource = new Resource({
@@ -258,7 +468,6 @@ const sharedResource = new Resource({
258
468
 
259
469
  // Initialize LoggerProvider if logging is enabled
260
470
  let loggerProvider = null;
261
- let globalLogger = null;
262
471
 
263
472
  if (loggingEnabled) {
264
473
  const logExporter = new OTLPLogExporter({
@@ -266,27 +475,90 @@ if (loggingEnabled) {
266
475
  headers
267
476
  });
268
477
 
478
+ const batchLogProcessor = new BatchLogRecordProcessor(logExporter);
269
479
  loggerProvider = new LoggerProvider({
270
480
  resource: sharedResource,
271
481
  });
272
- // sdk-logs 0.47.x ignores the `processors` constructor option (added in 0.52),
273
- // so the provider would silently keep a NoopLogRecordProcessor and drop every
274
- // emit(). Register the processor explicitly instead.
275
- loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(logExporter));
276
- apiLogs.setGlobalLoggerProvider(loggerProvider);
482
+ loggerProvider.addLogRecordProcessor(batchLogProcessor);
277
483
 
278
- globalLogger = loggerProvider.getLogger('securenow', '1.0.0');
484
+ // Auto-patch console.* so every log/warn/error becomes an OTel log record
485
+ const _logger = loggerProvider.getLogger('console', '1.0.0');
486
+ const _orig = { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug };
487
+ const SEV = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 };
488
+ function _emit(sn, st, args) {
489
+ try {
490
+ const activeCtx = context.active();
491
+ const spanCtx = trace.getSpanContext(activeCtx);
492
+ _logger.emit({
493
+ severityNumber: sn,
494
+ severityText: st,
495
+ body: args.map(a => (typeof a === 'object' && a !== null) ? JSON.stringify(a) : String(a)).join(' '),
496
+ attributes: { 'log.source': 'console', 'log.method': st.toLowerCase() },
497
+ ...(spanCtx && { context: activeCtx }),
498
+ });
499
+ } catch (_) {}
500
+ }
501
+ console.log = function (...a) { _emit(SEV.INFO, 'INFO', a); _orig.log.apply(console, a); };
502
+ console.info = function (...a) { _emit(SEV.INFO, 'INFO', a); _orig.info.apply(console, a); };
503
+ console.warn = function (...a) { _emit(SEV.WARN, 'WARN', a); _orig.warn.apply(console, a); };
504
+ console.error = function (...a) { _emit(SEV.ERROR, 'ERROR', a); _orig.error.apply(console, a); };
505
+ console.debug = function (...a) { _emit(SEV.DEBUG, 'DEBUG', a); _orig.debug.apply(console, a); };
506
+ console.__securenow_patched = true;
507
+ }
508
+
509
+ // -------- Guard against OTLP exporter socket errors --------
510
+ // The OTLP HTTP exporter uses keep-alive connections that can be reset by the
511
+ // remote end (ECONNRESET / "socket hang up"). These transient errors sometimes
512
+ // escape as unhandled exceptions or rejections because the underlying HTTP
513
+ // request's error path isn't fully covered by the OTel library. We install
514
+ // targeted process-level handlers to catch them and log at debug level instead
515
+ // of crashing the host app.
516
+ const _TRANSIENT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN']);
517
+ function _isOtlpTransientError(err) {
518
+ if (!err) return false;
519
+ if (_TRANSIENT_CODES.has(err.code)) return true;
520
+ if (typeof err.message === 'string' && /socket hang up|ECONNRESET/.test(err.message)) return true;
521
+ return false;
522
+ }
523
+ function _looksLikeOtlpStack(err) {
524
+ const s = err && err.stack;
525
+ if (!s) return false;
526
+ return /OTLPTraceExporter|OTLPLogExporter|otlp|exporter.*http|BatchSpanProcessor|BatchLogRecordProcessor/i.test(s)
527
+ || /node:_http_client|ClientRequest|TLSSocket/i.test(s);
279
528
  }
280
529
 
530
+ process.on('uncaughtException', (err, origin) => {
531
+ if (_isOtlpTransientError(err) && _looksLikeOtlpStack(err)) {
532
+ if (diagLevel === 'debug') {
533
+ console.debug('[securenow] Suppressed transient OTLP exporter error (%s): %s', origin, err.message);
534
+ }
535
+ return; // swallow — do not crash
536
+ }
537
+ // Not ours — re-throw so the default handler (or the app's own handler) fires
538
+ throw err;
539
+ });
540
+ process.on('unhandledRejection', (reason) => {
541
+ if (_isOtlpTransientError(reason) && _looksLikeOtlpStack(reason)) {
542
+ if (diagLevel === 'debug') {
543
+ console.debug('[securenow] Suppressed transient OTLP exporter rejection: %s', reason && reason.message);
544
+ }
545
+ return; // swallow
546
+ }
547
+ // Not ours — re-throw as unhandled so Node's default behaviour applies
548
+ throw reason;
549
+ });
550
+
281
551
  // -------- SDK --------
282
552
  const traceExporter = new OTLPTraceExporter({ url: tracesUrl, headers });
283
553
  const sdk = new NodeSDK({
284
554
  traceExporter,
285
555
  instrumentations: [
286
556
  httpInstrumentation,
557
+ ...(disabledMap['@opentelemetry/instrumentation-mongodb'] ? [] : [new MongoDBInstrumentation()]),
287
558
  ...getNodeAutoInstrumentations({
288
559
  ...disabledMap,
289
- '@opentelemetry/instrumentation-http': { enabled: false }, // We use our custom one above
560
+ '@opentelemetry/instrumentation-http': { enabled: false },
561
+ '@opentelemetry/instrumentation-mongodb': { enabled: false },
290
562
  }),
291
563
  ],
292
564
  resource: sharedResource,
@@ -305,22 +577,52 @@ const sdk = new NodeSDK({
305
577
  if (captureBody) {
306
578
  console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
307
579
  }
580
+ if (captureMultipart) {
581
+ console.log('[securenow] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
582
+ }
308
583
  if (String(env('SECURENOW_TEST_SPAN')) === '1') {
309
584
  const api = require('@opentelemetry/api');
310
585
  const tracer = api.trace.getTracer('securenow-smoke');
311
586
  const span = tracer.startSpan('securenow.startup.smoke'); span.end();
312
587
  }
588
+
589
+ // Free trial banner
590
+ const { isFreeTrial, patchHttpForBanner } = require('./free-trial-banner');
591
+ if (isFreeTrial(endpointBase) && String(env('SECURENOW_HIDE_BANNER')) !== '1') {
592
+ patchHttpForBanner();
593
+ }
594
+
595
+ // Firewall — auto-activates when SECURENOW_API_KEY is set
596
+ const firewallApiKey = env('SECURENOW_API_KEY');
597
+ if (firewallApiKey && env('SECURENOW_FIREWALL_ENABLED') !== '0') {
598
+ require('./firewall').init({
599
+ apiKey: firewallApiKey,
600
+ apiUrl: env('SECURENOW_API_URL') || 'https://api.securenow.ai',
601
+ versionCheckInterval: parseInt(env('SECURENOW_FIREWALL_VERSION_INTERVAL'), 10) || 10,
602
+ syncInterval: parseInt(env('SECURENOW_FIREWALL_SYNC_INTERVAL'), 10) || 300,
603
+ failMode: env('SECURENOW_FIREWALL_FAIL_MODE') || 'open',
604
+ statusCode: parseInt(env('SECURENOW_FIREWALL_STATUS_CODE'), 10) || 403,
605
+ log: env('SECURENOW_FIREWALL_LOG') !== '0',
606
+ tcp: env('SECURENOW_FIREWALL_TCP') === '1',
607
+ iptables: env('SECURENOW_FIREWALL_IPTABLES') === '1',
608
+ cloud: env('SECURENOW_FIREWALL_CLOUD') || null,
609
+ });
610
+ }
313
611
  } catch (e) {
314
612
  console.error('[securenow] OTel start failed:', e && e.stack || e);
315
613
  }
316
614
  })();
317
615
 
616
+ let shuttingDown = false;
318
617
  async function safeShutdown(sig) {
618
+ if (shuttingDown) return;
619
+ shuttingDown = true;
319
620
  try {
320
621
  await Promise.resolve(sdk.shutdown?.());
321
622
  if (loggerProvider) {
322
623
  await Promise.resolve(loggerProvider.shutdown?.());
323
624
  }
625
+ try { require('./firewall').shutdown(); } catch (_) {}
324
626
  console.log(`[securenow] Tracing and logging terminated on ${sig}`);
325
627
  }
326
628
  catch (e) { console.error('[securenow] Shutdown error:', e); }