securenow 8.23.0 → 8.25.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/NPM_README.md +730 -333
- package/README.md +91 -28
- package/SKILL-API.md +657 -530
- package/SKILL-CLI.md +293 -663
- package/app-config.js +90 -15
- package/cidr.js +139 -20
- package/cli/credentials.js +11 -0
- package/cli/init.js +121 -17
- package/cli/utils.js +26 -14
- package/cli.js +9 -6
- package/console-instrumentation.js +6 -0
- package/crash-capture.js +13 -1
- package/esm-detect.js +5 -1
- package/fastify-hooks.js +67 -0
- package/firewall-cloud.js +81 -32
- package/firewall-iptables.js +123 -62
- package/firewall-only.js +2 -0
- package/firewall-tcp.js +19 -5
- package/firewall.js +21 -1
- package/framework-routes.js +222 -0
- package/log-format.js +2 -0
- package/log-redaction.js +135 -0
- package/mcp/catalog.js +14 -9
- package/next-route.js +61 -0
- package/nextjs-auto-capture.d.ts +5 -2
- package/nextjs-auto-capture.js +77 -220
- package/nextjs-middleware.js +8 -4
- package/nextjs-wrapper.js +8 -4
- package/nextjs.d.ts +35 -67
- package/nextjs.js +75 -182
- package/nuxt-server-plugin.mjs +30 -478
- package/nuxt.mjs +15 -14
- package/package.json +8 -2
- package/pm2-env.js +47 -0
- package/register-vite.js +6 -2
- package/register.d.ts +27 -34
- package/request-capture.js +492 -0
- package/resolve-ip.js +23 -5
- package/sessions.d.ts +27 -0
- package/sessions.js +71 -10
- package/tracing.d.ts +40 -121
- package/tracing.js +39 -447
package/tracing.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// When the SDK does not start, the module still exports its API: getLogger() returns null, as it
|
|
4
|
+
// does with logging disabled, so app code that calls it keeps working under SECURENOW_DISABLED=1.
|
|
5
|
+
const NOT_STARTED = { loggerProvider: null, getLogger: () => null, isLoggingEnabled: () => false };
|
|
3
6
|
// Do nothing inside Node's internal ESM-hooks thread (see internal-thread.js).
|
|
4
|
-
if (require('./internal-thread').inInternalThread()) return;
|
|
7
|
+
if (require('./internal-thread').inInternalThread()) { module.exports = NOT_STARTED; return; }
|
|
5
8
|
// SECURENOW_DISABLED=1 turns the SDK off for this process (test runs).
|
|
6
|
-
if (require('./sdk-disabled').sdkDisabled('tracing')) return;
|
|
9
|
+
if (require('./sdk-disabled').sdkDisabled('tracing')) { module.exports = NOT_STARTED; return; }
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Preload with: node --require securenow/register app.js
|
|
@@ -44,293 +47,9 @@ function createResource(attributes) {
|
|
|
44
47
|
throw new Error('Unsupported @opentelemetry/resources version');
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
const {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// OTP/code aliases use exact normalized matching; other fields are matched
|
|
51
|
-
// substring-wise against lowercased keys (see redactSensitiveData),
|
|
52
|
-
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
53
|
-
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
54
|
-
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
55
|
-
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
56
|
-
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
57
|
-
const DEFAULT_SENSITIVE_FIELDS = [
|
|
58
|
-
...OTP_SENSITIVE_FIELDS,
|
|
59
|
-
// credentials / auth
|
|
60
|
-
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
61
|
-
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
62
|
-
'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
|
|
63
|
-
'cookie', 'set-cookie',
|
|
64
|
-
// financial
|
|
65
|
-
'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
|
|
66
|
-
'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
|
|
67
|
-
// PII
|
|
68
|
-
'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
|
|
69
|
-
'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
|
|
70
|
-
'passport', 'license',
|
|
71
|
-
];
|
|
72
|
-
|
|
73
|
-
// Conservative value-shape redactors. Key-name matching misses secrets that
|
|
74
|
-
// land in free-form string values (GraphQL bodies, message fields, etc.), so
|
|
75
|
-
// as a second layer we scrub string VALUES that *look like* a secret/PII.
|
|
76
|
-
// These are intentionally precise/bounded so they don't garble normal prose,
|
|
77
|
-
// and they only ever transform captured telemetry strings (read-only) — never
|
|
78
|
-
// the actual request/response stream. Compiled once at module load.
|
|
79
|
-
const VALUE_REDACTORS = [
|
|
80
|
-
// JWT: three base64url segments. Anchored to the eyJ header so it won't
|
|
81
|
-
// match arbitrary dotted tokens.
|
|
82
|
-
{ name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
|
|
83
|
-
// Bearer/Basic auth header value embedded in a body string.
|
|
84
|
-
{ name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
|
|
85
|
-
// Stripe-style / SecureNow live+test API keys.
|
|
86
|
-
{ name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
|
|
87
|
-
// Email addresses (bounded local/domain parts).
|
|
88
|
-
{ name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
|
|
89
|
-
// Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
|
|
90
|
-
// space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
|
|
91
|
-
{ name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
// Skip the value-shape scan on very large strings to bound per-body cost.
|
|
95
|
-
const MAX_VALUE_SCAN_LENGTH = 16384;
|
|
96
|
-
|
|
97
|
-
function luhnValid(digits) {
|
|
98
|
-
let sum = 0;
|
|
99
|
-
let alt = false;
|
|
100
|
-
for (let i = digits.length - 1; i >= 0; i--) {
|
|
101
|
-
let d = digits.charCodeAt(i) - 48;
|
|
102
|
-
if (d < 0 || d > 9) return false;
|
|
103
|
-
if (alt) { d *= 2; if (d > 9) d -= 9; }
|
|
104
|
-
sum += d;
|
|
105
|
-
alt = !alt;
|
|
106
|
-
}
|
|
107
|
-
return sum % 10 === 0;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Redact obvious secret/PII shapes inside a captured string VALUE.
|
|
112
|
-
* Returns the input unchanged when nothing matches (cheap common case).
|
|
113
|
-
*/
|
|
114
|
-
function redactSensitiveValue(value) {
|
|
115
|
-
if (typeof value !== 'string' || value.length === 0) return value;
|
|
116
|
-
// Credential assignments in GraphQL/query strings need field-aware
|
|
117
|
-
// redaction even though a short numeric OTP has no unique value shape.
|
|
118
|
-
value = redactOtpQuery(redactOtpGraphQL(value));
|
|
119
|
-
if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
|
|
120
|
-
let out = value;
|
|
121
|
-
for (const r of VALUE_REDACTORS) {
|
|
122
|
-
r.re.lastIndex = 0;
|
|
123
|
-
if (r.luhn) {
|
|
124
|
-
out = out.replace(r.re, (m) => {
|
|
125
|
-
const digits = m.replace(/[ -]/g, '');
|
|
126
|
-
if (digits.length < 13 || digits.length > 19) return m;
|
|
127
|
-
return luhnValid(digits) ? '[REDACTED]' : m;
|
|
128
|
-
});
|
|
129
|
-
} else {
|
|
130
|
-
out = out.replace(r.re, '[REDACTED]');
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function escapeRegex(str) {
|
|
137
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Redact sensitive fields from an object
|
|
142
|
-
*/
|
|
143
|
-
function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
144
|
-
if (!obj || typeof obj !== 'object') return obj;
|
|
145
|
-
|
|
146
|
-
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
147
|
-
|
|
148
|
-
for (const key of Object.keys(redacted)) {
|
|
149
|
-
|
|
150
|
-
if (matchesSensitiveField(key, sensitiveFields)) {
|
|
151
|
-
redacted[key] = '[REDACTED]';
|
|
152
|
-
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
153
|
-
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
|
154
|
-
} else if (typeof redacted[key] === 'string') {
|
|
155
|
-
// Second layer: even if the key name looks benign, scrub values that
|
|
156
|
-
// *look like* a secret/PII (JWT, bearer token, API key, email, card).
|
|
157
|
-
redacted[key] = redactSensitiveValue(redacted[key]);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return redacted;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Redact sensitive data from GraphQL query strings
|
|
166
|
-
*/
|
|
167
|
-
function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
168
|
-
if (!query || typeof query !== 'string') return query;
|
|
169
|
-
|
|
170
|
-
let redacted = query;
|
|
171
|
-
|
|
172
|
-
// Redact sensitive fields in GraphQL arguments and variables
|
|
173
|
-
sensitiveFields.filter(field => field === 'otp' || !isOtpCodeField(field)).forEach(field => {
|
|
174
|
-
const escaped = escapeRegex(field);
|
|
175
|
-
const patterns = [
|
|
176
|
-
new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
|
|
177
|
-
new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n]+)`, 'gi'),
|
|
178
|
-
];
|
|
179
|
-
|
|
180
|
-
patterns.forEach(pattern => {
|
|
181
|
-
redacted = redacted.replace(pattern, (match, prefix, value, suffix) => {
|
|
182
|
-
if (suffix) {
|
|
183
|
-
return `${prefix}[REDACTED]${suffix}`;
|
|
184
|
-
} else {
|
|
185
|
-
return `${prefix}[REDACTED]`;
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
|
|
192
|
-
// body — catches values the key-name pass above can't see.
|
|
193
|
-
redacted = redactOtpGraphQL(redactSensitiveValue(redacted));
|
|
194
|
-
|
|
195
|
-
return redacted;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// -------- Multipart streaming parser --------
|
|
199
|
-
// Streams through the request without buffering file content.
|
|
200
|
-
// Only part headers and text-field values are kept in memory,
|
|
201
|
-
// so memory stays bounded (~few KB) regardless of upload size.
|
|
202
|
-
|
|
203
|
-
function extractBoundary(contentType) {
|
|
204
|
-
const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i);
|
|
205
|
-
return match ? (match[1] || match[2]) : null;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete) {
|
|
209
|
-
const boundary = extractBoundary(contentType);
|
|
210
|
-
if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return null; }
|
|
211
|
-
|
|
212
|
-
const result = { fields: Object.create(null), files: [] };
|
|
213
|
-
let totalSize = 0;
|
|
214
|
-
let buf = Buffer.alloc(0);
|
|
215
|
-
|
|
216
|
-
const MAX_PARTS = 100;
|
|
217
|
-
let partCount = 0;
|
|
218
|
-
|
|
219
|
-
const FIRST_DELIM = Buffer.from('--' + boundary);
|
|
220
|
-
const DELIM = Buffer.from('\r\n--' + boundary);
|
|
221
|
-
const HDR_END = Buffer.from('\r\n\r\n');
|
|
222
|
-
|
|
223
|
-
let initialized = false;
|
|
224
|
-
let inHeaders = true;
|
|
225
|
-
let isFile = false;
|
|
226
|
-
let fldName = '';
|
|
227
|
-
let fName = '';
|
|
228
|
-
let pCT = '';
|
|
229
|
-
let bodyBytes = 0;
|
|
230
|
-
let textVal = '';
|
|
231
|
-
|
|
232
|
-
function flushPart() {
|
|
233
|
-
if (!fldName || fldName === '__proto__' || fldName === 'constructor' || fldName === 'prototype') return;
|
|
234
|
-
if (isFile) {
|
|
235
|
-
result.files.push({ field: fldName, filename: fName, contentType: pCT || 'unknown', size: bodyBytes });
|
|
236
|
-
} else {
|
|
237
|
-
const redact = matchesSensitiveField(fldName, sensitiveFields);
|
|
238
|
-
// Name-match redaction first; otherwise scrub secret/PII *shapes* in the value.
|
|
239
|
-
result.fields[fldName] = redact
|
|
240
|
-
? '[REDACTED]'
|
|
241
|
-
: redactSensitiveValue(textVal.substring(0, maxTextFieldSize));
|
|
242
|
-
}
|
|
243
|
-
fldName = ''; bodyBytes = 0; textVal = ''; partCount++;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function drain() {
|
|
247
|
-
if (!initialized) {
|
|
248
|
-
const i = buf.indexOf(FIRST_DELIM);
|
|
249
|
-
if (i === -1) {
|
|
250
|
-
if (buf.length > FIRST_DELIM.length + 4) buf = buf.slice(buf.length - FIRST_DELIM.length - 4);
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
buf = buf.slice(i + FIRST_DELIM.length);
|
|
254
|
-
initialized = true;
|
|
255
|
-
inHeaders = true;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
let guard = 200;
|
|
259
|
-
while (buf.length > 0 && guard-- > 0 && partCount < MAX_PARTS) {
|
|
260
|
-
if (inHeaders) {
|
|
261
|
-
if (buf.length >= 2 && buf[0] === 0x2D && buf[1] === 0x2D) { buf = Buffer.alloc(0); return; }
|
|
262
|
-
if (buf.length >= 2 && buf[0] === 0x0D && buf[1] === 0x0A) { buf = buf.slice(2); continue; }
|
|
263
|
-
|
|
264
|
-
const hi = buf.indexOf(HDR_END);
|
|
265
|
-
if (hi === -1) return;
|
|
266
|
-
|
|
267
|
-
const hdr = buf.slice(0, hi).toString('latin1');
|
|
268
|
-
buf = buf.slice(hi + 4);
|
|
269
|
-
|
|
270
|
-
const nm = hdr.match(/name="([^"]+)"/);
|
|
271
|
-
const fn = hdr.match(/filename="([^"]*)"/);
|
|
272
|
-
const ct = hdr.match(/Content-Type:\s*(.+)/i);
|
|
273
|
-
fldName = nm ? nm[1] : '';
|
|
274
|
-
fName = fn ? fn[1] : '';
|
|
275
|
-
pCT = ct ? ct[1].trim() : '';
|
|
276
|
-
isFile = !!fn;
|
|
277
|
-
bodyBytes = 0;
|
|
278
|
-
textVal = '';
|
|
279
|
-
inHeaders = false;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const di = buf.indexOf(DELIM);
|
|
283
|
-
if (di === -1) {
|
|
284
|
-
const safe = Math.max(0, buf.length - DELIM.length - 2);
|
|
285
|
-
if (safe > 0) {
|
|
286
|
-
bodyBytes += safe;
|
|
287
|
-
if (!isFile && textVal.length < maxTextFieldSize) {
|
|
288
|
-
textVal += buf.slice(0, safe).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
289
|
-
}
|
|
290
|
-
buf = buf.slice(safe);
|
|
291
|
-
}
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
bodyBytes += di;
|
|
296
|
-
if (!isFile && textVal.length < maxTextFieldSize) {
|
|
297
|
-
textVal += buf.slice(0, di).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
298
|
-
}
|
|
299
|
-
flushPart();
|
|
300
|
-
buf = buf.slice(di + DELIM.length);
|
|
301
|
-
inHeaders = true;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function onData(chunk) {
|
|
306
|
-
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
307
|
-
totalSize += data.length;
|
|
308
|
-
buf = Buffer.concat([buf, data]);
|
|
309
|
-
drain();
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function onEnd() {
|
|
313
|
-
try {
|
|
314
|
-
if (!inHeaders && fldName) {
|
|
315
|
-
bodyBytes += buf.length;
|
|
316
|
-
if (!isFile) textVal += buf.toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
317
|
-
flushPart();
|
|
318
|
-
}
|
|
319
|
-
onComplete({ parsed: result, totalSize });
|
|
320
|
-
} catch (e) {
|
|
321
|
-
onComplete({ error: 'PARSE_ERROR' });
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return { onData, onEnd };
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFieldSize, onComplete) {
|
|
329
|
-
const collector = createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete);
|
|
330
|
-
if (!collector) return;
|
|
331
|
-
request.on('data', collector.onData);
|
|
332
|
-
request.on('end', collector.onEnd);
|
|
333
|
-
}
|
|
50
|
+
const { withOtpRedactionExporter } = require('./otp-redaction');
|
|
51
|
+
// Header and body capture with redaction, shared with the Next.js integration.
|
|
52
|
+
const { createRequestCapture } = require('./request-capture');
|
|
334
53
|
|
|
335
54
|
// -------- ESM detection --------
|
|
336
55
|
// register.js auto-registers the hook via module.register() on Node >=20.6.
|
|
@@ -382,7 +101,9 @@ const baseName = rawBase || null;
|
|
|
382
101
|
// suffix breaks routing. config.runtime.noUuid can override.
|
|
383
102
|
const noUuid = appConfig.resolveNoUuid();
|
|
384
103
|
const strict = appConfig.boolConfig('runtime.strict', false);
|
|
385
|
-
|
|
104
|
+
// Fork and cluster workers both count: in cluster mode PM2's details are only in pm2_env at preload time.
|
|
105
|
+
const pm2Process = require('./pm2-env').readPm2Env();
|
|
106
|
+
const inPm2Cluster = !!pm2Process;
|
|
386
107
|
|
|
387
108
|
// Fail fast in cluster if base is missing (no more "free" names)
|
|
388
109
|
if (!baseName && inPm2Cluster && strict) {
|
|
@@ -405,16 +126,25 @@ const instancePrefix = baseName || 'securenow';
|
|
|
405
126
|
const serviceInstanceId = `${instancePrefix}-${randomUUID()}`;
|
|
406
127
|
|
|
407
128
|
// Loud line per worker to prove what was used
|
|
408
|
-
console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s → service.name=%s instance.id=%s',
|
|
129
|
+
console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s credentials=%s → service.name=%s instance.id=%s',
|
|
409
130
|
process.pid,
|
|
410
131
|
JSON.stringify(baseName),
|
|
411
132
|
JSON.stringify(endpointBase),
|
|
412
|
-
|
|
133
|
+
appConfig.resolveApiKey() ? 'set' : 'missing',
|
|
134
|
+
appConfig.describeCredentialsSource(),
|
|
413
135
|
serviceName,
|
|
414
136
|
serviceInstanceId
|
|
415
137
|
);
|
|
138
|
+
if (pm2Process && !resolvedApp.appKey) {
|
|
139
|
+
console.warn('[securenow] PM2 process %s: no .securenow/runtime.json in %s and no SECURENOW_CREDENTIALS. SecureNow sends nothing and blocks nothing in this process.',
|
|
140
|
+
pm2Process.name || pm2Process.id || process.pid, pm2Process.cwd || '(unknown app folder)');
|
|
141
|
+
}
|
|
416
142
|
|
|
417
143
|
// -------- instrumentations --------
|
|
144
|
+
// The OpenTelemetry bundle no longer ships a Fastify instrumentation: without these hooks a
|
|
145
|
+
// Fastify request span had no http.route and a failing handler left no exception event.
|
|
146
|
+
require('./fastify-hooks').installFastifyHooks();
|
|
147
|
+
|
|
418
148
|
const disabledMap = {};
|
|
419
149
|
for (const n of appConfig.listConfig('otel.disableInstrumentations')) {
|
|
420
150
|
disabledMap[n] = { enabled: false };
|
|
@@ -432,143 +162,13 @@ const frameworkInstrumentationConfig = appConfig.boolConfig('otel.middlewareSpan
|
|
|
432
162
|
|
|
433
163
|
// -------- Body Capture Configuration --------
|
|
434
164
|
// Opt-out defaults: set config.capture.body=false to disable.
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
// Credential-bearing request headers (cookies, auth, CSRF/XSRF, session, API keys, and any
|
|
443
|
-
// configured capture.sensitiveFields) are redacted by name before capture.
|
|
444
|
-
const isSensitiveHeaderName = require('./header-redaction').createHeaderNameRedactor(customSensitiveFields);
|
|
445
|
-
|
|
446
|
-
const BODY_CAPTURE_PATCH = Symbol.for('securenow.bodyCapture.emitPatch');
|
|
447
|
-
|
|
448
|
-
function installRequestBodyObserver(span, request, contentType) {
|
|
449
|
-
if (!request || request[BODY_CAPTURE_PATCH]) return;
|
|
450
|
-
|
|
451
|
-
const normalizedContentType = String(contentType || '').toLowerCase();
|
|
452
|
-
const isStructuredBody = captureBody && (
|
|
453
|
-
normalizedContentType.includes('application/json') ||
|
|
454
|
-
normalizedContentType.includes('application/graphql') ||
|
|
455
|
-
normalizedContentType.includes('application/x-www-form-urlencoded')
|
|
456
|
-
);
|
|
457
|
-
const isMultipartBody = normalizedContentType.includes('multipart/form-data');
|
|
458
|
-
|
|
459
|
-
if (!isStructuredBody && !isMultipartBody) return;
|
|
460
|
-
|
|
461
|
-
if (isMultipartBody && !captureMultipart) {
|
|
462
|
-
span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
|
|
463
|
-
span.setAttribute('http.request.body.type', 'multipart');
|
|
464
|
-
span.setAttribute('http.request.body.note', 'Multipart capture disabled by config.capture.multipart=false');
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
let chunks = [];
|
|
469
|
-
let size = 0;
|
|
470
|
-
let structuredDone = false;
|
|
471
|
-
|
|
472
|
-
const multipartCollector = isMultipartBody && captureMultipart
|
|
473
|
-
? createMultipartMetaCollector(normalizedContentType, allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
|
|
474
|
-
try {
|
|
475
|
-
if (error === 'BOUNDARY_NOT_FOUND') {
|
|
476
|
-
span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
|
|
477
|
-
span.setAttribute('http.request.body.type', 'multipart');
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
if (error) {
|
|
481
|
-
span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
|
|
482
|
-
span.setAttribute('http.request.body.type', 'multipart');
|
|
483
|
-
span.setAttribute('http.request.body.parse_error', true);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
span.setAttributes({
|
|
487
|
-
'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
|
|
488
|
-
'http.request.body.type': 'multipart',
|
|
489
|
-
'http.request.body.size': totalSize,
|
|
490
|
-
'http.request.body.fields_count': Object.keys(parsed.fields).length,
|
|
491
|
-
'http.request.body.files_count': parsed.files.length,
|
|
492
|
-
});
|
|
493
|
-
} catch (e) {
|
|
494
|
-
span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
|
|
495
|
-
span.setAttribute('http.request.body.type', 'multipart');
|
|
496
|
-
span.setAttribute('http.request.body.parse_error', true);
|
|
497
|
-
}
|
|
498
|
-
})
|
|
499
|
-
: null;
|
|
500
|
-
|
|
501
|
-
if (isMultipartBody && captureMultipart && !multipartCollector) return;
|
|
502
|
-
|
|
503
|
-
function finishStructuredCapture() {
|
|
504
|
-
if (!isStructuredBody || structuredDone) return;
|
|
505
|
-
structuredDone = true;
|
|
506
|
-
|
|
507
|
-
if (size > maxBodySize) {
|
|
508
|
-
span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
|
|
509
|
-
span.setAttribute('http.request.body.size', size);
|
|
510
|
-
chunks = [];
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (chunks.length === 0) return;
|
|
515
|
-
|
|
516
|
-
const body = Buffer.concat(chunks).toString('utf8');
|
|
517
|
-
chunks = [];
|
|
518
|
-
|
|
519
|
-
try {
|
|
520
|
-
if (normalizedContentType.includes('application/graphql')) {
|
|
521
|
-
const redacted = redactGraphQLQuery(body, allSensitiveFields);
|
|
522
|
-
span.setAttributes({
|
|
523
|
-
'http.request.body': redacted.substring(0, maxBodySize),
|
|
524
|
-
'http.request.body.type': 'graphql',
|
|
525
|
-
'http.request.body.size': size,
|
|
526
|
-
});
|
|
527
|
-
} else if (normalizedContentType.includes('application/json')) {
|
|
528
|
-
const parsed = JSON.parse(body);
|
|
529
|
-
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
530
|
-
span.setAttributes({
|
|
531
|
-
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
532
|
-
'http.request.body.type': 'json',
|
|
533
|
-
'http.request.body.size': size,
|
|
534
|
-
});
|
|
535
|
-
} else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
|
|
536
|
-
const parsed = Object.fromEntries(new URLSearchParams(body));
|
|
537
|
-
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
538
|
-
span.setAttributes({
|
|
539
|
-
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
540
|
-
'http.request.body.type': 'form',
|
|
541
|
-
'http.request.body.size': size,
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
} catch (e) {
|
|
545
|
-
span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
|
|
546
|
-
span.setAttribute('http.request.body.parse_error', true);
|
|
547
|
-
span.setAttribute('http.request.body.size', size);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const originalEmit = request.emit;
|
|
552
|
-
request[BODY_CAPTURE_PATCH] = true;
|
|
553
|
-
request.emit = function securenowObservedEmit(event, ...args) {
|
|
554
|
-
try {
|
|
555
|
-
if (event === 'data' && args.length > 0) {
|
|
556
|
-
const chunk = Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]);
|
|
557
|
-
if (isStructuredBody) {
|
|
558
|
-
size += chunk.length;
|
|
559
|
-
if (size <= maxBodySize) chunks.push(chunk);
|
|
560
|
-
}
|
|
561
|
-
if (multipartCollector) multipartCollector.onData(chunk);
|
|
562
|
-
} else if (event === 'end') {
|
|
563
|
-
finishStructuredCapture();
|
|
564
|
-
if (multipartCollector) multipartCollector.onEnd();
|
|
565
|
-
}
|
|
566
|
-
} catch (_) {
|
|
567
|
-
// Body capture must never interfere with the application request stream.
|
|
568
|
-
}
|
|
569
|
-
return originalEmit.apply(this, [event, ...args]);
|
|
570
|
-
};
|
|
571
|
-
}
|
|
165
|
+
const requestCapture = createRequestCapture({
|
|
166
|
+
captureBody: appConfig.boolConfig('capture.body', true),
|
|
167
|
+
captureMultipart: appConfig.boolConfig('capture.multipart', true),
|
|
168
|
+
maxBodySize: appConfig.numberConfig('capture.maxBodySize', 10240, 1024),
|
|
169
|
+
customSensitiveFields: appConfig.listConfig('capture.sensitiveFields'),
|
|
170
|
+
});
|
|
171
|
+
const { captureBody, captureMultipart, maxBodySize, allSensitiveFields } = requestCapture;
|
|
572
172
|
|
|
573
173
|
// -------- Trusted proxy IP resolution --------
|
|
574
174
|
const { resolveClientIpWithDetails } = require('./resolve-ip');
|
|
@@ -608,22 +208,8 @@ const httpInstrumentation = new HttpInstrumentation(withLegacyHttpSemconv({
|
|
|
608
208
|
}
|
|
609
209
|
}
|
|
610
210
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
for (const [k, v] of Object.entries(request.headers)) {
|
|
614
|
-
if (isSensitiveHeaderName(k)) { safe[k] = '[REDACTED]'; continue; }
|
|
615
|
-
safe[k] = redactSensitiveValue(redactOtpQuery(typeof v === 'string' ? v : String(v))).substring(0, 500);
|
|
616
|
-
}
|
|
617
|
-
const serialized = JSON.stringify(safe);
|
|
618
|
-
if (serialized.length <= 8192) {
|
|
619
|
-
span.setAttribute('http.request.headers', serialized);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if ((captureBody || captureMultipart) && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
|
|
624
|
-
const contentType = request.headers['content-type'] || '';
|
|
625
|
-
installRequestBodyObserver(span, request, contentType);
|
|
626
|
-
}
|
|
211
|
+
requestCapture.captureHeaders(span, request);
|
|
212
|
+
requestCapture.observeBody(span, request);
|
|
627
213
|
} catch (error) {
|
|
628
214
|
// Silently fail
|
|
629
215
|
}
|
|
@@ -656,7 +242,8 @@ if (loggingEnabled) {
|
|
|
656
242
|
const batchLogProcessor = new BatchLogRecordProcessor({ exporter: logExporter });
|
|
657
243
|
loggerProvider = new LoggerProvider({
|
|
658
244
|
resource: sharedResource,
|
|
659
|
-
|
|
245
|
+
// Secret shapes in log lines are redacted before export (log-redaction.js).
|
|
246
|
+
processors: require('./log-redaction').logProcessors(appConfig, batchLogProcessor),
|
|
660
247
|
});
|
|
661
248
|
|
|
662
249
|
// Auto-patch console.* so every log/warn/error becomes an OTel log record
|
|
@@ -749,7 +336,8 @@ function _reportSuppressedOtlpError(kind, err, origin) {
|
|
|
749
336
|
// app has its own handler, the error is recorded and that handler decides.
|
|
750
337
|
require('./crash-capture').installCrashCapture({
|
|
751
338
|
getLogger: () => (loggerProvider ? loggerProvider.getLogger('securenow.crash', '1.0.0') : null),
|
|
752
|
-
|
|
339
|
+
// Spans still in the batch processor are usually the requests that led to the crash.
|
|
340
|
+
flush: () => Promise.all([loggerProvider ? loggerProvider.forceFlush() : undefined, require('./crash-capture').flushTracerProvider()]),
|
|
753
341
|
isSdkError: (e) => _isOtlpTransientError(e) && (_looksLikeOtlpStack(e) || _looksLikeConfiguredOtlpEndpoint(e)),
|
|
754
342
|
onSdkError: _reportSuppressedOtlpError,
|
|
755
343
|
});
|
|
@@ -764,6 +352,8 @@ const sdk = new NodeSDK({
|
|
|
764
352
|
instrumentations: [
|
|
765
353
|
httpInstrumentation,
|
|
766
354
|
...(disabledMap['@opentelemetry/instrumentation-mongodb'] ? [] : [new MongoDBInstrumentation()]),
|
|
355
|
+
// http.route for Hono, h3, Feathers and Polka (no instrumentation in the OpenTelemetry bundle).
|
|
356
|
+
...(disabledMap['securenow-framework-routes'] ? [] : [new (require('./framework-routes').SecureNowRouteInstrumentation)()]),
|
|
767
357
|
...getNodeAutoInstrumentations({
|
|
768
358
|
...frameworkInstrumentationConfig,
|
|
769
359
|
...disabledMap,
|
|
@@ -820,6 +410,8 @@ const sdk = new NodeSDK({
|
|
|
820
410
|
aws: firewallOptions.aws,
|
|
821
411
|
gcp: firewallOptions.gcp,
|
|
822
412
|
});
|
|
413
|
+
// Zero-code session revocation / session-theft capture (config.sessions.guard / .capture).
|
|
414
|
+
require('./sessions').installHttpGuard(appConfig.resolveSessionsOptions());
|
|
823
415
|
}
|
|
824
416
|
} catch (e) {
|
|
825
417
|
console.error('[securenow] OTel start failed:', e && e.stack || e);
|