securenow 5.7.1 → 5.8.1
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/CONSUMING-APPS-GUIDE.md +8 -0
- package/NPM_README.md +22 -1
- package/README.md +3 -0
- package/cli/security.js +151 -2
- package/cli.js +4 -3
- package/docs/ENVIRONMENT-VARIABLES.md +47 -2
- package/docs/EXPRESS-BODY-CAPTURE.md +3 -2
- package/docs/REQUEST-BODY-CAPTURE.md +15 -6
- package/free-trial-banner.js +17 -3
- package/package.json +1 -1
- package/tracing.js +163 -4
- package/web-vite.mjs +17 -3
package/CONSUMING-APPS-GUIDE.md
CHANGED
|
@@ -362,6 +362,14 @@ OTEL_EXPORTER_OTLP_HEADERS="x-api-key=KEY"
|
|
|
362
362
|
SECURENOW_APPID=my-app # Your app name
|
|
363
363
|
OTEL_SERVICE_NAME=my-app # Alternative
|
|
364
364
|
|
|
365
|
+
# Request Body Capture
|
|
366
|
+
SECURENOW_CAPTURE_BODY=1 # Capture JSON/form/GraphQL request bodies
|
|
367
|
+
SECURENOW_MAX_BODY_SIZE=10240 # Max body size in bytes (default: 10KB)
|
|
368
|
+
SECURENOW_SENSITIVE_FIELDS="field1,field2" # Additional fields to redact
|
|
369
|
+
|
|
370
|
+
# Multipart Body Capture (v5.8.0+)
|
|
371
|
+
SECURENOW_CAPTURE_MULTIPART=1 # Capture multipart field values & file metadata (streaming)
|
|
372
|
+
|
|
365
373
|
# Debugging
|
|
366
374
|
OTEL_LOG_LEVEL=debug # Enable debug output
|
|
367
375
|
```
|
package/NPM_README.md
CHANGED
|
@@ -903,6 +903,7 @@ export default defineNuxtConfig({
|
|
|
903
903
|
| `SECURENOW_CAPTURE_BODY` | Enable request body capture in traces. Set to `1` to enable. | `0` |
|
|
904
904
|
| `SECURENOW_MAX_BODY_SIZE` | Maximum body size to capture in bytes. Bodies larger than this are truncated. | `10240` (10KB) |
|
|
905
905
|
| `SECURENOW_SENSITIVE_FIELDS` | Comma-separated list of additional field names to redact. | - |
|
|
906
|
+
| `SECURENOW_CAPTURE_MULTIPART` | Enable multipart/form-data capture. Streams through the request to extract text field values and file metadata (name, filename, content-type, size) without buffering file content. Set to `1` to enable. | `0` |
|
|
906
907
|
|
|
907
908
|
**Default sensitive fields (auto-redacted):** `password`, `passwd`, `pwd`, `secret`, `token`, `api_key`, `apikey`, `access_token`, `auth`, `credentials`, `mysql_pwd`, `stripeToken`, `card`, `cardnumber`, `ccv`, `cvc`, `cvv`, `ssn`, `pin`
|
|
908
909
|
|
|
@@ -1018,8 +1019,28 @@ export SECURENOW_MAX_BODY_SIZE=10240 # 10KB (optional)
|
|
|
1018
1019
|
- `application/json`
|
|
1019
1020
|
- `application/x-www-form-urlencoded`
|
|
1020
1021
|
- `application/graphql`
|
|
1022
|
+
- `multipart/form-data` (requires `SECURENOW_CAPTURE_MULTIPART=1`)
|
|
1021
1023
|
|
|
1022
|
-
|
|
1024
|
+
### Multipart Body Capture (v5.8.0+)
|
|
1025
|
+
|
|
1026
|
+
Enable with `SECURENOW_CAPTURE_MULTIPART=1` to capture multipart/form-data requests. Uses a streaming parser that never buffers file content — memory stays at ~few KB regardless of upload size.
|
|
1027
|
+
|
|
1028
|
+
**What gets captured:**
|
|
1029
|
+
- **Text fields** — field name and value (up to 1000 chars), with sensitive fields auto-redacted
|
|
1030
|
+
- **File fields** — metadata only: field name, filename, content-type, and size in bytes
|
|
1031
|
+
|
|
1032
|
+
**Example trace attribute:**
|
|
1033
|
+
```json
|
|
1034
|
+
{
|
|
1035
|
+
"fields": { "description": "My upload", "token": "[REDACTED]" },
|
|
1036
|
+
"files": [
|
|
1037
|
+
{ "field": "avatar", "filename": "photo.jpg", "contentType": "image/jpeg", "size": 524288 },
|
|
1038
|
+
{ "field": "resume", "filename": "cv.pdf", "contentType": "application/pdf", "size": 1048576 }
|
|
1039
|
+
]
|
|
1040
|
+
}
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
File binary content is never stored in traces.
|
|
1023
1044
|
|
|
1024
1045
|
### Sensitive Data Redaction
|
|
1025
1046
|
|
package/README.md
CHANGED
|
@@ -168,6 +168,9 @@ OTEL_EXPORTER_OTLP_HEADERS="x-api-key=..." # Authentication headers
|
|
|
168
168
|
SECURENOW_CAPTURE_BODY=1 # Capture request bodies in traces
|
|
169
169
|
SECURENOW_MAX_BODY_SIZE=10240 # Max body size in bytes
|
|
170
170
|
SECURENOW_SENSITIVE_FIELDS="field1,field2" # Additional fields to redact
|
|
171
|
+
|
|
172
|
+
# Optional: Multipart body capture (file upload metadata)
|
|
173
|
+
SECURENOW_CAPTURE_MULTIPART=1 # Capture multipart field names, values & file metadata
|
|
171
174
|
```
|
|
172
175
|
|
|
173
176
|
### Legacy Environment Variables (still supported)
|
package/cli/security.js
CHANGED
|
@@ -269,20 +269,36 @@ async function trustedRemove(args, flags) {
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// ── Helpers ──
|
|
273
|
+
|
|
274
|
+
async function resolveAppId(flags) {
|
|
275
|
+
const appKey = flags.app || config.getDefaultApp();
|
|
276
|
+
if (!appKey) return null;
|
|
277
|
+
const data = await api.get('/applications');
|
|
278
|
+
const apps = data.applications || [];
|
|
279
|
+
const match = apps.find(a => a.key === appKey);
|
|
280
|
+
return match ? { id: match._id, key: match.key, name: match.name } : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
272
283
|
// ── Forensics ──
|
|
273
284
|
|
|
274
285
|
async function forensicsQuery(args, flags) {
|
|
275
286
|
requireAuth();
|
|
276
287
|
const query = args.join(' ');
|
|
277
288
|
if (!query) {
|
|
278
|
-
ui.error('Query required. Usage: securenow forensics "your natural language query"');
|
|
289
|
+
ui.error('Query required. Usage: securenow forensics "your natural language query" [--app <key>]');
|
|
279
290
|
process.exit(1);
|
|
280
291
|
}
|
|
281
292
|
|
|
282
293
|
const s = ui.spinner('Submitting forensic query');
|
|
283
294
|
try {
|
|
284
295
|
const body = { query };
|
|
285
|
-
|
|
296
|
+
const resolved = await resolveAppId(flags);
|
|
297
|
+
if (resolved) {
|
|
298
|
+
body.applicationId = resolved.id;
|
|
299
|
+
} else if (flags.instance) {
|
|
300
|
+
body.instanceId = flags.instance;
|
|
301
|
+
}
|
|
286
302
|
|
|
287
303
|
const job = await api.post('/forensics/query', body);
|
|
288
304
|
const jobId = job.jobId;
|
|
@@ -387,6 +403,138 @@ async function forensicsLibrary(args, flags) {
|
|
|
387
403
|
}
|
|
388
404
|
}
|
|
389
405
|
|
|
406
|
+
// ── Forensics Chat ──
|
|
407
|
+
|
|
408
|
+
async function forensicsChat(args, flags) {
|
|
409
|
+
requireAuth();
|
|
410
|
+
const resolved = await resolveAppId(flags);
|
|
411
|
+
if (!resolved) {
|
|
412
|
+
ui.error('App required. Usage: securenow forensics chat --app <key>');
|
|
413
|
+
ui.info('Set a default with: securenow apps default <key>');
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
console.log('');
|
|
418
|
+
ui.heading(`Forensics Chat — ${resolved.name || resolved.key}`);
|
|
419
|
+
console.log(ui.c.dim(` App: ${resolved.key} (${resolved.id})`));
|
|
420
|
+
console.log(ui.c.dim(' Type your question, or "exit" to quit.\n'));
|
|
421
|
+
|
|
422
|
+
let conversationId = null;
|
|
423
|
+
|
|
424
|
+
const readline = require('readline');
|
|
425
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
426
|
+
|
|
427
|
+
const askLine = () => new Promise((resolve) => {
|
|
428
|
+
rl.question(ui.c.cyan(' you > '), (answer) => resolve(answer.trim()));
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
while (true) {
|
|
433
|
+
const message = await askLine();
|
|
434
|
+
if (!message || message.toLowerCase() === 'exit' || message.toLowerCase() === 'quit') {
|
|
435
|
+
console.log('');
|
|
436
|
+
ui.info('Chat ended.');
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const s = ui.spinner('Thinking');
|
|
441
|
+
try {
|
|
442
|
+
const body = { message, applicationKey: resolved.key };
|
|
443
|
+
if (conversationId) body.conversationId = conversationId;
|
|
444
|
+
|
|
445
|
+
const chatRes = await api.post('/forensics/chat', body);
|
|
446
|
+
conversationId = chatRes.conversationId;
|
|
447
|
+
|
|
448
|
+
if (chatRes.status === 'processing') {
|
|
449
|
+
let result;
|
|
450
|
+
for (let i = 0; i < 150; i++) {
|
|
451
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
452
|
+
result = await api.get(`/forensics/chat/status/${conversationId}`);
|
|
453
|
+
if (result.status === 'complete' || result.status === 'failed' || result.status === 'awaiting_confirmation') break;
|
|
454
|
+
const progress = result._progress;
|
|
455
|
+
if (progress?.action) s.update(progress.action);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (!result || result.status === 'processing') {
|
|
459
|
+
s.fail('Response timed out');
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
s.stop('Done');
|
|
464
|
+
printChatMessage(result.message);
|
|
465
|
+
|
|
466
|
+
if (result.status === 'awaiting_confirmation') {
|
|
467
|
+
const proceed = await ui.confirm('Proceed with this query?');
|
|
468
|
+
if (proceed) {
|
|
469
|
+
const cs = ui.spinner('Executing query');
|
|
470
|
+
await api.post(`/forensics/chat/confirm/${conversationId}`);
|
|
471
|
+
let confirmResult;
|
|
472
|
+
for (let i = 0; i < 90; i++) {
|
|
473
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
474
|
+
confirmResult = await api.get(`/forensics/chat/status/${conversationId}`);
|
|
475
|
+
if (confirmResult.status !== 'processing') break;
|
|
476
|
+
}
|
|
477
|
+
cs.stop('Done');
|
|
478
|
+
if (confirmResult?.message) printChatMessage(confirmResult.message);
|
|
479
|
+
} else {
|
|
480
|
+
ui.info('Query skipped. Ask a different question or refine.');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} else if (chatRes.message) {
|
|
484
|
+
s.stop('Done');
|
|
485
|
+
printChatMessage(chatRes.message);
|
|
486
|
+
} else {
|
|
487
|
+
s.stop('Done');
|
|
488
|
+
}
|
|
489
|
+
} catch (err) {
|
|
490
|
+
s.fail('Error');
|
|
491
|
+
ui.error(err.message);
|
|
492
|
+
}
|
|
493
|
+
console.log('');
|
|
494
|
+
}
|
|
495
|
+
} finally {
|
|
496
|
+
rl.close();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function printChatMessage(msg) {
|
|
501
|
+
if (!msg) return;
|
|
502
|
+
console.log('');
|
|
503
|
+
if (msg.content) {
|
|
504
|
+
console.log(` ${ui.c.green('ai >')} ${msg.content}`);
|
|
505
|
+
}
|
|
506
|
+
if (msg.sqlQuery) {
|
|
507
|
+
console.log('');
|
|
508
|
+
ui.subheading('Generated SQL');
|
|
509
|
+
console.log(`\n ${ui.c.dim(msg.sqlQuery)}\n`);
|
|
510
|
+
}
|
|
511
|
+
if (msg.results && Array.isArray(msg.results) && msg.results.length > 0) {
|
|
512
|
+
ui.subheading(`Results (${msg.resultCount ?? msg.results.length} rows)`);
|
|
513
|
+
console.log('');
|
|
514
|
+
const headers = Object.keys(msg.results[0]);
|
|
515
|
+
const rows = msg.results.map(row => headers.map(h => String(row[h] ?? '')));
|
|
516
|
+
ui.table(headers, rows);
|
|
517
|
+
}
|
|
518
|
+
if (msg.estimation) {
|
|
519
|
+
const est = msg.estimation;
|
|
520
|
+
const rowLabel = est.timedOut
|
|
521
|
+
? 'very large (estimation timed out)'
|
|
522
|
+
: est.estimatedRows != null
|
|
523
|
+
? `~${Number(est.estimatedRows).toLocaleString()}`
|
|
524
|
+
: 'unknown';
|
|
525
|
+
console.log(`\n ${ui.c.yellow('!')} Estimated rows: ${rowLabel}`);
|
|
526
|
+
if (est.suggestions?.length) {
|
|
527
|
+
console.log(ui.c.dim(' Suggestions:'));
|
|
528
|
+
for (const sug of est.suggestions) {
|
|
529
|
+
console.log(ui.c.dim(` • ${sug}`));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (msg.error && !msg.results) {
|
|
534
|
+
console.log(`\n ${ui.c.red('Error:')} ${msg.error}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
390
538
|
// ── IP Lookup ──
|
|
391
539
|
|
|
392
540
|
async function ipLookup(args, flags) {
|
|
@@ -670,6 +818,7 @@ module.exports = {
|
|
|
670
818
|
trustedAdd,
|
|
671
819
|
trustedRemove,
|
|
672
820
|
forensicsQuery,
|
|
821
|
+
forensicsChat,
|
|
673
822
|
forensicsLibrary,
|
|
674
823
|
ipLookup,
|
|
675
824
|
ipTraces,
|
package/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const ui = require('./cli/ui');
|
|
@@ -151,9 +151,10 @@ const COMMANDS = {
|
|
|
151
151
|
},
|
|
152
152
|
forensics: {
|
|
153
153
|
desc: 'Run forensic queries (natural language → SQL)',
|
|
154
|
-
usage: 'securenow forensics <query>',
|
|
154
|
+
usage: 'securenow forensics <query> [--app <key>]',
|
|
155
155
|
sub: {
|
|
156
|
-
query: { desc: 'Run a forensic query', run: (a, f) => require('./cli/security').forensicsQuery(a, f) },
|
|
156
|
+
query: { desc: 'Run a forensic query', flags: { app: 'App key to scope query' }, run: (a, f) => require('./cli/security').forensicsQuery(a, f) },
|
|
157
|
+
chat: { desc: 'Interactive forensics chat (scoped to an app)', usage: 'securenow forensics chat --app <key>', flags: { app: 'App key to chat with' }, run: (a, f) => require('./cli/security').forensicsChat(a, f) },
|
|
157
158
|
library: { desc: 'View saved queries', run: (a, f) => require('./cli/security').forensicsLibrary(a, f) },
|
|
158
159
|
},
|
|
159
160
|
defaultAction: (a, f) => require('./cli/security').forensicsQuery(a, f),
|
|
@@ -16,6 +16,7 @@ Complete reference for all environment variables supported by SecureNow.
|
|
|
16
16
|
| **SECURENOW_CAPTURE_BODY** | Optional | `0` | Enable request body capture |
|
|
17
17
|
| **SECURENOW_MAX_BODY_SIZE** | Optional | `10240` | Max body size in bytes |
|
|
18
18
|
| **SECURENOW_SENSITIVE_FIELDS** | Optional | - | Comma-separated list of fields to redact |
|
|
19
|
+
| **SECURENOW_CAPTURE_MULTIPART** | Optional | `0` | Enable multipart/form-data streaming capture |
|
|
19
20
|
| **SECURENOW_DISABLE_INSTRUMENTATIONS** | Optional | - | Comma-separated list of packages to disable |
|
|
20
21
|
| **SECURENOW_TEST_SPAN** | Optional | `0` | Emit test span on startup |
|
|
21
22
|
| **OTEL_SERVICE_NAME** | Optional | - | Alternative to SECURENOW_APPID |
|
|
@@ -279,8 +280,8 @@ export SECURENOW_CAPTURE_BODY=1
|
|
|
279
280
|
- `application/x-www-form-urlencoded`
|
|
280
281
|
- `application/graphql`
|
|
281
282
|
|
|
282
|
-
**Not captured:**
|
|
283
|
-
- `multipart/form-data` (
|
|
283
|
+
**Not captured (unless separately enabled):**
|
|
284
|
+
- `multipart/form-data` — requires `SECURENOW_CAPTURE_MULTIPART=1` (see below)
|
|
284
285
|
- Bodies larger than `SECURENOW_MAX_BODY_SIZE`
|
|
285
286
|
|
|
286
287
|
**Security:**
|
|
@@ -346,6 +347,50 @@ export SECURENOW_SENSITIVE_FIELDS="custom_secret,private_data,internal_id"
|
|
|
346
347
|
|
|
347
348
|
---
|
|
348
349
|
|
|
350
|
+
### SECURENOW_CAPTURE_MULTIPART
|
|
351
|
+
|
|
352
|
+
**Description:** Enable capture of `multipart/form-data` request bodies (file upload metadata and text fields). Uses a streaming parser that processes boundary markers on the fly — file binary content is never buffered or stored.
|
|
353
|
+
|
|
354
|
+
**Format:** `1` (enabled) or `0` (disabled)
|
|
355
|
+
|
|
356
|
+
**Default:** `0` (disabled)
|
|
357
|
+
|
|
358
|
+
**Example:**
|
|
359
|
+
```bash
|
|
360
|
+
export SECURENOW_CAPTURE_MULTIPART=1
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**What gets captured:**
|
|
364
|
+
- **Text fields** — field name and value (up to 1000 characters), with sensitive fields auto-redacted
|
|
365
|
+
- **File fields** — metadata only: field name, filename, content-type, and size in bytes (no binary content)
|
|
366
|
+
|
|
367
|
+
**Example span attribute (`http.request.body`):**
|
|
368
|
+
```json
|
|
369
|
+
{
|
|
370
|
+
"fields": { "description": "My upload", "token": "[REDACTED]" },
|
|
371
|
+
"files": [
|
|
372
|
+
{ "field": "avatar", "filename": "photo.jpg", "contentType": "image/jpeg", "size": 524288 },
|
|
373
|
+
{ "field": "document", "filename": "report.pdf", "contentType": "application/pdf", "size": 1048576 }
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Additional span attributes set:**
|
|
379
|
+
- `http.request.body.type` = `"multipart"`
|
|
380
|
+
- `http.request.body.size` — total raw request body size in bytes
|
|
381
|
+
- `http.request.body.fields_count` — number of text fields
|
|
382
|
+
- `http.request.body.files_count` — number of file fields
|
|
383
|
+
|
|
384
|
+
**Memory:** Bounded at ~few KB regardless of upload size (streaming parser discards file content as it passes through).
|
|
385
|
+
|
|
386
|
+
**Parts limit:** 100 parts maximum per request (safety guard).
|
|
387
|
+
|
|
388
|
+
**Requires:** `SECURENOW_CAPTURE_BODY=1` must also be set (multipart capture is gated behind general body capture).
|
|
389
|
+
|
|
390
|
+
**Since:** v5.8.0
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
349
394
|
## Instrumentation Control
|
|
350
395
|
|
|
351
396
|
### SECURENOW_DISABLE_INSTRUMENTATIONS
|
|
@@ -192,6 +192,7 @@ import express from 'express';
|
|
|
192
192
|
| `SECURENOW_CAPTURE_BODY` | Enable body capture (`1` or `true`) | `0` (disabled) |
|
|
193
193
|
| `SECURENOW_MAX_BODY_SIZE` | Max body size in bytes | `10240` (10KB) |
|
|
194
194
|
| `SECURENOW_SENSITIVE_FIELDS` | Comma-separated additional sensitive fields | (see below) |
|
|
195
|
+
| `SECURENOW_CAPTURE_MULTIPART` | Enable multipart/form-data streaming capture (`1` or `true`) | `0` (disabled) |
|
|
195
196
|
|
|
196
197
|
### Default Sensitive Fields
|
|
197
198
|
|
|
@@ -274,10 +275,10 @@ pm2 logs express-api --lines 100
|
|
|
274
275
|
| `application/json` | ✅ Yes | ✅ Yes | ✅ Yes |
|
|
275
276
|
| `application/graphql` | ✅ Yes | ✅ Yes | ✅ Yes |
|
|
276
277
|
| `application/x-www-form-urlencoded` | ✅ Yes | ✅ Yes | ✅ Yes |
|
|
277
|
-
| `multipart/form-data` |
|
|
278
|
+
| `multipart/form-data` | ✅ Metadata | ✅ Streaming | ✅ Yes |
|
|
278
279
|
| `text/plain` | ❌ No | N/A | N/A |
|
|
279
280
|
|
|
280
|
-
**Note**:
|
|
281
|
+
**Note**: Multipart capture requires `SECURENOW_CAPTURE_MULTIPART=1` (v5.8.0+). Uses a streaming parser — text field values and file metadata (name, filename, content-type, size) are captured; file binary content is never buffered or stored.
|
|
281
282
|
|
|
282
283
|
## 🔍 Example: Complete Express + PM2 Setup
|
|
283
284
|
|
|
@@ -55,16 +55,24 @@ SECURENOW_SENSITIVE_FIELDS=credit_card,email,phone
|
|
|
55
55
|
```
|
|
56
56
|
Parsed into object and sensitive fields redacted.
|
|
57
57
|
|
|
58
|
-
4. **Multipart** (`multipart/form-data`) -
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
4. **Multipart** (`multipart/form-data`) - ✅ Streaming Metadata Capture (v5.8.0+)
|
|
59
|
+
|
|
60
|
+
Requires `SECURENOW_CAPTURE_MULTIPART=1`. Uses a streaming parser — file binary content is never buffered.
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"fields": { "description": "Profile update", "token": "[REDACTED]" },
|
|
65
|
+
"files": [
|
|
66
|
+
{ "field": "avatar", "filename": "photo.jpg", "contentType": "image/jpeg", "size": 524288 }
|
|
67
|
+
]
|
|
68
|
+
}
|
|
61
69
|
```
|
|
62
|
-
|
|
70
|
+
Text field values are captured with sensitive-field redaction. File parts record metadata only (field, filename, content-type, size). Memory stays at ~few KB regardless of upload size.
|
|
63
71
|
|
|
64
72
|
### ❌ What's NOT Captured
|
|
65
73
|
|
|
66
74
|
- GET requests (no body)
|
|
67
|
-
- File
|
|
75
|
+
- File binary content (only metadata when multipart capture is enabled)
|
|
68
76
|
- Bodies larger than max size
|
|
69
77
|
- Binary data
|
|
70
78
|
- Non-POST/PUT/PATCH requests
|
|
@@ -218,6 +226,7 @@ mutation Login {
|
|
|
218
226
|
| `SECURENOW_CAPTURE_BODY` | `0` (disabled) | Enable body capture |
|
|
219
227
|
| `SECURENOW_MAX_BODY_SIZE` | `10240` (10KB) | Maximum body size to capture |
|
|
220
228
|
| `SECURENOW_SENSITIVE_FIELDS` | `` | Comma-separated custom sensitive fields |
|
|
229
|
+
| `SECURENOW_CAPTURE_MULTIPART` | `0` (disabled) | Enable multipart/form-data streaming capture (v5.8.0+) |
|
|
221
230
|
|
|
222
231
|
### Programmatic (Next.js)
|
|
223
232
|
|
|
@@ -529,7 +538,7 @@ SECURENOW_SENSITIVE_FIELDS="" # Don't do this!
|
|
|
529
538
|
|
|
530
539
|
### Q: Does this work with file uploads?
|
|
531
540
|
|
|
532
|
-
**A:**
|
|
541
|
+
**A:** Yes! Since v5.8.0, set `SECURENOW_CAPTURE_MULTIPART=1` to capture multipart/form-data requests. The streaming parser extracts text field values and file metadata (name, filename, content-type, size) without ever buffering file binary content. Memory stays bounded at ~few KB regardless of upload size.
|
|
533
542
|
|
|
534
543
|
### Q: What's the performance impact?
|
|
535
544
|
|
package/free-trial-banner.js
CHANGED
|
@@ -40,19 +40,33 @@ function _bannerClientCode() {
|
|
|
40
40
|
strong.textContent = 'Testing Environment:';
|
|
41
41
|
msg.appendChild(strong);
|
|
42
42
|
msg.appendChild(document.createTextNode(
|
|
43
|
-
' Only add test applications. For production usage,
|
|
43
|
+
' Only add test applications. For production usage, '
|
|
44
44
|
));
|
|
45
45
|
|
|
46
46
|
var link = document.createElement('a');
|
|
47
|
-
link.href = 'https://securenow.ai/
|
|
47
|
+
link.href = 'https://app.securenow.ai/dashboard/settings/instances';
|
|
48
48
|
link.target = '_blank';
|
|
49
49
|
link.rel = 'noopener';
|
|
50
50
|
link.style.cssText = 'color:#664D03;font-weight:600;text-decoration:underline';
|
|
51
|
-
link.textContent = '
|
|
51
|
+
link.textContent = 'create a new production instance';
|
|
52
52
|
msg.appendChild(link);
|
|
53
53
|
msg.appendChild(document.createTextNode('.'));
|
|
54
54
|
d.appendChild(msg);
|
|
55
55
|
|
|
56
|
+
var upgradeBtn = document.createElement('a');
|
|
57
|
+
upgradeBtn.href = 'https://app.securenow.ai/dashboard/settings/instances';
|
|
58
|
+
upgradeBtn.target = '_blank';
|
|
59
|
+
upgradeBtn.rel = 'noopener';
|
|
60
|
+
upgradeBtn.textContent = '\u26a1 Upgrade';
|
|
61
|
+
upgradeBtn.style.cssText =
|
|
62
|
+
'display:inline-flex;align-items:center;gap:4px;' +
|
|
63
|
+
'background:#D97706;color:#fff;padding:4px 12px;border-radius:4px;' +
|
|
64
|
+
'font-size:12px;font-weight:600;text-decoration:none;margin-left:10px;' +
|
|
65
|
+
'white-space:nowrap;transition:background 0.15s';
|
|
66
|
+
upgradeBtn.onmouseover = function () { upgradeBtn.style.background = '#B45309'; };
|
|
67
|
+
upgradeBtn.onmouseout = function () { upgradeBtn.style.background = '#D97706'; };
|
|
68
|
+
d.appendChild(upgradeBtn);
|
|
69
|
+
|
|
56
70
|
var close = document.createElement('button');
|
|
57
71
|
close.textContent = '\u00d7';
|
|
58
72
|
close.style.cssText =
|
package/package.json
CHANGED
package/tracing.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=... # full traces URL
|
|
17
17
|
* OTEL_EXPORTER_OTLP_HEADERS="k=v,k2=v2"
|
|
18
18
|
* SECURENOW_DISABLE_INSTRUMENTATIONS="pkg1,pkg2"
|
|
19
|
+
* SECURENOW_CAPTURE_MULTIPART=1 # capture multipart/form-data fields & file metadata (streaming, no file content buffered)
|
|
19
20
|
* OTEL_LOG_LEVEL=info|debug
|
|
20
21
|
* SECURENOW_TEST_SPAN=1
|
|
21
22
|
*
|
|
@@ -103,6 +104,131 @@ function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
|
103
104
|
return redacted;
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
// -------- Multipart streaming parser --------
|
|
108
|
+
// Streams through the request without buffering file content.
|
|
109
|
+
// Only part headers and text-field values are kept in memory,
|
|
110
|
+
// so memory stays bounded (~few KB) regardless of upload size.
|
|
111
|
+
|
|
112
|
+
function extractBoundary(contentType) {
|
|
113
|
+
const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i);
|
|
114
|
+
return match ? (match[1] || match[2]) : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFieldSize, onComplete) {
|
|
118
|
+
const boundary = extractBoundary(contentType);
|
|
119
|
+
if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return; }
|
|
120
|
+
|
|
121
|
+
const result = { fields: {}, files: [] };
|
|
122
|
+
let totalSize = 0;
|
|
123
|
+
let buf = Buffer.alloc(0);
|
|
124
|
+
|
|
125
|
+
const MAX_PARTS = 100;
|
|
126
|
+
let partCount = 0;
|
|
127
|
+
|
|
128
|
+
const FIRST_DELIM = Buffer.from('--' + boundary);
|
|
129
|
+
const DELIM = Buffer.from('\r\n--' + boundary);
|
|
130
|
+
const HDR_END = Buffer.from('\r\n\r\n');
|
|
131
|
+
|
|
132
|
+
let initialized = false;
|
|
133
|
+
let inHeaders = true;
|
|
134
|
+
let isFile = false;
|
|
135
|
+
let fldName = '';
|
|
136
|
+
let fName = '';
|
|
137
|
+
let pCT = '';
|
|
138
|
+
let bodyBytes = 0;
|
|
139
|
+
let textVal = '';
|
|
140
|
+
|
|
141
|
+
function flushPart() {
|
|
142
|
+
if (!fldName) return;
|
|
143
|
+
if (isFile) {
|
|
144
|
+
result.files.push({ field: fldName, filename: fName, contentType: pCT || 'unknown', size: bodyBytes });
|
|
145
|
+
} else {
|
|
146
|
+
const lower = fldName.toLowerCase();
|
|
147
|
+
const redact = sensitiveFields.some(f => lower.includes(f.toLowerCase()));
|
|
148
|
+
result.fields[fldName] = redact ? '[REDACTED]' : textVal.substring(0, maxTextFieldSize);
|
|
149
|
+
}
|
|
150
|
+
fldName = ''; bodyBytes = 0; textVal = ''; partCount++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function drain() {
|
|
154
|
+
if (!initialized) {
|
|
155
|
+
const i = buf.indexOf(FIRST_DELIM);
|
|
156
|
+
if (i === -1) {
|
|
157
|
+
if (buf.length > FIRST_DELIM.length + 4) buf = buf.slice(buf.length - FIRST_DELIM.length - 4);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
buf = buf.slice(i + FIRST_DELIM.length);
|
|
161
|
+
initialized = true;
|
|
162
|
+
inHeaders = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let guard = 200;
|
|
166
|
+
while (buf.length > 0 && guard-- > 0 && partCount < MAX_PARTS) {
|
|
167
|
+
if (inHeaders) {
|
|
168
|
+
if (buf.length >= 2 && buf[0] === 0x2D && buf[1] === 0x2D) { buf = Buffer.alloc(0); return; }
|
|
169
|
+
if (buf.length >= 2 && buf[0] === 0x0D && buf[1] === 0x0A) { buf = buf.slice(2); continue; }
|
|
170
|
+
|
|
171
|
+
const hi = buf.indexOf(HDR_END);
|
|
172
|
+
if (hi === -1) return;
|
|
173
|
+
|
|
174
|
+
const hdr = buf.slice(0, hi).toString('latin1');
|
|
175
|
+
buf = buf.slice(hi + 4);
|
|
176
|
+
|
|
177
|
+
const nm = hdr.match(/name="([^"]+)"/);
|
|
178
|
+
const fn = hdr.match(/filename="([^"]*)"/);
|
|
179
|
+
const ct = hdr.match(/Content-Type:\s*(.+)/i);
|
|
180
|
+
fldName = nm ? nm[1] : '';
|
|
181
|
+
fName = fn ? fn[1] : '';
|
|
182
|
+
pCT = ct ? ct[1].trim() : '';
|
|
183
|
+
isFile = !!fn;
|
|
184
|
+
bodyBytes = 0;
|
|
185
|
+
textVal = '';
|
|
186
|
+
inHeaders = false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const di = buf.indexOf(DELIM);
|
|
190
|
+
if (di === -1) {
|
|
191
|
+
const safe = Math.max(0, buf.length - DELIM.length - 2);
|
|
192
|
+
if (safe > 0) {
|
|
193
|
+
bodyBytes += safe;
|
|
194
|
+
if (!isFile && textVal.length < maxTextFieldSize) {
|
|
195
|
+
textVal += buf.slice(0, safe).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
196
|
+
}
|
|
197
|
+
buf = buf.slice(safe);
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
bodyBytes += di;
|
|
203
|
+
if (!isFile && textVal.length < maxTextFieldSize) {
|
|
204
|
+
textVal += buf.slice(0, di).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
205
|
+
}
|
|
206
|
+
flushPart();
|
|
207
|
+
buf = buf.slice(di + DELIM.length);
|
|
208
|
+
inHeaders = true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
request.on('data', (chunk) => {
|
|
213
|
+
totalSize += chunk.length;
|
|
214
|
+
buf = Buffer.concat([buf, chunk]);
|
|
215
|
+
drain();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
request.on('end', () => {
|
|
219
|
+
try {
|
|
220
|
+
if (!inHeaders && fldName) {
|
|
221
|
+
bodyBytes += buf.length;
|
|
222
|
+
if (!isFile) textVal += buf.toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
223
|
+
flushPart();
|
|
224
|
+
}
|
|
225
|
+
onComplete({ parsed: result, totalSize });
|
|
226
|
+
} catch (e) {
|
|
227
|
+
onComplete({ error: 'PARSE_ERROR' });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
106
232
|
// -------- ESM detection --------
|
|
107
233
|
// register.js auto-registers the hook via module.register() on Node >=20.6.
|
|
108
234
|
// This warning only fires if BOTH --import AND module.register() were skipped
|
|
@@ -194,6 +320,8 @@ const maxBodySize = parseInt(env('SECURENOW_MAX_BODY_SIZE') || '10240'); // 10KB
|
|
|
194
320
|
const customSensitiveFields = (env('SECURENOW_SENSITIVE_FIELDS') || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
195
321
|
const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
|
|
196
322
|
|
|
323
|
+
const captureMultipart = String(env('SECURENOW_CAPTURE_MULTIPART')) === '1' || String(env('SECURENOW_CAPTURE_MULTIPART')).toLowerCase() === 'true';
|
|
324
|
+
|
|
197
325
|
// -------- Trusted proxy IP resolution --------
|
|
198
326
|
// Only trust X-Forwarded-For / X-Real-IP when the direct connection comes from
|
|
199
327
|
// a known proxy (loopback, private RFC-1918/RFC-4193, or an explicit allowlist).
|
|
@@ -325,10 +453,38 @@ const httpInstrumentation = new HttpInstrumentation({
|
|
|
325
453
|
}
|
|
326
454
|
});
|
|
327
455
|
} else if (contentType.includes('multipart/form-data')) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
456
|
+
if (captureMultipart) {
|
|
457
|
+
collectMultipartMeta(request, contentType, allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
|
|
458
|
+
try {
|
|
459
|
+
if (error === 'BOUNDARY_NOT_FOUND') {
|
|
460
|
+
span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
|
|
461
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (error) {
|
|
465
|
+
span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
|
|
466
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
467
|
+
span.setAttribute('http.request.body.parse_error', true);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
span.setAttributes({
|
|
471
|
+
'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
|
|
472
|
+
'http.request.body.type': 'multipart',
|
|
473
|
+
'http.request.body.size': totalSize,
|
|
474
|
+
'http.request.body.fields_count': Object.keys(parsed.fields).length,
|
|
475
|
+
'http.request.body.files_count': parsed.files.length,
|
|
476
|
+
});
|
|
477
|
+
} catch (e) {
|
|
478
|
+
span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
|
|
479
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
480
|
+
span.setAttribute('http.request.body.parse_error', true);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
} else {
|
|
484
|
+
span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
|
|
485
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
486
|
+
span.setAttribute('http.request.body.note', 'Set SECURENOW_CAPTURE_MULTIPART=1 to enable');
|
|
487
|
+
}
|
|
332
488
|
}
|
|
333
489
|
}
|
|
334
490
|
} catch (error) {
|
|
@@ -416,6 +572,9 @@ const sdk = new NodeSDK({
|
|
|
416
572
|
if (captureBody) {
|
|
417
573
|
console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
|
|
418
574
|
}
|
|
575
|
+
if (captureMultipart) {
|
|
576
|
+
console.log('[securenow] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
|
|
577
|
+
}
|
|
419
578
|
if (String(env('SECURENOW_TEST_SPAN')) === '1') {
|
|
420
579
|
const api = require('@opentelemetry/api');
|
|
421
580
|
const tracer = api.trace.getTracer('securenow-smoke');
|
package/web-vite.mjs
CHANGED
|
@@ -173,19 +173,33 @@ function injectFreeTrialBanner(): void {
|
|
|
173
173
|
strong.textContent = 'Testing Environment:';
|
|
174
174
|
msg.appendChild(strong);
|
|
175
175
|
msg.appendChild(document.createTextNode(
|
|
176
|
-
' Only add test applications. For production usage,
|
|
176
|
+
' Only add test applications. For production usage, '
|
|
177
177
|
));
|
|
178
178
|
|
|
179
179
|
const link = document.createElement('a');
|
|
180
|
-
link.href = 'https://securenow.ai/
|
|
180
|
+
link.href = 'https://app.securenow.ai/dashboard/settings/instances';
|
|
181
181
|
link.target = '_blank';
|
|
182
182
|
link.rel = 'noopener';
|
|
183
183
|
link.style.cssText = 'color:#664D03;font-weight:600;text-decoration:underline';
|
|
184
|
-
link.textContent = '
|
|
184
|
+
link.textContent = 'create a new production instance';
|
|
185
185
|
msg.appendChild(link);
|
|
186
186
|
msg.appendChild(document.createTextNode('.'));
|
|
187
187
|
d.appendChild(msg);
|
|
188
188
|
|
|
189
|
+
const upgradeBtn = document.createElement('a');
|
|
190
|
+
upgradeBtn.href = 'https://app.securenow.ai/dashboard/settings/instances';
|
|
191
|
+
upgradeBtn.target = '_blank';
|
|
192
|
+
upgradeBtn.rel = 'noopener';
|
|
193
|
+
upgradeBtn.textContent = '\u26a1 Upgrade';
|
|
194
|
+
upgradeBtn.style.cssText =
|
|
195
|
+
'display:inline-flex;align-items:center;gap:4px;' +
|
|
196
|
+
'background:#D97706;color:#fff;padding:4px 12px;border-radius:4px;' +
|
|
197
|
+
'font-size:12px;font-weight:600;text-decoration:none;margin-left:10px;' +
|
|
198
|
+
'white-space:nowrap;transition:background 0.15s';
|
|
199
|
+
upgradeBtn.onmouseover = () => { upgradeBtn.style.background = '#B45309'; };
|
|
200
|
+
upgradeBtn.onmouseout = () => { upgradeBtn.style.background = '#D97706'; };
|
|
201
|
+
d.appendChild(upgradeBtn);
|
|
202
|
+
|
|
189
203
|
const close = document.createElement('button');
|
|
190
204
|
close.textContent = '\u00d7';
|
|
191
205
|
close.style.cssText =
|