securenow 4.0.2 → 4.0.5
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/AUTO-BODY-CAPTURE.md +409 -0
- package/BODY-CAPTURE-FIX.md +258 -0
- package/BODY-CAPTURE-QUICKSTART.md +147 -0
- package/CUSTOMER-GUIDE.md +23 -0
- package/EASIEST-SETUP.md +339 -0
- package/FINAL-SOLUTION.md +332 -0
- package/NEXTJS-BODY-CAPTURE-COMPARISON.md +320 -0
- package/NEXTJS-BODY-CAPTURE.md +368 -0
- package/NEXTJS-GUIDE.md +10 -0
- package/NEXTJS-QUICKSTART.md +1 -1
- package/NEXTJS-WRAPPER-APPROACH.md +411 -0
- package/QUICKSTART-BODY-CAPTURE.md +287 -0
- package/REDACTION-EXAMPLES.md +481 -0
- package/REQUEST-BODY-CAPTURE.md +575 -0
- package/SOLUTION-SUMMARY.md +309 -0
- package/cli.js +1 -1
- package/examples/instrumentation-with-auto-capture.ts +38 -0
- package/examples/nextjs-api-route-with-body-capture.ts +51 -0
- package/examples/nextjs-middleware.js +34 -0
- package/examples/nextjs-middleware.ts +34 -0
- package/nextjs-auto-capture.js +204 -0
- package/nextjs-middleware.js +178 -0
- package/nextjs-wrapper.js +155 -0
- package/nextjs.js +204 -14
- package/package.json +20 -2
- package/postinstall.js +117 -22
- package/tracing.js +154 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecureNow Next.js Middleware for Body Capture
|
|
3
|
+
*
|
|
4
|
+
* OPTIONAL: Import this in your Next.js app to enable automatic body capture
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
*
|
|
8
|
+
* Create middleware.ts in your Next.js app root:
|
|
9
|
+
*
|
|
10
|
+
* export { middleware } from 'securenow/nextjs-middleware';
|
|
11
|
+
* export const config = {
|
|
12
|
+
* matcher: '/api/:path*', // Apply to API routes only
|
|
13
|
+
* };
|
|
14
|
+
*
|
|
15
|
+
* That's it! Bodies are now captured with sensitive data redacted.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { trace, context, SpanStatusCode } = require('@opentelemetry/api');
|
|
19
|
+
|
|
20
|
+
// Default sensitive fields to redact
|
|
21
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
22
|
+
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
23
|
+
'access_token', 'auth', 'credentials', 'mysql_pwd', 'stripeToken',
|
|
24
|
+
'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Redact sensitive fields from an object
|
|
29
|
+
*/
|
|
30
|
+
function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
31
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
32
|
+
|
|
33
|
+
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
34
|
+
|
|
35
|
+
for (const key in redacted) {
|
|
36
|
+
const lowerKey = key.toLowerCase();
|
|
37
|
+
|
|
38
|
+
if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
|
|
39
|
+
redacted[key] = '[REDACTED]';
|
|
40
|
+
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
41
|
+
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return redacted;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Redact sensitive data from GraphQL query strings
|
|
50
|
+
*/
|
|
51
|
+
function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
52
|
+
if (!query || typeof query !== 'string') return query;
|
|
53
|
+
|
|
54
|
+
let redacted = query;
|
|
55
|
+
|
|
56
|
+
sensitiveFields.forEach(field => {
|
|
57
|
+
const patterns = [
|
|
58
|
+
new RegExp(`(${field}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
|
|
59
|
+
new RegExp(`(${field}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
patterns.forEach(pattern => {
|
|
63
|
+
redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
|
|
64
|
+
return suffix ? `${prefix}[REDACTED]${suffix}` : `${prefix}[REDACTED]`;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return redacted;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Next.js Middleware for Body Capture
|
|
74
|
+
*/
|
|
75
|
+
async function middleware(request) {
|
|
76
|
+
const { NextResponse } = require('next/server');
|
|
77
|
+
|
|
78
|
+
// Only capture for POST/PUT/PATCH
|
|
79
|
+
if (!['POST', 'PUT', 'PATCH'].includes(request.method)) {
|
|
80
|
+
return NextResponse.next();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get or create a tracer
|
|
84
|
+
const tracer = trace.getTracer('securenow-middleware');
|
|
85
|
+
let span = trace.getActiveSpan();
|
|
86
|
+
let createdSpan = false;
|
|
87
|
+
|
|
88
|
+
// If no active span, create one for this middleware
|
|
89
|
+
if (!span) {
|
|
90
|
+
const url = new URL(request.url);
|
|
91
|
+
span = tracer.startSpan(`middleware ${request.method} ${url.pathname}`);
|
|
92
|
+
createdSpan = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const contentType = request.headers.get('content-type') || '';
|
|
97
|
+
const maxBodySize = parseInt(process.env.SECURENOW_MAX_BODY_SIZE || '10240');
|
|
98
|
+
const customSensitiveFields = (process.env.SECURENOW_SENSITIVE_FIELDS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
99
|
+
const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
|
|
100
|
+
|
|
101
|
+
// Only capture supported types
|
|
102
|
+
if (contentType.includes('application/json') ||
|
|
103
|
+
contentType.includes('application/graphql')) {
|
|
104
|
+
|
|
105
|
+
// Clone the request to read body without consuming the original
|
|
106
|
+
const clonedRequest = request.clone();
|
|
107
|
+
const bodyText = await clonedRequest.text();
|
|
108
|
+
|
|
109
|
+
if (bodyText.length <= maxBodySize) {
|
|
110
|
+
let redactedBody;
|
|
111
|
+
|
|
112
|
+
if (contentType.includes('application/graphql')) {
|
|
113
|
+
// GraphQL: redact query string
|
|
114
|
+
redactedBody = redactGraphQLQuery(bodyText, allSensitiveFields);
|
|
115
|
+
} else {
|
|
116
|
+
// JSON: parse and redact
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(bodyText);
|
|
119
|
+
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
120
|
+
redactedBody = JSON.stringify(redacted);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
redactedBody = bodyText; // Keep as-is if parse fails
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
span.setAttributes({
|
|
127
|
+
'http.request.body': redactedBody.substring(0, maxBodySize),
|
|
128
|
+
'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
|
|
129
|
+
'http.request.body.size': bodyText.length,
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
|
|
133
|
+
}
|
|
134
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
135
|
+
const clonedRequest = request.clone();
|
|
136
|
+
const formData = await clonedRequest.formData();
|
|
137
|
+
const parsed = Object.fromEntries(formData);
|
|
138
|
+
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
139
|
+
|
|
140
|
+
span.setAttributes({
|
|
141
|
+
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
142
|
+
'http.request.body.type': 'form',
|
|
143
|
+
'http.request.body.size': JSON.stringify(parsed).length,
|
|
144
|
+
});
|
|
145
|
+
} else if (contentType.includes('multipart/form-data')) {
|
|
146
|
+
span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
|
|
147
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// End span if we created it
|
|
151
|
+
if (createdSpan) {
|
|
152
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
153
|
+
span.end();
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// Silently fail - don't break the request
|
|
157
|
+
console.debug('[securenow] Body capture failed:', error.message);
|
|
158
|
+
|
|
159
|
+
// End span with error if we created it
|
|
160
|
+
if (createdSpan && span) {
|
|
161
|
+
span.setStatus({
|
|
162
|
+
code: SpanStatusCode.ERROR,
|
|
163
|
+
message: error.message
|
|
164
|
+
});
|
|
165
|
+
span.end();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return NextResponse.next();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
middleware,
|
|
174
|
+
redactSensitiveData,
|
|
175
|
+
redactGraphQLQuery,
|
|
176
|
+
DEFAULT_SENSITIVE_FIELDS,
|
|
177
|
+
};
|
|
178
|
+
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
|
|
20
|
+
// Default sensitive fields to redact
|
|
21
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
22
|
+
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
23
|
+
'access_token', 'auth', 'credentials', 'mysql_pwd', 'stripeToken',
|
|
24
|
+
'card', 'cardnumber', 'ccv', 'cvc', 'cvv', 'ssn', 'pin',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Redact sensitive fields from an object
|
|
29
|
+
*/
|
|
30
|
+
function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
31
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
32
|
+
|
|
33
|
+
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
34
|
+
|
|
35
|
+
for (const key in redacted) {
|
|
36
|
+
const lowerKey = key.toLowerCase();
|
|
37
|
+
|
|
38
|
+
if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
|
|
39
|
+
redacted[key] = '[REDACTED]';
|
|
40
|
+
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
41
|
+
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return redacted;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Capture body from Request object (clone to avoid consuming)
|
|
50
|
+
*/
|
|
51
|
+
async function captureRequestBody(request) {
|
|
52
|
+
const captureBody = String(process.env.SECURENOW_CAPTURE_BODY) === '1' ||
|
|
53
|
+
String(process.env.SECURENOW_CAPTURE_BODY).toLowerCase() === 'true';
|
|
54
|
+
|
|
55
|
+
if (!captureBody) return;
|
|
56
|
+
if (!['POST', 'PUT', 'PATCH'].includes(request.method)) return;
|
|
57
|
+
|
|
58
|
+
const span = trace.getActiveSpan();
|
|
59
|
+
if (!span) return;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const contentType = request.headers.get('content-type') || '';
|
|
63
|
+
const maxBodySize = parseInt(process.env.SECURENOW_MAX_BODY_SIZE || '10240');
|
|
64
|
+
const customSensitiveFields = (process.env.SECURENOW_SENSITIVE_FIELDS || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
65
|
+
const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
|
|
66
|
+
|
|
67
|
+
// Only for supported types
|
|
68
|
+
if (!contentType.includes('application/json') &&
|
|
69
|
+
!contentType.includes('application/graphql') &&
|
|
70
|
+
!contentType.includes('application/x-www-form-urlencoded')) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Clone to avoid consuming the original
|
|
75
|
+
const cloned = request.clone();
|
|
76
|
+
const bodyText = await cloned.text();
|
|
77
|
+
|
|
78
|
+
if (bodyText.length > maxBodySize) {
|
|
79
|
+
span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
|
|
80
|
+
span.setAttribute('http.request.body.size', bodyText.length);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Parse and redact based on type
|
|
85
|
+
let redacted;
|
|
86
|
+
if (contentType.includes('application/json') || contentType.includes('application/graphql')) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(bodyText);
|
|
89
|
+
redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
90
|
+
span.setAttributes({
|
|
91
|
+
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
92
|
+
'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
|
|
93
|
+
'http.request.body.size': bodyText.length,
|
|
94
|
+
});
|
|
95
|
+
} catch (e) {
|
|
96
|
+
span.setAttribute('http.request.body', '[INVALID JSON]');
|
|
97
|
+
}
|
|
98
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
99
|
+
const params = new URLSearchParams(bodyText);
|
|
100
|
+
const parsed = Object.fromEntries(params);
|
|
101
|
+
redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
102
|
+
span.setAttributes({
|
|
103
|
+
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
104
|
+
'http.request.body.type': 'form',
|
|
105
|
+
'http.request.body.size': bodyText.length,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// Silently fail - never block the request
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Wrap a Next.js API route handler to capture body
|
|
115
|
+
* This is OPTIONAL and NON-INVASIVE - only use on routes where you want body capture
|
|
116
|
+
*/
|
|
117
|
+
function withSecureNow(handler) {
|
|
118
|
+
return async function wrappedHandler(request, context) {
|
|
119
|
+
// Capture body asynchronously (doesn't block handler)
|
|
120
|
+
captureRequestBody(request).catch(() => {
|
|
121
|
+
// Ignore errors silently
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Call original handler immediately - no blocking!
|
|
125
|
+
return handler(request, context);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Alternative: Auto-capture wrapper that tries to capture AFTER handler runs
|
|
131
|
+
* This is even safer as it never interferes with the handler logic
|
|
132
|
+
*/
|
|
133
|
+
function withSecureNowAsync(handler) {
|
|
134
|
+
return async function wrappedHandler(request, context) {
|
|
135
|
+
// Try to capture body in background (non-blocking)
|
|
136
|
+
const capturePromise = captureRequestBody(request);
|
|
137
|
+
|
|
138
|
+
// Run handler
|
|
139
|
+
const response = await handler(request, context);
|
|
140
|
+
|
|
141
|
+
// Wait for capture to finish (but don't fail if it doesn't)
|
|
142
|
+
await capturePromise.catch(() => {});
|
|
143
|
+
|
|
144
|
+
return response;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
withSecureNow,
|
|
150
|
+
withSecureNowAsync,
|
|
151
|
+
captureRequestBody,
|
|
152
|
+
redactSensitiveData,
|
|
153
|
+
DEFAULT_SENSITIVE_FIELDS,
|
|
154
|
+
};
|
|
155
|
+
|
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,18 @@ 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
|
|
279
|
+
// Add HTTP instrumentation with request hooks to capture IP, headers
|
|
280
|
+
// NOTE: Body capture is DISABLED at this level for Next.js to prevent conflicts
|
|
102
281
|
new HttpInstrumentation({
|
|
103
282
|
requireParentforOutgoingSpans: false,
|
|
104
283
|
requireParentforIncomingSpans: false,
|
|
284
|
+
// Ignore request/response bodies to prevent Next.js conflicts
|
|
285
|
+
ignoreIncomingRequestHook: (request) => {
|
|
286
|
+
// Never ignore - we want to trace all requests
|
|
287
|
+
return false;
|
|
288
|
+
},
|
|
105
289
|
requestHook: (span, request) => {
|
|
290
|
+
// SYNCHRONOUS ONLY - no async operations to avoid timing issues
|
|
106
291
|
try {
|
|
107
292
|
// Capture client IP from various headers
|
|
108
293
|
const headers = request.headers || {};
|
|
@@ -116,7 +301,7 @@ function registerSecureNow(options = {}) {
|
|
|
116
301
|
request.socket?.remoteAddress ||
|
|
117
302
|
'unknown';
|
|
118
303
|
|
|
119
|
-
// Add IP and request metadata to span
|
|
304
|
+
// Add IP and request metadata to span (synchronously)
|
|
120
305
|
span.setAttributes({
|
|
121
306
|
'http.client_ip': clientIp,
|
|
122
307
|
'http.user_agent': headers['user-agent'] || 'unknown',
|
|
@@ -140,21 +325,21 @@ function registerSecureNow(options = {}) {
|
|
|
140
325
|
if (headers['cf-ipcountry']) {
|
|
141
326
|
span.setAttribute('http.geo.country', headers['cf-ipcountry']);
|
|
142
327
|
}
|
|
328
|
+
|
|
329
|
+
// -------- Request Body NOT captured at HTTP instrumentation level --------
|
|
330
|
+
// IMPORTANT: Do NOT attempt to read request.body or listen to 'data' events
|
|
331
|
+
// Next.js manages request streams internally and reading them here causes:
|
|
332
|
+
// - "Response body object should not be disturbed or locked" errors
|
|
333
|
+
// - Hanging requests that never complete
|
|
334
|
+
// - Body data unavailable to Next.js route handlers
|
|
335
|
+
//
|
|
336
|
+
// Body capture must be done in Next.js middleware using request.clone()
|
|
337
|
+
|
|
143
338
|
} catch (error) {
|
|
144
339
|
// Silently fail to not break the request
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
responseHook: (span, response) => {
|
|
149
|
-
try {
|
|
150
|
-
// Add response metadata
|
|
151
|
-
if (response.statusCode) {
|
|
152
|
-
span.setAttribute('http.status_code', response.statusCode);
|
|
153
|
-
}
|
|
154
|
-
} catch (error) {
|
|
155
|
-
console.debug('[securenow] Failed to capture response metadata:', error.message);
|
|
340
|
+
// Do not log in production to avoid noise
|
|
156
341
|
}
|
|
157
|
-
}
|
|
342
|
+
}
|
|
158
343
|
}),
|
|
159
344
|
],
|
|
160
345
|
instrumentationConfig: {
|
|
@@ -180,6 +365,11 @@ function registerSecureNow(options = {}) {
|
|
|
180
365
|
isRegistered = true;
|
|
181
366
|
console.log('[securenow] ✅ OpenTelemetry started for Next.js → %s', tracesUrl);
|
|
182
367
|
console.log('[securenow] 📊 Auto-capturing: IP, User-Agent, Headers, Geographic data');
|
|
368
|
+
console.log('[securenow] ⚠️ Body capture DISABLED at HTTP instrumentation level (prevents Next.js conflicts)');
|
|
369
|
+
if (captureBody) {
|
|
370
|
+
console.log('[securenow] 💡 SECURENOW_CAPTURE_BODY is set but has no effect in Next.js HTTP instrumentation');
|
|
371
|
+
console.log('[securenow] 💡 Body capture must be implemented differently for Next.js (coming soon)');
|
|
372
|
+
}
|
|
183
373
|
|
|
184
374
|
// Optional test span
|
|
185
375
|
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.
|
|
3
|
+
"version": "4.0.5",
|
|
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",
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
"./register": "./register.js",
|
|
34
34
|
"./tracing": "./tracing.js",
|
|
35
35
|
"./nextjs": "./nextjs.js",
|
|
36
|
+
"./nextjs-auto-capture": "./nextjs-auto-capture.js",
|
|
37
|
+
"./nextjs-middleware": "./nextjs-middleware.js",
|
|
38
|
+
"./nextjs-wrapper": "./nextjs-wrapper.js",
|
|
36
39
|
"./nextjs-webpack-config": "./nextjs-webpack-config.js",
|
|
37
40
|
"./register-vite": "./register-vite.js",
|
|
38
41
|
"./web-vite": {
|
|
@@ -44,6 +47,9 @@
|
|
|
44
47
|
"register.js",
|
|
45
48
|
"tracing.js",
|
|
46
49
|
"nextjs.js",
|
|
50
|
+
"nextjs-auto-capture.js",
|
|
51
|
+
"nextjs-middleware.js",
|
|
52
|
+
"nextjs-wrapper.js",
|
|
47
53
|
"cli.js",
|
|
48
54
|
"postinstall.js",
|
|
49
55
|
"register-vite.js",
|
|
@@ -54,7 +60,19 @@
|
|
|
54
60
|
"NEXTJS-QUICKSTART.md",
|
|
55
61
|
"CUSTOMER-GUIDE.md",
|
|
56
62
|
"AUTO-SETUP.md",
|
|
57
|
-
"AUTOMATIC-IP-CAPTURE.md"
|
|
63
|
+
"AUTOMATIC-IP-CAPTURE.md",
|
|
64
|
+
"REQUEST-BODY-CAPTURE.md",
|
|
65
|
+
"BODY-CAPTURE-QUICKSTART.md",
|
|
66
|
+
"REDACTION-EXAMPLES.md",
|
|
67
|
+
"NEXTJS-BODY-CAPTURE.md",
|
|
68
|
+
"NEXTJS-BODY-CAPTURE-COMPARISON.md",
|
|
69
|
+
"NEXTJS-WRAPPER-APPROACH.md",
|
|
70
|
+
"QUICKSTART-BODY-CAPTURE.md",
|
|
71
|
+
"AUTO-BODY-CAPTURE.md",
|
|
72
|
+
"EASIEST-SETUP.md",
|
|
73
|
+
"SOLUTION-SUMMARY.md",
|
|
74
|
+
"BODY-CAPTURE-FIX.md",
|
|
75
|
+
"FINAL-SOLUTION.md"
|
|
58
76
|
],
|
|
59
77
|
"dependencies": {
|
|
60
78
|
"@opentelemetry/api": "1.7.0",
|