securenow 8.22.0 → 8.24.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.
@@ -1,271 +1,129 @@
1
+ 'use strict';
2
+
1
3
  /**
2
- * SecureNow Next.js Automatic Body Capture
3
- *
4
- * This module automatically patches Next.js request handling to capture bodies
5
- * WITHOUT requiring customers to wrap their handlers or change their code.
6
- *
7
- * Usage in instrumentation.ts:
8
- *
9
- * import { registerSecureNow } from 'securenow/nextjs';
10
- * import 'securenow/nextjs-auto-capture'; // Just import this line!
11
- *
12
- * export function register() {
13
- * registerSecureNow();
14
- * }
15
- *
16
- * That's it! Bodies are now captured automatically.
4
+ * SecureNow Next.js body capture, fallback layer.
5
+ *
6
+ * registerSecureNow() (securenow/nextjs) captures request headers and bodies at the HTTP layer,
7
+ * on the request's server span, like every other SecureNow integration. This module covers hosts
8
+ * where that layer never sees the request: it records the body when a route handler calls
9
+ * request.text() or request.json(). Once the HTTP layer has captured a request in this process,
10
+ * it leaves bodies to it.
11
+ *
12
+ * The generated instrumentation file imports it; importing it is all it needs.
17
13
  */
18
14
 
19
- const { trace } = require('@opentelemetry/api');
20
15
  const appConfig = require('./app-config');
16
+ const { DEFAULT_SENSITIVE_FIELDS, redactSensitiveData, redactBodyText } = require('./request-capture');
21
17
 
22
- const { OTP_SENSITIVE_FIELDS, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery } = require('./otp-redaction');
23
-
24
- // Default sensitive fields to redact from request bodies.
25
- // OTP/code aliases use exact normalized matching; other fields are matched
26
- // substring-wise against lowercased keys (see redactSensitiveData),
27
- // so e.g. 'card' also catches 'creditCard' and 'account' also catches
28
- // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
29
- // telemetry value is always safer than a leaked secret. Entries are kept
30
- // specific enough to avoid nuking broad benign keys (e.g. we use
31
- // 'firstname'/'lastname'/'fullname', never bare 'name').
32
- const DEFAULT_SENSITIVE_FIELDS = [
33
- ...OTP_SENSITIVE_FIELDS,
34
- // credentials / auth
35
- 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
36
- 'access_token', 'auth', 'authorization', 'bearer', 'credentials',
37
- 'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
38
- 'cookie', 'set-cookie',
39
- // financial
40
- 'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
41
- 'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
42
- // PII
43
- 'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
44
- 'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
45
- 'passport', 'license',
46
- ];
47
-
48
- // Conservative value-shape redactors. Key-name matching misses secrets that
49
- // land in free-form string values (GraphQL bodies, message fields, etc.), so
50
- // as a second layer we scrub string VALUES that *look like* a secret/PII.
51
- // These are intentionally precise/bounded so they don't garble normal prose,
52
- // and they only ever transform captured telemetry strings (read-only) — never
53
- // the actual request/response stream. Compiled once at module load.
54
- const VALUE_REDACTORS = [
55
- // JWT: three base64url segments. Anchored to the eyJ header so it won't
56
- // match arbitrary dotted tokens.
57
- { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
58
- // Bearer/Basic auth header value embedded in a body string.
59
- { name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
60
- // Stripe-style / SecureNow live+test API keys.
61
- { name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
62
- // Email addresses (bounded local/domain parts).
63
- { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
64
- // Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
65
- // space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
66
- { name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
67
- ];
68
-
69
- // Skip the value-shape scan on very large strings to bound per-body cost.
70
- const MAX_VALUE_SCAN_LENGTH = 16384;
18
+ const HTTP_CAPTURE_ACTIVE = Symbol.for('securenow.nextjs.httpCaptureActive');
19
+ const PATCHED = Symbol.for('securenow.nextjs.autoCapturePatched');
71
20
 
72
- function luhnValid(digits) {
73
- let sum = 0;
74
- let alt = false;
75
- for (let i = digits.length - 1; i >= 0; i--) {
76
- let d = digits.charCodeAt(i) - 48;
77
- if (d < 0 || d > 9) return false;
78
- if (alt) { d *= 2; if (d > 9) d -= 9; }
79
- sum += d;
80
- alt = !alt;
21
+ function activeSpan() {
22
+ try {
23
+ return require('@opentelemetry/api').trace.getActiveSpan() || null;
24
+ } catch (_) {
25
+ return null;
81
26
  }
82
- return sum % 10 === 0;
83
27
  }
84
28
 
85
29
  /**
86
- * Redact obvious secret/PII shapes inside a captured string VALUE.
87
- * Returns the input unchanged when nothing matches (cheap common case).
30
+ * Check if body capture is enabled
88
31
  */
89
- function redactSensitiveValue(value) {
90
- if (typeof value !== 'string' || value.length === 0) return value;
91
- // Credential assignments in GraphQL/query strings need field-aware
92
- // redaction even though a short numeric OTP has no unique value shape.
93
- value = redactOtpQuery(redactOtpGraphQL(value));
94
- if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
95
- let out = value;
96
- for (const r of VALUE_REDACTORS) {
97
- r.re.lastIndex = 0;
98
- if (r.luhn) {
99
- out = out.replace(r.re, (m) => {
100
- const digits = m.replace(/[ -]/g, '');
101
- if (digits.length < 13 || digits.length > 19) return m;
102
- return luhnValid(digits) ? '[REDACTED]' : m;
103
- });
104
- } else {
105
- out = out.replace(r.re, '[REDACTED]');
106
- }
107
- }
108
- return out;
32
+ function isBodyCaptureEnabled() {
33
+ return appConfig.boolConfig('capture.body', true);
109
34
  }
110
35
 
111
- /**
112
- * Redact sensitive fields from an object
113
- */
114
- function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
115
- if (!obj || typeof obj !== 'object') return obj;
116
-
117
- const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
118
-
119
- for (const key of Object.keys(redacted)) {
120
-
121
- if (matchesSensitiveField(key, sensitiveFields)) {
122
- redacted[key] = '[REDACTED]';
123
- } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
124
- redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
125
- } else if (typeof redacted[key] === 'string') {
126
- // Second layer: even if the key name looks benign, scrub values that
127
- // *look like* a secret/PII (JWT, bearer token, API key, email, card).
128
- redacted[key] = redactSensitiveValue(redacted[key]);
36
+ // Attach the redacted body to the span right away, while the handler that is awaiting its body
37
+ // still holds the span open. (Deferring this with setImmediate put it after the span had ended,
38
+ // and an ended span drops new attributes, so no body was ever recorded.)
39
+ function captureBodyText(span, bodyText, contentType) {
40
+ if (!span || typeof bodyText !== 'string') return;
41
+ try {
42
+ const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
43
+ const sensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...appConfig.listConfig('capture.sensitiveFields')];
44
+ if (bodyText.length > maxBodySize) {
45
+ span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
46
+ span.setAttribute('http.request.body.size', bodyText.length);
47
+ return;
129
48
  }
49
+ let redacted;
50
+ try {
51
+ redacted = redactBodyText(bodyText, contentType, sensitiveFields);
52
+ } catch (_) {
53
+ span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
54
+ span.setAttribute('http.request.body.parse_error', true);
55
+ return;
56
+ }
57
+ if (!redacted) return;
58
+ span.setAttributes({
59
+ 'http.request.body': redacted.body.substring(0, maxBodySize),
60
+ 'http.request.body.type': redacted.type,
61
+ 'http.request.body.size': bodyText.length,
62
+ });
63
+ } catch (_) {
64
+ // Never break the request.
130
65
  }
131
-
132
- return redacted;
133
66
  }
134
67
 
135
68
  /**
136
- * Safe body capture that doesn't interfere with Next.js
69
+ * Capture from a Request whose body was already read through the patch, or can still be cloned.
137
70
  */
138
71
  async function safeBodyCapture(request, span) {
139
- if (!span) return;
140
-
72
+ if (!span || !request) return;
141
73
  try {
142
- const contentType = request.headers.get('content-type') || '';
143
- const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
144
- const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
145
- const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
146
-
147
- // Only for supported types
148
- if (!contentType.includes('application/json') &&
149
- !contentType.includes('application/graphql') &&
150
- !contentType.includes('application/x-www-form-urlencoded')) {
151
- return;
152
- }
153
-
154
- // Try to read from cache if available (Next.js may have already read it)
155
- let bodyText;
156
-
157
- // Attempt 1: Check if body was already cached by Next.js
158
- if (request._bodyText) {
159
- bodyText = request._bodyText;
160
- } else {
161
- // Attempt 2: Try to clone and read
74
+ const contentType = (request.headers && typeof request.headers.get === 'function' && request.headers.get('content-type')) || '';
75
+ let bodyText = request._bodyText;
76
+ if (bodyText === undefined) {
162
77
  try {
163
- const cloned = request.clone();
164
- bodyText = await cloned.text();
165
- // Cache it for Next.js
166
- request._bodyText = bodyText;
167
- } catch (e) {
168
- // If clone fails, body was already consumed - skip silently
78
+ bodyText = await request.clone().text();
79
+ } catch (_) {
169
80
  return;
170
81
  }
171
82
  }
172
-
173
- if (bodyText.length > maxBodySize) {
174
- span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
175
- return;
176
- }
177
-
178
- // Parse and redact
179
- if (contentType.includes('application/json') || contentType.includes('application/graphql')) {
180
- try {
181
- const parsed = JSON.parse(bodyText);
182
- const redacted = redactSensitiveData(parsed, allSensitiveFields);
183
- span.setAttributes({
184
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
185
- 'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
186
- 'http.request.body.size': bodyText.length,
187
- });
188
- } catch (e) {
189
- // Parse error - skip
190
- }
191
- } else if (contentType.includes('application/x-www-form-urlencoded')) {
192
- try {
193
- const params = new URLSearchParams(bodyText);
194
- const parsed = Object.fromEntries(params);
195
- const redacted = redactSensitiveData(parsed, allSensitiveFields);
196
- span.setAttributes({
197
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
198
- 'http.request.body.type': 'form',
199
- 'http.request.body.size': bodyText.length,
200
- });
201
- } catch (e) {
202
- // Parse error - skip
203
- }
204
- }
205
- } catch (error) {
83
+ captureBodyText(span, bodyText, contentType);
84
+ } catch (_) {
206
85
  // Silently fail - never break the request
207
86
  }
208
87
  }
209
88
 
210
89
  /**
211
- * Check if body capture is enabled
212
- */
213
- function isBodyCaptureEnabled() {
214
- return appConfig.boolConfig('capture.body', true);
215
- }
216
-
217
- /**
218
- * Patch Next.js Request to cache body text
219
- * This allows us to read the body without consuming it
90
+ * Patch Request.text()/json() so the body a route handler reads is recorded on its span.
220
91
  */
221
92
  function patchNextRequest() {
222
- if (typeof Request === 'undefined') return;
223
-
93
+ if (typeof Request === 'undefined' || Request.prototype[PATCHED]) return;
94
+
224
95
  const originalText = Request.prototype.text;
225
- const originalJson = Request.prototype.json;
226
-
227
- // Patch text() to cache result
228
- Request.prototype.text = async function() {
96
+
97
+ Request.prototype.text = async function securenowText() {
229
98
  if (this._bodyText !== undefined) {
230
99
  return this._bodyText;
231
100
  }
101
+ const span = activeSpan();
232
102
  const text = await originalText.call(this);
233
103
  this._bodyText = text;
234
-
235
- // Capture for tracing if enabled
236
- if (isBodyCaptureEnabled() && ['POST', 'PUT', 'PATCH'].includes(this.method)) {
237
- const span = trace.getActiveSpan();
238
- if (span) {
239
- // Schedule capture after this call (non-blocking)
240
- setImmediate(() => {
241
- safeBodyCapture(this, span).catch(() => {});
242
- });
243
- }
104
+
105
+ if (!globalThis[HTTP_CAPTURE_ACTIVE] && isBodyCaptureEnabled() && ['POST', 'PUT', 'PATCH'].includes(this.method)) {
106
+ const contentType = (this.headers && typeof this.headers.get === 'function' && this.headers.get('content-type')) || '';
107
+ captureBodyText(span, text, contentType);
244
108
  }
245
-
109
+
246
110
  return text;
247
111
  };
248
-
249
- // Patch json() to cache and capture
250
- Request.prototype.json = async function() {
251
- // First get text
252
- const text = await this.text();
253
- // Then parse
254
- return JSON.parse(text);
112
+
113
+ Request.prototype.json = async function securenowJson() {
114
+ return JSON.parse(await this.text());
255
115
  };
256
-
257
- console.log('[securenow] ✅ Auto-capture: Patched Next.js Request for automatic body capture');
116
+
117
+ Object.defineProperty(Request.prototype, PATCHED, { value: true });
118
+ console.log('[securenow] Next.js body capture fallback installed (request.text() / request.json())');
258
119
  }
259
120
 
260
121
  // Auto-patch when module is imported
261
122
  if (isBodyCaptureEnabled()) {
262
123
  try {
263
124
  patchNextRequest();
264
- console.log('[securenow] 📝 Automatic body capture: ENABLED');
265
- console.log('[securenow] 💡 No code changes needed - bodies captured automatically!');
266
125
  } catch (error) {
267
- console.warn('[securenow] ⚠️ Auto-capture patch failed:', error.message);
268
- console.warn('[securenow] 💡 Body capture disabled. Use manual approach if needed.');
126
+ console.warn('[securenow] Next.js body capture fallback not installed:', error && error.message);
269
127
  }
270
128
  } else {
271
129
  console.log('[securenow] Automatic body capture: DISABLED (config.capture.body=false)');
@@ -277,4 +135,3 @@ module.exports = {
277
135
  redactSensitiveData,
278
136
  isBodyCaptureEnabled,
279
137
  };
280
-
package/nextjs.js CHANGED
@@ -7,24 +7,32 @@
7
7
  *
8
8
  * 1. Add securenow to serverExternalPackages and standalone output tracing
9
9
  * includes in next.config.js:
10
- *
10
+ *
11
11
  * const nextConfig = {
12
12
  * serverExternalPackages: ["securenow"],
13
13
  * outputFileTracingIncludes: {
14
14
  * "/*": ["<securenow package glob>"],
15
15
  * },
16
16
  * };
17
- *
18
- * 2. Create instrumentation.ts (or .js) in your project root:
19
- *
17
+ *
18
+ * 2. Create instrumentation.ts (or .js) in your project root (`npx securenow init` writes it):
19
+ *
20
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");
21
+ * if (process.env.NEXT_RUNTIME === "nodejs") {
22
+ * try {
23
+ * const securenowNext = await import("securenow/nextjs");
24
+ * const registerSecureNow = securenowNext.registerSecureNow || securenowNext.default?.registerSecureNow;
25
+ * registerSecureNow({ captureBody: true });
26
+ * await import("securenow/nextjs-auto-capture");
27
+ * } catch (error) {
28
+ * console.error("[securenow] SecureNow did not start; the app is running without it:", error);
29
+ * }
30
+ * }
26
31
  * }
27
- *
32
+ *
33
+ * The import stays inside the nodejs block, without webpackIgnore, so Next.js leaves it out
34
+ * of the Edge build and traces securenow and its dependencies into standalone output.
35
+ *
28
36
  * 3. Run `npx securenow login` and `npx securenow init`.
29
37
  * The SDK reads app identity, collector, firewall, capture, and
30
38
  * deploymentEnvironment from .securenow/credentials.json.
@@ -34,7 +42,6 @@ const { randomUUID } = require('crypto');
34
42
  const { nodeSdkDefaultTelemetryOptions } = require('./otel-defaults');
35
43
  const appConfig = require('./app-config');
36
44
  const { resolveClientIpWithDetails } = require('./resolve-ip');
37
- const otelResources = require('@opentelemetry/resources');
38
45
 
39
46
  const nodeSdkTelemetryOptions = nodeSdkDefaultTelemetryOptions();
40
47
 
@@ -50,6 +57,10 @@ function requireNodeBuiltin(name) {
50
57
  }
51
58
 
52
59
  function createResource(attributes) {
60
+ // Required here, not at the top of the file: a bundle that lacks the OpenTelemetry packages
61
+ // (an incomplete standalone image) must still load this module, so the firewall starts and
62
+ // the app keeps serving.
63
+ const otelResources = require('@opentelemetry/resources');
53
64
  if (typeof otelResources.resourceFromAttributes === 'function') {
54
65
  return otelResources.resourceFromAttributes(attributes);
55
66
  }
@@ -59,158 +70,12 @@ function createResource(attributes) {
59
70
  throw new Error('Unsupported @opentelemetry/resources version');
60
71
  }
61
72
 
62
- const { OTP_SENSITIVE_FIELDS, isOtpCodeField, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery, withOtpRedactionExporter } = require('./otp-redaction');
63
-
64
- // Default sensitive fields to redact from request bodies.
65
- // OTP/code aliases use exact normalized matching; other fields are matched
66
- // substring-wise against lowercased keys (see redactSensitiveData),
67
- // so e.g. 'card' also catches 'creditCard' and 'account' also catches
68
- // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
69
- // telemetry value is always safer than a leaked secret. Entries are kept
70
- // specific enough to avoid nuking broad benign keys (e.g. we use
71
- // 'firstname'/'lastname'/'fullname', never bare 'name').
72
- const DEFAULT_SENSITIVE_FIELDS = [
73
- ...OTP_SENSITIVE_FIELDS,
74
- // credentials / auth
75
- 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
76
- 'access_token', 'auth', 'authorization', 'bearer', 'credentials',
77
- 'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
78
- 'cookie', 'set-cookie',
79
- // financial
80
- 'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
81
- 'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
82
- // PII
83
- 'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
84
- 'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
85
- 'passport', 'license',
86
- ];
87
-
88
- // Conservative value-shape redactors. Key-name matching misses secrets that
89
- // land in free-form string values (GraphQL bodies, message fields, etc.), so
90
- // as a second layer we scrub string VALUES that *look like* a secret/PII.
91
- // These are intentionally precise/bounded so they don't garble normal prose,
92
- // and they only ever transform captured telemetry strings (read-only) — never
93
- // the actual request/response stream. Compiled once at module load.
94
- const VALUE_REDACTORS = [
95
- // JWT: three base64url segments. Anchored to the eyJ header so it won't
96
- // match arbitrary dotted tokens.
97
- { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
98
- // Bearer/Basic auth header value embedded in a body string.
99
- { name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
100
- // Stripe-style / SecureNow live+test API keys.
101
- { name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
102
- // Email addresses (bounded local/domain parts).
103
- { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
104
- // Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
105
- // space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
106
- { name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
107
- ];
108
-
109
- // Skip the value-shape scan on very large strings to bound per-body cost.
110
- const MAX_VALUE_SCAN_LENGTH = 16384;
111
-
112
- function luhnValid(digits) {
113
- let sum = 0;
114
- let alt = false;
115
- for (let i = digits.length - 1; i >= 0; i--) {
116
- let d = digits.charCodeAt(i) - 48;
117
- if (d < 0 || d > 9) return false;
118
- if (alt) { d *= 2; if (d > 9) d -= 9; }
119
- sum += d;
120
- alt = !alt;
121
- }
122
- return sum % 10 === 0;
123
- }
73
+ const { redactOtpQuery, withOtpRedactionExporter } = require('./otp-redaction');
74
+ const { createRequestCapture } = require('./request-capture');
124
75
 
125
- /**
126
- * Redact obvious secret/PII shapes inside a captured string VALUE.
127
- * Returns the input unchanged when nothing matches (cheap common case).
128
- */
129
- function redactSensitiveValue(value) {
130
- if (typeof value !== 'string' || value.length === 0) return value;
131
- // Credential assignments in GraphQL/query strings need field-aware
132
- // redaction even though a short numeric OTP has no unique value shape.
133
- value = redactOtpQuery(redactOtpGraphQL(value));
134
- if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
135
- let out = value;
136
- for (const r of VALUE_REDACTORS) {
137
- r.re.lastIndex = 0;
138
- if (r.luhn) {
139
- out = out.replace(r.re, (m) => {
140
- const digits = m.replace(/[ -]/g, '');
141
- if (digits.length < 13 || digits.length > 19) return m;
142
- return luhnValid(digits) ? '[REDACTED]' : m;
143
- });
144
- } else {
145
- out = out.replace(r.re, '[REDACTED]');
146
- }
147
- }
148
- return out;
149
- }
150
-
151
- /**
152
- * Redact sensitive fields from an object
153
- */
154
- function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
155
- if (!obj || typeof obj !== 'object') return obj;
156
-
157
- const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
158
-
159
- for (const key of Object.keys(redacted)) {
160
-
161
- if (matchesSensitiveField(key, sensitiveFields)) {
162
- redacted[key] = '[REDACTED]';
163
- } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
164
- // Recursively redact nested objects
165
- redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
166
- } else if (typeof redacted[key] === 'string') {
167
- // Second layer: even if the key name looks benign, scrub values that
168
- // *look like* a secret/PII (JWT, bearer token, API key, email, card).
169
- redacted[key] = redactSensitiveValue(redacted[key]);
170
- }
171
- }
172
-
173
- return redacted;
174
- }
175
-
176
- function escapeRegex(str) {
177
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
178
- }
179
-
180
- /**
181
- * Redact sensitive data from GraphQL query strings
182
- */
183
- function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
184
- if (!query || typeof query !== 'string') return query;
185
-
186
- let redacted = query;
187
-
188
- // Redact sensitive fields in GraphQL arguments and variables
189
- // Matches patterns like: password: "value" or password:"value" or password:'value'
190
- sensitiveFields.filter(field => field === 'otp' || !isOtpCodeField(field)).forEach(field => {
191
- const escaped = escapeRegex(field);
192
- const patterns = [
193
- new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
194
- new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
195
- ];
196
-
197
- patterns.forEach(pattern => {
198
- redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
199
- if (suffix) {
200
- return `${prefix}[REDACTED]${suffix}`;
201
- } else {
202
- return `${prefix}[REDACTED]`;
203
- }
204
- });
205
- });
206
- });
207
-
208
- // Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
209
- // body — catches values the key-name pass above can't see.
210
- redacted = redactOtpGraphQL(redactSensitiveValue(redacted));
211
-
212
- return redacted;
213
- }
76
+ // Set once the HTTP layer has captured a request in this process: nextjs-auto-capture then
77
+ // leaves bodies to it instead of attaching a second copy to Next.js's own span.
78
+ const HTTP_CAPTURE_ACTIVE = Symbol.for('securenow.nextjs.httpCaptureActive');
214
79
 
215
80
  /**
216
81
  * Register SecureNow OpenTelemetry for Next.js using @vercel/otel
@@ -280,10 +145,13 @@ function registerSecureNow(options = {}) {
280
145
 
281
146
  // -------- Body Capture Configuration --------
282
147
  // Opt-out default: set config.capture.body=false (or options.captureBody=false) to disable.
283
- const captureBody = options.captureBody ?? appConfig.boolConfig('capture.body', true);
284
- const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
285
- const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
286
- const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
148
+ const requestCapture = createRequestCapture({
149
+ captureBody: options.captureBody ?? appConfig.boolConfig('capture.body', true),
150
+ captureMultipart: appConfig.boolConfig('capture.multipart', true),
151
+ maxBodySize: appConfig.numberConfig('capture.maxBodySize', 10240, 1024),
152
+ customSensitiveFields: appConfig.listConfig('capture.sensitiveFields'),
153
+ });
154
+ const { captureBody } = requestCapture;
287
155
 
288
156
  // -------- Log environment detection --------
289
157
  if (!isVercel) {
@@ -432,11 +300,13 @@ function registerSecureNow(options = {}) {
432
300
  );
433
301
  }
434
302
 
435
- // -------- Request Body NOT captured at HTTP instrumentation level --------
436
- // IMPORTANT: Do NOT attempt to read request.body or listen to 'data' events
437
- // Next.js manages request streams internally and reading them here causes conflicts
438
- // Body capture must be done in Next.js middleware using request.clone()
439
-
303
+ // Headers (redacted) and body, the same attributes as every other SecureNow integration.
304
+ // The body is observed through the request's own emit('data') while Next.js reads it:
305
+ // no listener is added and nothing is read on Next.js's behalf.
306
+ requestCapture.captureHeaders(span, request);
307
+ requestCapture.observeBody(span, request);
308
+ globalThis[HTTP_CAPTURE_ACTIVE] = true;
309
+
440
310
  } catch (error) {
441
311
  // Silently fail to not break the request
442
312
  if (otelLogLevel === 'debug') {
@@ -519,7 +389,10 @@ function registerSecureNow(options = {}) {
519
389
  let _crashLoggerProvider = null;
520
390
  require('./crash-capture').installCrashCapture({
521
391
  getLogger: () => (_crashLoggerProvider ? _crashLoggerProvider.getLogger('securenow.crash', '1.0.0') : null),
522
- flush: () => (_crashLoggerProvider ? _crashLoggerProvider.forceFlush() : undefined),
392
+ flush: () => Promise.all([
393
+ _crashLoggerProvider ? _crashLoggerProvider.forceFlush() : undefined,
394
+ require('./crash-capture').flushTracerProvider(),
395
+ ]),
523
396
  isSdkError: (e) => _isOtlpTransientError(e) && (_looksLikeOtlpStack(e) || _looksLikeConfiguredOtlpEndpoint(e)),
524
397
  onSdkError: _reportSuppressedOtlpError,
525
398
  });
@@ -708,10 +581,7 @@ function registerSecureNow(options = {}) {
708
581
  console.log('[securenow] - Geographic data (Vercel/Cloudflare)');
709
582
  console.log('[securenow] - Request IDs, CSRF tokens, Auth presence');
710
583
  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
- }
584
+ console.log('[securenow] - Request headers (redacted)%s', captureBody ? ' and JSON/form/GraphQL bodies (redacted)' : '');
715
585
 
716
586
  // Optional test span
717
587
  if (appConfig.boolConfig('runtime.testSpan', false)) {