securenow 8.11.2 → 8.12.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-wrapper.js CHANGED
@@ -1,240 +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
-
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
21
  const { OTP_SENSITIVE_FIELDS, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery } = require('./otp-redaction');
22
22
 
23
- // Default sensitive fields to redact from request bodies.
23
+ // Default sensitive fields to redact from request bodies.
24
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').
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
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;
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
90
  // Credential assignments in GraphQL/query strings need field-aware
91
91
  // redaction even though a short numeric OTP has no unique value shape.
92
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
-
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
@@ -268,7 +268,6 @@ function registerSecureNow(options = {}) {
268
268
 
269
269
  // -------- Endpoint Configuration --------
270
270
  const resolvedEndpoints = appConfig.resolveEndpoints({ endpoint: options.endpoint || resolvedApp.instance });
271
- const endpointBase = resolvedEndpoints.endpointBase;
272
271
  const tracesUrl = resolvedEndpoints.tracesUrl;
273
272
  const logsUrl = resolvedEndpoints.logsUrl;
274
273
  const headers = resolvedEndpoints.headers;
@@ -704,14 +703,6 @@ function registerSecureNow(options = {}) {
704
703
 
705
704
  isRegistered = true;
706
705
 
707
- // Free trial banner (optional - may not be bundled in standalone builds)
708
- try {
709
- const { isFreeTrial, patchHttpForBanner } = requireRuntimeModule('./free-trial-banner');
710
- if (isFreeTrial(endpointBase) && !appConfig.boolConfig('runtime.hideBanner', false)) {
711
- patchHttpForBanner();
712
- }
713
- } catch (_) {}
714
-
715
706
  console.log('[securenow] OpenTelemetry started for Next.js -> %s', tracesUrl);
716
707
  console.log('[securenow] Auto-capturing comprehensive request metadata:');
717
708
  console.log('[securenow] - IP addresses (x-forwarded-for, x-real-ip, socket)');
@@ -196,7 +196,6 @@ export default defineNitroPlugin(async (nitroApp) => {
196
196
 
197
197
  // ── Endpoints ──
198
198
  const resolvedEndpoints = appConfig.resolveEndpoints({ endpoint: opts.endpoint || resolvedApp.instance });
199
- const endpointBase = resolvedEndpoints.endpointBase;
200
199
  const tracesUrl = resolvedEndpoints.tracesUrl;
201
200
  const logsUrl = resolvedEndpoints.logsUrl;
202
201
  const headers = resolvedEndpoints.headers;
@@ -400,16 +399,6 @@ export default defineNitroPlugin(async (nitroApp) => {
400
399
  );
401
400
  }
402
401
 
403
- // ── Free trial banner ──
404
- try {
405
- const { isFreeTrial, patchHttpForBanner } = await import('./free-trial-banner.js');
406
- if (isFreeTrial(endpointBase) && !appConfig.boolConfig('runtime.hideBanner', false)) {
407
- patchHttpForBanner();
408
- }
409
- } catch {
410
- // not critical
411
- }
412
-
413
402
  // ── Firewall — runs independently from OTel ──
414
403
  const firewallOptions = appConfig.resolveFirewallOptions();
415
404
  if (firewallOptions.apiKey) {