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.
@@ -1,195 +1,274 @@
1
- /**
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.
17
- */
18
-
19
- const { trace } = require('@opentelemetry/api');
20
- const appConfig = require('./app-config');
21
-
22
- // Default sensitive fields to redact
23
- const DEFAULT_SENSITIVE_FIELDS = [
24
- 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
25
- 'access_token', 'auth', 'credentials', 'mysql_pwd', 'stripeToken',
26
- 'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
27
- ];
28
-
29
- /**
30
- * Redact sensitive fields from an object
31
- */
32
- function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
33
- if (!obj || typeof obj !== 'object') return obj;
34
-
35
- const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
36
-
37
- for (const key of Object.keys(redacted)) {
38
- const lowerKey = key.toLowerCase();
39
-
40
- if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
41
- redacted[key] = '[REDACTED]';
42
- } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
43
- redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
44
- }
45
- }
46
-
47
- return redacted;
48
- }
49
-
50
- /**
51
- * Safe body capture that doesn't interfere with Next.js
52
- */
53
- async function safeBodyCapture(request, span) {
54
- if (!span) return;
55
-
56
- try {
57
- const contentType = request.headers.get('content-type') || '';
58
- const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
59
- const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
60
- const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
61
-
62
- // Only for supported types
63
- if (!contentType.includes('application/json') &&
64
- !contentType.includes('application/graphql') &&
65
- !contentType.includes('application/x-www-form-urlencoded')) {
66
- return;
67
- }
68
-
69
- // Try to read from cache if available (Next.js may have already read it)
70
- let bodyText;
71
-
72
- // Attempt 1: Check if body was already cached by Next.js
73
- if (request._bodyText) {
74
- bodyText = request._bodyText;
75
- } else {
76
- // Attempt 2: Try to clone and read
77
- try {
78
- const cloned = request.clone();
79
- bodyText = await cloned.text();
80
- // Cache it for Next.js
81
- request._bodyText = bodyText;
82
- } catch (e) {
83
- // If clone fails, body was already consumed - skip silently
84
- return;
85
- }
86
- }
87
-
88
- if (bodyText.length > maxBodySize) {
89
- span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
90
- return;
91
- }
92
-
93
- // Parse and redact
94
- if (contentType.includes('application/json') || contentType.includes('application/graphql')) {
95
- try {
96
- const parsed = JSON.parse(bodyText);
97
- const redacted = redactSensitiveData(parsed, allSensitiveFields);
98
- span.setAttributes({
99
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
100
- 'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
101
- 'http.request.body.size': bodyText.length,
102
- });
103
- } catch (e) {
104
- // Parse error - skip
105
- }
106
- } else if (contentType.includes('application/x-www-form-urlencoded')) {
107
- try {
108
- const params = new URLSearchParams(bodyText);
109
- const parsed = Object.fromEntries(params);
110
- const redacted = redactSensitiveData(parsed, allSensitiveFields);
111
- span.setAttributes({
112
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
113
- 'http.request.body.type': 'form',
114
- 'http.request.body.size': bodyText.length,
115
- });
116
- } catch (e) {
117
- // Parse error - skip
118
- }
119
- }
120
- } catch (error) {
121
- // Silently fail - never break the request
122
- }
123
- }
124
-
125
- /**
126
- * Check if body capture is enabled
127
- */
128
- function isBodyCaptureEnabled() {
129
- return appConfig.boolConfig('capture.body', true);
130
- }
131
-
132
- /**
133
- * Patch Next.js Request to cache body text
134
- * This allows us to read the body without consuming it
135
- */
136
- function patchNextRequest() {
137
- if (typeof Request === 'undefined') return;
138
-
139
- const originalText = Request.prototype.text;
140
- const originalJson = Request.prototype.json;
141
-
142
- // Patch text() to cache result
143
- Request.prototype.text = async function() {
144
- if (this._bodyText !== undefined) {
145
- return this._bodyText;
146
- }
147
- const text = await originalText.call(this);
148
- this._bodyText = text;
149
-
150
- // Capture for tracing if enabled
151
- if (isBodyCaptureEnabled() && ['POST', 'PUT', 'PATCH'].includes(this.method)) {
152
- const span = trace.getActiveSpan();
153
- if (span) {
154
- // Schedule capture after this call (non-blocking)
155
- setImmediate(() => {
156
- safeBodyCapture(this, span).catch(() => {});
157
- });
158
- }
159
- }
160
-
161
- return text;
162
- };
163
-
164
- // Patch json() to cache and capture
165
- Request.prototype.json = async function() {
166
- // First get text
167
- const text = await this.text();
168
- // Then parse
169
- return JSON.parse(text);
170
- };
171
-
172
- console.log('[securenow] Auto-capture: Patched Next.js Request for automatic body capture');
173
- }
174
-
175
- // Auto-patch when module is imported
176
- if (isBodyCaptureEnabled()) {
177
- try {
178
- patchNextRequest();
179
- console.log('[securenow] 📝 Automatic body capture: ENABLED');
180
- console.log('[securenow] 💡 No code changes needed - bodies captured automatically!');
181
- } catch (error) {
182
- console.warn('[securenow] ⚠️ Auto-capture patch failed:', error.message);
183
- console.warn('[securenow] 💡 Body capture disabled. Use manual approach if needed.');
184
- }
185
- } else {
186
- console.log('[securenow] Automatic body capture: DISABLED (config.capture.body=false)');
187
- }
188
-
189
- module.exports = {
190
- patchNextRequest,
191
- safeBodyCapture,
192
- redactSensitiveData,
193
- isBodyCaptureEnabled,
194
- };
195
-
1
+ /**
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.
17
+ */
18
+
19
+ const { trace } = require('@opentelemetry/api');
20
+ const appConfig = require('./app-config');
21
+
22
+ // Default sensitive fields to redact from request bodies.
23
+ // Matched substring-wise against lowercased keys (see redactSensitiveData),
24
+ // so e.g. 'card' also catches 'creditCard' and 'account' also catches
25
+ // 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
26
+ // telemetry value is always safer than a leaked secret. Entries are kept
27
+ // specific enough to avoid nuking broad benign keys (e.g. we use
28
+ // 'firstname'/'lastname'/'fullname', never bare 'name').
29
+ const DEFAULT_SENSITIVE_FIELDS = [
30
+ // credentials / auth
31
+ 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
32
+ 'access_token', 'auth', 'authorization', 'bearer', 'credentials',
33
+ 'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
34
+ 'cookie', 'set-cookie',
35
+ // financial
36
+ 'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
37
+ 'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
38
+ // PII
39
+ 'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
40
+ 'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
41
+ 'passport', 'license',
42
+ ];
43
+
44
+ // Conservative value-shape redactors. Key-name matching misses secrets that
45
+ // land in free-form string values (GraphQL bodies, message fields, etc.), so
46
+ // as a second layer we scrub string VALUES that *look like* a secret/PII.
47
+ // These are intentionally precise/bounded so they don't garble normal prose,
48
+ // and they only ever transform captured telemetry strings (read-only) — never
49
+ // the actual request/response stream. Compiled once at module load.
50
+ const VALUE_REDACTORS = [
51
+ // JWT: three base64url segments. Anchored to the eyJ header so it won't
52
+ // match arbitrary dotted tokens.
53
+ { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
54
+ // Bearer/Basic auth header value embedded in a body string.
55
+ { name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
56
+ // Stripe-style / SecureNow live+test API keys.
57
+ { name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
58
+ // Email addresses (bounded local/domain parts).
59
+ { name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
60
+ // Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
61
+ // space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
62
+ { name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
63
+ ];
64
+
65
+ // Skip the value-shape scan on very large strings to bound per-body cost.
66
+ const MAX_VALUE_SCAN_LENGTH = 16384;
67
+
68
+ function luhnValid(digits) {
69
+ let sum = 0;
70
+ let alt = false;
71
+ for (let i = digits.length - 1; i >= 0; i--) {
72
+ let d = digits.charCodeAt(i) - 48;
73
+ if (d < 0 || d > 9) return false;
74
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
75
+ sum += d;
76
+ alt = !alt;
77
+ }
78
+ return sum % 10 === 0;
79
+ }
80
+
81
+ /**
82
+ * Redact obvious secret/PII shapes inside a captured string VALUE.
83
+ * Returns the input unchanged when nothing matches (cheap common case).
84
+ */
85
+ function redactSensitiveValue(value) {
86
+ if (typeof value !== 'string' || value.length === 0) return value;
87
+ if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound the work
88
+ let out = value;
89
+ for (const r of VALUE_REDACTORS) {
90
+ r.re.lastIndex = 0;
91
+ if (r.luhn) {
92
+ out = out.replace(r.re, (m) => {
93
+ const digits = m.replace(/[ -]/g, '');
94
+ if (digits.length < 13 || digits.length > 19) return m;
95
+ return luhnValid(digits) ? '[REDACTED]' : m;
96
+ });
97
+ } else {
98
+ out = out.replace(r.re, '[REDACTED]');
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Redact sensitive fields from an object
106
+ */
107
+ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
108
+ if (!obj || typeof obj !== 'object') return obj;
109
+
110
+ const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
111
+
112
+ for (const key of Object.keys(redacted)) {
113
+ const lowerKey = key.toLowerCase();
114
+
115
+ if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
116
+ redacted[key] = '[REDACTED]';
117
+ } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
118
+ redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
119
+ } else if (typeof redacted[key] === 'string') {
120
+ // Second layer: even if the key name looks benign, scrub values that
121
+ // *look like* a secret/PII (JWT, bearer token, API key, email, card).
122
+ redacted[key] = redactSensitiveValue(redacted[key]);
123
+ }
124
+ }
125
+
126
+ return redacted;
127
+ }
128
+
129
+ /**
130
+ * Safe body capture that doesn't interfere with Next.js
131
+ */
132
+ async function safeBodyCapture(request, span) {
133
+ if (!span) return;
134
+
135
+ try {
136
+ const contentType = request.headers.get('content-type') || '';
137
+ const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
138
+ const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
139
+ const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
140
+
141
+ // Only for supported types
142
+ if (!contentType.includes('application/json') &&
143
+ !contentType.includes('application/graphql') &&
144
+ !contentType.includes('application/x-www-form-urlencoded')) {
145
+ return;
146
+ }
147
+
148
+ // Try to read from cache if available (Next.js may have already read it)
149
+ let bodyText;
150
+
151
+ // Attempt 1: Check if body was already cached by Next.js
152
+ if (request._bodyText) {
153
+ bodyText = request._bodyText;
154
+ } else {
155
+ // Attempt 2: Try to clone and read
156
+ try {
157
+ const cloned = request.clone();
158
+ bodyText = await cloned.text();
159
+ // Cache it for Next.js
160
+ request._bodyText = bodyText;
161
+ } catch (e) {
162
+ // If clone fails, body was already consumed - skip silently
163
+ return;
164
+ }
165
+ }
166
+
167
+ if (bodyText.length > maxBodySize) {
168
+ span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
169
+ return;
170
+ }
171
+
172
+ // Parse and redact
173
+ if (contentType.includes('application/json') || contentType.includes('application/graphql')) {
174
+ try {
175
+ const parsed = JSON.parse(bodyText);
176
+ const redacted = redactSensitiveData(parsed, allSensitiveFields);
177
+ span.setAttributes({
178
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
179
+ 'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
180
+ 'http.request.body.size': bodyText.length,
181
+ });
182
+ } catch (e) {
183
+ // Parse error - skip
184
+ }
185
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
186
+ try {
187
+ const params = new URLSearchParams(bodyText);
188
+ const parsed = Object.fromEntries(params);
189
+ const redacted = redactSensitiveData(parsed, allSensitiveFields);
190
+ span.setAttributes({
191
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
192
+ 'http.request.body.type': 'form',
193
+ 'http.request.body.size': bodyText.length,
194
+ });
195
+ } catch (e) {
196
+ // Parse error - skip
197
+ }
198
+ }
199
+ } catch (error) {
200
+ // Silently fail - never break the request
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Check if body capture is enabled
206
+ */
207
+ function isBodyCaptureEnabled() {
208
+ return appConfig.boolConfig('capture.body', true);
209
+ }
210
+
211
+ /**
212
+ * Patch Next.js Request to cache body text
213
+ * This allows us to read the body without consuming it
214
+ */
215
+ function patchNextRequest() {
216
+ if (typeof Request === 'undefined') return;
217
+
218
+ const originalText = Request.prototype.text;
219
+ const originalJson = Request.prototype.json;
220
+
221
+ // Patch text() to cache result
222
+ Request.prototype.text = async function() {
223
+ if (this._bodyText !== undefined) {
224
+ return this._bodyText;
225
+ }
226
+ const text = await originalText.call(this);
227
+ this._bodyText = text;
228
+
229
+ // Capture for tracing if enabled
230
+ if (isBodyCaptureEnabled() && ['POST', 'PUT', 'PATCH'].includes(this.method)) {
231
+ const span = trace.getActiveSpan();
232
+ if (span) {
233
+ // Schedule capture after this call (non-blocking)
234
+ setImmediate(() => {
235
+ safeBodyCapture(this, span).catch(() => {});
236
+ });
237
+ }
238
+ }
239
+
240
+ return text;
241
+ };
242
+
243
+ // Patch json() to cache and capture
244
+ Request.prototype.json = async function() {
245
+ // First get text
246
+ const text = await this.text();
247
+ // Then parse
248
+ return JSON.parse(text);
249
+ };
250
+
251
+ console.log('[securenow] ✅ Auto-capture: Patched Next.js Request for automatic body capture');
252
+ }
253
+
254
+ // Auto-patch when module is imported
255
+ if (isBodyCaptureEnabled()) {
256
+ try {
257
+ patchNextRequest();
258
+ console.log('[securenow] 📝 Automatic body capture: ENABLED');
259
+ console.log('[securenow] 💡 No code changes needed - bodies captured automatically!');
260
+ } catch (error) {
261
+ console.warn('[securenow] ⚠️ Auto-capture patch failed:', error.message);
262
+ console.warn('[securenow] 💡 Body capture disabled. Use manual approach if needed.');
263
+ }
264
+ } else {
265
+ console.log('[securenow] Automatic body capture: DISABLED (config.capture.body=false)');
266
+ }
267
+
268
+ module.exports = {
269
+ patchNextRequest,
270
+ safeBodyCapture,
271
+ redactSensitiveData,
272
+ isBodyCaptureEnabled,
273
+ };
274
+