securenow 8.5.0 → 8.7.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/tracing.js CHANGED
@@ -1,758 +1,844 @@
1
- 'use strict';
2
-
3
- /**
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
10
- *
11
- * Config:
12
- * Runtime config is read from .securenow/credentials.json.
13
- * Run `npx securenow login` and `npx securenow init` to create it.
14
- * Production should mount/copy tokenless runtime credentials to the same path.
15
- */
16
-
17
- const { nodeSdkDefaultTelemetryOptions } = require('./otel-defaults');
18
- const nodeSdkTelemetryOptions = nodeSdkDefaultTelemetryOptions();
19
-
20
- const { diag, DiagConsoleLogger, DiagLogLevel, context, trace } = require('@opentelemetry/api');
21
- const { NodeSDK } = require('@opentelemetry/sdk-node');
22
- const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
23
- const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
24
- const { LoggerProvider, BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
25
- const otelResources = require('@opentelemetry/resources');
26
- const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
27
- const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
28
- const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb');
29
- const { randomUUID } = require('crypto');
30
- const appConfig = require('./app-config');
31
-
32
- function createResource(attributes) {
33
- if (typeof otelResources.resourceFromAttributes === 'function') {
34
- return otelResources.resourceFromAttributes(attributes);
35
- }
36
- if (typeof otelResources.Resource === 'function') {
37
- return new otelResources.Resource(attributes);
38
- }
39
- throw new Error('Unsupported @opentelemetry/resources version');
40
- }
41
-
42
- // Default sensitive fields to redact from request bodies
43
- const DEFAULT_SENSITIVE_FIELDS = [
44
- 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
45
- 'access_token', 'auth', 'credentials', 'mysql_pwd', 'stripeToken',
46
- 'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
47
- ];
48
-
49
- function escapeRegex(str) {
50
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
- }
52
-
53
- /**
54
- * Redact sensitive fields from an object
55
- */
56
- function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
57
- if (!obj || typeof obj !== 'object') return obj;
58
-
59
- const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
60
-
61
- for (const key of Object.keys(redacted)) {
62
- const lowerKey = key.toLowerCase();
63
-
64
- if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
65
- redacted[key] = '[REDACTED]';
66
- } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
67
- redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
68
- }
69
- }
70
-
71
- return redacted;
72
- }
73
-
74
- /**
75
- * Redact sensitive data from GraphQL query strings
76
- */
77
- function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
78
- if (!query || typeof query !== 'string') return query;
79
-
80
- let redacted = query;
81
-
82
- // Redact sensitive fields in GraphQL arguments and variables
83
- sensitiveFields.forEach(field => {
84
- const escaped = escapeRegex(field);
85
- const patterns = [
86
- new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
87
- new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
88
- ];
89
-
90
- patterns.forEach(pattern => {
91
- redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
92
- if (suffix) {
93
- return `${prefix}[REDACTED]${suffix}`;
94
- } else {
95
- return `${prefix}[REDACTED]`;
96
- }
97
- });
98
- });
99
- });
100
-
101
- return redacted;
102
- }
103
-
104
- // -------- Multipart streaming parser --------
105
- // Streams through the request without buffering file content.
106
- // Only part headers and text-field values are kept in memory,
107
- // so memory stays bounded (~few KB) regardless of upload size.
108
-
109
- function extractBoundary(contentType) {
110
- const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i);
111
- return match ? (match[1] || match[2]) : null;
112
- }
113
-
114
- function createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete) {
115
- const boundary = extractBoundary(contentType);
116
- if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return null; }
117
-
118
- const result = { fields: Object.create(null), files: [] };
119
- let totalSize = 0;
120
- let buf = Buffer.alloc(0);
121
-
122
- const MAX_PARTS = 100;
123
- let partCount = 0;
124
-
125
- const FIRST_DELIM = Buffer.from('--' + boundary);
126
- const DELIM = Buffer.from('\r\n--' + boundary);
127
- const HDR_END = Buffer.from('\r\n\r\n');
128
-
129
- let initialized = false;
130
- let inHeaders = true;
131
- let isFile = false;
132
- let fldName = '';
133
- let fName = '';
134
- let pCT = '';
135
- let bodyBytes = 0;
136
- let textVal = '';
137
-
138
- function flushPart() {
139
- if (!fldName || fldName === '__proto__' || fldName === 'constructor' || fldName === 'prototype') return;
140
- if (isFile) {
141
- result.files.push({ field: fldName, filename: fName, contentType: pCT || 'unknown', size: bodyBytes });
142
- } else {
143
- const lower = fldName.toLowerCase();
144
- const redact = sensitiveFields.some(f => lower.includes(f.toLowerCase()));
145
- result.fields[fldName] = redact ? '[REDACTED]' : textVal.substring(0, maxTextFieldSize);
146
- }
147
- fldName = ''; bodyBytes = 0; textVal = ''; partCount++;
148
- }
149
-
150
- function drain() {
151
- if (!initialized) {
152
- const i = buf.indexOf(FIRST_DELIM);
153
- if (i === -1) {
154
- if (buf.length > FIRST_DELIM.length + 4) buf = buf.slice(buf.length - FIRST_DELIM.length - 4);
155
- return;
156
- }
157
- buf = buf.slice(i + FIRST_DELIM.length);
158
- initialized = true;
159
- inHeaders = true;
160
- }
161
-
162
- let guard = 200;
163
- while (buf.length > 0 && guard-- > 0 && partCount < MAX_PARTS) {
164
- if (inHeaders) {
165
- if (buf.length >= 2 && buf[0] === 0x2D && buf[1] === 0x2D) { buf = Buffer.alloc(0); return; }
166
- if (buf.length >= 2 && buf[0] === 0x0D && buf[1] === 0x0A) { buf = buf.slice(2); continue; }
167
-
168
- const hi = buf.indexOf(HDR_END);
169
- if (hi === -1) return;
170
-
171
- const hdr = buf.slice(0, hi).toString('latin1');
172
- buf = buf.slice(hi + 4);
173
-
174
- const nm = hdr.match(/name="([^"]+)"/);
175
- const fn = hdr.match(/filename="([^"]*)"/);
176
- const ct = hdr.match(/Content-Type:\s*(.+)/i);
177
- fldName = nm ? nm[1] : '';
178
- fName = fn ? fn[1] : '';
179
- pCT = ct ? ct[1].trim() : '';
180
- isFile = !!fn;
181
- bodyBytes = 0;
182
- textVal = '';
183
- inHeaders = false;
184
- }
185
-
186
- const di = buf.indexOf(DELIM);
187
- if (di === -1) {
188
- const safe = Math.max(0, buf.length - DELIM.length - 2);
189
- if (safe > 0) {
190
- bodyBytes += safe;
191
- if (!isFile && textVal.length < maxTextFieldSize) {
192
- textVal += buf.slice(0, safe).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
193
- }
194
- buf = buf.slice(safe);
195
- }
196
- return;
197
- }
198
-
199
- bodyBytes += di;
200
- if (!isFile && textVal.length < maxTextFieldSize) {
201
- textVal += buf.slice(0, di).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
202
- }
203
- flushPart();
204
- buf = buf.slice(di + DELIM.length);
205
- inHeaders = true;
206
- }
207
- }
208
-
209
- function onData(chunk) {
210
- const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
211
- totalSize += data.length;
212
- buf = Buffer.concat([buf, data]);
213
- drain();
214
- }
215
-
216
- function onEnd() {
217
- try {
218
- if (!inHeaders && fldName) {
219
- bodyBytes += buf.length;
220
- if (!isFile) textVal += buf.toString('utf8').substring(0, maxTextFieldSize - textVal.length);
221
- flushPart();
222
- }
223
- onComplete({ parsed: result, totalSize });
224
- } catch (e) {
225
- onComplete({ error: 'PARSE_ERROR' });
226
- }
227
- }
228
-
229
- return { onData, onEnd };
230
- }
231
-
232
- function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFieldSize, onComplete) {
233
- const collector = createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete);
234
- if (!collector) return;
235
- request.on('data', collector.onData);
236
- request.on('end', collector.onEnd);
237
- }
238
-
239
- // -------- ESM detection --------
240
- // register.js auto-registers the hook via module.register() on Node >=20.6.
241
- // This warning only fires if BOTH --import AND module.register() were skipped
242
- // (e.g. Node 18, or require('securenow/tracing') called directly without register.js).
243
- (() => {
244
- try {
245
- const fs = require('fs');
246
- const path = require('path');
247
- const pkgPath = path.resolve(process.cwd(), 'package.json');
248
- if (fs.existsSync(pkgPath)) {
249
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
250
- if (pkg.type === 'module') {
251
- const execArgv = process.execArgv.join(' ');
252
- const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
253
- const hasModuleRegister = typeof require('node:module').register === 'function';
254
- if (!hasCliHook && !hasModuleRegister) {
255
- console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
256
- console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
257
- }
258
- }
259
- }
260
- } catch (_) {}
261
- })();
262
-
263
- // -------- diagnostics --------
264
- const diagLevel = String(appConfig.configValue('otel.logLevel', '') || '').toLowerCase();
265
- (() => {
266
- const level = diagLevel === 'debug' ? DiagLogLevel.DEBUG :
267
- diagLevel === 'info' ? DiagLogLevel.INFO :
268
- diagLevel === 'warn' ? DiagLogLevel.WARN :
269
- diagLevel === 'error' ? DiagLogLevel.ERROR : DiagLogLevel.NONE;
270
- diag.setLogger(new DiagConsoleLogger(), level);
271
- console.log('[securenow] preload loaded pid=%d', process.pid);
272
- })();
273
-
274
- // -------- endpoints & app resolution --------
275
- // Resolution order for endpoint/appId/apiKey: .securenow/credentials.json -> package.json#name -> defaults.
276
- const resolvedApp = appConfig.resolveAll();
277
- const resolvedEndpoints = appConfig.resolveEndpoints();
278
-
279
- const endpointBase = resolvedEndpoints.endpointBase;
280
- const tracesUrl = resolvedEndpoints.tracesUrl;
281
- const logsUrl = resolvedEndpoints.logsUrl;
282
-
283
- // resolveEndpoints() also adds app routing and runtime auth headers when
284
- // explicit OTLP headers did not provide them.
285
- const headers = resolvedEndpoints.headers;
286
-
287
- // -------- naming rules --------
288
- const rawBase = (resolvedApp.appId || '').trim().replace(/^['"]|['"]$/g, '');
289
- const baseName = rawBase || null;
290
- // Auto-disables the per-worker suffix when we resolved a routing UUID from
291
- // credentials — the dashboard does exact-match IN on service.name, so any
292
- // suffix breaks routing. config.runtime.noUuid can override.
293
- const noUuid = appConfig.resolveNoUuid();
294
- const strict = appConfig.boolConfig('runtime.strict', false);
295
- const inPm2Cluster = !!(process.env.NODE_APP_INSTANCE || process.env.pm_id);
296
-
297
- // Fail fast in cluster if base is missing (no more "free" names)
298
- if (!baseName && inPm2Cluster && strict) {
299
- console.error('[securenow] FATAL: app identity missing in cluster (pid=%d). Exiting due to config.runtime.strict=true.', process.pid);
300
- // small delay so the log flushes
301
- setTimeout(() => process.exit(1), 10);
302
- }
303
-
304
- // service.name
305
- let serviceName;
306
- if (baseName) {
307
- serviceName = noUuid ? baseName : `${baseName}-${randomUUID()}`;
308
- } else {
309
- // last-resort fallback (only if STRlCT is off). You can rename this to make it obvious in monitoring.
310
- serviceName = `securenow-free-${randomUUID()}`;
311
- }
312
-
313
- // service.instance.id = <appid-or-fallback>-<uuid> (unique per worker)
314
- const instancePrefix = baseName || 'securenow';
315
- const serviceInstanceId = `${instancePrefix}-${randomUUID()}`;
316
-
317
- // Loud line per worker to prove what was used
318
- console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s → service.name=%s instance.id=%s',
319
- process.pid,
320
- JSON.stringify(baseName),
321
- JSON.stringify(endpointBase),
322
- resolvedApp.appKey ? 'set' : 'none',
323
- serviceName,
324
- serviceInstanceId
325
- );
326
-
327
- // -------- instrumentations --------
328
- const disabledMap = {};
329
- for (const n of appConfig.listConfig('otel.disableInstrumentations')) {
330
- disabledMap[n] = { enabled: false };
331
- }
332
-
333
- // -------- Body Capture Configuration --------
334
- // Opt-out defaults: set config.capture.body=false to disable.
335
- const captureBody = appConfig.boolConfig('capture.body', true);
336
- const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
337
- const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
338
- const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
339
-
340
- const captureMultipart = appConfig.boolConfig('capture.multipart', true);
341
-
342
- const BODY_CAPTURE_PATCH = Symbol.for('securenow.bodyCapture.emitPatch');
343
-
344
- function installRequestBodyObserver(span, request, contentType) {
345
- if (!request || request[BODY_CAPTURE_PATCH]) return;
346
-
347
- const normalizedContentType = String(contentType || '').toLowerCase();
348
- const isStructuredBody = captureBody && (
349
- normalizedContentType.includes('application/json') ||
350
- normalizedContentType.includes('application/graphql') ||
351
- normalizedContentType.includes('application/x-www-form-urlencoded')
352
- );
353
- const isMultipartBody = normalizedContentType.includes('multipart/form-data');
354
-
355
- if (!isStructuredBody && !isMultipartBody) return;
356
-
357
- if (isMultipartBody && !captureMultipart) {
358
- span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
359
- span.setAttribute('http.request.body.type', 'multipart');
360
- span.setAttribute('http.request.body.note', 'Multipart capture disabled by config.capture.multipart=false');
361
- return;
362
- }
363
-
364
- let chunks = [];
365
- let size = 0;
366
- let structuredDone = false;
367
-
368
- const multipartCollector = isMultipartBody && captureMultipart
369
- ? createMultipartMetaCollector(normalizedContentType, allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
370
- try {
371
- if (error === 'BOUNDARY_NOT_FOUND') {
372
- span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
373
- span.setAttribute('http.request.body.type', 'multipart');
374
- return;
375
- }
376
- if (error) {
377
- span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
378
- span.setAttribute('http.request.body.type', 'multipart');
379
- span.setAttribute('http.request.body.parse_error', true);
380
- return;
381
- }
382
- span.setAttributes({
383
- 'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
384
- 'http.request.body.type': 'multipart',
385
- 'http.request.body.size': totalSize,
386
- 'http.request.body.fields_count': Object.keys(parsed.fields).length,
387
- 'http.request.body.files_count': parsed.files.length,
388
- });
389
- } catch (e) {
390
- span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
391
- span.setAttribute('http.request.body.type', 'multipart');
392
- span.setAttribute('http.request.body.parse_error', true);
393
- }
394
- })
395
- : null;
396
-
397
- if (isMultipartBody && captureMultipart && !multipartCollector) return;
398
-
399
- function finishStructuredCapture() {
400
- if (!isStructuredBody || structuredDone) return;
401
- structuredDone = true;
402
-
403
- if (size > maxBodySize) {
404
- span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
405
- span.setAttribute('http.request.body.size', size);
406
- chunks = [];
407
- return;
408
- }
409
-
410
- if (chunks.length === 0) return;
411
-
412
- const body = Buffer.concat(chunks).toString('utf8');
413
- chunks = [];
414
-
415
- try {
416
- if (normalizedContentType.includes('application/graphql')) {
417
- const redacted = redactGraphQLQuery(body, allSensitiveFields);
418
- span.setAttributes({
419
- 'http.request.body': redacted.substring(0, maxBodySize),
420
- 'http.request.body.type': 'graphql',
421
- 'http.request.body.size': size,
422
- });
423
- } else if (normalizedContentType.includes('application/json')) {
424
- const parsed = JSON.parse(body);
425
- const redacted = redactSensitiveData(parsed, allSensitiveFields);
426
- span.setAttributes({
427
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
428
- 'http.request.body.type': 'json',
429
- 'http.request.body.size': size,
430
- });
431
- } else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
432
- const parsed = Object.fromEntries(new URLSearchParams(body));
433
- const redacted = redactSensitiveData(parsed, allSensitiveFields);
434
- span.setAttributes({
435
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
436
- 'http.request.body.type': 'form',
437
- 'http.request.body.size': size,
438
- });
439
- }
440
- } catch (e) {
441
- span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
442
- span.setAttribute('http.request.body.parse_error', true);
443
- span.setAttribute('http.request.body.size', size);
444
- }
445
- }
446
-
447
- const originalEmit = request.emit;
448
- request[BODY_CAPTURE_PATCH] = true;
449
- request.emit = function securenowObservedEmit(event, ...args) {
450
- try {
451
- if (event === 'data' && args.length > 0) {
452
- const chunk = Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]);
453
- if (isStructuredBody) {
454
- size += chunk.length;
455
- if (size <= maxBodySize) chunks.push(chunk);
456
- }
457
- if (multipartCollector) multipartCollector.onData(chunk);
458
- } else if (event === 'end') {
459
- finishStructuredCapture();
460
- if (multipartCollector) multipartCollector.onEnd();
461
- }
462
- } catch (_) {
463
- // Body capture must never interfere with the application request stream.
464
- }
465
- return originalEmit.apply(this, [event, ...args]);
466
- };
467
- }
468
-
469
- // -------- Trusted proxy IP resolution --------
470
- const { resolveClientIpWithDetails } = require('./resolve-ip');
471
-
472
- // Configure HTTP instrumentation with body capture
473
- const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
474
- const httpInstrumentation = new HttpInstrumentation({
475
- requestHook: (span, request) => {
476
- try {
477
- const ipDetails = resolveClientIpWithDetails(request);
478
- if (ipDetails.ip) {
479
- span.setAttribute('http.client_ip', ipDetails.ip);
480
- span.setAttribute('http.client_ip.source', ipDetails.source);
481
- span.setAttribute('http.socket_ip', ipDetails.socketIp || '');
482
- span.setAttribute('http.proxy.trusted', String(!!ipDetails.trustedProxy));
483
- if (ipDetails.forwardedFor) {
484
- span.setAttribute('http.forwarded_for', ipDetails.forwardedFor);
485
- span.setAttribute('http.request.header.x_forwarded_for', ipDetails.forwardedFor);
486
- }
487
- if (ipDetails.realIp) {
488
- span.setAttribute('http.real_ip', ipDetails.realIp);
489
- span.setAttribute('http.request.header.x_real_ip', ipDetails.realIp);
490
- }
491
- if (ipDetails.cfConnectingIp) {
492
- span.setAttribute('http.cf.connecting_ip', ipDetails.cfConnectingIp);
493
- span.setAttribute('http.request.header.cf_connecting_ip', ipDetails.cfConnectingIp);
494
- }
495
- if (ipDetails.trueClientIp) {
496
- span.setAttribute('http.request.header.true_client_ip', ipDetails.trueClientIp);
497
- }
498
- if (ipDetails.clientIp) {
499
- span.setAttribute('http.request.header.x_client_ip', ipDetails.clientIp);
500
- }
501
- }
502
-
503
- if (request.headers) {
504
- const SKIP_HEADERS = new Set(['cookie', 'authorization', 'proxy-authorization', 'set-cookie', 'x-api-key', 'x-auth-token']);
505
- const safe = {};
506
- for (const [k, v] of Object.entries(request.headers)) {
507
- if (SKIP_HEADERS.has(k.toLowerCase())) { safe[k] = '[REDACTED]'; continue; }
508
- safe[k] = typeof v === 'string' ? v.substring(0, 500) : String(v);
509
- }
510
- const serialized = JSON.stringify(safe);
511
- if (serialized.length <= 8192) {
512
- span.setAttribute('http.request.headers', serialized);
513
- }
514
- }
515
-
516
- if ((captureBody || captureMultipart) && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
517
- const contentType = request.headers['content-type'] || '';
518
- installRequestBodyObserver(span, request, contentType);
519
- }
520
- } catch (error) {
521
- // Silently fail
522
- }
523
- },
524
- });
525
-
526
- // -------- Logging Configuration --------
527
- // Opt-out default: set config.logging.enabled=false to disable.
528
- const loggingEnabled = appConfig.boolConfig('logging.enabled', true);
529
-
530
- // Create shared resource for both traces and logs
531
- const sharedResource = createResource({
532
- [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
533
- [SemanticResourceAttributes.SERVICE_INSTANCE_ID]: serviceInstanceId,
534
- [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: appConfig.resolveDeploymentEnvironment(),
535
- [SemanticResourceAttributes.SERVICE_VERSION]: process.env.npm_package_version || undefined,
536
- });
537
-
538
- // Initialize LoggerProvider if logging is enabled
539
- let loggerProvider = null;
540
-
541
- if (loggingEnabled) {
542
- const logExporter = new OTLPLogExporter({
543
- url: logsUrl,
544
- headers
545
- });
546
-
547
- const batchLogProcessor = new BatchLogRecordProcessor(logExporter);
548
- loggerProvider = new LoggerProvider({
549
- resource: sharedResource,
550
- processors: [batchLogProcessor],
551
- });
552
-
553
- // Auto-patch console.* so every log/warn/error becomes an OTel log record
554
- const _logger = loggerProvider.getLogger('console', '1.0.0');
555
- const _orig = console.__securenow_original || { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug };
556
- if (!console.__securenow_original) console.__securenow_original = _orig;
557
- const SEV = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 };
558
- function _emit(sn, st, args) {
559
- try {
560
- const activeCtx = context.active();
561
- const spanCtx = trace.getSpanContext(activeCtx);
562
- _logger.emit({
563
- severityNumber: sn,
564
- severityText: st,
565
- body: args.map(a => (typeof a === 'object' && a !== null) ? JSON.stringify(a) : String(a)).join(' '),
566
- attributes: { 'log.source': 'console', 'log.method': st.toLowerCase() },
567
- ...(spanCtx && { context: activeCtx }),
568
- });
569
- } catch (_) {}
570
- }
571
- console.log = function (...a) { _emit(SEV.INFO, 'INFO', a); _orig.log.apply(console, a); };
572
- console.info = function (...a) { _emit(SEV.INFO, 'INFO', a); _orig.info.apply(console, a); };
573
- console.warn = function (...a) { _emit(SEV.WARN, 'WARN', a); _orig.warn.apply(console, a); };
574
- console.error = function (...a) { _emit(SEV.ERROR, 'ERROR', a); _orig.error.apply(console, a); };
575
- console.debug = function (...a) { _emit(SEV.DEBUG, 'DEBUG', a); _orig.debug.apply(console, a); };
576
- console.__securenow_patched = true;
577
- }
578
-
579
- // -------- Guard against OTLP exporter socket errors --------
580
- // The OTLP HTTP exporter uses keep-alive connections that can be reset by the
581
- // remote end (ECONNRESET / "socket hang up"). These transient errors sometimes
582
- // escape as unhandled exceptions or rejections because the underlying HTTP
583
- // request's error path isn't fully covered by the OTel library. We install
584
- // targeted process-level handlers to catch them and log at debug level instead
585
- // of crashing the host app.
586
- const _TRANSIENT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND']);
587
- function _isOtlpTransientError(err) {
588
- if (!err) return false;
589
- if (_TRANSIENT_CODES.has(err.code)) return true;
590
- if (typeof err.message === 'string' && /socket hang up|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed out/i.test(err.message)) return true;
591
- return false;
592
- }
593
- function _looksLikeOtlpStack(err) {
594
- const s = err && err.stack;
595
- if (!s) return false;
596
- return /OTLPTraceExporter|OTLPLogExporter|otlp|exporter.*http|BatchSpanProcessor|BatchLogRecordProcessor/i.test(s)
597
- || /node:_http_client|ClientRequest|TLSSocket/i.test(s);
598
- }
599
- function _looksLikeConfiguredOtlpEndpoint(err) {
600
- const text = `${err && err.hostname || ''} ${err && err.host || ''} ${err && err.message || ''}`;
601
- try {
602
- const hosts = [new URL(tracesUrl).hostname, new URL(logsUrl).hostname].filter(Boolean);
603
- return hosts.some((host) => host && text.includes(host));
604
- } catch (_) {
605
- return false;
606
- }
607
- }
608
- function _originalConsole(method) {
609
- const originals = console.__securenow_original || console.__securenowOriginalConsole;
610
- return (originals && originals[method]) || console[method] || console.log;
611
- }
612
- let _lastSuppressedOtlpErrorLogAt = 0;
613
- function _formatOtlpError(err) {
614
- if (!err) return 'unknown error';
615
- const parts = [err.message || String(err)];
616
- if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
617
- if (err.syscall) parts.push(`syscall=${err.syscall}`);
618
- if (err.hostname) parts.push(`host=${err.hostname}`);
619
- return parts.join(' ');
620
- }
621
- function _reportSuppressedOtlpError(kind, err, origin) {
622
- const level = String(diagLevel || '').toLowerCase();
623
- if (level === 'none') return;
624
- const now = Date.now();
625
- if (level !== 'debug' && now - _lastSuppressedOtlpErrorLogAt < 60_000) return;
626
- _lastSuppressedOtlpErrorLogAt = now;
627
- const method = level === 'debug' ? 'debug' : 'error';
628
- _originalConsole(method).call(
629
- console,
630
- '[securenow] OTLP exporter %s suppressed (%s). Telemetry may be missing until the ingest endpoint is reachable: %s',
631
- kind,
632
- origin || 'async',
633
- _formatOtlpError(err)
634
- );
635
- }
636
-
637
- process.on('uncaughtException', (err, origin) => {
638
- if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
639
- _reportSuppressedOtlpError('error', err, origin);
640
- return; // swallow - do not crash
641
- }
642
- // Not ours — re-throw so the default handler (or the app's own handler) fires
643
- throw err;
644
- });
645
- process.on('unhandledRejection', (reason) => {
646
- if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
647
- _reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
648
- return; // swallow
649
- }
650
- // Not ours — re-throw as unhandled so Node's default behaviour applies
651
- throw reason;
652
- });
653
-
654
- // -------- SDK --------
655
- const traceExporter = new OTLPTraceExporter({ url: tracesUrl, headers });
656
- const sdk = new NodeSDK({
657
- ...nodeSdkTelemetryOptions,
658
- traceExporter,
659
- instrumentations: [
660
- httpInstrumentation,
661
- ...(disabledMap['@opentelemetry/instrumentation-mongodb'] ? [] : [new MongoDBInstrumentation()]),
662
- ...getNodeAutoInstrumentations({
663
- ...disabledMap,
664
- '@opentelemetry/instrumentation-http': { enabled: false },
665
- '@opentelemetry/instrumentation-mongodb': { enabled: false },
666
- }),
667
- ],
668
- resource: sharedResource,
669
- });
670
-
671
- // -------- start / shutdown (sync/async safe) --------
672
- (async () => {
673
- try {
674
- await Promise.resolve(sdk.start?.());
675
- console.log('[securenow] OTel SDK started → %s', tracesUrl);
676
- if (loggingEnabled) {
677
- console.log('[securenow] 📋 Logging: ENABLED → %s', logsUrl);
678
- } else {
679
- console.log('[securenow] Logging: DISABLED (config.logging.enabled=false)');
680
- }
681
- if (captureBody) {
682
- console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
683
- }
684
- if (captureMultipart) {
685
- console.log('[securenow] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
686
- }
687
- if (appConfig.boolConfig('runtime.testSpan', false)) {
688
- const api = require('@opentelemetry/api');
689
- const tracer = api.trace.getTracer('securenow-smoke');
690
- const span = tracer.startSpan('securenow.startup.smoke'); span.end();
691
- }
692
-
693
- // Free trial banner
694
- const { isFreeTrial, patchHttpForBanner } = require('./free-trial-banner');
695
- if (isFreeTrial(endpointBase) && !appConfig.boolConfig('runtime.hideBanner', false)) {
696
- patchHttpForBanner();
697
- }
698
-
699
- // Firewall — auto-activates only when a real snk_live_ key is resolvable.
700
- // resolveApiKey() enforces the prefix, so we skip cleanly when the app has
701
- // only an app-routing UUID (or nothing at all) — no 401 polling loops.
702
- const firewallOptions = appConfig.resolveFirewallOptions();
703
- if (firewallOptions.apiKey) {
704
- require('./firewall').init({
705
- apiKey: firewallOptions.apiKey,
706
- appKey: firewallOptions.appKey,
707
- environment: firewallOptions.environment,
708
- apiUrl: firewallOptions.apiUrl,
709
- apiUrlFallbacks: firewallOptions.apiUrlFallbacks,
710
- versionCheckInterval: firewallOptions.versionCheckInterval,
711
- syncInterval: firewallOptions.syncInterval,
712
- failMode: firewallOptions.failMode,
713
- statusCode: firewallOptions.statusCode,
714
- log: firewallOptions.log,
715
- tcp: firewallOptions.tcp,
716
- iptables: firewallOptions.iptables,
717
- cloud: firewallOptions.cloud,
718
- cloudDryRun: firewallOptions.cloudDryRun,
719
- cloudflare: firewallOptions.cloudflare,
720
- aws: firewallOptions.aws,
721
- gcp: firewallOptions.gcp,
722
- });
723
- }
724
- } catch (e) {
725
- console.error('[securenow] OTel start failed:', e && e.stack || e);
726
- }
727
- })();
728
-
729
- let shuttingDown = false;
730
- async function safeShutdown(sig) {
731
- if (shuttingDown) return;
732
- shuttingDown = true;
733
- try {
734
- await Promise.resolve(sdk.shutdown?.());
735
- if (loggerProvider) {
736
- await Promise.resolve(loggerProvider.shutdown?.());
737
- }
738
- try { require('./firewall').shutdown(); } catch (_) {}
739
- console.log(`[securenow] Tracing and logging terminated on ${sig}`);
740
- }
741
- catch (e) { console.error('[securenow] Shutdown error:', e); }
742
- finally { process.exit(0); }
743
- }
744
- process.on('SIGINT', () => safeShutdown('SIGINT'));
745
- process.on('SIGTERM', () => safeShutdown('SIGTERM'));
746
-
747
- // -------- Export logger for consuming applications --------
748
- module.exports = {
749
- loggerProvider,
750
- getLogger: (name = 'default', version = '1.0.0') => {
751
- if (!loggerProvider) {
752
- console.warn('[securenow] Logging is disabled (config.logging.enabled=false). Enable it in .securenow/credentials.json to use getLogger().');
753
- return null;
754
- }
755
- return loggerProvider.getLogger(name, version);
756
- },
757
- isLoggingEnabled: () => loggingEnabled,
758
- };
1
+ 'use strict';
2
+
3
+ /**
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
10
+ *
11
+ * Config:
12
+ * Runtime config is read from .securenow/credentials.json.
13
+ * Run `npx securenow login` and `npx securenow init` to create it.
14
+ * Production should mount/copy tokenless runtime credentials to the same path.
15
+ */
16
+
17
+ const { nodeSdkDefaultTelemetryOptions } = require('./otel-defaults');
18
+ const nodeSdkTelemetryOptions = nodeSdkDefaultTelemetryOptions();
19
+
20
+ const { diag, DiagConsoleLogger, DiagLogLevel, context, trace } = require('@opentelemetry/api');
21
+ const { NodeSDK } = require('@opentelemetry/sdk-node');
22
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
23
+ const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
24
+ const { LoggerProvider, BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
25
+ const otelResources = require('@opentelemetry/resources');
26
+ const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
27
+ const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
28
+ const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb');
29
+ const { randomUUID } = require('crypto');
30
+ const appConfig = require('./app-config');
31
+
32
+ function createResource(attributes) {
33
+ if (typeof otelResources.resourceFromAttributes === 'function') {
34
+ return otelResources.resourceFromAttributes(attributes);
35
+ }
36
+ if (typeof otelResources.Resource === 'function') {
37
+ return new otelResources.Resource(attributes);
38
+ }
39
+ throw new Error('Unsupported @opentelemetry/resources version');
40
+ }
41
+
42
+ // Default sensitive fields to redact from request bodies.
43
+ // Matched substring-wise against lowercased keys (see redactSensitiveData),
44
+ // so e.g. 'card' also catches 'creditCard' and 'account' also catches
45
+ // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
46
+ // telemetry value is always safer than a leaked secret. Entries are kept
47
+ // specific enough to avoid nuking broad benign keys (e.g. we use
48
+ // 'firstname'/'lastname'/'fullname', never bare 'name').
49
+ const DEFAULT_SENSITIVE_FIELDS = [
50
+ // credentials / auth
51
+ 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
52
+ 'access_token', 'auth', 'authorization', 'bearer', 'credentials',
53
+ 'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
54
+ 'cookie', 'set-cookie',
55
+ // financial
56
+ 'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
57
+ 'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
58
+ // PII
59
+ 'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
60
+ 'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
61
+ 'passport', 'license',
62
+ ];
63
+
64
+ // Conservative value-shape redactors. Key-name matching misses secrets that
65
+ // land in free-form string values (GraphQL bodies, message fields, etc.), so
66
+ // as a second layer we scrub string VALUES that *look like* a secret/PII.
67
+ // These are intentionally precise/bounded so they don't garble normal prose,
68
+ // and they only ever transform captured telemetry strings (read-only) — never
69
+ // the actual request/response stream. Compiled once at module load.
70
+ const VALUE_REDACTORS = [
71
+ // JWT: three base64url segments. Anchored to the eyJ header so it won't
72
+ // match arbitrary dotted tokens.
73
+ { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
74
+ // Bearer/Basic auth header value embedded in a body string.
75
+ { name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
76
+ // Stripe-style / SecureNow live+test API keys.
77
+ { name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
78
+ // Email addresses (bounded local/domain parts).
79
+ { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
80
+ // Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
81
+ // space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
82
+ { name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
83
+ ];
84
+
85
+ // Skip the value-shape scan on very large strings to bound per-body cost.
86
+ const MAX_VALUE_SCAN_LENGTH = 16384;
87
+
88
+ function luhnValid(digits) {
89
+ let sum = 0;
90
+ let alt = false;
91
+ for (let i = digits.length - 1; i >= 0; i--) {
92
+ let d = digits.charCodeAt(i) - 48;
93
+ if (d < 0 || d > 9) return false;
94
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
95
+ sum += d;
96
+ alt = !alt;
97
+ }
98
+ return sum % 10 === 0;
99
+ }
100
+
101
+ /**
102
+ * Redact obvious secret/PII shapes inside a captured string VALUE.
103
+ * Returns the input unchanged when nothing matches (cheap common case).
104
+ */
105
+ function redactSensitiveValue(value) {
106
+ if (typeof value !== 'string' || value.length === 0) return value;
107
+ if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound the work
108
+ let out = value;
109
+ for (const r of VALUE_REDACTORS) {
110
+ r.re.lastIndex = 0;
111
+ if (r.luhn) {
112
+ out = out.replace(r.re, (m) => {
113
+ const digits = m.replace(/[ -]/g, '');
114
+ if (digits.length < 13 || digits.length > 19) return m;
115
+ return luhnValid(digits) ? '[REDACTED]' : m;
116
+ });
117
+ } else {
118
+ out = out.replace(r.re, '[REDACTED]');
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function escapeRegex(str) {
125
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
126
+ }
127
+
128
+ /**
129
+ * Redact sensitive fields from an object
130
+ */
131
+ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
132
+ if (!obj || typeof obj !== 'object') return obj;
133
+
134
+ const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
135
+
136
+ for (const key of Object.keys(redacted)) {
137
+ const lowerKey = key.toLowerCase();
138
+
139
+ if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
140
+ redacted[key] = '[REDACTED]';
141
+ } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
142
+ redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
143
+ } else if (typeof redacted[key] === 'string') {
144
+ // Second layer: even if the key name looks benign, scrub values that
145
+ // *look like* a secret/PII (JWT, bearer token, API key, email, card).
146
+ redacted[key] = redactSensitiveValue(redacted[key]);
147
+ }
148
+ }
149
+
150
+ return redacted;
151
+ }
152
+
153
+ /**
154
+ * Redact sensitive data from GraphQL query strings
155
+ */
156
+ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
157
+ if (!query || typeof query !== 'string') return query;
158
+
159
+ let redacted = query;
160
+
161
+ // Redact sensitive fields in GraphQL arguments and variables
162
+ sensitiveFields.forEach(field => {
163
+ const escaped = escapeRegex(field);
164
+ const patterns = [
165
+ new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
166
+ new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
167
+ ];
168
+
169
+ patterns.forEach(pattern => {
170
+ redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
171
+ if (suffix) {
172
+ return `${prefix}[REDACTED]${suffix}`;
173
+ } else {
174
+ return `${prefix}[REDACTED]`;
175
+ }
176
+ });
177
+ });
178
+ });
179
+
180
+ // Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
181
+ // body — catches values the key-name pass above can't see.
182
+ redacted = redactSensitiveValue(redacted);
183
+
184
+ return redacted;
185
+ }
186
+
187
+ // -------- Multipart streaming parser --------
188
+ // Streams through the request without buffering file content.
189
+ // Only part headers and text-field values are kept in memory,
190
+ // so memory stays bounded (~few KB) regardless of upload size.
191
+
192
+ function extractBoundary(contentType) {
193
+ const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i);
194
+ return match ? (match[1] || match[2]) : null;
195
+ }
196
+
197
+ function createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete) {
198
+ const boundary = extractBoundary(contentType);
199
+ if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return null; }
200
+
201
+ const result = { fields: Object.create(null), files: [] };
202
+ let totalSize = 0;
203
+ let buf = Buffer.alloc(0);
204
+
205
+ const MAX_PARTS = 100;
206
+ let partCount = 0;
207
+
208
+ const FIRST_DELIM = Buffer.from('--' + boundary);
209
+ const DELIM = Buffer.from('\r\n--' + boundary);
210
+ const HDR_END = Buffer.from('\r\n\r\n');
211
+
212
+ let initialized = false;
213
+ let inHeaders = true;
214
+ let isFile = false;
215
+ let fldName = '';
216
+ let fName = '';
217
+ let pCT = '';
218
+ let bodyBytes = 0;
219
+ let textVal = '';
220
+
221
+ function flushPart() {
222
+ if (!fldName || fldName === '__proto__' || fldName === 'constructor' || fldName === 'prototype') return;
223
+ if (isFile) {
224
+ result.files.push({ field: fldName, filename: fName, contentType: pCT || 'unknown', size: bodyBytes });
225
+ } else {
226
+ const lower = fldName.toLowerCase();
227
+ const redact = sensitiveFields.some(f => lower.includes(f.toLowerCase()));
228
+ // Name-match redaction first; otherwise scrub secret/PII *shapes* in the value.
229
+ result.fields[fldName] = redact
230
+ ? '[REDACTED]'
231
+ : redactSensitiveValue(textVal.substring(0, maxTextFieldSize));
232
+ }
233
+ fldName = ''; bodyBytes = 0; textVal = ''; partCount++;
234
+ }
235
+
236
+ function drain() {
237
+ if (!initialized) {
238
+ const i = buf.indexOf(FIRST_DELIM);
239
+ if (i === -1) {
240
+ if (buf.length > FIRST_DELIM.length + 4) buf = buf.slice(buf.length - FIRST_DELIM.length - 4);
241
+ return;
242
+ }
243
+ buf = buf.slice(i + FIRST_DELIM.length);
244
+ initialized = true;
245
+ inHeaders = true;
246
+ }
247
+
248
+ let guard = 200;
249
+ while (buf.length > 0 && guard-- > 0 && partCount < MAX_PARTS) {
250
+ if (inHeaders) {
251
+ if (buf.length >= 2 && buf[0] === 0x2D && buf[1] === 0x2D) { buf = Buffer.alloc(0); return; }
252
+ if (buf.length >= 2 && buf[0] === 0x0D && buf[1] === 0x0A) { buf = buf.slice(2); continue; }
253
+
254
+ const hi = buf.indexOf(HDR_END);
255
+ if (hi === -1) return;
256
+
257
+ const hdr = buf.slice(0, hi).toString('latin1');
258
+ buf = buf.slice(hi + 4);
259
+
260
+ const nm = hdr.match(/name="([^"]+)"/);
261
+ const fn = hdr.match(/filename="([^"]*)"/);
262
+ const ct = hdr.match(/Content-Type:\s*(.+)/i);
263
+ fldName = nm ? nm[1] : '';
264
+ fName = fn ? fn[1] : '';
265
+ pCT = ct ? ct[1].trim() : '';
266
+ isFile = !!fn;
267
+ bodyBytes = 0;
268
+ textVal = '';
269
+ inHeaders = false;
270
+ }
271
+
272
+ const di = buf.indexOf(DELIM);
273
+ if (di === -1) {
274
+ const safe = Math.max(0, buf.length - DELIM.length - 2);
275
+ if (safe > 0) {
276
+ bodyBytes += safe;
277
+ if (!isFile && textVal.length < maxTextFieldSize) {
278
+ textVal += buf.slice(0, safe).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
279
+ }
280
+ buf = buf.slice(safe);
281
+ }
282
+ return;
283
+ }
284
+
285
+ bodyBytes += di;
286
+ if (!isFile && textVal.length < maxTextFieldSize) {
287
+ textVal += buf.slice(0, di).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
288
+ }
289
+ flushPart();
290
+ buf = buf.slice(di + DELIM.length);
291
+ inHeaders = true;
292
+ }
293
+ }
294
+
295
+ function onData(chunk) {
296
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
297
+ totalSize += data.length;
298
+ buf = Buffer.concat([buf, data]);
299
+ drain();
300
+ }
301
+
302
+ function onEnd() {
303
+ try {
304
+ if (!inHeaders && fldName) {
305
+ bodyBytes += buf.length;
306
+ if (!isFile) textVal += buf.toString('utf8').substring(0, maxTextFieldSize - textVal.length);
307
+ flushPart();
308
+ }
309
+ onComplete({ parsed: result, totalSize });
310
+ } catch (e) {
311
+ onComplete({ error: 'PARSE_ERROR' });
312
+ }
313
+ }
314
+
315
+ return { onData, onEnd };
316
+ }
317
+
318
+ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFieldSize, onComplete) {
319
+ const collector = createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete);
320
+ if (!collector) return;
321
+ request.on('data', collector.onData);
322
+ request.on('end', collector.onEnd);
323
+ }
324
+
325
+ // -------- ESM detection --------
326
+ // register.js auto-registers the hook via module.register() on Node >=20.6.
327
+ // This warning only fires if BOTH --import AND module.register() were skipped
328
+ // (e.g. Node 18, or require('securenow/tracing') called directly without register.js).
329
+ (() => {
330
+ try {
331
+ const fs = require('fs');
332
+ const path = require('path');
333
+ const pkgPath = path.resolve(process.cwd(), 'package.json');
334
+ if (fs.existsSync(pkgPath)) {
335
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
336
+ if (pkg.type === 'module') {
337
+ const execArgv = process.execArgv.join(' ');
338
+ const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
339
+ const hasModuleRegister = typeof require('node:module').register === 'function';
340
+ if (!hasCliHook && !hasModuleRegister) {
341
+ console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
342
+ console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
343
+ }
344
+ }
345
+ }
346
+ } catch (_) {}
347
+ })();
348
+
349
+ // -------- diagnostics --------
350
+ const diagLevel = String(appConfig.configValue('otel.logLevel', '') || '').toLowerCase();
351
+ (() => {
352
+ const level = diagLevel === 'debug' ? DiagLogLevel.DEBUG :
353
+ diagLevel === 'info' ? DiagLogLevel.INFO :
354
+ diagLevel === 'warn' ? DiagLogLevel.WARN :
355
+ diagLevel === 'error' ? DiagLogLevel.ERROR : DiagLogLevel.NONE;
356
+ diag.setLogger(new DiagConsoleLogger(), level);
357
+ console.log('[securenow] preload loaded pid=%d', process.pid);
358
+ })();
359
+
360
+ // -------- endpoints & app resolution --------
361
+ // Resolution order for endpoint/appId/apiKey: .securenow/credentials.json -> package.json#name -> defaults.
362
+ const resolvedApp = appConfig.resolveAll();
363
+ const resolvedEndpoints = appConfig.resolveEndpoints();
364
+
365
+ const endpointBase = resolvedEndpoints.endpointBase;
366
+ const tracesUrl = resolvedEndpoints.tracesUrl;
367
+ const logsUrl = resolvedEndpoints.logsUrl;
368
+
369
+ // resolveEndpoints() also adds app routing and runtime auth headers when
370
+ // explicit OTLP headers did not provide them.
371
+ const headers = resolvedEndpoints.headers;
372
+
373
+ // -------- naming rules --------
374
+ const rawBase = (resolvedApp.appId || '').trim().replace(/^['"]|['"]$/g, '');
375
+ const baseName = rawBase || null;
376
+ // Auto-disables the per-worker suffix when we resolved a routing UUID from
377
+ // credentials — the dashboard does exact-match IN on service.name, so any
378
+ // suffix breaks routing. config.runtime.noUuid can override.
379
+ const noUuid = appConfig.resolveNoUuid();
380
+ const strict = appConfig.boolConfig('runtime.strict', false);
381
+ const inPm2Cluster = !!(process.env.NODE_APP_INSTANCE || process.env.pm_id);
382
+
383
+ // Fail fast in cluster if base is missing (no more "free" names)
384
+ if (!baseName && inPm2Cluster && strict) {
385
+ console.error('[securenow] FATAL: app identity missing in cluster (pid=%d). Exiting due to config.runtime.strict=true.', process.pid);
386
+ // small delay so the log flushes
387
+ setTimeout(() => process.exit(1), 10);
388
+ }
389
+
390
+ // service.name
391
+ let serviceName;
392
+ if (baseName) {
393
+ serviceName = noUuid ? baseName : `${baseName}-${randomUUID()}`;
394
+ } else {
395
+ // last-resort fallback (only if STRlCT is off). You can rename this to make it obvious in monitoring.
396
+ serviceName = `securenow-free-${randomUUID()}`;
397
+ }
398
+
399
+ // service.instance.id = <appid-or-fallback>-<uuid> (unique per worker)
400
+ const instancePrefix = baseName || 'securenow';
401
+ const serviceInstanceId = `${instancePrefix}-${randomUUID()}`;
402
+
403
+ // Loud line per worker to prove what was used
404
+ console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s → service.name=%s instance.id=%s',
405
+ process.pid,
406
+ JSON.stringify(baseName),
407
+ JSON.stringify(endpointBase),
408
+ resolvedApp.appKey ? 'set' : 'none',
409
+ serviceName,
410
+ serviceInstanceId
411
+ );
412
+
413
+ // -------- instrumentations --------
414
+ const disabledMap = {};
415
+ for (const n of appConfig.listConfig('otel.disableInstrumentations')) {
416
+ disabledMap[n] = { enabled: false };
417
+ }
418
+
419
+ // -------- Body Capture Configuration --------
420
+ // Opt-out defaults: set config.capture.body=false to disable.
421
+ const captureBody = appConfig.boolConfig('capture.body', true);
422
+ const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
423
+ const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
424
+ const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
425
+
426
+ const captureMultipart = appConfig.boolConfig('capture.multipart', true);
427
+
428
+ const BODY_CAPTURE_PATCH = Symbol.for('securenow.bodyCapture.emitPatch');
429
+
430
+ function installRequestBodyObserver(span, request, contentType) {
431
+ if (!request || request[BODY_CAPTURE_PATCH]) return;
432
+
433
+ const normalizedContentType = String(contentType || '').toLowerCase();
434
+ const isStructuredBody = captureBody && (
435
+ normalizedContentType.includes('application/json') ||
436
+ normalizedContentType.includes('application/graphql') ||
437
+ normalizedContentType.includes('application/x-www-form-urlencoded')
438
+ );
439
+ const isMultipartBody = normalizedContentType.includes('multipart/form-data');
440
+
441
+ if (!isStructuredBody && !isMultipartBody) return;
442
+
443
+ if (isMultipartBody && !captureMultipart) {
444
+ span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
445
+ span.setAttribute('http.request.body.type', 'multipart');
446
+ span.setAttribute('http.request.body.note', 'Multipart capture disabled by config.capture.multipart=false');
447
+ return;
448
+ }
449
+
450
+ let chunks = [];
451
+ let size = 0;
452
+ let structuredDone = false;
453
+
454
+ const multipartCollector = isMultipartBody && captureMultipart
455
+ ? createMultipartMetaCollector(normalizedContentType, allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
456
+ try {
457
+ if (error === 'BOUNDARY_NOT_FOUND') {
458
+ span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
459
+ span.setAttribute('http.request.body.type', 'multipart');
460
+ return;
461
+ }
462
+ if (error) {
463
+ span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
464
+ span.setAttribute('http.request.body.type', 'multipart');
465
+ span.setAttribute('http.request.body.parse_error', true);
466
+ return;
467
+ }
468
+ span.setAttributes({
469
+ 'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
470
+ 'http.request.body.type': 'multipart',
471
+ 'http.request.body.size': totalSize,
472
+ 'http.request.body.fields_count': Object.keys(parsed.fields).length,
473
+ 'http.request.body.files_count': parsed.files.length,
474
+ });
475
+ } catch (e) {
476
+ span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
477
+ span.setAttribute('http.request.body.type', 'multipart');
478
+ span.setAttribute('http.request.body.parse_error', true);
479
+ }
480
+ })
481
+ : null;
482
+
483
+ if (isMultipartBody && captureMultipart && !multipartCollector) return;
484
+
485
+ function finishStructuredCapture() {
486
+ if (!isStructuredBody || structuredDone) return;
487
+ structuredDone = true;
488
+
489
+ if (size > maxBodySize) {
490
+ span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
491
+ span.setAttribute('http.request.body.size', size);
492
+ chunks = [];
493
+ return;
494
+ }
495
+
496
+ if (chunks.length === 0) return;
497
+
498
+ const body = Buffer.concat(chunks).toString('utf8');
499
+ chunks = [];
500
+
501
+ try {
502
+ if (normalizedContentType.includes('application/graphql')) {
503
+ const redacted = redactGraphQLQuery(body, allSensitiveFields);
504
+ span.setAttributes({
505
+ 'http.request.body': redacted.substring(0, maxBodySize),
506
+ 'http.request.body.type': 'graphql',
507
+ 'http.request.body.size': size,
508
+ });
509
+ } else if (normalizedContentType.includes('application/json')) {
510
+ const parsed = JSON.parse(body);
511
+ const redacted = redactSensitiveData(parsed, allSensitiveFields);
512
+ span.setAttributes({
513
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
514
+ 'http.request.body.type': 'json',
515
+ 'http.request.body.size': size,
516
+ });
517
+ } else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
518
+ const parsed = Object.fromEntries(new URLSearchParams(body));
519
+ const redacted = redactSensitiveData(parsed, allSensitiveFields);
520
+ span.setAttributes({
521
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
522
+ 'http.request.body.type': 'form',
523
+ 'http.request.body.size': size,
524
+ });
525
+ }
526
+ } catch (e) {
527
+ span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
528
+ span.setAttribute('http.request.body.parse_error', true);
529
+ span.setAttribute('http.request.body.size', size);
530
+ }
531
+ }
532
+
533
+ const originalEmit = request.emit;
534
+ request[BODY_CAPTURE_PATCH] = true;
535
+ request.emit = function securenowObservedEmit(event, ...args) {
536
+ try {
537
+ if (event === 'data' && args.length > 0) {
538
+ const chunk = Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]);
539
+ if (isStructuredBody) {
540
+ size += chunk.length;
541
+ if (size <= maxBodySize) chunks.push(chunk);
542
+ }
543
+ if (multipartCollector) multipartCollector.onData(chunk);
544
+ } else if (event === 'end') {
545
+ finishStructuredCapture();
546
+ if (multipartCollector) multipartCollector.onEnd();
547
+ }
548
+ } catch (_) {
549
+ // Body capture must never interfere with the application request stream.
550
+ }
551
+ return originalEmit.apply(this, [event, ...args]);
552
+ };
553
+ }
554
+
555
+ // -------- Trusted proxy IP resolution --------
556
+ const { resolveClientIpWithDetails } = require('./resolve-ip');
557
+
558
+ // Configure HTTP instrumentation with body capture
559
+ const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
560
+ const httpInstrumentation = new HttpInstrumentation({
561
+ requestHook: (span, request) => {
562
+ try {
563
+ const ipDetails = resolveClientIpWithDetails(request);
564
+ if (ipDetails.ip) {
565
+ span.setAttribute('http.client_ip', ipDetails.ip);
566
+ span.setAttribute('http.client_ip.source', ipDetails.source);
567
+ span.setAttribute('http.socket_ip', ipDetails.socketIp || '');
568
+ span.setAttribute('http.proxy.trusted', String(!!ipDetails.trustedProxy));
569
+ if (ipDetails.forwardedFor) {
570
+ span.setAttribute('http.forwarded_for', ipDetails.forwardedFor);
571
+ span.setAttribute('http.request.header.x_forwarded_for', ipDetails.forwardedFor);
572
+ }
573
+ if (ipDetails.realIp) {
574
+ span.setAttribute('http.real_ip', ipDetails.realIp);
575
+ span.setAttribute('http.request.header.x_real_ip', ipDetails.realIp);
576
+ }
577
+ if (ipDetails.cfConnectingIp) {
578
+ span.setAttribute('http.cf.connecting_ip', ipDetails.cfConnectingIp);
579
+ span.setAttribute('http.request.header.cf_connecting_ip', ipDetails.cfConnectingIp);
580
+ }
581
+ if (ipDetails.trueClientIp) {
582
+ span.setAttribute('http.request.header.true_client_ip', ipDetails.trueClientIp);
583
+ }
584
+ if (ipDetails.clientIp) {
585
+ span.setAttribute('http.request.header.x_client_ip', ipDetails.clientIp);
586
+ }
587
+ }
588
+
589
+ if (request.headers) {
590
+ const SKIP_HEADERS = new Set(['cookie', 'authorization', 'proxy-authorization', 'set-cookie', 'x-api-key', 'x-auth-token']);
591
+ const safe = {};
592
+ for (const [k, v] of Object.entries(request.headers)) {
593
+ if (SKIP_HEADERS.has(k.toLowerCase())) { safe[k] = '[REDACTED]'; continue; }
594
+ safe[k] = typeof v === 'string' ? v.substring(0, 500) : String(v);
595
+ }
596
+ const serialized = JSON.stringify(safe);
597
+ if (serialized.length <= 8192) {
598
+ span.setAttribute('http.request.headers', serialized);
599
+ }
600
+ }
601
+
602
+ if ((captureBody || captureMultipart) && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
603
+ const contentType = request.headers['content-type'] || '';
604
+ installRequestBodyObserver(span, request, contentType);
605
+ }
606
+ } catch (error) {
607
+ // Silently fail
608
+ }
609
+ },
610
+ });
611
+
612
+ // -------- Logging Configuration --------
613
+ // Opt-out default: set config.logging.enabled=false to disable.
614
+ const loggingEnabled = appConfig.boolConfig('logging.enabled', true);
615
+
616
+ // Create shared resource for both traces and logs
617
+ const sharedResource = createResource({
618
+ [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
619
+ [SemanticResourceAttributes.SERVICE_INSTANCE_ID]: serviceInstanceId,
620
+ [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: appConfig.resolveDeploymentEnvironment(),
621
+ [SemanticResourceAttributes.SERVICE_VERSION]: process.env.npm_package_version || undefined,
622
+ });
623
+
624
+ // Initialize LoggerProvider if logging is enabled
625
+ let loggerProvider = null;
626
+
627
+ if (loggingEnabled) {
628
+ const logExporter = new OTLPLogExporter({
629
+ url: logsUrl,
630
+ headers
631
+ });
632
+
633
+ const batchLogProcessor = new BatchLogRecordProcessor(logExporter);
634
+ loggerProvider = new LoggerProvider({
635
+ resource: sharedResource,
636
+ processors: [batchLogProcessor],
637
+ });
638
+
639
+ // Auto-patch console.* so every log/warn/error becomes an OTel log record
640
+ const _logger = loggerProvider.getLogger('console', '1.0.0');
641
+ const _orig = console.__securenow_original || { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug };
642
+ if (!console.__securenow_original) console.__securenow_original = _orig;
643
+ const SEV = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 };
644
+ function _emit(sn, st, args) {
645
+ try {
646
+ const activeCtx = context.active();
647
+ const spanCtx = trace.getSpanContext(activeCtx);
648
+ _logger.emit({
649
+ severityNumber: sn,
650
+ severityText: st,
651
+ body: args.map(a => (typeof a === 'object' && a !== null) ? JSON.stringify(a) : String(a)).join(' '),
652
+ attributes: { 'log.source': 'console', 'log.method': st.toLowerCase() },
653
+ ...(spanCtx && { context: activeCtx }),
654
+ });
655
+ } catch (_) {}
656
+ }
657
+ console.log = function (...a) { _emit(SEV.INFO, 'INFO', a); _orig.log.apply(console, a); };
658
+ console.info = function (...a) { _emit(SEV.INFO, 'INFO', a); _orig.info.apply(console, a); };
659
+ console.warn = function (...a) { _emit(SEV.WARN, 'WARN', a); _orig.warn.apply(console, a); };
660
+ console.error = function (...a) { _emit(SEV.ERROR, 'ERROR', a); _orig.error.apply(console, a); };
661
+ console.debug = function (...a) { _emit(SEV.DEBUG, 'DEBUG', a); _orig.debug.apply(console, a); };
662
+ console.__securenow_patched = true;
663
+ }
664
+
665
+ // -------- Guard against OTLP exporter socket errors --------
666
+ // The OTLP HTTP exporter uses keep-alive connections that can be reset by the
667
+ // remote end (ECONNRESET / "socket hang up"). These transient errors sometimes
668
+ // escape as unhandled exceptions or rejections because the underlying HTTP
669
+ // request's error path isn't fully covered by the OTel library. We install
670
+ // targeted process-level handlers to catch them and log at debug level instead
671
+ // of crashing the host app.
672
+ const _TRANSIENT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND']);
673
+ function _isOtlpTransientError(err) {
674
+ if (!err) return false;
675
+ if (_TRANSIENT_CODES.has(err.code)) return true;
676
+ if (typeof err.message === 'string' && /socket hang up|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed out/i.test(err.message)) return true;
677
+ return false;
678
+ }
679
+ function _looksLikeOtlpStack(err) {
680
+ const s = err && err.stack;
681
+ if (!s) return false;
682
+ return /OTLPTraceExporter|OTLPLogExporter|otlp|exporter.*http|BatchSpanProcessor|BatchLogRecordProcessor/i.test(s)
683
+ || /node:_http_client|ClientRequest|TLSSocket/i.test(s);
684
+ }
685
+ function _looksLikeConfiguredOtlpEndpoint(err) {
686
+ const text = `${err && err.hostname || ''} ${err && err.host || ''} ${err && err.message || ''}`;
687
+ try {
688
+ const hosts = [new URL(tracesUrl).hostname, new URL(logsUrl).hostname].filter(Boolean);
689
+ return hosts.some((host) => host && text.includes(host));
690
+ } catch (_) {
691
+ return false;
692
+ }
693
+ }
694
+ function _originalConsole(method) {
695
+ const originals = console.__securenow_original || console.__securenowOriginalConsole;
696
+ return (originals && originals[method]) || console[method] || console.log;
697
+ }
698
+ let _lastSuppressedOtlpErrorLogAt = 0;
699
+ function _formatOtlpError(err) {
700
+ if (!err) return 'unknown error';
701
+ const parts = [err.message || String(err)];
702
+ if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
703
+ if (err.syscall) parts.push(`syscall=${err.syscall}`);
704
+ if (err.hostname) parts.push(`host=${err.hostname}`);
705
+ return parts.join(' ');
706
+ }
707
+ function _reportSuppressedOtlpError(kind, err, origin) {
708
+ const level = String(diagLevel || '').toLowerCase();
709
+ if (level === 'none') return;
710
+ const now = Date.now();
711
+ if (level !== 'debug' && now - _lastSuppressedOtlpErrorLogAt < 60_000) return;
712
+ _lastSuppressedOtlpErrorLogAt = now;
713
+ const method = level === 'debug' ? 'debug' : 'error';
714
+ _originalConsole(method).call(
715
+ console,
716
+ '[securenow] OTLP exporter %s suppressed (%s). Telemetry may be missing until the ingest endpoint is reachable: %s',
717
+ kind,
718
+ origin || 'async',
719
+ _formatOtlpError(err)
720
+ );
721
+ }
722
+
723
+ process.on('uncaughtException', (err, origin) => {
724
+ if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
725
+ _reportSuppressedOtlpError('error', err, origin);
726
+ return; // swallow - do not crash
727
+ }
728
+ // Not ours — re-throw so the default handler (or the app's own handler) fires
729
+ throw err;
730
+ });
731
+ process.on('unhandledRejection', (reason) => {
732
+ if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
733
+ _reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
734
+ return; // swallow
735
+ }
736
+ // Not ours — re-throw as unhandled so Node's default behaviour applies
737
+ throw reason;
738
+ });
739
+
740
+ // -------- SDK --------
741
+ const traceExporter = new OTLPTraceExporter({ url: tracesUrl, headers });
742
+ const sdk = new NodeSDK({
743
+ ...nodeSdkTelemetryOptions,
744
+ traceExporter,
745
+ instrumentations: [
746
+ httpInstrumentation,
747
+ ...(disabledMap['@opentelemetry/instrumentation-mongodb'] ? [] : [new MongoDBInstrumentation()]),
748
+ ...getNodeAutoInstrumentations({
749
+ ...disabledMap,
750
+ '@opentelemetry/instrumentation-http': { enabled: false },
751
+ '@opentelemetry/instrumentation-mongodb': { enabled: false },
752
+ }),
753
+ ],
754
+ resource: sharedResource,
755
+ });
756
+
757
+ // -------- start / shutdown (sync/async safe) --------
758
+ (async () => {
759
+ try {
760
+ await Promise.resolve(sdk.start?.());
761
+ console.log('[securenow] OTel SDK started → %s', tracesUrl);
762
+ if (loggingEnabled) {
763
+ console.log('[securenow] 📋 Logging: ENABLED → %s', logsUrl);
764
+ } else {
765
+ console.log('[securenow] Logging: DISABLED (config.logging.enabled=false)');
766
+ }
767
+ if (captureBody) {
768
+ console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
769
+ }
770
+ if (captureMultipart) {
771
+ console.log('[securenow] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
772
+ }
773
+ if (appConfig.boolConfig('runtime.testSpan', false)) {
774
+ const api = require('@opentelemetry/api');
775
+ const tracer = api.trace.getTracer('securenow-smoke');
776
+ const span = tracer.startSpan('securenow.startup.smoke'); span.end();
777
+ }
778
+
779
+ // Free trial banner
780
+ const { isFreeTrial, patchHttpForBanner } = require('./free-trial-banner');
781
+ if (isFreeTrial(endpointBase) && !appConfig.boolConfig('runtime.hideBanner', false)) {
782
+ patchHttpForBanner();
783
+ }
784
+
785
+ // Firewall — auto-activates only when a real snk_live_ key is resolvable.
786
+ // resolveApiKey() enforces the prefix, so we skip cleanly when the app has
787
+ // only an app-routing UUID (or nothing at all) — no 401 polling loops.
788
+ const firewallOptions = appConfig.resolveFirewallOptions();
789
+ if (firewallOptions.apiKey) {
790
+ require('./firewall').init({
791
+ apiKey: firewallOptions.apiKey,
792
+ appKey: firewallOptions.appKey,
793
+ environment: firewallOptions.environment,
794
+ apiUrl: firewallOptions.apiUrl,
795
+ apiUrlFallbacks: firewallOptions.apiUrlFallbacks,
796
+ versionCheckInterval: firewallOptions.versionCheckInterval,
797
+ syncInterval: firewallOptions.syncInterval,
798
+ failMode: firewallOptions.failMode,
799
+ statusCode: firewallOptions.statusCode,
800
+ log: firewallOptions.log,
801
+ tcp: firewallOptions.tcp,
802
+ iptables: firewallOptions.iptables,
803
+ cloud: firewallOptions.cloud,
804
+ cloudDryRun: firewallOptions.cloudDryRun,
805
+ cloudflare: firewallOptions.cloudflare,
806
+ aws: firewallOptions.aws,
807
+ gcp: firewallOptions.gcp,
808
+ });
809
+ }
810
+ } catch (e) {
811
+ console.error('[securenow] OTel start failed:', e && e.stack || e);
812
+ }
813
+ })();
814
+
815
+ let shuttingDown = false;
816
+ async function safeShutdown(sig) {
817
+ if (shuttingDown) return;
818
+ shuttingDown = true;
819
+ try {
820
+ await Promise.resolve(sdk.shutdown?.());
821
+ if (loggerProvider) {
822
+ await Promise.resolve(loggerProvider.shutdown?.());
823
+ }
824
+ try { require('./firewall').shutdown(); } catch (_) {}
825
+ console.log(`[securenow] Tracing and logging terminated on ${sig}`);
826
+ }
827
+ catch (e) { console.error('[securenow] Shutdown error:', e); }
828
+ finally { process.exit(0); }
829
+ }
830
+ process.on('SIGINT', () => safeShutdown('SIGINT'));
831
+ process.on('SIGTERM', () => safeShutdown('SIGTERM'));
832
+
833
+ // -------- Export logger for consuming applications --------
834
+ module.exports = {
835
+ loggerProvider,
836
+ getLogger: (name = 'default', version = '1.0.0') => {
837
+ if (!loggerProvider) {
838
+ console.warn('[securenow] Logging is disabled (config.logging.enabled=false). Enable it in .securenow/credentials.json to use getLogger().');
839
+ return null;
840
+ }
841
+ return loggerProvider.getLogger(name, version);
842
+ },
843
+ isLoggingEnabled: () => loggingEnabled,
844
+ };