securenow 8.11.1 → 8.11.3

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-wrapper.js CHANGED
@@ -1,234 +1,240 @@
1
- /**
2
- * SecureNow Next.js API Route Wrapper for Body Capture
3
- *
4
- * This approach is NON-INVASIVE and runs INSIDE your handler,
5
- * so it never blocks or interferes with middleware or routing.
6
- *
7
- * Usage:
8
- *
9
- * import { withSecureNow } from 'securenow/nextjs-wrapper';
10
- *
11
- * export const POST = withSecureNow(async (request) => {
12
- * // Your handler code - request.body is available as parsed JSON
13
- * const data = await request.json();
14
- * return Response.json({ success: true });
15
- * });
16
- */
17
-
18
- const { trace } = require('@opentelemetry/api');
19
- const appConfig = require('./app-config');
20
-
21
- // Default sensitive fields to redact from request bodies.
22
- // Matched substring-wise against lowercased keys (see redactSensitiveData),
23
- // so e.g. 'card' also catches 'creditCard' and 'account' also catches
24
- // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
25
- // telemetry value is always safer than a leaked secret. Entries are kept
26
- // specific enough to avoid nuking broad benign keys (e.g. we use
27
- // 'firstname'/'lastname'/'fullname', never bare 'name').
28
- const DEFAULT_SENSITIVE_FIELDS = [
29
- // credentials / auth
30
- 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
31
- 'access_token', 'auth', 'authorization', 'bearer', 'credentials',
32
- 'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
33
- 'cookie', 'set-cookie',
34
- // financial
35
- 'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
36
- 'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
37
- // PII
38
- 'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
39
- 'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
40
- 'passport', 'license',
41
- ];
42
-
43
- // Conservative value-shape redactors. Key-name matching misses secrets that
44
- // land in free-form string values (GraphQL bodies, message fields, etc.), so
45
- // as a second layer we scrub string VALUES that *look like* a secret/PII.
46
- // These are intentionally precise/bounded so they don't garble normal prose,
47
- // and they only ever transform captured telemetry strings (read-only) — never
48
- // the actual request/response stream. Compiled once at module load.
49
- const VALUE_REDACTORS = [
50
- // JWT: three base64url segments. Anchored to the eyJ header so it won't
51
- // match arbitrary dotted tokens.
52
- { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
53
- // Bearer/Basic auth header value embedded in a body string.
54
- { name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
55
- // Stripe-style / SecureNow live+test API keys.
56
- { name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
57
- // Email addresses (bounded local/domain parts).
58
- { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
59
- // Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
60
- // space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
61
- { name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
62
- ];
63
-
64
- // Skip the value-shape scan on very large strings to bound per-body cost.
65
- const MAX_VALUE_SCAN_LENGTH = 16384;
66
-
67
- function luhnValid(digits) {
68
- let sum = 0;
69
- let alt = false;
70
- for (let i = digits.length - 1; i >= 0; i--) {
71
- let d = digits.charCodeAt(i) - 48;
72
- if (d < 0 || d > 9) return false;
73
- if (alt) { d *= 2; if (d > 9) d -= 9; }
74
- sum += d;
75
- alt = !alt;
76
- }
77
- return sum % 10 === 0;
78
- }
79
-
80
- /**
81
- * Redact obvious secret/PII shapes inside a captured string VALUE.
82
- * Returns the input unchanged when nothing matches (cheap common case).
83
- */
84
- function redactSensitiveValue(value) {
85
- if (typeof value !== 'string' || value.length === 0) return value;
86
- if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound the work
87
- let out = value;
88
- for (const r of VALUE_REDACTORS) {
89
- r.re.lastIndex = 0;
90
- if (r.luhn) {
91
- out = out.replace(r.re, (m) => {
92
- const digits = m.replace(/[ -]/g, '');
93
- if (digits.length < 13 || digits.length > 19) return m;
94
- return luhnValid(digits) ? '[REDACTED]' : m;
95
- });
96
- } else {
97
- out = out.replace(r.re, '[REDACTED]');
98
- }
99
- }
100
- return out;
101
- }
102
-
103
- /**
104
- * Redact sensitive fields from an object
105
- */
106
- function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
107
- if (!obj || typeof obj !== 'object') return obj;
108
-
109
- const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
110
-
111
- for (const key of Object.keys(redacted)) {
112
- const lowerKey = key.toLowerCase();
113
-
114
- if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
115
- redacted[key] = '[REDACTED]';
116
- } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
117
- redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
118
- } else if (typeof redacted[key] === 'string') {
119
- // Second layer: even if the key name looks benign, scrub values that
120
- // *look like* a secret/PII (JWT, bearer token, API key, email, card).
121
- redacted[key] = redactSensitiveValue(redacted[key]);
122
- }
123
- }
124
-
125
- return redacted;
126
- }
127
-
128
- /**
129
- * Capture body from Request object (clone to avoid consuming)
130
- */
131
- async function captureRequestBody(request) {
132
- const captureBody = appConfig.boolConfig('capture.body', true);
133
-
134
- if (!captureBody) return;
135
- if (!['POST', 'PUT', 'PATCH'].includes(request.method)) return;
136
-
137
- const span = trace.getActiveSpan();
138
- if (!span) return;
139
-
140
- try {
141
- const contentType = request.headers.get('content-type') || '';
142
- const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
143
- const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
144
- const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
145
-
146
- // Only for supported types
147
- if (!contentType.includes('application/json') &&
148
- !contentType.includes('application/graphql') &&
149
- !contentType.includes('application/x-www-form-urlencoded')) {
150
- return;
151
- }
152
-
153
- // Clone to avoid consuming the original
154
- const cloned = request.clone();
155
- const bodyText = await cloned.text();
156
-
157
- if (bodyText.length > maxBodySize) {
158
- span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
159
- span.setAttribute('http.request.body.size', bodyText.length);
160
- return;
161
- }
162
-
163
- // Parse and redact based on type
164
- let redacted;
165
- if (contentType.includes('application/json') || contentType.includes('application/graphql')) {
166
- try {
167
- const parsed = JSON.parse(bodyText);
168
- redacted = redactSensitiveData(parsed, allSensitiveFields);
169
- span.setAttributes({
170
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
171
- 'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
172
- 'http.request.body.size': bodyText.length,
173
- });
174
- } catch (e) {
175
- span.setAttribute('http.request.body', '[INVALID JSON]');
176
- }
177
- } else if (contentType.includes('application/x-www-form-urlencoded')) {
178
- const params = new URLSearchParams(bodyText);
179
- const parsed = Object.fromEntries(params);
180
- redacted = redactSensitiveData(parsed, allSensitiveFields);
181
- span.setAttributes({
182
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
183
- 'http.request.body.type': 'form',
184
- 'http.request.body.size': bodyText.length,
185
- });
186
- }
187
- } catch (error) {
188
- // Silently fail - never block the request
189
- }
190
- }
191
-
192
- /**
193
- * Wrap a Next.js API route handler to capture body
194
- * This is OPTIONAL and NON-INVASIVE - only use on routes where you want body capture
195
- */
196
- function withSecureNow(handler) {
197
- return async function wrappedHandler(request, context) {
198
- // Capture body asynchronously (doesn't block handler)
199
- captureRequestBody(request).catch(() => {
200
- // Ignore errors silently
201
- });
202
-
203
- // Call original handler immediately - no blocking!
204
- return handler(request, context);
205
- };
206
- }
207
-
208
- /**
209
- * Alternative: Auto-capture wrapper that tries to capture AFTER handler runs
210
- * This is even safer as it never interferes with the handler logic
211
- */
212
- function withSecureNowAsync(handler) {
213
- return async function wrappedHandler(request, context) {
214
- // Try to capture body in background (non-blocking)
215
- const capturePromise = captureRequestBody(request);
216
-
217
- // Run handler
218
- const response = await handler(request, context);
219
-
220
- // Wait for capture to finish (but don't fail if it doesn't)
221
- await capturePromise.catch(() => {});
222
-
223
- return response;
224
- };
225
- }
226
-
227
- module.exports = {
228
- withSecureNow,
229
- withSecureNowAsync,
230
- captureRequestBody,
231
- redactSensitiveData,
232
- DEFAULT_SENSITIVE_FIELDS,
233
- };
234
-
1
+ /**
2
+ * SecureNow Next.js API Route Wrapper for Body Capture
3
+ *
4
+ * This approach is NON-INVASIVE and runs INSIDE your handler,
5
+ * so it never blocks or interferes with middleware or routing.
6
+ *
7
+ * Usage:
8
+ *
9
+ * import { withSecureNow } from 'securenow/nextjs-wrapper';
10
+ *
11
+ * export const POST = withSecureNow(async (request) => {
12
+ * // Your handler code - request.body is available as parsed JSON
13
+ * const data = await request.json();
14
+ * return Response.json({ success: true });
15
+ * });
16
+ */
17
+
18
+ const { trace } = require('@opentelemetry/api');
19
+ const appConfig = require('./app-config');
20
+
21
+ const { OTP_SENSITIVE_FIELDS, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery } = require('./otp-redaction');
22
+
23
+ // Default sensitive fields to redact from request bodies.
24
+ // OTP/code aliases use exact normalized matching; other fields are matched
25
+ // substring-wise against lowercased keys (see redactSensitiveData),
26
+ // so e.g. 'card' also catches 'creditCard' and 'account' also catches
27
+ // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
28
+ // telemetry value is always safer than a leaked secret. Entries are kept
29
+ // specific enough to avoid nuking broad benign keys (e.g. we use
30
+ // 'firstname'/'lastname'/'fullname', never bare 'name').
31
+ const DEFAULT_SENSITIVE_FIELDS = [
32
+ ...OTP_SENSITIVE_FIELDS,
33
+ // credentials / auth
34
+ 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
35
+ 'access_token', 'auth', 'authorization', 'bearer', 'credentials',
36
+ 'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
37
+ 'cookie', 'set-cookie',
38
+ // financial
39
+ 'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
40
+ 'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
41
+ // PII
42
+ 'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
43
+ 'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
44
+ 'passport', 'license',
45
+ ];
46
+
47
+ // Conservative value-shape redactors. Key-name matching misses secrets that
48
+ // land in free-form string values (GraphQL bodies, message fields, etc.), so
49
+ // as a second layer we scrub string VALUES that *look like* a secret/PII.
50
+ // These are intentionally precise/bounded so they don't garble normal prose,
51
+ // and they only ever transform captured telemetry strings (read-only) — never
52
+ // the actual request/response stream. Compiled once at module load.
53
+ const VALUE_REDACTORS = [
54
+ // JWT: three base64url segments. Anchored to the eyJ header so it won't
55
+ // match arbitrary dotted tokens.
56
+ { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
57
+ // Bearer/Basic auth header value embedded in a body string.
58
+ { name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
59
+ // Stripe-style / SecureNow live+test API keys.
60
+ { name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
61
+ // Email addresses (bounded local/domain parts).
62
+ { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
63
+ // Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
64
+ // space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
65
+ { name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
66
+ ];
67
+
68
+ // Skip the value-shape scan on very large strings to bound per-body cost.
69
+ const MAX_VALUE_SCAN_LENGTH = 16384;
70
+
71
+ function luhnValid(digits) {
72
+ let sum = 0;
73
+ let alt = false;
74
+ for (let i = digits.length - 1; i >= 0; i--) {
75
+ let d = digits.charCodeAt(i) - 48;
76
+ if (d < 0 || d > 9) return false;
77
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
78
+ sum += d;
79
+ alt = !alt;
80
+ }
81
+ return sum % 10 === 0;
82
+ }
83
+
84
+ /**
85
+ * Redact obvious secret/PII shapes inside a captured string VALUE.
86
+ * Returns the input unchanged when nothing matches (cheap common case).
87
+ */
88
+ function redactSensitiveValue(value) {
89
+ if (typeof value !== 'string' || value.length === 0) return value;
90
+ // Credential assignments in GraphQL/query strings need field-aware
91
+ // redaction even though a short numeric OTP has no unique value shape.
92
+ value = redactOtpQuery(redactOtpGraphQL(value));
93
+ if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
94
+ let out = value;
95
+ for (const r of VALUE_REDACTORS) {
96
+ r.re.lastIndex = 0;
97
+ if (r.luhn) {
98
+ out = out.replace(r.re, (m) => {
99
+ const digits = m.replace(/[ -]/g, '');
100
+ if (digits.length < 13 || digits.length > 19) return m;
101
+ return luhnValid(digits) ? '[REDACTED]' : m;
102
+ });
103
+ } else {
104
+ out = out.replace(r.re, '[REDACTED]');
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Redact sensitive fields from an object
112
+ */
113
+ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
114
+ if (!obj || typeof obj !== 'object') return obj;
115
+
116
+ const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
117
+
118
+ for (const key of Object.keys(redacted)) {
119
+
120
+ if (matchesSensitiveField(key, sensitiveFields)) {
121
+ redacted[key] = '[REDACTED]';
122
+ } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
123
+ redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
124
+ } else if (typeof redacted[key] === 'string') {
125
+ // Second layer: even if the key name looks benign, scrub values that
126
+ // *look like* a secret/PII (JWT, bearer token, API key, email, card).
127
+ redacted[key] = redactSensitiveValue(redacted[key]);
128
+ }
129
+ }
130
+
131
+ return redacted;
132
+ }
133
+
134
+ /**
135
+ * Capture body from Request object (clone to avoid consuming)
136
+ */
137
+ async function captureRequestBody(request) {
138
+ const captureBody = appConfig.boolConfig('capture.body', true);
139
+
140
+ if (!captureBody) return;
141
+ if (!['POST', 'PUT', 'PATCH'].includes(request.method)) return;
142
+
143
+ const span = trace.getActiveSpan();
144
+ if (!span) return;
145
+
146
+ try {
147
+ const contentType = request.headers.get('content-type') || '';
148
+ const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
149
+ const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
150
+ const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
151
+
152
+ // Only for supported types
153
+ if (!contentType.includes('application/json') &&
154
+ !contentType.includes('application/graphql') &&
155
+ !contentType.includes('application/x-www-form-urlencoded')) {
156
+ return;
157
+ }
158
+
159
+ // Clone to avoid consuming the original
160
+ const cloned = request.clone();
161
+ const bodyText = await cloned.text();
162
+
163
+ if (bodyText.length > maxBodySize) {
164
+ span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
165
+ span.setAttribute('http.request.body.size', bodyText.length);
166
+ return;
167
+ }
168
+
169
+ // Parse and redact based on type
170
+ let redacted;
171
+ if (contentType.includes('application/json') || contentType.includes('application/graphql')) {
172
+ try {
173
+ const parsed = JSON.parse(bodyText);
174
+ redacted = redactSensitiveData(parsed, allSensitiveFields);
175
+ span.setAttributes({
176
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
177
+ 'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
178
+ 'http.request.body.size': bodyText.length,
179
+ });
180
+ } catch (e) {
181
+ span.setAttribute('http.request.body', '[INVALID JSON]');
182
+ }
183
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
184
+ const params = new URLSearchParams(bodyText);
185
+ const parsed = Object.fromEntries(params);
186
+ redacted = redactSensitiveData(parsed, allSensitiveFields);
187
+ span.setAttributes({
188
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
189
+ 'http.request.body.type': 'form',
190
+ 'http.request.body.size': bodyText.length,
191
+ });
192
+ }
193
+ } catch (error) {
194
+ // Silently fail - never block the request
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Wrap a Next.js API route handler to capture body
200
+ * This is OPTIONAL and NON-INVASIVE - only use on routes where you want body capture
201
+ */
202
+ function withSecureNow(handler) {
203
+ return async function wrappedHandler(request, context) {
204
+ // Capture body asynchronously (doesn't block handler)
205
+ captureRequestBody(request).catch(() => {
206
+ // Ignore errors silently
207
+ });
208
+
209
+ // Call original handler immediately - no blocking!
210
+ return handler(request, context);
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Alternative: Auto-capture wrapper that tries to capture AFTER handler runs
216
+ * This is even safer as it never interferes with the handler logic
217
+ */
218
+ function withSecureNowAsync(handler) {
219
+ return async function wrappedHandler(request, context) {
220
+ // Try to capture body in background (non-blocking)
221
+ const capturePromise = captureRequestBody(request);
222
+
223
+ // Run handler
224
+ const response = await handler(request, context);
225
+
226
+ // Wait for capture to finish (but don't fail if it doesn't)
227
+ await capturePromise.catch(() => {});
228
+
229
+ return response;
230
+ };
231
+ }
232
+
233
+ module.exports = {
234
+ withSecureNow,
235
+ withSecureNowAsync,
236
+ captureRequestBody,
237
+ redactSensitiveData,
238
+ DEFAULT_SENSITIVE_FIELDS,
239
+ };
240
+
package/nextjs.js CHANGED
@@ -59,14 +59,18 @@ function createResource(attributes) {
59
59
  throw new Error('Unsupported @opentelemetry/resources version');
60
60
  }
61
61
 
62
+ const { OTP_SENSITIVE_FIELDS, isOtpCodeField, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery, withOtpRedactionExporter } = require('./otp-redaction');
63
+
62
64
  // Default sensitive fields to redact from request bodies.
63
- // Matched substring-wise against lowercased keys (see redactSensitiveData),
65
+ // OTP/code aliases use exact normalized matching; other fields are matched
66
+ // substring-wise against lowercased keys (see redactSensitiveData),
64
67
  // so e.g. 'card' also catches 'creditCard' and 'account' also catches
65
68
  // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
66
69
  // telemetry value is always safer than a leaked secret. Entries are kept
67
70
  // specific enough to avoid nuking broad benign keys (e.g. we use
68
71
  // 'firstname'/'lastname'/'fullname', never bare 'name').
69
72
  const DEFAULT_SENSITIVE_FIELDS = [
73
+ ...OTP_SENSITIVE_FIELDS,
70
74
  // credentials / auth
71
75
  'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
72
76
  'access_token', 'auth', 'authorization', 'bearer', 'credentials',
@@ -124,7 +128,10 @@ function luhnValid(digits) {
124
128
  */
125
129
  function redactSensitiveValue(value) {
126
130
  if (typeof value !== 'string' || value.length === 0) return value;
127
- if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound the work
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
128
135
  let out = value;
129
136
  for (const r of VALUE_REDACTORS) {
130
137
  r.re.lastIndex = 0;
@@ -150,9 +157,8 @@ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
150
157
  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
151
158
 
152
159
  for (const key of Object.keys(redacted)) {
153
- const lowerKey = key.toLowerCase();
154
160
 
155
- if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
161
+ if (matchesSensitiveField(key, sensitiveFields)) {
156
162
  redacted[key] = '[REDACTED]';
157
163
  } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
158
164
  // Recursively redact nested objects
@@ -181,7 +187,7 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
181
187
 
182
188
  // Redact sensitive fields in GraphQL arguments and variables
183
189
  // Matches patterns like: password: "value" or password:"value" or password:'value'
184
- sensitiveFields.forEach(field => {
190
+ sensitiveFields.filter(field => field === 'otp' || !isOtpCodeField(field)).forEach(field => {
185
191
  const escaped = escapeRegex(field);
186
192
  const patterns = [
187
193
  new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
@@ -201,7 +207,7 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
201
207
 
202
208
  // Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
203
209
  // body — catches values the key-name pass above can't see.
204
- redacted = redactSensitiveValue(redacted);
210
+ redacted = redactOtpGraphQL(redactSensitiveValue(redacted));
205
211
 
206
212
  return redacted;
207
213
  }
@@ -320,7 +326,7 @@ function registerSecureNow(options = {}) {
320
326
 
321
327
  // ======== REQUEST METADATA ========
322
328
  const userAgent = headers['user-agent'] || '';
323
- const referer = headers['referer'] || headers['referrer'] || '';
329
+ const referer = redactOtpQuery(headers['referer'] || headers['referrer'] || '');
324
330
  const accept = headers['accept'] || '';
325
331
  const acceptLanguage = headers['accept-language'] || '';
326
332
  const acceptEncoding = headers['accept-encoding'] || '';
@@ -329,7 +335,7 @@ function registerSecureNow(options = {}) {
329
335
  const origin = headers['origin'] || '';
330
336
 
331
337
  // ======== PROXY & LOAD BALANCER ========
332
- const originalUri = headers['x-original-uri'] || '';
338
+ const originalUri = redactOtpQuery(headers['x-original-uri'] || '');
333
339
  const originalMethod = headers['x-original-method'] || '';
334
340
  const requestId = headers['x-request-id'] || headers['x-trace-id'] || headers['x-correlation-id'] || '';
335
341
 
@@ -528,6 +534,7 @@ function registerSecureNow(options = {}) {
528
534
  const { registerOTel } = require('@vercel/otel');
529
535
 
530
536
  registerOTel({
537
+ traceExporter: withOtpRedactionExporter(new (require('@opentelemetry/exporter-trace-otlp-http').OTLPTraceExporter)({ url: tracesUrl, headers })),
531
538
  serviceName: serviceName,
532
539
  attributes: {
533
540
  'deployment.environment': deploymentEnvironment,
@@ -558,10 +565,10 @@ function registerSecureNow(options = {}) {
558
565
  const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
559
566
  const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
560
567
 
561
- const traceExporter = new OTLPTraceExporter({
568
+ const traceExporter = withOtpRedactionExporter(new OTLPTraceExporter({
562
569
  url: tracesUrl,
563
- headers
564
- });
570
+ headers
571
+ }));
565
572
 
566
573
  const sdk = new NodeSDK({
567
574
  ...nodeSdkTelemetryOptions,
@@ -643,7 +650,7 @@ function registerSecureNow(options = {}) {
643
650
  if (event === 'request' && req && res) {
644
651
  const start = Date.now();
645
652
  const method = req.method;
646
- const url = req.url;
653
+ const url = redactOtpQuery(req.url);
647
654
  res.on('finish', () => {
648
655
  const reqCtx = otelContext.active();
649
656
  const reqSpanCtx = otelTrace.getSpanContext(reqCtx);