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/nextjs.js CHANGED
@@ -1,685 +1,768 @@
1
- 'use strict';
2
-
3
- /**
4
- * SecureNow Next.js Integration using @vercel/otel
5
- *
6
- * Usage in Next.js app:
7
- *
8
- * 1. Add securenow to serverExternalPackages and standalone output tracing
9
- * includes in next.config.js:
10
- *
11
- * const nextConfig = {
12
- * serverExternalPackages: ["securenow"],
13
- * outputFileTracingIncludes: {
14
- * "/*": ["<securenow package glob>"],
15
- * },
16
- * };
17
- *
18
- * 2. Create instrumentation.ts (or .js) in your project root:
19
- *
20
- * export async function register() {
21
- * if (process.env.NEXT_RUNTIME !== "nodejs") return;
22
- * const securenowNext = await import(/* webpackIgnore: true *\/ "securenow/nextjs");
23
- * const registerSecureNow = securenowNext.registerSecureNow || securenowNext.default?.registerSecureNow;
24
- * registerSecureNow({ captureBody: true });
25
- * await import(/* webpackIgnore: true *\/ "securenow/nextjs-auto-capture");
26
- * }
27
- *
28
- * 3. Run `npx securenow login` and `npx securenow init`.
29
- * The SDK reads app identity, collector, firewall, capture, and
30
- * deploymentEnvironment from .securenow/credentials.json.
31
- */
32
-
33
- const { randomUUID } = require('crypto');
34
- const { nodeSdkDefaultTelemetryOptions } = require('./otel-defaults');
35
- const appConfig = require('./app-config');
36
- const { resolveClientIpWithDetails } = require('./resolve-ip');
37
- const otelResources = require('@opentelemetry/resources');
38
-
39
- const nodeSdkTelemetryOptions = nodeSdkDefaultTelemetryOptions();
40
-
41
- let isRegistered = false;
42
-
43
- function requireRuntimeModule(name) {
44
- const nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : eval('require');
45
- return nodeRequire(name);
46
- }
47
-
48
- function requireNodeBuiltin(name) {
49
- return requireRuntimeModule(name);
50
- }
51
-
52
- function createResource(attributes) {
53
- if (typeof otelResources.resourceFromAttributes === 'function') {
54
- return otelResources.resourceFromAttributes(attributes);
55
- }
56
- if (typeof otelResources.Resource === 'function') {
57
- return new otelResources.Resource(attributes);
58
- }
59
- throw new Error('Unsupported @opentelemetry/resources version');
60
- }
61
-
62
- // Default sensitive fields to redact from request bodies
63
- const DEFAULT_SENSITIVE_FIELDS = [
64
- 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
65
- 'access_token', 'auth', 'credentials', 'mysql_pwd', 'stripeToken',
66
- 'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
67
- ];
68
-
69
- /**
70
- * Redact sensitive fields from an object
71
- */
72
- function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
73
- if (!obj || typeof obj !== 'object') return obj;
74
-
75
- const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
76
-
77
- for (const key of Object.keys(redacted)) {
78
- const lowerKey = key.toLowerCase();
79
-
80
- if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
81
- redacted[key] = '[REDACTED]';
82
- } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
83
- // Recursively redact nested objects
84
- redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
85
- }
86
- }
87
-
88
- return redacted;
89
- }
90
-
91
- function escapeRegex(str) {
92
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
- }
94
-
95
- /**
96
- * Redact sensitive data from GraphQL query strings
97
- */
98
- function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
99
- if (!query || typeof query !== 'string') return query;
100
-
101
- let redacted = query;
102
-
103
- // Redact sensitive fields in GraphQL arguments and variables
104
- // Matches patterns like: password: "value" or password:"value" or password:'value'
105
- sensitiveFields.forEach(field => {
106
- const escaped = escapeRegex(field);
107
- const patterns = [
108
- new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
109
- new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
110
- ];
111
-
112
- patterns.forEach(pattern => {
113
- redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
114
- if (suffix) {
115
- return `${prefix}[REDACTED]${suffix}`;
116
- } else {
117
- return `${prefix}[REDACTED]`;
118
- }
119
- });
120
- });
121
- });
122
-
123
- return redacted;
124
- }
125
-
126
- /**
127
- * Register SecureNow OpenTelemetry for Next.js using @vercel/otel
128
- * @param {Object} options - Optional configuration
129
- * @param {string} options.serviceName - Service name (defaults to .securenow/credentials.json app.key/app.name)
130
- * @param {string} options.endpoint - Advanced OTLP endpoint override (defaults to the SecureNow ingest gateway)
131
- * @param {string} options.environment - deployment.environment override (defaults to config.runtime.deploymentEnvironment)
132
- * @param {boolean} options.noUuid - Don't append UUID to service name
133
- */
134
- function registerSecureNow(options = {}) {
135
- // Only register once
136
- if (isRegistered) {
137
- console.log('[securenow] Already registered, skipping...');
138
- return;
139
- }
140
-
141
- // Skip for Edge runtime
142
- if (process.env.NEXT_RUNTIME === 'edge') {
143
- console.log('[securenow] Skipping Edge runtime (Node.js only)');
144
- return;
145
- }
146
-
147
- // Detect environment outside try block for error handling
148
- const isVercel = !!(process.env.VERCEL || process.env.VERCEL_ENV || process.env.VERCEL_URL);
149
- let deploymentEnvironment = appConfig.resolveDeploymentEnvironment();
150
-
151
- try {
152
- console.log('[securenow] Next.js integration loading (pid=%d)', process.pid);
153
-
154
- // -------- Configuration --------
155
- // Resolution order: explicit options -> .securenow/credentials.json -> package.json#name.
156
- // Telemetry goes to the stable ingest gateway by default; the API gateway
157
- // routes by app.key to the dashboard-selected instance.
158
- const resolvedApp = appConfig.resolveAll();
159
-
160
- const rawBase = (options.serviceName || resolvedApp.appId || '').trim().replace(/^['"]|['"]$/g, '');
161
- const baseName = rawBase || null;
162
- // Default: auto-disable suffix when logged in (appId is the routing UUID
163
- // and the dashboard does exact match). opts.noUuid or config.runtime.noUuid
164
- // can still override.
165
- const noUuid = appConfig.resolveNoUuid({ noUuid: options.noUuid });
166
- deploymentEnvironment = appConfig.normalizeDeploymentEnvironment(
167
- options.environment || resolvedApp.deploymentEnvironment
168
- );
169
-
170
- // service.name
171
- let serviceName;
172
- if (baseName) {
173
- serviceName = noUuid ? baseName : `${baseName}-${randomUUID()}`;
174
- } else {
175
- serviceName = `nextjs-app-${randomUUID()}`;
176
- console.warn('[securenow] No app identity resolved. Using fallback: %s', serviceName);
177
- console.warn('[securenow] Run `npx securenow login` and `npx securenow init` to write .securenow/credentials.json');
178
- }
179
-
180
- // -------- Endpoint Configuration --------
181
- const resolvedEndpoints = appConfig.resolveEndpoints({ endpoint: options.endpoint || resolvedApp.instance });
182
- const endpointBase = resolvedEndpoints.endpointBase;
183
- const tracesUrl = resolvedEndpoints.tracesUrl;
184
- const logsUrl = resolvedEndpoints.logsUrl;
185
- const headers = resolvedEndpoints.headers;
186
- const otelLogLevel = String(appConfig.configValue('otel.logLevel', '') || '').toLowerCase();
187
- const isDevelopmentRuntime = process.env.NODE_ENV === 'development';
188
-
189
- console.log('[securenow] Next.js App -> service.name=%s', serviceName);
190
- console.log('[securenow] Environment: %s', deploymentEnvironment);
191
-
192
- // -------- Body Capture Configuration --------
193
- // Opt-out default: set config.capture.body=false (or options.captureBody=false) to disable.
194
- const captureBody = options.captureBody ?? appConfig.boolConfig('capture.body', true);
195
- const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
196
- const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
197
- const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
198
-
199
- // -------- Log environment detection --------
200
- if (!isVercel) {
201
- console.log('[securenow] Self-hosted environment detected (EC2/PM2) - using vanilla SDK');
202
- }
203
-
204
- // -------- Use different initialization based on environment --------
205
- const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
206
-
207
- // Configure HTTP instrumentation with comprehensive header capture
208
- const httpInstrumentation = new HttpInstrumentation({
209
- requireParentforOutgoingSpans: false,
210
- requireParentforIncomingSpans: false,
211
- ignoreIncomingRequestHook: (request) => {
212
- // Never ignore - we want to trace all requests
213
- return false;
214
- },
215
- requestHook: (span, request) => {
216
- // SYNCHRONOUS ONLY - no async operations to avoid timing issues
217
- try {
218
- // Capture all headers
219
- const headers = request.headers || {};
220
-
221
- // ======== IP ADDRESS CAPTURE ========
222
- const ipDetails = resolveClientIpWithDetails(request);
223
- const forwardedFor = ipDetails.forwardedFor;
224
- const realIp = ipDetails.realIp;
225
- const socketIp = ipDetails.socketIp;
226
- const primaryIp = ipDetails.ip || 'unknown';
227
-
228
- // ======== PROTOCOL & CONNECTION ========
229
- const scheme = headers['x-forwarded-proto'] ||
230
- (request.socket?.encrypted ? 'https' : 'http');
231
- const host = headers['x-forwarded-host'] || headers['host'] || '';
232
- const port = headers['x-forwarded-port'] || request.socket?.localPort || '';
233
-
234
- // ======== REQUEST METADATA ========
235
- const userAgent = headers['user-agent'] || '';
236
- const referer = headers['referer'] || headers['referrer'] || '';
237
- const accept = headers['accept'] || '';
238
- const acceptLanguage = headers['accept-language'] || '';
239
- const acceptEncoding = headers['accept-encoding'] || '';
240
- const contentType = headers['content-type'] || '';
241
- const contentLength = headers['content-length'] || '';
242
- const origin = headers['origin'] || '';
243
-
244
- // ======== PROXY & LOAD BALANCER ========
245
- const originalUri = headers['x-original-uri'] || '';
246
- const originalMethod = headers['x-original-method'] || '';
247
- const requestId = headers['x-request-id'] || headers['x-trace-id'] || headers['x-correlation-id'] || '';
248
-
249
- // ======== SET ALL ATTRIBUTES ========
250
- const attributes = {
251
- // IP & Network
252
- 'http.client_ip': primaryIp,
253
- 'http.client_ip.source': ipDetails.source,
254
- 'http.forwarded_for': forwardedFor || '',
255
- 'http.real_ip': realIp || '',
256
- 'http.socket_ip': socketIp || '',
257
- 'http.proxy.trusted': String(!!ipDetails.trustedProxy),
258
- 'http.request.header.x_forwarded_for': forwardedFor || '',
259
- 'http.request.header.x_real_ip': realIp || '',
260
- 'http.request.header.cf_connecting_ip': ipDetails.cfConnectingIp || '',
261
- 'http.request.header.true_client_ip': ipDetails.trueClientIp || '',
262
- 'http.request.header.x_client_ip': ipDetails.clientIp || '',
263
-
264
- // Protocol & Host
265
- 'http.scheme': scheme,
266
- 'http.host': host,
267
- 'http.port': port.toString(),
268
- 'http.forwarded_proto': headers['x-forwarded-proto'] || '',
269
- 'http.forwarded_host': headers['x-forwarded-host'] || '',
270
-
271
- // Request Details
272
- 'http.user_agent': userAgent,
273
- 'http.referer': referer,
274
- 'http.origin': origin,
275
- 'http.accept': accept,
276
- 'http.accept_language': acceptLanguage,
277
- 'http.accept_encoding': acceptEncoding,
278
- 'http.content_type': contentType,
279
- 'http.content_length': contentLength,
280
-
281
- // Proxy & Routing
282
- 'http.original_uri': originalUri,
283
- 'http.original_method': originalMethod,
284
- 'http.request_id': requestId,
285
-
286
- // Connection Info
287
- 'http.connection': headers['connection'] || '',
288
- 'http.upgrade': headers['upgrade'] || '',
289
- };
290
-
291
- // Set all attributes at once
292
- span.setAttributes(attributes);
293
-
294
- // ======== GEOGRAPHIC DATA ========
295
- // Vercel geo headers
296
- if (headers['x-vercel-ip-country']) {
297
- span.setAttributes({
298
- 'http.geo.country': headers['x-vercel-ip-country'],
299
- 'http.geo.region': headers['x-vercel-ip-country-region'] || '',
300
- 'http.geo.city': headers['x-vercel-ip-city'] || '',
301
- 'http.geo.latitude': headers['x-vercel-ip-latitude'] || '',
302
- 'http.geo.longitude': headers['x-vercel-ip-longitude'] || '',
303
- 'http.geo.timezone': headers['x-vercel-ip-timezone'] || '',
304
- });
305
- }
306
-
307
- // Cloudflare geo headers
308
- if (headers['cf-ipcountry']) {
309
- span.setAttributes({
310
- 'http.geo.country': headers['cf-ipcountry'],
311
- 'http.geo.cf_ray': headers['cf-ray'] || '',
312
- 'http.geo.cf_visitor': headers['cf-visitor'] || '',
313
- });
314
- }
315
-
316
- // Cloudflare additional headers
317
- if (headers['cf-connecting-ip']) {
318
- span.setAttribute('http.cf.connecting_ip', headers['cf-connecting-ip']);
319
- }
320
-
321
- // ======== SECURITY HEADERS ========
322
- if (headers['x-csrf-token']) {
323
- span.setAttribute('http.security.csrf_token_present', 'true');
324
- }
325
- if (headers['authorization']) {
326
- span.setAttribute('http.security.auth_present', 'true');
327
- // Never log the actual token!
328
- }
329
- if (headers['cookie']) {
330
- span.setAttribute('http.security.cookies_present', 'true');
331
- // Never log actual cookies!
332
- }
333
-
334
- // Debug log in development
335
- if (isDevelopmentRuntime || otelLogLevel === 'debug') {
336
- console.log('[securenow] Captured IP: %s (from: %s)',
337
- primaryIp,
338
- ipDetails.source || 'unknown'
339
- );
340
- }
341
-
342
- // -------- Request Body NOT captured at HTTP instrumentation level --------
343
- // IMPORTANT: Do NOT attempt to read request.body or listen to 'data' events
344
- // Next.js manages request streams internally and reading them here causes conflicts
345
- // Body capture must be done in Next.js middleware using request.clone()
346
-
347
- } catch (error) {
348
- // Silently fail to not break the request
349
- if (otelLogLevel === 'debug') {
350
- console.error('[securenow] Error in requestHook:', error.message);
351
- }
352
- }
353
- },
354
- responseHook: (span, response) => {
355
- try {
356
- // Capture response metadata
357
- span.setAttributes({
358
- 'http.status_code': response.statusCode || 0,
359
- 'http.status_message': response.statusMessage || '',
360
- 'http.response.content_type': response.headers?.['content-type'] || '',
361
- 'http.response.content_length': response.headers?.['content-length'] || '',
362
- });
363
- } catch (error) {
364
- // Silently fail
365
- }
366
- },
367
- });
368
-
369
- // -------- Guard against OTLP exporter socket errors --------
370
- // The OTLP HTTP exporter uses keep-alive connections that can be reset by
371
- // the remote end (ECONNRESET / "socket hang up"). These transient errors
372
- // sometimes escape as unhandled exceptions or rejections. We catch them
373
- // here and log at debug level instead of crashing the host app.
374
- const _TRANSIENT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND']);
375
- function _isOtlpTransientError(err) {
376
- if (!err) return false;
377
- if (_TRANSIENT_CODES.has(err.code)) return true;
378
- if (typeof err.message === 'string' && /socket hang up|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed out/i.test(err.message)) return true;
379
- return false;
380
- }
381
- function _looksLikeOtlpStack(err) {
382
- const s = err && err.stack;
383
- if (!s) return false;
384
- return /OTLPTraceExporter|OTLPLogExporter|otlp|exporter.*http|BatchSpanProcessor|BatchLogRecordProcessor/i.test(s)
385
- || /node:_http_client|ClientRequest|TLSSocket/i.test(s);
386
- }
387
- function _looksLikeConfiguredOtlpEndpoint(err) {
388
- const text = `${err && err.hostname || ''} ${err && err.host || ''} ${err && err.message || ''}`;
389
- try {
390
- const hosts = [new URL(tracesUrl).hostname, new URL(logsUrl).hostname].filter(Boolean);
391
- return hosts.some((host) => host && text.includes(host));
392
- } catch (_) {
393
- return false;
394
- }
395
- }
396
- const _diagDebug = otelLogLevel === 'debug';
397
- let _lastSuppressedOtlpErrorLogAt = 0;
398
- function _originalConsole(method) {
399
- const originals = console.__securenow_original || console.__securenowOriginalConsole;
400
- return (originals && originals[method]) || console[method] || console.log;
401
- }
402
- function _formatOtlpError(err) {
403
- if (!err) return 'unknown error';
404
- const parts = [err.message || String(err)];
405
- if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
406
- if (err.syscall) parts.push(`syscall=${err.syscall}`);
407
- if (err.hostname) parts.push(`host=${err.hostname}`);
408
- return parts.join(' ');
409
- }
410
- function _reportSuppressedOtlpError(kind, err, origin) {
411
- if (otelLogLevel === 'none') return;
412
- const now = Date.now();
413
- if (!_diagDebug && now - _lastSuppressedOtlpErrorLogAt < 60_000) return;
414
- _lastSuppressedOtlpErrorLogAt = now;
415
- const method = _diagDebug ? 'debug' : 'error';
416
- _originalConsole(method).call(
417
- console,
418
- '[securenow] OTLP exporter %s suppressed (%s). Telemetry may be missing until the ingest endpoint is reachable: %s',
419
- kind,
420
- origin || 'async',
421
- _formatOtlpError(err)
422
- );
423
- }
424
- process.on('uncaughtException', (err, origin) => {
425
- if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
426
- _reportSuppressedOtlpError('error', err, origin);
427
- return;
428
- }
429
- throw err;
430
- });
431
- process.on('unhandledRejection', (reason) => {
432
- if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
433
- _reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
434
- return;
435
- }
436
- throw reason;
437
- });
438
-
439
- if (isVercel) {
440
- // -------- Vercel Environment: Use @vercel/otel --------
441
- const { registerOTel } = require('@vercel/otel');
442
-
443
- registerOTel({
444
- serviceName: serviceName,
445
- attributes: {
446
- 'deployment.environment': deploymentEnvironment,
447
- 'service.version': process.env.npm_package_version || process.env.VERCEL_GIT_COMMIT_SHA || undefined,
448
- 'vercel.region': process.env.VERCEL_REGION || undefined,
449
- },
450
- instrumentations: [httpInstrumentation],
451
- instrumentationConfig: {
452
- fetch: {
453
- // Propagate context to your backend APIs
454
- propagateContextUrls: [
455
- /^https?:\/\/localhost/,
456
- /^https?:\/\/.*\.vercel\.app/,
457
- // Add your backend domains here
458
- ],
459
- // Optionally ignore certain URLs
460
- ignoreUrls: [
461
- /_next\/static/,
462
- /_next\/image/,
463
- /\.map$/,
464
- ],
465
- },
466
- },
467
- });
468
- } else {
469
- // -------- Self-Hosted (EC2/PM2): Use Vanilla OpenTelemetry SDK --------
470
- const { NodeSDK } = require('@opentelemetry/sdk-node');
471
- const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
472
- const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
473
-
474
- const traceExporter = new OTLPTraceExporter({
475
- url: tracesUrl,
476
- headers
477
- });
478
-
479
- const sdk = new NodeSDK({
480
- ...nodeSdkTelemetryOptions,
481
- serviceName: serviceName,
482
- traceExporter: traceExporter,
483
- instrumentations: [httpInstrumentation],
484
- resource: createResource({
485
- [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
486
- [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: deploymentEnvironment,
487
- [SemanticResourceAttributes.SERVICE_VERSION]: process.env.npm_package_version || undefined,
488
- }),
489
- });
490
-
491
- sdk.start();
492
- console.log('[securenow] Vanilla SDK initialized for self-hosted environment');
493
-
494
- // -------- Logging (self-hosted only) --------
495
- // Opt-out default: set config.logging.enabled=false to disable.
496
- const loggingEnabled = appConfig.boolConfig('logging.enabled', true);
497
- if (loggingEnabled) {
498
- try {
499
- const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
500
- const { LoggerProvider, BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
501
-
502
- const logExporter = new OTLPLogExporter({
503
- url: logsUrl,
504
- headers,
505
- });
506
-
507
- const loggerProvider = new LoggerProvider({
508
- resource: createResource({
509
- [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
510
- [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: deploymentEnvironment,
511
- }),
512
- processors: [new BatchLogRecordProcessor(logExporter)],
513
- });
514
-
515
- // Patch console to forward logs as OTLP log records
516
- const logger = loggerProvider.getLogger('console', '1.0.0');
517
- const SeverityNumber = { INFO: 9, WARN: 13, ERROR: 17 };
518
- const origLog = console.log;
519
- const origWarn = console.warn;
520
- const origError = console.error;
521
-
522
- const { context: otelContext, trace: otelTrace } = require('@opentelemetry/api');
523
- function _emitLog(sn, st, args) {
524
- try {
525
- const activeCtx = otelContext.active();
526
- const spanCtx = otelTrace.getSpanContext(activeCtx);
527
- logger.emit({
528
- severityNumber: sn,
529
- severityText: st,
530
- body: args.map(String).join(' '),
531
- ...(spanCtx && { context: activeCtx }),
532
- });
533
- } catch (_) {}
534
- }
535
- console.log = (...args) => {
536
- origLog.apply(console, args);
537
- _emitLog(SeverityNumber.INFO, 'INFO', args);
538
- };
539
- console.warn = (...args) => {
540
- origWarn.apply(console, args);
541
- _emitLog(SeverityNumber.WARN, 'WARN', args);
542
- };
543
- console.error = (...args) => {
544
- origError.apply(console, args);
545
- _emitLog(SeverityNumber.ERROR, 'ERROR', args);
546
- };
547
-
548
- console.log('[securenow] Logging: ENABLED -> %s', logsUrl);
549
-
550
- // Auto-log every incoming HTTP request/response
551
- try {
552
- const http = requireNodeBuiltin('node:http');
553
- const originalEmit = http.Server.prototype.emit;
554
- http.Server.prototype.emit = function (event, req, res) {
555
- if (event === 'request' && req && res) {
556
- const start = Date.now();
557
- const method = req.method;
558
- const url = req.url;
559
- res.on('finish', () => {
560
- const reqCtx = otelContext.active();
561
- const reqSpanCtx = otelTrace.getSpanContext(reqCtx);
562
- const duration = Date.now() - start;
563
- const status = res.statusCode;
564
- const ipDetails = resolveClientIpWithDetails(req);
565
- const ip = ipDetails.ip || '-';
566
- const ua = req.headers['user-agent'] || '-';
567
- const body = `${method} ${url} ${status} ${duration}ms ip=${ip} ua=${ua}`;
568
- const severity = status >= 500 ? SeverityNumber.ERROR : status >= 400 ? SeverityNumber.WARN : SeverityNumber.INFO;
569
- const severityText = status >= 500 ? 'ERROR' : status >= 400 ? 'WARN' : 'INFO';
570
- origLog.call(console, '[securenow] %s %s %d %dms', method, url, status, duration);
571
- try {
572
- logger.emit({
573
- severityNumber: severity,
574
- severityText,
575
- body,
576
- attributes: {
577
- 'http.method': method,
578
- 'http.url': url,
579
- 'http.status_code': status,
580
- 'http.duration_ms': duration,
581
- 'http.client_ip': ip,
582
- 'http.client_ip.source': ipDetails.source || 'unknown',
583
- 'http.socket_ip': ipDetails.socketIp || '',
584
- 'http.forwarded_for': ipDetails.forwardedFor || '',
585
- 'http.real_ip': ipDetails.realIp || '',
586
- 'http.proxy.trusted': String(!!ipDetails.trustedProxy),
587
- 'http.user_agent': ua,
588
- },
589
- ...(reqSpanCtx && { context: reqCtx }),
590
- });
591
- } catch (_) {}
592
- });
593
- }
594
- return originalEmit.apply(this, arguments);
595
- };
596
- console.log('[securenow] HTTP request logging: ENABLED');
597
- } catch (_) {}
598
-
599
- // Graceful shutdown for logs
600
- process.on('SIGTERM', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { requireRuntimeModule('./firewall').shutdown(); } catch (_) {} });
601
- process.on('SIGINT', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { requireRuntimeModule('./firewall').shutdown(); } catch (_) {} });
602
- } catch (e) {
603
- console.warn('[securenow] Logging setup failed (missing @opentelemetry/exporter-logs-otlp-http or @opentelemetry/sdk-logs):', e.message);
604
- }
605
- } else {
606
- console.log('[securenow] Logging: DISABLED (config.logging.enabled=false)');
607
- }
608
- }
609
-
610
- isRegistered = true;
611
-
612
- // Free trial banner (optional - may not be bundled in standalone builds)
613
- try {
614
- const { isFreeTrial, patchHttpForBanner } = requireRuntimeModule('./free-trial-banner');
615
- if (isFreeTrial(endpointBase) && !appConfig.boolConfig('runtime.hideBanner', false)) {
616
- patchHttpForBanner();
617
- }
618
- } catch (_) {}
619
-
620
- console.log('[securenow] OpenTelemetry started for Next.js -> %s', tracesUrl);
621
- console.log('[securenow] Auto-capturing comprehensive request metadata:');
622
- console.log('[securenow] - IP addresses (x-forwarded-for, x-real-ip, socket)');
623
- console.log('[securenow] - User-Agent, Referer, Origin, Accept headers');
624
- console.log('[securenow] - Protocol, Host, Port (proxy-aware)');
625
- console.log('[securenow] - Geographic data (Vercel/Cloudflare)');
626
- console.log('[securenow] - Request IDs, CSRF tokens, Auth presence');
627
- console.log('[securenow] - Response status, content-type, content-length');
628
- console.log('[securenow] Body capture DISABLED at HTTP instrumentation level (prevents Next.js conflicts)');
629
- if (captureBody) {
630
- console.log('[securenow] For body capture in Next.js, use: import "securenow/nextjs-auto-capture"');
631
- }
632
-
633
- // Optional test span
634
- if (appConfig.boolConfig('runtime.testSpan', false)) {
635
- const api = require('@opentelemetry/api');
636
- const tracer = api.trace.getTracer('securenow-nextjs');
637
- const span = tracer.startSpan('securenow.nextjs.startup');
638
- span.setAttribute('next.runtime', process.env.NEXT_RUNTIME || 'nodejs');
639
- span.end();
640
- console.log('[securenow] Test span created');
641
- }
642
-
643
- } catch (error) {
644
- console.error('[securenow] Failed to initialize OpenTelemetry:', error);
645
- if (isVercel) {
646
- console.error('[securenow] Make sure you have @vercel/otel installed: npm install @vercel/otel');
647
- } else {
648
- console.error('[securenow] Make sure OpenTelemetry dependencies are installed');
649
- }
650
- }
651
-
652
- // Firewall - runs independently from OTel so it works even if tracing fails.
653
- // Key and environment come from .securenow/credentials.json (written by
654
- // login/init or credentials runtime), so no .env entry is needed.
655
- const firewallOptions = appConfig.resolveFirewallOptions();
656
- if (firewallOptions.apiKey) {
657
- try {
658
- requireRuntimeModule('./firewall').init({
659
- apiKey: firewallOptions.apiKey,
660
- appKey: firewallOptions.appKey,
661
- environment: deploymentEnvironment || firewallOptions.environment,
662
- apiUrl: firewallOptions.apiUrl,
663
- apiUrlFallbacks: firewallOptions.apiUrlFallbacks,
664
- versionCheckInterval: firewallOptions.versionCheckInterval,
665
- syncInterval: firewallOptions.syncInterval,
666
- failMode: firewallOptions.failMode,
667
- statusCode: firewallOptions.statusCode,
668
- log: firewallOptions.log,
669
- tcp: firewallOptions.tcp,
670
- iptables: firewallOptions.iptables,
671
- cloud: firewallOptions.cloud,
672
- cloudDryRun: firewallOptions.cloudDryRun,
673
- cloudflare: firewallOptions.cloudflare,
674
- aws: firewallOptions.aws,
675
- gcp: firewallOptions.gcp,
676
- });
677
- } catch (e) {
678
- console.warn('[securenow] Firewall init failed:', e.message);
679
- }
680
- }
681
- }
682
-
683
- module.exports = {
684
- registerSecureNow,
685
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * SecureNow Next.js Integration using @vercel/otel
5
+ *
6
+ * Usage in Next.js app:
7
+ *
8
+ * 1. Add securenow to serverExternalPackages and standalone output tracing
9
+ * includes in next.config.js:
10
+ *
11
+ * const nextConfig = {
12
+ * serverExternalPackages: ["securenow"],
13
+ * outputFileTracingIncludes: {
14
+ * "/*": ["<securenow package glob>"],
15
+ * },
16
+ * };
17
+ *
18
+ * 2. Create instrumentation.ts (or .js) in your project root:
19
+ *
20
+ * export async function register() {
21
+ * if (process.env.NEXT_RUNTIME !== "nodejs") return;
22
+ * const securenowNext = await import(/* webpackIgnore: true *\/ "securenow/nextjs");
23
+ * const registerSecureNow = securenowNext.registerSecureNow || securenowNext.default?.registerSecureNow;
24
+ * registerSecureNow({ captureBody: true });
25
+ * await import(/* webpackIgnore: true *\/ "securenow/nextjs-auto-capture");
26
+ * }
27
+ *
28
+ * 3. Run `npx securenow login` and `npx securenow init`.
29
+ * The SDK reads app identity, collector, firewall, capture, and
30
+ * deploymentEnvironment from .securenow/credentials.json.
31
+ */
32
+
33
+ const { randomUUID } = require('crypto');
34
+ const { nodeSdkDefaultTelemetryOptions } = require('./otel-defaults');
35
+ const appConfig = require('./app-config');
36
+ const { resolveClientIpWithDetails } = require('./resolve-ip');
37
+ const otelResources = require('@opentelemetry/resources');
38
+
39
+ const nodeSdkTelemetryOptions = nodeSdkDefaultTelemetryOptions();
40
+
41
+ let isRegistered = false;
42
+
43
+ function requireRuntimeModule(name) {
44
+ const nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : eval('require');
45
+ return nodeRequire(name);
46
+ }
47
+
48
+ function requireNodeBuiltin(name) {
49
+ return requireRuntimeModule(name);
50
+ }
51
+
52
+ function createResource(attributes) {
53
+ if (typeof otelResources.resourceFromAttributes === 'function') {
54
+ return otelResources.resourceFromAttributes(attributes);
55
+ }
56
+ if (typeof otelResources.Resource === 'function') {
57
+ return new otelResources.Resource(attributes);
58
+ }
59
+ throw new Error('Unsupported @opentelemetry/resources version');
60
+ }
61
+
62
+ // Default sensitive fields to redact from request bodies.
63
+ // Matched substring-wise against lowercased keys (see redactSensitiveData),
64
+ // so e.g. 'card' also catches 'creditCard' and 'account' also catches
65
+ // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
66
+ // telemetry value is always safer than a leaked secret. Entries are kept
67
+ // specific enough to avoid nuking broad benign keys (e.g. we use
68
+ // 'firstname'/'lastname'/'fullname', never bare 'name').
69
+ const DEFAULT_SENSITIVE_FIELDS = [
70
+ // credentials / auth
71
+ 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
72
+ 'access_token', 'auth', 'authorization', 'bearer', 'credentials',
73
+ 'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
74
+ 'cookie', 'set-cookie',
75
+ // financial
76
+ 'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
77
+ 'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
78
+ // PII
79
+ 'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
80
+ 'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
81
+ 'passport', 'license',
82
+ ];
83
+
84
+ // Conservative value-shape redactors. Key-name matching misses secrets that
85
+ // land in free-form string values (GraphQL bodies, message fields, etc.), so
86
+ // as a second layer we scrub string VALUES that *look like* a secret/PII.
87
+ // These are intentionally precise/bounded so they don't garble normal prose,
88
+ // and they only ever transform captured telemetry strings (read-only) — never
89
+ // the actual request/response stream. Compiled once at module load.
90
+ const VALUE_REDACTORS = [
91
+ // JWT: three base64url segments. Anchored to the eyJ header so it won't
92
+ // match arbitrary dotted tokens.
93
+ { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
94
+ // Bearer/Basic auth header value embedded in a body string.
95
+ { name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
96
+ // Stripe-style / SecureNow live+test API keys.
97
+ { name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
98
+ // Email addresses (bounded local/domain parts).
99
+ { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
100
+ // Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
101
+ // space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
102
+ { name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
103
+ ];
104
+
105
+ // Skip the value-shape scan on very large strings to bound per-body cost.
106
+ const MAX_VALUE_SCAN_LENGTH = 16384;
107
+
108
+ function luhnValid(digits) {
109
+ let sum = 0;
110
+ let alt = false;
111
+ for (let i = digits.length - 1; i >= 0; i--) {
112
+ let d = digits.charCodeAt(i) - 48;
113
+ if (d < 0 || d > 9) return false;
114
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
115
+ sum += d;
116
+ alt = !alt;
117
+ }
118
+ return sum % 10 === 0;
119
+ }
120
+
121
+ /**
122
+ * Redact obvious secret/PII shapes inside a captured string VALUE.
123
+ * Returns the input unchanged when nothing matches (cheap common case).
124
+ */
125
+ function redactSensitiveValue(value) {
126
+ if (typeof value !== 'string' || value.length === 0) return value;
127
+ if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound the work
128
+ let out = value;
129
+ for (const r of VALUE_REDACTORS) {
130
+ r.re.lastIndex = 0;
131
+ if (r.luhn) {
132
+ out = out.replace(r.re, (m) => {
133
+ const digits = m.replace(/[ -]/g, '');
134
+ if (digits.length < 13 || digits.length > 19) return m;
135
+ return luhnValid(digits) ? '[REDACTED]' : m;
136
+ });
137
+ } else {
138
+ out = out.replace(r.re, '[REDACTED]');
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * Redact sensitive fields from an object
146
+ */
147
+ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
148
+ if (!obj || typeof obj !== 'object') return obj;
149
+
150
+ const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
151
+
152
+ for (const key of Object.keys(redacted)) {
153
+ const lowerKey = key.toLowerCase();
154
+
155
+ if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
156
+ redacted[key] = '[REDACTED]';
157
+ } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
158
+ // Recursively redact nested objects
159
+ redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
160
+ } else if (typeof redacted[key] === 'string') {
161
+ // Second layer: even if the key name looks benign, scrub values that
162
+ // *look like* a secret/PII (JWT, bearer token, API key, email, card).
163
+ redacted[key] = redactSensitiveValue(redacted[key]);
164
+ }
165
+ }
166
+
167
+ return redacted;
168
+ }
169
+
170
+ function escapeRegex(str) {
171
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
172
+ }
173
+
174
+ /**
175
+ * Redact sensitive data from GraphQL query strings
176
+ */
177
+ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
178
+ if (!query || typeof query !== 'string') return query;
179
+
180
+ let redacted = query;
181
+
182
+ // Redact sensitive fields in GraphQL arguments and variables
183
+ // Matches patterns like: password: "value" or password:"value" or password:'value'
184
+ sensitiveFields.forEach(field => {
185
+ const escaped = escapeRegex(field);
186
+ const patterns = [
187
+ new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
188
+ new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
189
+ ];
190
+
191
+ patterns.forEach(pattern => {
192
+ redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
193
+ if (suffix) {
194
+ return `${prefix}[REDACTED]${suffix}`;
195
+ } else {
196
+ return `${prefix}[REDACTED]`;
197
+ }
198
+ });
199
+ });
200
+ });
201
+
202
+ // Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
203
+ // body — catches values the key-name pass above can't see.
204
+ redacted = redactSensitiveValue(redacted);
205
+
206
+ return redacted;
207
+ }
208
+
209
+ /**
210
+ * Register SecureNow OpenTelemetry for Next.js using @vercel/otel
211
+ * @param {Object} options - Optional configuration
212
+ * @param {string} options.serviceName - Service name (defaults to .securenow/credentials.json app.key/app.name)
213
+ * @param {string} options.endpoint - Advanced OTLP endpoint override (defaults to the SecureNow ingest gateway)
214
+ * @param {string} options.environment - deployment.environment override (defaults to config.runtime.deploymentEnvironment)
215
+ * @param {boolean} options.noUuid - Don't append UUID to service name
216
+ */
217
+ function registerSecureNow(options = {}) {
218
+ // Only register once
219
+ if (isRegistered) {
220
+ console.log('[securenow] Already registered, skipping...');
221
+ return;
222
+ }
223
+
224
+ // Skip for Edge runtime
225
+ if (process.env.NEXT_RUNTIME === 'edge') {
226
+ console.log('[securenow] Skipping Edge runtime (Node.js only)');
227
+ return;
228
+ }
229
+
230
+ // Detect environment outside try block for error handling
231
+ const isVercel = !!(process.env.VERCEL || process.env.VERCEL_ENV || process.env.VERCEL_URL);
232
+ let deploymentEnvironment = appConfig.resolveDeploymentEnvironment();
233
+
234
+ try {
235
+ console.log('[securenow] Next.js integration loading (pid=%d)', process.pid);
236
+
237
+ // -------- Configuration --------
238
+ // Resolution order: explicit options -> .securenow/credentials.json -> package.json#name.
239
+ // Telemetry goes to the stable ingest gateway by default; the API gateway
240
+ // routes by app.key to the dashboard-selected instance.
241
+ const resolvedApp = appConfig.resolveAll();
242
+
243
+ const rawBase = (options.serviceName || resolvedApp.appId || '').trim().replace(/^['"]|['"]$/g, '');
244
+ const baseName = rawBase || null;
245
+ // Default: auto-disable suffix when logged in (appId is the routing UUID
246
+ // and the dashboard does exact match). opts.noUuid or config.runtime.noUuid
247
+ // can still override.
248
+ const noUuid = appConfig.resolveNoUuid({ noUuid: options.noUuid });
249
+ deploymentEnvironment = appConfig.normalizeDeploymentEnvironment(
250
+ options.environment || resolvedApp.deploymentEnvironment
251
+ );
252
+
253
+ // service.name
254
+ let serviceName;
255
+ if (baseName) {
256
+ serviceName = noUuid ? baseName : `${baseName}-${randomUUID()}`;
257
+ } else {
258
+ serviceName = `nextjs-app-${randomUUID()}`;
259
+ console.warn('[securenow] No app identity resolved. Using fallback: %s', serviceName);
260
+ console.warn('[securenow] Run `npx securenow login` and `npx securenow init` to write .securenow/credentials.json');
261
+ }
262
+
263
+ // -------- Endpoint Configuration --------
264
+ const resolvedEndpoints = appConfig.resolveEndpoints({ endpoint: options.endpoint || resolvedApp.instance });
265
+ const endpointBase = resolvedEndpoints.endpointBase;
266
+ const tracesUrl = resolvedEndpoints.tracesUrl;
267
+ const logsUrl = resolvedEndpoints.logsUrl;
268
+ const headers = resolvedEndpoints.headers;
269
+ const otelLogLevel = String(appConfig.configValue('otel.logLevel', '') || '').toLowerCase();
270
+ const isDevelopmentRuntime = process.env.NODE_ENV === 'development';
271
+
272
+ console.log('[securenow] Next.js App -> service.name=%s', serviceName);
273
+ console.log('[securenow] Environment: %s', deploymentEnvironment);
274
+
275
+ // -------- Body Capture Configuration --------
276
+ // Opt-out default: set config.capture.body=false (or options.captureBody=false) to disable.
277
+ const captureBody = options.captureBody ?? appConfig.boolConfig('capture.body', true);
278
+ const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
279
+ const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
280
+ const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
281
+
282
+ // -------- Log environment detection --------
283
+ if (!isVercel) {
284
+ console.log('[securenow] Self-hosted environment detected (EC2/PM2) - using vanilla SDK');
285
+ }
286
+
287
+ // -------- Use different initialization based on environment --------
288
+ const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
289
+
290
+ // Configure HTTP instrumentation with comprehensive header capture
291
+ const httpInstrumentation = new HttpInstrumentation({
292
+ requireParentforOutgoingSpans: false,
293
+ requireParentforIncomingSpans: false,
294
+ ignoreIncomingRequestHook: (request) => {
295
+ // Never ignore - we want to trace all requests
296
+ return false;
297
+ },
298
+ requestHook: (span, request) => {
299
+ // SYNCHRONOUS ONLY - no async operations to avoid timing issues
300
+ try {
301
+ // Capture all headers
302
+ const headers = request.headers || {};
303
+
304
+ // ======== IP ADDRESS CAPTURE ========
305
+ const ipDetails = resolveClientIpWithDetails(request);
306
+ const forwardedFor = ipDetails.forwardedFor;
307
+ const realIp = ipDetails.realIp;
308
+ const socketIp = ipDetails.socketIp;
309
+ const primaryIp = ipDetails.ip || 'unknown';
310
+
311
+ // ======== PROTOCOL & CONNECTION ========
312
+ const scheme = headers['x-forwarded-proto'] ||
313
+ (request.socket?.encrypted ? 'https' : 'http');
314
+ const host = headers['x-forwarded-host'] || headers['host'] || '';
315
+ const port = headers['x-forwarded-port'] || request.socket?.localPort || '';
316
+
317
+ // ======== REQUEST METADATA ========
318
+ const userAgent = headers['user-agent'] || '';
319
+ const referer = headers['referer'] || headers['referrer'] || '';
320
+ const accept = headers['accept'] || '';
321
+ const acceptLanguage = headers['accept-language'] || '';
322
+ const acceptEncoding = headers['accept-encoding'] || '';
323
+ const contentType = headers['content-type'] || '';
324
+ const contentLength = headers['content-length'] || '';
325
+ const origin = headers['origin'] || '';
326
+
327
+ // ======== PROXY & LOAD BALANCER ========
328
+ const originalUri = headers['x-original-uri'] || '';
329
+ const originalMethod = headers['x-original-method'] || '';
330
+ const requestId = headers['x-request-id'] || headers['x-trace-id'] || headers['x-correlation-id'] || '';
331
+
332
+ // ======== SET ALL ATTRIBUTES ========
333
+ const attributes = {
334
+ // IP & Network
335
+ 'http.client_ip': primaryIp,
336
+ 'http.client_ip.source': ipDetails.source,
337
+ 'http.forwarded_for': forwardedFor || '',
338
+ 'http.real_ip': realIp || '',
339
+ 'http.socket_ip': socketIp || '',
340
+ 'http.proxy.trusted': String(!!ipDetails.trustedProxy),
341
+ 'http.request.header.x_forwarded_for': forwardedFor || '',
342
+ 'http.request.header.x_real_ip': realIp || '',
343
+ 'http.request.header.cf_connecting_ip': ipDetails.cfConnectingIp || '',
344
+ 'http.request.header.true_client_ip': ipDetails.trueClientIp || '',
345
+ 'http.request.header.x_client_ip': ipDetails.clientIp || '',
346
+
347
+ // Protocol & Host
348
+ 'http.scheme': scheme,
349
+ 'http.host': host,
350
+ 'http.port': port.toString(),
351
+ 'http.forwarded_proto': headers['x-forwarded-proto'] || '',
352
+ 'http.forwarded_host': headers['x-forwarded-host'] || '',
353
+
354
+ // Request Details
355
+ 'http.user_agent': userAgent,
356
+ 'http.referer': referer,
357
+ 'http.origin': origin,
358
+ 'http.accept': accept,
359
+ 'http.accept_language': acceptLanguage,
360
+ 'http.accept_encoding': acceptEncoding,
361
+ 'http.content_type': contentType,
362
+ 'http.content_length': contentLength,
363
+
364
+ // Proxy & Routing
365
+ 'http.original_uri': originalUri,
366
+ 'http.original_method': originalMethod,
367
+ 'http.request_id': requestId,
368
+
369
+ // Connection Info
370
+ 'http.connection': headers['connection'] || '',
371
+ 'http.upgrade': headers['upgrade'] || '',
372
+ };
373
+
374
+ // Set all attributes at once
375
+ span.setAttributes(attributes);
376
+
377
+ // ======== GEOGRAPHIC DATA ========
378
+ // Vercel geo headers
379
+ if (headers['x-vercel-ip-country']) {
380
+ span.setAttributes({
381
+ 'http.geo.country': headers['x-vercel-ip-country'],
382
+ 'http.geo.region': headers['x-vercel-ip-country-region'] || '',
383
+ 'http.geo.city': headers['x-vercel-ip-city'] || '',
384
+ 'http.geo.latitude': headers['x-vercel-ip-latitude'] || '',
385
+ 'http.geo.longitude': headers['x-vercel-ip-longitude'] || '',
386
+ 'http.geo.timezone': headers['x-vercel-ip-timezone'] || '',
387
+ });
388
+ }
389
+
390
+ // Cloudflare geo headers
391
+ if (headers['cf-ipcountry']) {
392
+ span.setAttributes({
393
+ 'http.geo.country': headers['cf-ipcountry'],
394
+ 'http.geo.cf_ray': headers['cf-ray'] || '',
395
+ 'http.geo.cf_visitor': headers['cf-visitor'] || '',
396
+ });
397
+ }
398
+
399
+ // Cloudflare additional headers
400
+ if (headers['cf-connecting-ip']) {
401
+ span.setAttribute('http.cf.connecting_ip', headers['cf-connecting-ip']);
402
+ }
403
+
404
+ // ======== SECURITY HEADERS ========
405
+ if (headers['x-csrf-token']) {
406
+ span.setAttribute('http.security.csrf_token_present', 'true');
407
+ }
408
+ if (headers['authorization']) {
409
+ span.setAttribute('http.security.auth_present', 'true');
410
+ // Never log the actual token!
411
+ }
412
+ if (headers['cookie']) {
413
+ span.setAttribute('http.security.cookies_present', 'true');
414
+ // Never log actual cookies!
415
+ }
416
+
417
+ // Debug log in development
418
+ if (isDevelopmentRuntime || otelLogLevel === 'debug') {
419
+ console.log('[securenow] Captured IP: %s (from: %s)',
420
+ primaryIp,
421
+ ipDetails.source || 'unknown'
422
+ );
423
+ }
424
+
425
+ // -------- Request Body NOT captured at HTTP instrumentation level --------
426
+ // IMPORTANT: Do NOT attempt to read request.body or listen to 'data' events
427
+ // Next.js manages request streams internally and reading them here causes conflicts
428
+ // Body capture must be done in Next.js middleware using request.clone()
429
+
430
+ } catch (error) {
431
+ // Silently fail to not break the request
432
+ if (otelLogLevel === 'debug') {
433
+ console.error('[securenow] Error in requestHook:', error.message);
434
+ }
435
+ }
436
+ },
437
+ responseHook: (span, response) => {
438
+ try {
439
+ // Capture response metadata
440
+ span.setAttributes({
441
+ 'http.status_code': response.statusCode || 0,
442
+ 'http.status_message': response.statusMessage || '',
443
+ 'http.response.content_type': response.headers?.['content-type'] || '',
444
+ 'http.response.content_length': response.headers?.['content-length'] || '',
445
+ });
446
+ } catch (error) {
447
+ // Silently fail
448
+ }
449
+ },
450
+ });
451
+
452
+ // -------- Guard against OTLP exporter socket errors --------
453
+ // The OTLP HTTP exporter uses keep-alive connections that can be reset by
454
+ // the remote end (ECONNRESET / "socket hang up"). These transient errors
455
+ // sometimes escape as unhandled exceptions or rejections. We catch them
456
+ // here and log at debug level instead of crashing the host app.
457
+ const _TRANSIENT_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND']);
458
+ function _isOtlpTransientError(err) {
459
+ if (!err) return false;
460
+ if (_TRANSIENT_CODES.has(err.code)) return true;
461
+ if (typeof err.message === 'string' && /socket hang up|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed out/i.test(err.message)) return true;
462
+ return false;
463
+ }
464
+ function _looksLikeOtlpStack(err) {
465
+ const s = err && err.stack;
466
+ if (!s) return false;
467
+ return /OTLPTraceExporter|OTLPLogExporter|otlp|exporter.*http|BatchSpanProcessor|BatchLogRecordProcessor/i.test(s)
468
+ || /node:_http_client|ClientRequest|TLSSocket/i.test(s);
469
+ }
470
+ function _looksLikeConfiguredOtlpEndpoint(err) {
471
+ const text = `${err && err.hostname || ''} ${err && err.host || ''} ${err && err.message || ''}`;
472
+ try {
473
+ const hosts = [new URL(tracesUrl).hostname, new URL(logsUrl).hostname].filter(Boolean);
474
+ return hosts.some((host) => host && text.includes(host));
475
+ } catch (_) {
476
+ return false;
477
+ }
478
+ }
479
+ const _diagDebug = otelLogLevel === 'debug';
480
+ let _lastSuppressedOtlpErrorLogAt = 0;
481
+ function _originalConsole(method) {
482
+ const originals = console.__securenow_original || console.__securenowOriginalConsole;
483
+ return (originals && originals[method]) || console[method] || console.log;
484
+ }
485
+ function _formatOtlpError(err) {
486
+ if (!err) return 'unknown error';
487
+ const parts = [err.message || String(err)];
488
+ if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
489
+ if (err.syscall) parts.push(`syscall=${err.syscall}`);
490
+ if (err.hostname) parts.push(`host=${err.hostname}`);
491
+ return parts.join(' ');
492
+ }
493
+ function _reportSuppressedOtlpError(kind, err, origin) {
494
+ if (otelLogLevel === 'none') return;
495
+ const now = Date.now();
496
+ if (!_diagDebug && now - _lastSuppressedOtlpErrorLogAt < 60_000) return;
497
+ _lastSuppressedOtlpErrorLogAt = now;
498
+ const method = _diagDebug ? 'debug' : 'error';
499
+ _originalConsole(method).call(
500
+ console,
501
+ '[securenow] OTLP exporter %s suppressed (%s). Telemetry may be missing until the ingest endpoint is reachable: %s',
502
+ kind,
503
+ origin || 'async',
504
+ _formatOtlpError(err)
505
+ );
506
+ }
507
+ process.on('uncaughtException', (err, origin) => {
508
+ if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
509
+ _reportSuppressedOtlpError('error', err, origin);
510
+ return;
511
+ }
512
+ throw err;
513
+ });
514
+ process.on('unhandledRejection', (reason) => {
515
+ if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
516
+ _reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
517
+ return;
518
+ }
519
+ throw reason;
520
+ });
521
+
522
+ if (isVercel) {
523
+ // -------- Vercel Environment: Use @vercel/otel --------
524
+ const { registerOTel } = require('@vercel/otel');
525
+
526
+ registerOTel({
527
+ serviceName: serviceName,
528
+ attributes: {
529
+ 'deployment.environment': deploymentEnvironment,
530
+ 'service.version': process.env.npm_package_version || process.env.VERCEL_GIT_COMMIT_SHA || undefined,
531
+ 'vercel.region': process.env.VERCEL_REGION || undefined,
532
+ },
533
+ instrumentations: [httpInstrumentation],
534
+ instrumentationConfig: {
535
+ fetch: {
536
+ // Propagate context to your backend APIs
537
+ propagateContextUrls: [
538
+ /^https?:\/\/localhost/,
539
+ /^https?:\/\/.*\.vercel\.app/,
540
+ // Add your backend domains here
541
+ ],
542
+ // Optionally ignore certain URLs
543
+ ignoreUrls: [
544
+ /_next\/static/,
545
+ /_next\/image/,
546
+ /\.map$/,
547
+ ],
548
+ },
549
+ },
550
+ });
551
+ } else {
552
+ // -------- Self-Hosted (EC2/PM2): Use Vanilla OpenTelemetry SDK --------
553
+ const { NodeSDK } = require('@opentelemetry/sdk-node');
554
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
555
+ const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
556
+
557
+ const traceExporter = new OTLPTraceExporter({
558
+ url: tracesUrl,
559
+ headers
560
+ });
561
+
562
+ const sdk = new NodeSDK({
563
+ ...nodeSdkTelemetryOptions,
564
+ serviceName: serviceName,
565
+ traceExporter: traceExporter,
566
+ instrumentations: [httpInstrumentation],
567
+ resource: createResource({
568
+ [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
569
+ [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: deploymentEnvironment,
570
+ [SemanticResourceAttributes.SERVICE_VERSION]: process.env.npm_package_version || undefined,
571
+ }),
572
+ });
573
+
574
+ sdk.start();
575
+ console.log('[securenow] Vanilla SDK initialized for self-hosted environment');
576
+
577
+ // -------- Logging (self-hosted only) --------
578
+ // Opt-out default: set config.logging.enabled=false to disable.
579
+ const loggingEnabled = appConfig.boolConfig('logging.enabled', true);
580
+ if (loggingEnabled) {
581
+ try {
582
+ const { OTLPLogExporter } = require('@opentelemetry/exporter-logs-otlp-http');
583
+ const { LoggerProvider, BatchLogRecordProcessor } = require('@opentelemetry/sdk-logs');
584
+
585
+ const logExporter = new OTLPLogExporter({
586
+ url: logsUrl,
587
+ headers,
588
+ });
589
+
590
+ const loggerProvider = new LoggerProvider({
591
+ resource: createResource({
592
+ [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
593
+ [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: deploymentEnvironment,
594
+ }),
595
+ processors: [new BatchLogRecordProcessor(logExporter)],
596
+ });
597
+
598
+ // Patch console to forward logs as OTLP log records
599
+ const logger = loggerProvider.getLogger('console', '1.0.0');
600
+ const SeverityNumber = { INFO: 9, WARN: 13, ERROR: 17 };
601
+ const origLog = console.log;
602
+ const origWarn = console.warn;
603
+ const origError = console.error;
604
+
605
+ const { context: otelContext, trace: otelTrace } = require('@opentelemetry/api');
606
+ function _emitLog(sn, st, args) {
607
+ try {
608
+ const activeCtx = otelContext.active();
609
+ const spanCtx = otelTrace.getSpanContext(activeCtx);
610
+ logger.emit({
611
+ severityNumber: sn,
612
+ severityText: st,
613
+ body: args.map(String).join(' '),
614
+ ...(spanCtx && { context: activeCtx }),
615
+ });
616
+ } catch (_) {}
617
+ }
618
+ console.log = (...args) => {
619
+ origLog.apply(console, args);
620
+ _emitLog(SeverityNumber.INFO, 'INFO', args);
621
+ };
622
+ console.warn = (...args) => {
623
+ origWarn.apply(console, args);
624
+ _emitLog(SeverityNumber.WARN, 'WARN', args);
625
+ };
626
+ console.error = (...args) => {
627
+ origError.apply(console, args);
628
+ _emitLog(SeverityNumber.ERROR, 'ERROR', args);
629
+ };
630
+
631
+ console.log('[securenow] Logging: ENABLED -> %s', logsUrl);
632
+
633
+ // Auto-log every incoming HTTP request/response
634
+ try {
635
+ const http = requireNodeBuiltin('node:http');
636
+ const originalEmit = http.Server.prototype.emit;
637
+ http.Server.prototype.emit = function (event, req, res) {
638
+ if (event === 'request' && req && res) {
639
+ const start = Date.now();
640
+ const method = req.method;
641
+ const url = req.url;
642
+ res.on('finish', () => {
643
+ const reqCtx = otelContext.active();
644
+ const reqSpanCtx = otelTrace.getSpanContext(reqCtx);
645
+ const duration = Date.now() - start;
646
+ const status = res.statusCode;
647
+ const ipDetails = resolveClientIpWithDetails(req);
648
+ const ip = ipDetails.ip || '-';
649
+ const ua = req.headers['user-agent'] || '-';
650
+ const body = `${method} ${url} ${status} ${duration}ms ip=${ip} ua=${ua}`;
651
+ const severity = status >= 500 ? SeverityNumber.ERROR : status >= 400 ? SeverityNumber.WARN : SeverityNumber.INFO;
652
+ const severityText = status >= 500 ? 'ERROR' : status >= 400 ? 'WARN' : 'INFO';
653
+ origLog.call(console, '[securenow] %s %s %d %dms', method, url, status, duration);
654
+ try {
655
+ logger.emit({
656
+ severityNumber: severity,
657
+ severityText,
658
+ body,
659
+ attributes: {
660
+ 'http.method': method,
661
+ 'http.url': url,
662
+ 'http.status_code': status,
663
+ 'http.duration_ms': duration,
664
+ 'http.client_ip': ip,
665
+ 'http.client_ip.source': ipDetails.source || 'unknown',
666
+ 'http.socket_ip': ipDetails.socketIp || '',
667
+ 'http.forwarded_for': ipDetails.forwardedFor || '',
668
+ 'http.real_ip': ipDetails.realIp || '',
669
+ 'http.proxy.trusted': String(!!ipDetails.trustedProxy),
670
+ 'http.user_agent': ua,
671
+ },
672
+ ...(reqSpanCtx && { context: reqCtx }),
673
+ });
674
+ } catch (_) {}
675
+ });
676
+ }
677
+ return originalEmit.apply(this, arguments);
678
+ };
679
+ console.log('[securenow] HTTP request logging: ENABLED');
680
+ } catch (_) {}
681
+
682
+ // Graceful shutdown for logs
683
+ process.on('SIGTERM', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { requireRuntimeModule('./firewall').shutdown(); } catch (_) {} });
684
+ process.on('SIGINT', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { requireRuntimeModule('./firewall').shutdown(); } catch (_) {} });
685
+ } catch (e) {
686
+ console.warn('[securenow] Logging setup failed (missing @opentelemetry/exporter-logs-otlp-http or @opentelemetry/sdk-logs):', e.message);
687
+ }
688
+ } else {
689
+ console.log('[securenow] Logging: DISABLED (config.logging.enabled=false)');
690
+ }
691
+ }
692
+
693
+ isRegistered = true;
694
+
695
+ // Free trial banner (optional - may not be bundled in standalone builds)
696
+ try {
697
+ const { isFreeTrial, patchHttpForBanner } = requireRuntimeModule('./free-trial-banner');
698
+ if (isFreeTrial(endpointBase) && !appConfig.boolConfig('runtime.hideBanner', false)) {
699
+ patchHttpForBanner();
700
+ }
701
+ } catch (_) {}
702
+
703
+ console.log('[securenow] OpenTelemetry started for Next.js -> %s', tracesUrl);
704
+ console.log('[securenow] Auto-capturing comprehensive request metadata:');
705
+ console.log('[securenow] - IP addresses (x-forwarded-for, x-real-ip, socket)');
706
+ console.log('[securenow] - User-Agent, Referer, Origin, Accept headers');
707
+ console.log('[securenow] - Protocol, Host, Port (proxy-aware)');
708
+ console.log('[securenow] - Geographic data (Vercel/Cloudflare)');
709
+ console.log('[securenow] - Request IDs, CSRF tokens, Auth presence');
710
+ console.log('[securenow] - Response status, content-type, content-length');
711
+ console.log('[securenow] Body capture DISABLED at HTTP instrumentation level (prevents Next.js conflicts)');
712
+ if (captureBody) {
713
+ console.log('[securenow] For body capture in Next.js, use: import "securenow/nextjs-auto-capture"');
714
+ }
715
+
716
+ // Optional test span
717
+ if (appConfig.boolConfig('runtime.testSpan', false)) {
718
+ const api = require('@opentelemetry/api');
719
+ const tracer = api.trace.getTracer('securenow-nextjs');
720
+ const span = tracer.startSpan('securenow.nextjs.startup');
721
+ span.setAttribute('next.runtime', process.env.NEXT_RUNTIME || 'nodejs');
722
+ span.end();
723
+ console.log('[securenow] Test span created');
724
+ }
725
+
726
+ } catch (error) {
727
+ console.error('[securenow] Failed to initialize OpenTelemetry:', error);
728
+ if (isVercel) {
729
+ console.error('[securenow] Make sure you have @vercel/otel installed: npm install @vercel/otel');
730
+ } else {
731
+ console.error('[securenow] Make sure OpenTelemetry dependencies are installed');
732
+ }
733
+ }
734
+
735
+ // Firewall - runs independently from OTel so it works even if tracing fails.
736
+ // Key and environment come from .securenow/credentials.json (written by
737
+ // login/init or credentials runtime), so no .env entry is needed.
738
+ const firewallOptions = appConfig.resolveFirewallOptions();
739
+ if (firewallOptions.apiKey) {
740
+ try {
741
+ requireRuntimeModule('./firewall').init({
742
+ apiKey: firewallOptions.apiKey,
743
+ appKey: firewallOptions.appKey,
744
+ environment: deploymentEnvironment || firewallOptions.environment,
745
+ apiUrl: firewallOptions.apiUrl,
746
+ apiUrlFallbacks: firewallOptions.apiUrlFallbacks,
747
+ versionCheckInterval: firewallOptions.versionCheckInterval,
748
+ syncInterval: firewallOptions.syncInterval,
749
+ failMode: firewallOptions.failMode,
750
+ statusCode: firewallOptions.statusCode,
751
+ log: firewallOptions.log,
752
+ tcp: firewallOptions.tcp,
753
+ iptables: firewallOptions.iptables,
754
+ cloud: firewallOptions.cloud,
755
+ cloudDryRun: firewallOptions.cloudDryRun,
756
+ cloudflare: firewallOptions.cloudflare,
757
+ aws: firewallOptions.aws,
758
+ gcp: firewallOptions.gcp,
759
+ });
760
+ } catch (e) {
761
+ console.warn('[securenow] Firewall init failed:', e.message);
762
+ }
763
+ }
764
+ }
765
+
766
+ module.exports = {
767
+ registerSecureNow,
768
+ };