securenow 4.0.1 → 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,8 +256,18 @@ function registerSecureNow(options = {}) {
86
256
 
87
257
  console.log('[securenow] 🚀 Next.js App → service.name=%s', serviceName);
88
258
 
89
- // -------- Use @vercel/otel --------
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
+
267
+ // -------- Use @vercel/otel with enhanced configuration --------
90
268
  const { registerOTel } = require('@vercel/otel');
269
+ const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
270
+
91
271
  registerOTel({
92
272
  serviceName: serviceName,
93
273
  attributes: {
@@ -95,10 +275,138 @@ function registerSecureNow(options = {}) {
95
275
  'service.version': process.env.npm_package_version || process.env.VERCEL_GIT_COMMIT_SHA || undefined,
96
276
  'vercel.region': process.env.VERCEL_REGION || undefined,
97
277
  },
278
+ instrumentations: [
279
+ // Add HTTP instrumentation with request hooks to capture IP, headers, and body
280
+ new HttpInstrumentation({
281
+ requireParentforOutgoingSpans: false,
282
+ requireParentforIncomingSpans: false,
283
+ requestHook: async (span, request) => {
284
+ try {
285
+ // Capture client IP from various headers
286
+ const headers = request.headers || {};
287
+
288
+ // Try different header sources for IP
289
+ const clientIp =
290
+ headers['x-forwarded-for']?.split(',')[0]?.trim() ||
291
+ headers['x-real-ip'] ||
292
+ headers['cf-connecting-ip'] || // Cloudflare
293
+ headers['x-client-ip'] ||
294
+ request.socket?.remoteAddress ||
295
+ 'unknown';
296
+
297
+ // Add IP and request metadata to span
298
+ span.setAttributes({
299
+ 'http.client_ip': clientIp,
300
+ 'http.user_agent': headers['user-agent'] || 'unknown',
301
+ 'http.referer': headers['referer'] || headers['referrer'] || '',
302
+ 'http.host': headers['host'] || '',
303
+ 'http.scheme': request.socket?.encrypted ? 'https' : 'http',
304
+ 'http.forwarded_for': headers['x-forwarded-for'] || '',
305
+ 'http.real_ip': headers['x-real-ip'] || '',
306
+ 'http.request_id': headers['x-request-id'] || headers['x-trace-id'] || '',
307
+ });
308
+
309
+ // Add geographic headers if available (Vercel/Cloudflare)
310
+ if (headers['x-vercel-ip-country']) {
311
+ span.setAttributes({
312
+ 'http.geo.country': headers['x-vercel-ip-country'],
313
+ 'http.geo.region': headers['x-vercel-ip-country-region'] || '',
314
+ 'http.geo.city': headers['x-vercel-ip-city'] || '',
315
+ });
316
+ }
317
+
318
+ if (headers['cf-ipcountry']) {
319
+ span.setAttribute('http.geo.country', headers['cf-ipcountry']);
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
+ }
365
+ } catch (error) {
366
+ // Silently fail to not break the request
367
+ console.debug('[securenow] Failed to capture request metadata:', error.message);
368
+ }
369
+ },
370
+ responseHook: (span, response) => {
371
+ try {
372
+ // Add response metadata
373
+ if (response.statusCode) {
374
+ span.setAttribute('http.status_code', response.statusCode);
375
+ }
376
+ } catch (error) {
377
+ console.debug('[securenow] Failed to capture response metadata:', error.message);
378
+ }
379
+ },
380
+ }),
381
+ ],
382
+ instrumentationConfig: {
383
+ fetch: {
384
+ // Propagate context to your backend APIs
385
+ propagateContextUrls: [
386
+ /^https?:\/\/localhost/,
387
+ /^https?:\/\/.*\.vercel\.app/,
388
+ // Add your backend domains here
389
+ ],
390
+ // Optionally ignore certain URLs
391
+ ignoreUrls: [
392
+ /_next\/static/,
393
+ /_next\/image/,
394
+ /\.map$/,
395
+ ],
396
+ // Add resource name template for better span naming
397
+ resourceNameTemplate: '{http.method} {http.target}',
398
+ },
399
+ },
98
400
  });
99
401
 
100
402
  isRegistered = true;
101
403
  console.log('[securenow] ✅ OpenTelemetry started for Next.js → %s', tracesUrl);
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
+ }
102
410
 
103
411
  // Optional test span
104
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.1",
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",
@@ -53,7 +53,11 @@
53
53
  "NEXTJS-GUIDE.md",
54
54
  "NEXTJS-QUICKSTART.md",
55
55
  "CUSTOMER-GUIDE.md",
56
- "AUTO-SETUP.md"
56
+ "AUTO-SETUP.md",
57
+ "AUTOMATIC-IP-CAPTURE.md",
58
+ "REQUEST-BODY-CAPTURE.md",
59
+ "BODY-CAPTURE-QUICKSTART.md",
60
+ "REDACTION-EXAMPLES.md"
57
61
  ],
58
62
  "dependencies": {
59
63
  "@opentelemetry/api": "1.7.0",
@@ -62,6 +66,7 @@
62
66
  "@opentelemetry/instrumentation": "0.47.0",
63
67
  "@opentelemetry/instrumentation-document-load": "0.47.0",
64
68
  "@opentelemetry/instrumentation-fetch": "0.47.0",
69
+ "@opentelemetry/instrumentation-http": "^0.208.0",
65
70
  "@opentelemetry/instrumentation-user-interaction": "0.47.0",
66
71
  "@opentelemetry/instrumentation-xml-http-request": "0.47.0",
67
72
  "@opentelemetry/resources": "1.20.0",
package/postinstall.js CHANGED
@@ -211,3 +211,5 @@ if (require.main === module || process.env.npm_config_global !== 'true') {
211
211
  module.exports = { setup };
212
212
 
213
213
 
214
+
215
+
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');