securenow 4.0.2 → 4.0.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.js CHANGED
@@ -25,6 +25,176 @@ const env = k => process.env[k] ?? process.env[k.toUpperCase()] ?? process.env[k
25
25
 
26
26
  let isRegistered = false;
27
27
 
28
+ // Default sensitive fields to redact from request bodies
29
+ const DEFAULT_SENSITIVE_FIELDS = [
30
+ 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
31
+ 'access_token', 'auth', 'credentials', 'mysql_pwd', 'stripeToken',
32
+ 'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
33
+ ];
34
+
35
+ /**
36
+ * Redact sensitive fields from an object
37
+ */
38
+ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
39
+ if (!obj || typeof obj !== 'object') return obj;
40
+
41
+ const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
42
+
43
+ for (const key in redacted) {
44
+ const lowerKey = key.toLowerCase();
45
+
46
+ // Check if field is sensitive
47
+ if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
48
+ redacted[key] = '[REDACTED]';
49
+ } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
50
+ // Recursively redact nested objects
51
+ redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
52
+ }
53
+ }
54
+
55
+ return redacted;
56
+ }
57
+
58
+ /**
59
+ * Redact sensitive data from GraphQL query strings
60
+ */
61
+ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
62
+ if (!query || typeof query !== 'string') return query;
63
+
64
+ let redacted = query;
65
+
66
+ // Redact sensitive fields in GraphQL arguments and variables
67
+ // Matches patterns like: password: "value" or password:"value" or password:'value'
68
+ sensitiveFields.forEach(field => {
69
+ // Match field: "value" or field: 'value' or field:"value" (with optional spaces)
70
+ const patterns = [
71
+ new RegExp(`(${field}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
72
+ new RegExp(`(${field}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
73
+ ];
74
+
75
+ patterns.forEach(pattern => {
76
+ redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
77
+ if (suffix) {
78
+ return `${prefix}[REDACTED]${suffix}`;
79
+ } else {
80
+ return `${prefix}[REDACTED]`;
81
+ }
82
+ });
83
+ });
84
+ });
85
+
86
+ return redacted;
87
+ }
88
+
89
+ /**
90
+ * Parse and capture request body safely
91
+ */
92
+ async function captureRequestBody(request, maxSize = 10240) {
93
+ try {
94
+ const contentType = request.headers['content-type'] || '';
95
+ let body = '';
96
+
97
+ // Collect body chunks
98
+ const chunks = [];
99
+ let size = 0;
100
+
101
+ return new Promise((resolve) => {
102
+ request.on('data', (chunk) => {
103
+ size += chunk.length;
104
+ if (size <= maxSize) {
105
+ chunks.push(chunk);
106
+ }
107
+ });
108
+
109
+ request.on('end', () => {
110
+ if (size > maxSize) {
111
+ resolve({
112
+ captured: false,
113
+ reason: `Body too large (${size} bytes > ${maxSize} bytes)`,
114
+ size
115
+ });
116
+ return;
117
+ }
118
+
119
+ body = Buffer.concat(chunks).toString('utf8');
120
+
121
+ // Parse based on content type
122
+ if (contentType.includes('application/json')) {
123
+ try {
124
+ const parsed = JSON.parse(body);
125
+ resolve({
126
+ captured: true,
127
+ type: 'json',
128
+ body: parsed,
129
+ size
130
+ });
131
+ } catch (e) {
132
+ resolve({
133
+ captured: true,
134
+ type: 'json',
135
+ body: body.substring(0, 1000),
136
+ parseError: true,
137
+ size
138
+ });
139
+ }
140
+ } else if (contentType.includes('application/graphql')) {
141
+ // GraphQL queries need redaction too!
142
+ resolve({
143
+ captured: true,
144
+ type: 'graphql',
145
+ body: body, // Will be redacted later
146
+ size
147
+ });
148
+ } else if (contentType.includes('multipart/form-data')) {
149
+ // Multipart is NOT captured (files can be huge)
150
+ resolve({
151
+ captured: false,
152
+ type: 'multipart',
153
+ reason: 'Multipart data not captured (file uploads)',
154
+ size
155
+ });
156
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
157
+ try {
158
+ const params = new URLSearchParams(body);
159
+ const parsed = Object.fromEntries(params);
160
+ resolve({
161
+ captured: true,
162
+ type: 'form',
163
+ body: parsed,
164
+ size
165
+ });
166
+ } catch (e) {
167
+ resolve({
168
+ captured: true,
169
+ type: 'form',
170
+ body: body.substring(0, 1000),
171
+ size
172
+ });
173
+ }
174
+ } else {
175
+ resolve({
176
+ captured: true,
177
+ type: 'text',
178
+ body: body.substring(0, 1000),
179
+ size
180
+ });
181
+ }
182
+ });
183
+
184
+ request.on('error', () => {
185
+ resolve({ captured: false, reason: 'Stream error' });
186
+ });
187
+
188
+ // Timeout after 100ms
189
+ setTimeout(() => {
190
+ resolve({ captured: false, reason: 'Timeout' });
191
+ }, 100);
192
+ });
193
+ } catch (error) {
194
+ return { captured: false, reason: error.message };
195
+ }
196
+ }
197
+
28
198
  /**
29
199
  * Register SecureNow OpenTelemetry for Next.js using @vercel/otel
30
200
  * @param {Object} options - Optional configuration
@@ -86,6 +256,14 @@ function registerSecureNow(options = {}) {
86
256
 
87
257
  console.log('[securenow] 🚀 Next.js App → service.name=%s', serviceName);
88
258
 
259
+ // -------- Body Capture Configuration --------
260
+ const captureBody = String(env('SECURENOW_CAPTURE_BODY')) === '1' ||
261
+ String(env('SECURENOW_CAPTURE_BODY')).toLowerCase() === 'true' ||
262
+ options.captureBody === true;
263
+ const maxBodySize = parseInt(env('SECURENOW_MAX_BODY_SIZE') || '10240'); // 10KB default
264
+ const customSensitiveFields = (env('SECURENOW_SENSITIVE_FIELDS') || '').split(',').map(s => s.trim()).filter(Boolean);
265
+ const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
266
+
89
267
  // -------- Use @vercel/otel with enhanced configuration --------
90
268
  const { registerOTel } = require('@vercel/otel');
91
269
  const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
@@ -98,11 +276,11 @@ function registerSecureNow(options = {}) {
98
276
  'vercel.region': process.env.VERCEL_REGION || undefined,
99
277
  },
100
278
  instrumentations: [
101
- // Add HTTP instrumentation with request hooks to capture IP and headers
279
+ // Add HTTP instrumentation with request hooks to capture IP, headers, and body
102
280
  new HttpInstrumentation({
103
281
  requireParentforOutgoingSpans: false,
104
282
  requireParentforIncomingSpans: false,
105
- requestHook: (span, request) => {
283
+ requestHook: async (span, request) => {
106
284
  try {
107
285
  // Capture client IP from various headers
108
286
  const headers = request.headers || {};
@@ -140,6 +318,50 @@ function registerSecureNow(options = {}) {
140
318
  if (headers['cf-ipcountry']) {
141
319
  span.setAttribute('http.geo.country', headers['cf-ipcountry']);
142
320
  }
321
+
322
+ // -------- Capture Request Body --------
323
+ if (captureBody && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
324
+ const contentType = headers['content-type'] || '';
325
+
326
+ // Only capture JSON, GraphQL, and form data (not large files)
327
+ if (contentType.includes('application/json') ||
328
+ contentType.includes('application/graphql') ||
329
+ contentType.includes('application/x-www-form-urlencoded')) {
330
+
331
+ const bodyResult = await captureRequestBody(request, maxBodySize);
332
+
333
+ if (bodyResult.captured) {
334
+ let redactedBody;
335
+
336
+ // Redact based on type
337
+ if (bodyResult.type === 'graphql') {
338
+ // GraphQL: redact query string
339
+ redactedBody = redactGraphQLQuery(bodyResult.body, allSensitiveFields);
340
+ } else if (typeof bodyResult.body === 'object') {
341
+ // JSON/Form: redact object properties
342
+ redactedBody = redactSensitiveData(bodyResult.body, allSensitiveFields);
343
+ } else {
344
+ // Plain text: basic redaction
345
+ redactedBody = bodyResult.body;
346
+ }
347
+
348
+ span.setAttributes({
349
+ 'http.request.body': typeof redactedBody === 'string'
350
+ ? redactedBody.substring(0, maxBodySize)
351
+ : JSON.stringify(redactedBody).substring(0, maxBodySize),
352
+ 'http.request.body.type': bodyResult.type,
353
+ 'http.request.body.size': bodyResult.size,
354
+ });
355
+ } else {
356
+ span.setAttribute('http.request.body.capture_failed', bodyResult.reason || 'unknown');
357
+ }
358
+ } else if (contentType.includes('multipart/form-data')) {
359
+ // Multipart is NOT captured at all
360
+ span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
361
+ span.setAttribute('http.request.body.type', 'multipart');
362
+ span.setAttribute('http.request.body.note', 'File uploads not captured by design');
363
+ }
364
+ }
143
365
  } catch (error) {
144
366
  // Silently fail to not break the request
145
367
  console.debug('[securenow] Failed to capture request metadata:', error.message);
@@ -180,6 +402,11 @@ function registerSecureNow(options = {}) {
180
402
  isRegistered = true;
181
403
  console.log('[securenow] ✅ OpenTelemetry started for Next.js → %s', tracesUrl);
182
404
  console.log('[securenow] 📊 Auto-capturing: IP, User-Agent, Headers, Geographic data');
405
+ if (captureBody) {
406
+ console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
407
+ } else {
408
+ console.log('[securenow] 📝 Request body capture: DISABLED (set SECURENOW_CAPTURE_BODY=1 to enable)');
409
+ }
183
410
 
184
411
  // Optional test span
185
412
  if (String(env('SECURENOW_TEST_SPAN')) === '1') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "4.0.2",
3
+ "version": "4.0.3",
4
4
  "description": "OpenTelemetry instrumentation for Node.js and Next.js - Send traces to SigNoz or any OTLP backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",
@@ -54,7 +54,10 @@
54
54
  "NEXTJS-QUICKSTART.md",
55
55
  "CUSTOMER-GUIDE.md",
56
56
  "AUTO-SETUP.md",
57
- "AUTOMATIC-IP-CAPTURE.md"
57
+ "AUTOMATIC-IP-CAPTURE.md",
58
+ "REQUEST-BODY-CAPTURE.md",
59
+ "BODY-CAPTURE-QUICKSTART.md",
60
+ "REDACTION-EXAMPLES.md"
58
61
  ],
59
62
  "dependencies": {
60
63
  "@opentelemetry/api": "1.7.0",
package/tracing.js CHANGED
@@ -37,6 +37,64 @@ const parseHeaders = str => {
37
37
  return out;
38
38
  };
39
39
 
40
+ // Default sensitive fields to redact from request bodies
41
+ const DEFAULT_SENSITIVE_FIELDS = [
42
+ 'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
43
+ 'access_token', 'auth', 'credentials', 'mysql_pwd', 'stripeToken',
44
+ 'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
45
+ ];
46
+
47
+ /**
48
+ * Redact sensitive fields from an object
49
+ */
50
+ function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
51
+ if (!obj || typeof obj !== 'object') return obj;
52
+
53
+ const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
54
+
55
+ for (const key in redacted) {
56
+ const lowerKey = key.toLowerCase();
57
+
58
+ if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
59
+ redacted[key] = '[REDACTED]';
60
+ } else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
61
+ redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
62
+ }
63
+ }
64
+
65
+ return redacted;
66
+ }
67
+
68
+ /**
69
+ * Redact sensitive data from GraphQL query strings
70
+ */
71
+ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
72
+ if (!query || typeof query !== 'string') return query;
73
+
74
+ let redacted = query;
75
+
76
+ // Redact sensitive fields in GraphQL arguments and variables
77
+ sensitiveFields.forEach(field => {
78
+ // Match patterns: field: "value" or field: 'value' or field:"value"
79
+ const patterns = [
80
+ new RegExp(`(${field}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
81
+ new RegExp(`(${field}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
82
+ ];
83
+
84
+ patterns.forEach(pattern => {
85
+ redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
86
+ if (suffix) {
87
+ return `${prefix}[REDACTED]${suffix}`;
88
+ } else {
89
+ return `${prefix}[REDACTED]`;
90
+ }
91
+ });
92
+ });
93
+ });
94
+
95
+ return redacted;
96
+ }
97
+
40
98
  // -------- diagnostics --------
41
99
  (() => {
42
100
  const L = (env('OTEL_LOG_LEVEL') || '').toLowerCase();
@@ -97,11 +155,103 @@ for (const n of (env('SECURENOW_DISABLE_INSTRUMENTATIONS') || '').split(',').map
97
155
  disabledMap[n] = { enabled: false };
98
156
  }
99
157
 
158
+ // -------- Body Capture Configuration --------
159
+ const captureBody = String(env('SECURENOW_CAPTURE_BODY')) === '1' || String(env('SECURENOW_CAPTURE_BODY')).toLowerCase() === 'true';
160
+ const maxBodySize = parseInt(env('SECURENOW_MAX_BODY_SIZE') || '10240'); // 10KB default
161
+ const customSensitiveFields = (env('SECURENOW_SENSITIVE_FIELDS') || '').split(',').map(s => s.trim()).filter(Boolean);
162
+ const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
163
+
164
+ // Configure HTTP instrumentation with body capture
165
+ const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
166
+ const httpInstrumentation = new HttpInstrumentation({
167
+ requestHook: (span, request) => {
168
+ try {
169
+ if (captureBody && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
170
+ const contentType = request.headers['content-type'] || '';
171
+
172
+ if (contentType.includes('application/json') ||
173
+ contentType.includes('application/graphql') ||
174
+ contentType.includes('application/x-www-form-urlencoded')) {
175
+
176
+ let body = '';
177
+ const chunks = [];
178
+ let size = 0;
179
+
180
+ request.on('data', (chunk) => {
181
+ size += chunk.length;
182
+ if (size <= maxBodySize) {
183
+ chunks.push(chunk);
184
+ }
185
+ });
186
+
187
+ request.on('end', () => {
188
+ if (size <= maxBodySize && chunks.length > 0) {
189
+ body = Buffer.concat(chunks).toString('utf8');
190
+
191
+ try {
192
+ let redacted;
193
+
194
+ if (contentType.includes('application/graphql')) {
195
+ // GraphQL: redact query string
196
+ redacted = redactGraphQLQuery(body, allSensitiveFields);
197
+ span.setAttributes({
198
+ 'http.request.body': redacted.substring(0, maxBodySize),
199
+ 'http.request.body.type': 'graphql',
200
+ 'http.request.body.size': size,
201
+ });
202
+ } else if (contentType.includes('application/json')) {
203
+ // JSON: parse and redact object
204
+ const parsed = JSON.parse(body);
205
+ redacted = redactSensitiveData(parsed, allSensitiveFields);
206
+ span.setAttributes({
207
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
208
+ 'http.request.body.type': 'json',
209
+ 'http.request.body.size': size,
210
+ });
211
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
212
+ // Form: parse and redact
213
+ const parsed = Object.fromEntries(new URLSearchParams(body));
214
+ redacted = redactSensitiveData(parsed, allSensitiveFields);
215
+ span.setAttributes({
216
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
217
+ 'http.request.body.type': 'form',
218
+ 'http.request.body.size': size,
219
+ });
220
+ }
221
+ } catch (e) {
222
+ // Parse error: capture as-is (truncated)
223
+ span.setAttribute('http.request.body', body.substring(0, 1000));
224
+ span.setAttribute('http.request.body.parse_error', true);
225
+ }
226
+ } else if (size > maxBodySize) {
227
+ span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
228
+ span.setAttribute('http.request.body.size', size);
229
+ }
230
+ });
231
+ } else if (contentType.includes('multipart/form-data')) {
232
+ // Multipart is NOT captured
233
+ span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
234
+ span.setAttribute('http.request.body.type', 'multipart');
235
+ span.setAttribute('http.request.body.note', 'File uploads not captured by design');
236
+ }
237
+ }
238
+ } catch (error) {
239
+ // Silently fail
240
+ }
241
+ },
242
+ });
243
+
100
244
  // -------- SDK --------
101
245
  const traceExporter = new OTLPTraceExporter({ url: tracesUrl, headers });
102
246
  const sdk = new NodeSDK({
103
247
  traceExporter,
104
- instrumentations: getNodeAutoInstrumentations({ ...disabledMap }),
248
+ instrumentations: [
249
+ httpInstrumentation,
250
+ ...getNodeAutoInstrumentations({
251
+ ...disabledMap,
252
+ '@opentelemetry/instrumentation-http': { enabled: false }, // We use our custom one above
253
+ }),
254
+ ],
105
255
  resource: new Resource({
106
256
  [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
107
257
  [SemanticResourceAttributes.SERVICE_INSTANCE_ID]: serviceInstanceId,
@@ -115,6 +265,9 @@ const sdk = new NodeSDK({
115
265
  try {
116
266
  await Promise.resolve(sdk.start?.());
117
267
  console.log('[securenow] OTel SDK started → %s', tracesUrl);
268
+ if (captureBody) {
269
+ console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
270
+ }
118
271
  if (String(env('SECURENOW_TEST_SPAN')) === '1') {
119
272
  const api = require('@opentelemetry/api');
120
273
  const tracer = api.trace.getTracer('securenow-smoke');