securenow 7.7.10 → 7.7.12

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 CHANGED
@@ -157,7 +157,8 @@ npx securenow login --global
157
157
  # Check who you're logged in as (shows auth source)
158
158
  npx securenow whoami
159
159
 
160
- # Already have a firewall key? Store it without re-running login:
160
+ # Need or already have a firewall key? Create or store it without re-running login:
161
+ npx securenow api-key create --name "CLI firewall"
161
162
  npx securenow api-key set snk_live_abc123... # --global for ~/.securenow/
162
163
  npx securenow api-key show # masked key + source
163
164
  npx securenow api-key clear # remove just the key
@@ -491,7 +492,8 @@ npx securenow logs --json --level error | jq '.logs'
491
492
  | | `apps info <id>` | Application details |
492
493
  | | `apps delete <id>` | Delete application |
493
494
  | | `apps default <key>` | Set default app |
494
- | **API Key** | `api-key set <snk_live_...> [--global]` | Save firewall key to `.securenow/credentials.json` |
495
+ | **API Key** | `api-key create [--name "CLI firewall"] [--global]` | Mint and save a firewall key with your logged-in session |
496
+ | | `api-key set <snk_live_...> [--global]` | Save firewall key to `.securenow/credentials.json` |
495
497
  | | `api-key show` | Show masked key + source |
496
498
  | | `api-key clear [--global]` | Remove stored key (leaves session/app) |
497
499
  | **Observe** | `traces` | List traces |
package/README.md CHANGED
@@ -175,6 +175,7 @@ npx securenow login --global # save to ~/.securenow/ instead
175
175
  npx securenow login --token <TOKEN> # headless (CI)
176
176
  npx securenow init --env local # scaffold framework files + local env scope
177
177
  npx securenow credentials runtime --env production # write tokenless production credentials file
178
+ npx securenow api-key create --name "CLI firewall" # mint + store firewall key
178
179
  npx securenow api-key set snk_live_... # store firewall key in .securenow/credentials.json
179
180
 
180
181
  # Apps
@@ -301,6 +302,7 @@ After install, the `securenow` CLI is available via `npx securenow` or globally
301
302
  | `securenow logout` | Clear project-local credentials |
302
303
  | `securenow logout --global` | Clear ~/.securenow/ instead |
303
304
  | `securenow whoami` | Show current session (email, app, expiry) |
305
+ | `securenow api-key create [--name "CLI firewall"]` | Mint and store a firewall key using your session token |
304
306
  | `securenow api-key set <snk_live_...>` | Store firewall key in `.securenow/credentials.json` (`--global` for `~/.securenow/`) |
305
307
  | `securenow api-key show` | Print masked key + source file |
306
308
  | `securenow api-key clear` | Remove stored key (`--global` for `~/.securenow/`) |
package/SKILL-CLI.md CHANGED
@@ -153,16 +153,17 @@ securenow apps scan [--yes] # scan all app domains for new subd
153
153
 
154
154
  Manage the firewall API key stored in the credentials file. Since v7.5.1 the login flow writes `snk_live_...` keys to `.securenow/credentials.json` by default, so no env var is required for local dev.
155
155
 
156
- ```bash
157
- securenow api-key set snk_live_xxxxxxxxxx # save to project ./.securenow/ (default)
158
- securenow api-key set snk_live_xxx --global # save to ~/.securenow/ instead
156
+ ```bash
157
+ securenow api-key create --name "CLI firewall" # mint + save a firewall key with your logged-in session
158
+ securenow api-key set snk_live_xxxxxxxxxx # save to project ./.securenow/ (default)
159
+ securenow api-key set snk_live_xxx --global # save to ~/.securenow/ instead
159
160
  securenow api-key show # print the masked current key + its source
160
161
  securenow api-key clear # remove just the API key (keeps session/app)
161
162
  securenow api-key clear --global # same, but from the global file
162
163
  securenow credentials runtime --env production # write .securenow/credentials.production.json for production secret-file deploys
163
164
  ```
164
165
 
165
- The key must start with `snk_live_`. `securenow login` with firewall enabled writes the key automatically; use `api-key set` when you already have a key from the dashboard, or to rotate it later.
166
+ The key must start with `snk_live_`. `securenow login` with firewall enabled writes the key automatically; use `api-key create` when you have an account session but no runtime key yet, or `api-key set` when you already have a key from the dashboard.
166
167
 
167
168
  ### Init — Project Setup
168
169
 
package/cli/apiKey.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { api, requireAuth } = require('./client');
3
4
  const config = require('./config');
4
5
  const ui = require('./ui');
5
6
 
@@ -8,6 +9,47 @@ function maskKey(key) {
8
9
  return `${key.slice(0, 12)}••••••${key.slice(-4)}`;
9
10
  }
10
11
 
12
+ async function create(args, flags) {
13
+ requireAuth();
14
+
15
+ const name = flags.name || args.join(' ').trim() || 'CLI firewall';
16
+ const preset = flags.preset || 'firewall';
17
+ const local = flags.global ? false : true;
18
+
19
+ const s = ui.spinner(`Creating ${preset} API key`);
20
+ try {
21
+ const result = await api.post('/api-keys', { name, preset });
22
+ const key = result && result.key;
23
+ if (!key || !key.startsWith('snk_live_')) {
24
+ throw new Error('API did not return a valid firewall key');
25
+ }
26
+
27
+ config.setApiKey(key, { local });
28
+ if (local) config.ensureLocalGitignore();
29
+ s.stop('API key created and saved');
30
+
31
+ if (flags.json) {
32
+ ui.json({
33
+ id: result.apiKey?._id || result.apiKey?.id || null,
34
+ name: result.apiKey?.name || name,
35
+ preset,
36
+ key: maskKey(key),
37
+ savedTo: local ? 'project .securenow/credentials.json' : '~/.securenow/credentials.json',
38
+ });
39
+ return;
40
+ }
41
+
42
+ ui.success(`API key saved (${maskKey(key)})`);
43
+ ui.info(local
44
+ ? 'Stored in project .securenow/credentials.json (local)'
45
+ : 'Stored in ~/.securenow/credentials.json (global)');
46
+ ui.info('The plaintext key is not printed. The SDK will read it from the credentials file.');
47
+ } catch (err) {
48
+ s.fail('Failed to create API key');
49
+ throw err;
50
+ }
51
+ }
52
+
11
53
  async function set(args, flags) {
12
54
  const key = args[0];
13
55
  if (!key) {
@@ -52,4 +94,4 @@ async function show() {
52
94
  ui.info(`Source: ${config.getAuthSource()}`);
53
95
  }
54
96
 
55
- module.exports = { set, clear, show };
97
+ module.exports = { create, set, clear, show };
package/cli/config.js CHANGED
@@ -47,6 +47,12 @@ function saveJSON(filepath, data) {
47
47
  }
48
48
  }
49
49
 
50
+ function credentialsForWrite(targetFile) {
51
+ const inherited = loadCredentials() || {};
52
+ const existing = loadJSON(targetFile);
53
+ return appConfig.mergeCredentials(inherited, existing) || {};
54
+ }
55
+
50
56
  function credentialsFileForLocal(local) {
51
57
  return local ? LOCAL_CREDENTIALS_FILE : CREDENTIALS_FILE;
52
58
  }
@@ -113,7 +119,7 @@ function withOnboardingFirewallEnabled(creds) {
113
119
  function ensureCredentialDefaults({ local, enableFirewall = false } = {}) {
114
120
  const useLocal = local === true || (local == null && hasLocalCredentials());
115
121
  const targetFile = credentialsFileForLocal(useLocal);
116
- const existing = loadJSON(targetFile);
122
+ const existing = useLocal ? credentialsForWrite(targetFile) : loadJSON(targetFile);
117
123
  const payload = enableFirewall
118
124
  ? withOnboardingFirewallEnabled(existing || {})
119
125
  : appConfig.withCredentialDefaults(existing || {}) || {};
@@ -168,7 +174,7 @@ function getApp() {
168
174
  function setApiKey(apiKey, { local } = {}) {
169
175
  const useLocal = local === true || (local == null && hasLocalCredentials());
170
176
  const targetFile = credentialsFileForLocal(useLocal);
171
- const existing = loadJSON(targetFile);
177
+ const existing = useLocal ? credentialsForWrite(targetFile) : loadJSON(targetFile);
172
178
  saveJSON(targetFile, withOnboardingFirewallEnabled({ ...existing, apiKey }));
173
179
  }
174
180
 
@@ -189,7 +195,7 @@ function getApiKey() {
189
195
  function setApp(app, { local } = {}) {
190
196
  const useLocal = local === true || (local == null && hasLocalCredentials());
191
197
  const targetFile = credentialsFileForLocal(useLocal);
192
- const existing = loadJSON(targetFile);
198
+ const existing = useLocal ? credentialsForWrite(targetFile) : loadJSON(targetFile);
193
199
  saveJSON(targetFile, appConfig.withCredentialDefaults({
194
200
  ...existing,
195
201
  app: {
package/cli.js CHANGED
@@ -77,11 +77,21 @@ const COMMANDS = {
77
77
  },
78
78
  'api-key': {
79
79
  desc: 'Manage the firewall API key stored in .securenow/credentials.json',
80
- usage: 'securenow api-key <subcommand> [options]',
81
- sub: {
82
- set: {
83
- desc: 'Save an API key (snk_live_...) to the credentials file',
84
- usage: 'securenow api-key set <snk_live_...> [--global]',
80
+ usage: 'securenow api-key <subcommand> [options]',
81
+ sub: {
82
+ create: {
83
+ desc: 'Create a firewall API key with your session token and save it',
84
+ usage: 'securenow api-key create [name] [--name <name>] [--preset firewall] [--global]',
85
+ flags: {
86
+ name: 'Human-readable key name',
87
+ preset: 'API key preset to create (default: firewall)',
88
+ global: 'Save to ~/.securenow/ instead of project-local',
89
+ },
90
+ run: (a, f) => require('./cli/apiKey').create(a, f),
91
+ },
92
+ set: {
93
+ desc: 'Save an API key (snk_live_...) to the credentials file',
94
+ usage: 'securenow api-key set <snk_live_...> [--global]',
85
95
  flags: { global: 'Save to ~/.securenow/ instead of project-local' },
86
96
  run: (a, f) => require('./cli/apiKey').set(a, f),
87
97
  },
@@ -97,7 +97,8 @@ var BANNER_SCRIPT =
97
97
  */
98
98
  function patchHttpForBanner() {
99
99
  try {
100
- var http = require('http');
100
+ var nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : eval('require');
101
+ var http = nodeRequire('node:http');
101
102
  var _origWrite = http.ServerResponse.prototype.write;
102
103
  var _origEnd = http.ServerResponse.prototype.end;
103
104
 
package/nextjs.js CHANGED
@@ -34,6 +34,15 @@ const env = appConfig.env;
34
34
 
35
35
  let isRegistered = false;
36
36
 
37
+ function requireRuntimeModule(name) {
38
+ const nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : eval('require');
39
+ return nodeRequire(name);
40
+ }
41
+
42
+ function requireNodeBuiltin(name) {
43
+ return requireRuntimeModule(name);
44
+ }
45
+
37
46
  function createResource(attributes) {
38
47
  if (typeof otelResources.resourceFromAttributes === 'function') {
39
48
  return otelResources.resourceFromAttributes(attributes);
@@ -501,7 +510,7 @@ function registerSecureNow(options = {}) {
501
510
 
502
511
  // Auto-log every incoming HTTP request/response
503
512
  try {
504
- const http = require('http');
513
+ const http = requireNodeBuiltin('node:http');
505
514
  const originalEmit = http.Server.prototype.emit;
506
515
  http.Server.prototype.emit = function (event, req, res) {
507
516
  if (event === 'request' && req && res) {
@@ -549,8 +558,8 @@ function registerSecureNow(options = {}) {
549
558
  } catch (_) {}
550
559
 
551
560
  // Graceful shutdown for logs
552
- process.on('SIGTERM', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { require('./firewall').shutdown(); } catch (_) {} });
553
- process.on('SIGINT', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { require('./firewall').shutdown(); } catch (_) {} });
561
+ process.on('SIGTERM', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { requireRuntimeModule('./firewall').shutdown(); } catch (_) {} });
562
+ process.on('SIGINT', async () => { try { await loggerProvider.shutdown(); } catch (_) {} try { requireRuntimeModule('./firewall').shutdown(); } catch (_) {} });
554
563
  } catch (e) {
555
564
  console.warn('[securenow] ⚠️ Logging setup failed (missing @opentelemetry/exporter-logs-otlp-http or @opentelemetry/sdk-logs):', e.message);
556
565
  }
@@ -563,7 +572,7 @@ function registerSecureNow(options = {}) {
563
572
 
564
573
  // Free trial banner (optional — may not be bundled in standalone builds)
565
574
  try {
566
- const { isFreeTrial, patchHttpForBanner } = require('./free-trial-banner');
575
+ const { isFreeTrial, patchHttpForBanner } = requireRuntimeModule('./free-trial-banner');
567
576
  if (isFreeTrial(endpointBase) && String(env('SECURENOW_HIDE_BANNER')) !== '1') {
568
577
  patchHttpForBanner();
569
578
  }
@@ -607,7 +616,7 @@ function registerSecureNow(options = {}) {
607
616
  const firewallOptions = appConfig.resolveFirewallOptions();
608
617
  if (firewallOptions.apiKey && firewallOptions.enabled) {
609
618
  try {
610
- require('./firewall').init({
619
+ requireRuntimeModule('./firewall').init({
611
620
  apiKey: firewallOptions.apiKey,
612
621
  appKey: firewallOptions.appKey,
613
622
  environment: deploymentEnvironment || firewallOptions.environment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "7.7.10",
3
+ "version": "7.7.12",
4
4
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",
package/tracing.js CHANGED
@@ -124,9 +124,9 @@ function extractBoundary(contentType) {
124
124
  return match ? (match[1] || match[2]) : null;
125
125
  }
126
126
 
127
- function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFieldSize, onComplete) {
127
+ function createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete) {
128
128
  const boundary = extractBoundary(contentType);
129
- if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return; }
129
+ if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return null; }
130
130
 
131
131
  const result = { fields: Object.create(null), files: [] };
132
132
  let totalSize = 0;
@@ -219,13 +219,14 @@ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFiel
219
219
  }
220
220
  }
221
221
 
222
- request.on('data', (chunk) => {
223
- totalSize += chunk.length;
224
- buf = Buffer.concat([buf, chunk]);
222
+ function onData(chunk) {
223
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
224
+ totalSize += data.length;
225
+ buf = Buffer.concat([buf, data]);
225
226
  drain();
226
- });
227
+ }
227
228
 
228
- request.on('end', () => {
229
+ function onEnd() {
229
230
  try {
230
231
  if (!inHeaders && fldName) {
231
232
  bodyBytes += buf.length;
@@ -236,7 +237,16 @@ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFiel
236
237
  } catch (e) {
237
238
  onComplete({ error: 'PARSE_ERROR' });
238
239
  }
239
- });
240
+ }
241
+
242
+ return { onData, onEnd };
243
+ }
244
+
245
+ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFieldSize, onComplete) {
246
+ const collector = createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete);
247
+ if (!collector) return;
248
+ request.on('data', collector.onData);
249
+ request.on('end', collector.onEnd);
240
250
  }
241
251
 
242
252
  // -------- ESM detection --------
@@ -342,6 +352,133 @@ const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveField
342
352
 
343
353
  const captureMultipart = !/^(0|false)$/i.test(String(env('SECURENOW_CAPTURE_MULTIPART') ?? ''));
344
354
 
355
+ const BODY_CAPTURE_PATCH = Symbol.for('securenow.bodyCapture.emitPatch');
356
+
357
+ function installRequestBodyObserver(span, request, contentType) {
358
+ if (!request || request[BODY_CAPTURE_PATCH]) return;
359
+
360
+ const normalizedContentType = String(contentType || '').toLowerCase();
361
+ const isStructuredBody = captureBody && (
362
+ normalizedContentType.includes('application/json') ||
363
+ normalizedContentType.includes('application/graphql') ||
364
+ normalizedContentType.includes('application/x-www-form-urlencoded')
365
+ );
366
+ const isMultipartBody = normalizedContentType.includes('multipart/form-data');
367
+
368
+ if (!isStructuredBody && !isMultipartBody) return;
369
+
370
+ if (isMultipartBody && !captureMultipart) {
371
+ span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
372
+ span.setAttribute('http.request.body.type', 'multipart');
373
+ span.setAttribute('http.request.body.note', 'Multipart capture disabled (SECURENOW_CAPTURE_MULTIPART=0)');
374
+ return;
375
+ }
376
+
377
+ let chunks = [];
378
+ let size = 0;
379
+ let structuredDone = false;
380
+
381
+ const multipartCollector = isMultipartBody && captureMultipart
382
+ ? createMultipartMetaCollector(normalizedContentType, allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
383
+ try {
384
+ if (error === 'BOUNDARY_NOT_FOUND') {
385
+ span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
386
+ span.setAttribute('http.request.body.type', 'multipart');
387
+ return;
388
+ }
389
+ if (error) {
390
+ span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
391
+ span.setAttribute('http.request.body.type', 'multipart');
392
+ span.setAttribute('http.request.body.parse_error', true);
393
+ return;
394
+ }
395
+ span.setAttributes({
396
+ 'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
397
+ 'http.request.body.type': 'multipart',
398
+ 'http.request.body.size': totalSize,
399
+ 'http.request.body.fields_count': Object.keys(parsed.fields).length,
400
+ 'http.request.body.files_count': parsed.files.length,
401
+ });
402
+ } catch (e) {
403
+ span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
404
+ span.setAttribute('http.request.body.type', 'multipart');
405
+ span.setAttribute('http.request.body.parse_error', true);
406
+ }
407
+ })
408
+ : null;
409
+
410
+ if (isMultipartBody && captureMultipart && !multipartCollector) return;
411
+
412
+ function finishStructuredCapture() {
413
+ if (!isStructuredBody || structuredDone) return;
414
+ structuredDone = true;
415
+
416
+ if (size > maxBodySize) {
417
+ span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
418
+ span.setAttribute('http.request.body.size', size);
419
+ chunks = [];
420
+ return;
421
+ }
422
+
423
+ if (chunks.length === 0) return;
424
+
425
+ const body = Buffer.concat(chunks).toString('utf8');
426
+ chunks = [];
427
+
428
+ try {
429
+ if (normalizedContentType.includes('application/graphql')) {
430
+ const redacted = redactGraphQLQuery(body, allSensitiveFields);
431
+ span.setAttributes({
432
+ 'http.request.body': redacted.substring(0, maxBodySize),
433
+ 'http.request.body.type': 'graphql',
434
+ 'http.request.body.size': size,
435
+ });
436
+ } else if (normalizedContentType.includes('application/json')) {
437
+ const parsed = JSON.parse(body);
438
+ const redacted = redactSensitiveData(parsed, allSensitiveFields);
439
+ span.setAttributes({
440
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
441
+ 'http.request.body.type': 'json',
442
+ 'http.request.body.size': size,
443
+ });
444
+ } else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
445
+ const parsed = Object.fromEntries(new URLSearchParams(body));
446
+ const redacted = redactSensitiveData(parsed, allSensitiveFields);
447
+ span.setAttributes({
448
+ 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
449
+ 'http.request.body.type': 'form',
450
+ 'http.request.body.size': size,
451
+ });
452
+ }
453
+ } catch (e) {
454
+ span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
455
+ span.setAttribute('http.request.body.parse_error', true);
456
+ span.setAttribute('http.request.body.size', size);
457
+ }
458
+ }
459
+
460
+ const originalEmit = request.emit;
461
+ request[BODY_CAPTURE_PATCH] = true;
462
+ request.emit = function securenowObservedEmit(event, ...args) {
463
+ try {
464
+ if (event === 'data' && args.length > 0) {
465
+ const chunk = Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]);
466
+ if (isStructuredBody) {
467
+ size += chunk.length;
468
+ if (size <= maxBodySize) chunks.push(chunk);
469
+ }
470
+ if (multipartCollector) multipartCollector.onData(chunk);
471
+ } else if (event === 'end') {
472
+ finishStructuredCapture();
473
+ if (multipartCollector) multipartCollector.onEnd();
474
+ }
475
+ } catch (_) {
476
+ // Body capture must never interfere with the application request stream.
477
+ }
478
+ return originalEmit.apply(this, [event, ...args]);
479
+ };
480
+ }
481
+
345
482
  // -------- Trusted proxy IP resolution --------
346
483
  const { resolveClientIpWithDetails } = require('./resolve-ip');
347
484
 
@@ -391,100 +528,7 @@ const httpInstrumentation = new HttpInstrumentation({
391
528
 
392
529
  if ((captureBody || captureMultipart) && request.method && ['POST', 'PUT', 'PATCH'].includes(request.method)) {
393
530
  const contentType = request.headers['content-type'] || '';
394
-
395
- if (captureBody && (contentType.includes('application/json') ||
396
- contentType.includes('application/graphql') ||
397
- contentType.includes('application/x-www-form-urlencoded'))) {
398
-
399
- let body = '';
400
- const chunks = [];
401
- let size = 0;
402
-
403
- request.on('data', (chunk) => {
404
- size += chunk.length;
405
- if (size <= maxBodySize) {
406
- chunks.push(chunk);
407
- }
408
- });
409
-
410
- request.on('end', () => {
411
- if (size <= maxBodySize && chunks.length > 0) {
412
- body = Buffer.concat(chunks).toString('utf8');
413
-
414
- try {
415
- let redacted;
416
-
417
- if (contentType.includes('application/graphql')) {
418
- // GraphQL: redact query string
419
- redacted = redactGraphQLQuery(body, allSensitiveFields);
420
- span.setAttributes({
421
- 'http.request.body': redacted.substring(0, maxBodySize),
422
- 'http.request.body.type': 'graphql',
423
- 'http.request.body.size': size,
424
- });
425
- } else if (contentType.includes('application/json')) {
426
- // JSON: parse and redact object
427
- const parsed = JSON.parse(body);
428
- redacted = redactSensitiveData(parsed, allSensitiveFields);
429
- span.setAttributes({
430
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
431
- 'http.request.body.type': 'json',
432
- 'http.request.body.size': size,
433
- });
434
- } else if (contentType.includes('application/x-www-form-urlencoded')) {
435
- // Form: parse and redact
436
- const parsed = Object.fromEntries(new URLSearchParams(body));
437
- redacted = redactSensitiveData(parsed, allSensitiveFields);
438
- span.setAttributes({
439
- 'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
440
- 'http.request.body.type': 'form',
441
- 'http.request.body.size': size,
442
- });
443
- }
444
- } catch (e) {
445
- span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
446
- span.setAttribute('http.request.body.parse_error', true);
447
- span.setAttribute('http.request.body.size', size);
448
- }
449
- } else if (size > maxBodySize) {
450
- span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
451
- span.setAttribute('http.request.body.size', size);
452
- }
453
- });
454
- } else if (contentType.includes('multipart/form-data')) {
455
- if (captureMultipart) {
456
- collectMultipartMeta(request, contentType, allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
457
- try {
458
- if (error === 'BOUNDARY_NOT_FOUND') {
459
- span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
460
- span.setAttribute('http.request.body.type', 'multipart');
461
- return;
462
- }
463
- if (error) {
464
- span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
465
- span.setAttribute('http.request.body.type', 'multipart');
466
- span.setAttribute('http.request.body.parse_error', true);
467
- return;
468
- }
469
- span.setAttributes({
470
- 'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
471
- 'http.request.body.type': 'multipart',
472
- 'http.request.body.size': totalSize,
473
- 'http.request.body.fields_count': Object.keys(parsed.fields).length,
474
- 'http.request.body.files_count': parsed.files.length,
475
- });
476
- } catch (e) {
477
- span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
478
- span.setAttribute('http.request.body.type', 'multipart');
479
- span.setAttribute('http.request.body.parse_error', true);
480
- }
481
- });
482
- } else {
483
- span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
484
- span.setAttribute('http.request.body.type', 'multipart');
485
- span.setAttribute('http.request.body.note', 'Multipart capture disabled (SECURENOW_CAPTURE_MULTIPART=0)');
486
- }
487
- }
531
+ installRequestBodyObserver(span, request, contentType);
488
532
  }
489
533
  } catch (error) {
490
534
  // Silently fail