securenow 8.11.0 → 8.11.2

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