securenow 8.6.0 → 8.7.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/cli/security.js +80 -8
- package/cli.js +10 -3
- package/nextjs-auto-capture.js +274 -195
- package/nextjs-middleware.js +268 -185
- package/nextjs-wrapper.js +234 -155
- package/nextjs.js +768 -685
- package/nuxt-server-plugin.mjs +506 -426
- package/package.json +1 -1
- package/tracing.js +844 -758
package/cli/security.js
CHANGED
|
@@ -63,17 +63,68 @@ async function alertRulesRoute(args, flags) {
|
|
|
63
63
|
if (sub === 'exclusions') {
|
|
64
64
|
return alertRuleExclusions(args.slice(1), flags);
|
|
65
65
|
}
|
|
66
|
+
if (sub === 'promote') {
|
|
67
|
+
return alertRuleSetMode(args.slice(1), flags, 'prod');
|
|
68
|
+
}
|
|
69
|
+
if (sub === 'demote') {
|
|
70
|
+
return alertRuleSetMode(args.slice(1), flags, 'test');
|
|
71
|
+
}
|
|
66
72
|
if (sub === 'list') {
|
|
67
73
|
return alertRulesList(args.slice(1), flags);
|
|
68
74
|
}
|
|
69
75
|
return alertRulesList(args, flags);
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
// Promote (test -> prod) or demote (prod -> test) one or more rules. In test mode a
|
|
79
|
+
// rule detects only — instant.block, automations, and the autonomous operator all
|
|
80
|
+
// skip its notifications. Promoting to prod makes those mitigations eligible.
|
|
81
|
+
async function alertRuleSetMode(args, flags, mode) {
|
|
82
|
+
requireAuth();
|
|
83
|
+
const ids = args.filter(Boolean);
|
|
84
|
+
const verb = mode === 'prod' ? 'promote' : 'demote';
|
|
85
|
+
if (ids.length === 0) {
|
|
86
|
+
ui.error(`Usage: securenow alerts rules ${verb} <rule-id> [<rule-id> ...] [--yes]`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
if (mode === 'prod' && !flags.yes && !flags.force) {
|
|
90
|
+
ui.warn('Promoting to prod ARMS automatic mitigation (instant.block, automations, autonomous operator) for matching detections.');
|
|
91
|
+
const ok = await ui.confirm(`Promote ${ids.length} rule${ids.length !== 1 ? 's' : ''} to prod (mitigations become eligible)?`);
|
|
92
|
+
if (!ok) { ui.info('Cancelled'); return; }
|
|
93
|
+
}
|
|
94
|
+
let okCount = 0;
|
|
95
|
+
const failures = [];
|
|
96
|
+
for (const id of ids) {
|
|
97
|
+
const s = ui.spinner(`${mode === 'prod' ? 'Promoting' : 'Demoting'} ${id}`);
|
|
98
|
+
try {
|
|
99
|
+
await api.put(`/alert-rules/${id}`, { mode });
|
|
100
|
+
s.stop(`${ui.truncate(id, 12)} → ${mode}`);
|
|
101
|
+
okCount++;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
s.fail(`Failed on ${ui.truncate(id, 12)}: ${err.message}`);
|
|
104
|
+
failures.push(id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (flags.json) { ui.json({ mode, updated: okCount, failed: failures }); return; }
|
|
108
|
+
console.log('');
|
|
109
|
+
ui.success(`${okCount}/${ids.length} rule${ids.length !== 1 ? 's' : ''} set to ${mode}.`);
|
|
110
|
+
if (mode === 'prod') ui.info('Enable the matching automations to let mitigations act. Reverse anytime: securenow alerts rules demote <id>');
|
|
111
|
+
else ui.info('Detect-only — no mitigation will act. Promote when confident: securenow alerts rules promote <id>');
|
|
112
|
+
}
|
|
113
|
+
|
|
72
114
|
async function alertRulesList(args, flags) {
|
|
73
115
|
requireAuth();
|
|
116
|
+
// Server-side filters (API supports ?mode/status/active/isSystem).
|
|
117
|
+
const query = {};
|
|
118
|
+
if (flags.mode === 'test' || flags.mode === 'prod') query.mode = flags.mode;
|
|
119
|
+
if (['Active', 'Disabled', 'Paused'].includes(flags.status)) query.status = flags.status;
|
|
120
|
+
if (flags.active === true || flags.active === 'true') query.active = 'true';
|
|
121
|
+
else if (flags.active === false || flags.active === 'false') query.active = 'false';
|
|
122
|
+
if (flags.system === true || flags.system === 'true') query.isSystem = 'true';
|
|
123
|
+
else if (flags.user === true || flags.user === 'true') query.isSystem = 'false';
|
|
124
|
+
|
|
74
125
|
const s = ui.spinner('Fetching alert rules');
|
|
75
126
|
try {
|
|
76
|
-
const data = await api.get('/alert-rules');
|
|
127
|
+
const data = await api.get('/alert-rules', Object.keys(query).length ? { query } : undefined);
|
|
77
128
|
const rules = data.alertRules || [];
|
|
78
129
|
s.stop(`Found ${rules.length} rule${rules.length !== 1 ? 's' : ''}`);
|
|
79
130
|
|
|
@@ -81,15 +132,17 @@ async function alertRulesList(args, flags) {
|
|
|
81
132
|
|
|
82
133
|
console.log('');
|
|
83
134
|
const rows = rules.map((r) => [
|
|
84
|
-
ui.c.dim(ui.truncate(r._id, 12)),
|
|
135
|
+
ui.c.dim(ui.truncate(r._id || r.id, 12)),
|
|
85
136
|
r.name || '—',
|
|
86
137
|
ruleStatusBadge(r),
|
|
138
|
+
r.mode === 'test' ? ui.c.yellow('test') : ui.c.dim('prod'),
|
|
87
139
|
formatRuleApplicationsCell(r),
|
|
88
140
|
r.schedule?.enabled === false ? ui.c.dim('off') : (r.schedule?.description || r.schedule?.cronExpression || '—'),
|
|
89
141
|
]);
|
|
90
|
-
ui.table(['ID', 'Name', 'Status', 'Applications', 'Schedule'], rows);
|
|
142
|
+
ui.table(['ID', 'Name', 'Status', 'Mode', 'Applications', 'Schedule'], rows);
|
|
91
143
|
console.log('');
|
|
92
|
-
console.log(ui.c.dim('
|
|
144
|
+
console.log(ui.c.dim(' Filters: --mode test|prod --status Active|Disabled|Paused --active true|false --system|--user'));
|
|
145
|
+
console.log(ui.c.dim(' Mode: test = detect-only (no mitigation, human review). Promote: securenow alerts rules promote <id>'));
|
|
93
146
|
console.log('');
|
|
94
147
|
} catch (err) {
|
|
95
148
|
s.fail('Failed to fetch alert rules');
|
|
@@ -236,6 +289,7 @@ async function alertRuleShow(args, flags) {
|
|
|
236
289
|
ui.keyValue([
|
|
237
290
|
['ID', r._id || r.id || id],
|
|
238
291
|
['Status', r.status || '—'],
|
|
292
|
+
['Mode', r.mode === 'test' ? ui.c.yellow('test (detect-only, no mitigation)') : (r.mode || 'prod')],
|
|
239
293
|
['System rule', r.isSystem ? 'yes' : 'no'],
|
|
240
294
|
['Applications', appLine],
|
|
241
295
|
['Schedule', r.schedule?.enabled === false ? 'disabled' : (r.schedule?.description || r.schedule?.cronExpression || '—')],
|
|
@@ -258,7 +312,7 @@ async function alertRuleUpdate(args, flags) {
|
|
|
258
312
|
}
|
|
259
313
|
const id = args[0];
|
|
260
314
|
if (!id) {
|
|
261
|
-
ui.error('Usage: securenow alerts rules update <rule-id>
|
|
315
|
+
ui.error('Usage: securenow alerts rules update <rule-id> [--applications-all | --apps <k1,k2>] [--status Active|Disabled|Paused | --enable | --disable | --pause] [--mode test|prod]');
|
|
262
316
|
process.exit(1);
|
|
263
317
|
}
|
|
264
318
|
|
|
@@ -297,8 +351,25 @@ async function alertRuleUpdate(args, flags) {
|
|
|
297
351
|
ui.error('No application keys parsed from --apps');
|
|
298
352
|
process.exit(1);
|
|
299
353
|
}
|
|
300
|
-
}
|
|
301
|
-
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Status: --status X, or convenience --enable / --disable / --pause.
|
|
357
|
+
let status = null;
|
|
358
|
+
if (flags.enable === true || flags.enable === 'true') status = 'Active';
|
|
359
|
+
else if (flags.disable === true || flags.disable === 'true') status = 'Disabled';
|
|
360
|
+
else if (flags.pause === true || flags.pause === 'true') status = 'Paused';
|
|
361
|
+
else if (flags.status) {
|
|
362
|
+
const match = ['Active', 'Disabled', 'Paused'].find((x) => x.toLowerCase() === String(flags.status).toLowerCase());
|
|
363
|
+
if (!match) { ui.error('--status must be Active, Disabled, or Paused'); process.exit(1); }
|
|
364
|
+
status = match;
|
|
365
|
+
}
|
|
366
|
+
if (status) body.status = status;
|
|
367
|
+
|
|
368
|
+
// Lifecycle mode promote/demote (also available as: alerts rules promote|demote <id>).
|
|
369
|
+
if (flags.mode === 'test' || flags.mode === 'prod') body.mode = flags.mode;
|
|
370
|
+
|
|
371
|
+
if (Object.keys(body).length === 0) {
|
|
372
|
+
ui.error('Nothing to update. Use --applications-all/--apps, --status (or --enable/--disable/--pause), or --mode test|prod (or: alerts rules promote/demote <id>).');
|
|
302
373
|
process.exit(1);
|
|
303
374
|
}
|
|
304
375
|
|
|
@@ -306,7 +377,8 @@ async function alertRuleUpdate(args, flags) {
|
|
|
306
377
|
try {
|
|
307
378
|
await api.put(`/alert-rules/${id}`, body);
|
|
308
379
|
s.stop('Updated');
|
|
309
|
-
|
|
380
|
+
const extras = [body.status && `status → ${body.status}`, body.mode && `mode → ${body.mode}`].filter(Boolean);
|
|
381
|
+
ui.success('Alert rule updated' + (extras.length ? ` (${extras.join(', ')})` : ''));
|
|
310
382
|
} catch (err) {
|
|
311
383
|
s.fail('Failed to update alert rule');
|
|
312
384
|
throw err;
|
package/cli.js
CHANGED
|
@@ -255,8 +255,8 @@ const COMMANDS = {
|
|
|
255
255
|
usage: 'securenow alerts <subcommand> [options]',
|
|
256
256
|
sub: {
|
|
257
257
|
rules: {
|
|
258
|
-
desc: 'Create, list, show, update, set-sql, validate, delete, test, or tune alert rules',
|
|
259
|
-
usage: 'securenow alerts rules <list|create|show|update|set-sql|validate|delete|test|dry-run-query|tune-query|exclusions> [options]',
|
|
258
|
+
desc: 'Create, list, show, update, promote/demote, set-sql, validate, delete, test, or tune alert rules',
|
|
259
|
+
usage: 'securenow alerts rules <list|create|show|update|promote|demote|set-sql|validate|delete|test|dry-run-query|tune-query|exclusions> [options]',
|
|
260
260
|
flags: {
|
|
261
261
|
json: 'Output as JSON',
|
|
262
262
|
name: 'With create: rule name',
|
|
@@ -275,7 +275,14 @@ const COMMANDS = {
|
|
|
275
275
|
'no-applications-all': 'With update: scope to explicit --apps list',
|
|
276
276
|
apps: 'Comma-separated app keys (with create/update)',
|
|
277
277
|
app: 'Application key for rule tests',
|
|
278
|
-
mode: '
|
|
278
|
+
mode: 'With test: dry_run | live. With list/update: lifecycle test | prod (test = detect-only, no mitigation)',
|
|
279
|
+
status: 'With update: Active | Disabled | Paused. With list: filter by status',
|
|
280
|
+
enable: 'With update: shortcut for --status Active',
|
|
281
|
+
disable: 'With update: shortcut for --status Disabled',
|
|
282
|
+
pause: 'With update: shortcut for --status Paused',
|
|
283
|
+
active: 'With list: filter active (true) or non-active (false)',
|
|
284
|
+
system: 'With list: only system rules',
|
|
285
|
+
user: 'With list: only user (non-system) rules',
|
|
279
286
|
wait: 'Wait for rule test completion',
|
|
280
287
|
sql: 'Detection/candidate/replacement SQL, @file, or - for stdin (create, set-sql, update, validate, dry-run-query, tune-query)',
|
|
281
288
|
query: 'Alias for --sql',
|
package/nextjs-auto-capture.js
CHANGED
|
@@ -1,195 +1,274 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SecureNow Next.js Automatic Body Capture
|
|
3
|
-
*
|
|
4
|
-
* This module automatically patches Next.js request handling to capture bodies
|
|
5
|
-
* WITHOUT requiring customers to wrap their handlers or change their code.
|
|
6
|
-
*
|
|
7
|
-
* Usage in instrumentation.ts:
|
|
8
|
-
*
|
|
9
|
-
* import { registerSecureNow } from 'securenow/nextjs';
|
|
10
|
-
* import 'securenow/nextjs-auto-capture'; // Just import this line!
|
|
11
|
-
*
|
|
12
|
-
* export function register() {
|
|
13
|
-
* registerSecureNow();
|
|
14
|
-
* }
|
|
15
|
-
*
|
|
16
|
-
* That's it! Bodies are now captured automatically.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const { trace } = require('@opentelemetry/api');
|
|
20
|
-
const appConfig = require('./app-config');
|
|
21
|
-
|
|
22
|
-
// Default sensitive fields to redact
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
} else {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
};
|
|
195
|
-
|
|
1
|
+
/**
|
|
2
|
+
* SecureNow Next.js Automatic Body Capture
|
|
3
|
+
*
|
|
4
|
+
* This module automatically patches Next.js request handling to capture bodies
|
|
5
|
+
* WITHOUT requiring customers to wrap their handlers or change their code.
|
|
6
|
+
*
|
|
7
|
+
* Usage in instrumentation.ts:
|
|
8
|
+
*
|
|
9
|
+
* import { registerSecureNow } from 'securenow/nextjs';
|
|
10
|
+
* import 'securenow/nextjs-auto-capture'; // Just import this line!
|
|
11
|
+
*
|
|
12
|
+
* export function register() {
|
|
13
|
+
* registerSecureNow();
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* That's it! Bodies are now captured automatically.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { trace } = require('@opentelemetry/api');
|
|
20
|
+
const appConfig = require('./app-config');
|
|
21
|
+
|
|
22
|
+
// Default sensitive fields to redact from request bodies.
|
|
23
|
+
// Matched substring-wise against lowercased keys (see redactSensitiveData),
|
|
24
|
+
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
25
|
+
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
26
|
+
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
27
|
+
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
28
|
+
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
29
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
30
|
+
// credentials / auth
|
|
31
|
+
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
32
|
+
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
33
|
+
'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
|
|
34
|
+
'cookie', 'set-cookie',
|
|
35
|
+
// financial
|
|
36
|
+
'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
|
|
37
|
+
'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
|
|
38
|
+
// PII
|
|
39
|
+
'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
|
|
40
|
+
'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
|
|
41
|
+
'passport', 'license',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Conservative value-shape redactors. Key-name matching misses secrets that
|
|
45
|
+
// land in free-form string values (GraphQL bodies, message fields, etc.), so
|
|
46
|
+
// as a second layer we scrub string VALUES that *look like* a secret/PII.
|
|
47
|
+
// These are intentionally precise/bounded so they don't garble normal prose,
|
|
48
|
+
// and they only ever transform captured telemetry strings (read-only) — never
|
|
49
|
+
// the actual request/response stream. Compiled once at module load.
|
|
50
|
+
const VALUE_REDACTORS = [
|
|
51
|
+
// JWT: three base64url segments. Anchored to the eyJ header so it won't
|
|
52
|
+
// match arbitrary dotted tokens.
|
|
53
|
+
{ name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
|
|
54
|
+
// Bearer/Basic auth header value embedded in a body string.
|
|
55
|
+
{ name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
|
|
56
|
+
// Stripe-style / SecureNow live+test API keys.
|
|
57
|
+
{ name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
|
|
58
|
+
// Email addresses (bounded local/domain parts).
|
|
59
|
+
{ name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
|
|
60
|
+
// Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
|
|
61
|
+
// space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
|
|
62
|
+
{ name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// Skip the value-shape scan on very large strings to bound per-body cost.
|
|
66
|
+
const MAX_VALUE_SCAN_LENGTH = 16384;
|
|
67
|
+
|
|
68
|
+
function luhnValid(digits) {
|
|
69
|
+
let sum = 0;
|
|
70
|
+
let alt = false;
|
|
71
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
72
|
+
let d = digits.charCodeAt(i) - 48;
|
|
73
|
+
if (d < 0 || d > 9) return false;
|
|
74
|
+
if (alt) { d *= 2; if (d > 9) d -= 9; }
|
|
75
|
+
sum += d;
|
|
76
|
+
alt = !alt;
|
|
77
|
+
}
|
|
78
|
+
return sum % 10 === 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Redact obvious secret/PII shapes inside a captured string VALUE.
|
|
83
|
+
* Returns the input unchanged when nothing matches (cheap common case).
|
|
84
|
+
*/
|
|
85
|
+
function redactSensitiveValue(value) {
|
|
86
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
87
|
+
if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound the work
|
|
88
|
+
let out = value;
|
|
89
|
+
for (const r of VALUE_REDACTORS) {
|
|
90
|
+
r.re.lastIndex = 0;
|
|
91
|
+
if (r.luhn) {
|
|
92
|
+
out = out.replace(r.re, (m) => {
|
|
93
|
+
const digits = m.replace(/[ -]/g, '');
|
|
94
|
+
if (digits.length < 13 || digits.length > 19) return m;
|
|
95
|
+
return luhnValid(digits) ? '[REDACTED]' : m;
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
out = out.replace(r.re, '[REDACTED]');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Redact sensitive fields from an object
|
|
106
|
+
*/
|
|
107
|
+
function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
108
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
109
|
+
|
|
110
|
+
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
111
|
+
|
|
112
|
+
for (const key of Object.keys(redacted)) {
|
|
113
|
+
const lowerKey = key.toLowerCase();
|
|
114
|
+
|
|
115
|
+
if (sensitiveFields.some(field => lowerKey.includes(field.toLowerCase()))) {
|
|
116
|
+
redacted[key] = '[REDACTED]';
|
|
117
|
+
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
118
|
+
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
|
119
|
+
} else if (typeof redacted[key] === 'string') {
|
|
120
|
+
// Second layer: even if the key name looks benign, scrub values that
|
|
121
|
+
// *look like* a secret/PII (JWT, bearer token, API key, email, card).
|
|
122
|
+
redacted[key] = redactSensitiveValue(redacted[key]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return redacted;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Safe body capture that doesn't interfere with Next.js
|
|
131
|
+
*/
|
|
132
|
+
async function safeBodyCapture(request, span) {
|
|
133
|
+
if (!span) return;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const contentType = request.headers.get('content-type') || '';
|
|
137
|
+
const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
|
|
138
|
+
const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
|
|
139
|
+
const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
|
|
140
|
+
|
|
141
|
+
// Only for supported types
|
|
142
|
+
if (!contentType.includes('application/json') &&
|
|
143
|
+
!contentType.includes('application/graphql') &&
|
|
144
|
+
!contentType.includes('application/x-www-form-urlencoded')) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Try to read from cache if available (Next.js may have already read it)
|
|
149
|
+
let bodyText;
|
|
150
|
+
|
|
151
|
+
// Attempt 1: Check if body was already cached by Next.js
|
|
152
|
+
if (request._bodyText) {
|
|
153
|
+
bodyText = request._bodyText;
|
|
154
|
+
} else {
|
|
155
|
+
// Attempt 2: Try to clone and read
|
|
156
|
+
try {
|
|
157
|
+
const cloned = request.clone();
|
|
158
|
+
bodyText = await cloned.text();
|
|
159
|
+
// Cache it for Next.js
|
|
160
|
+
request._bodyText = bodyText;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
// If clone fails, body was already consumed - skip silently
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (bodyText.length > maxBodySize) {
|
|
168
|
+
span.setAttribute('http.request.body', `[TOO LARGE: ${bodyText.length} bytes]`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Parse and redact
|
|
173
|
+
if (contentType.includes('application/json') || contentType.includes('application/graphql')) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(bodyText);
|
|
176
|
+
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
177
|
+
span.setAttributes({
|
|
178
|
+
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
179
|
+
'http.request.body.type': contentType.includes('graphql') ? 'graphql' : 'json',
|
|
180
|
+
'http.request.body.size': bodyText.length,
|
|
181
|
+
});
|
|
182
|
+
} catch (e) {
|
|
183
|
+
// Parse error - skip
|
|
184
|
+
}
|
|
185
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
186
|
+
try {
|
|
187
|
+
const params = new URLSearchParams(bodyText);
|
|
188
|
+
const parsed = Object.fromEntries(params);
|
|
189
|
+
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
190
|
+
span.setAttributes({
|
|
191
|
+
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
192
|
+
'http.request.body.type': 'form',
|
|
193
|
+
'http.request.body.size': bodyText.length,
|
|
194
|
+
});
|
|
195
|
+
} catch (e) {
|
|
196
|
+
// Parse error - skip
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// Silently fail - never break the request
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if body capture is enabled
|
|
206
|
+
*/
|
|
207
|
+
function isBodyCaptureEnabled() {
|
|
208
|
+
return appConfig.boolConfig('capture.body', true);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Patch Next.js Request to cache body text
|
|
213
|
+
* This allows us to read the body without consuming it
|
|
214
|
+
*/
|
|
215
|
+
function patchNextRequest() {
|
|
216
|
+
if (typeof Request === 'undefined') return;
|
|
217
|
+
|
|
218
|
+
const originalText = Request.prototype.text;
|
|
219
|
+
const originalJson = Request.prototype.json;
|
|
220
|
+
|
|
221
|
+
// Patch text() to cache result
|
|
222
|
+
Request.prototype.text = async function() {
|
|
223
|
+
if (this._bodyText !== undefined) {
|
|
224
|
+
return this._bodyText;
|
|
225
|
+
}
|
|
226
|
+
const text = await originalText.call(this);
|
|
227
|
+
this._bodyText = text;
|
|
228
|
+
|
|
229
|
+
// Capture for tracing if enabled
|
|
230
|
+
if (isBodyCaptureEnabled() && ['POST', 'PUT', 'PATCH'].includes(this.method)) {
|
|
231
|
+
const span = trace.getActiveSpan();
|
|
232
|
+
if (span) {
|
|
233
|
+
// Schedule capture after this call (non-blocking)
|
|
234
|
+
setImmediate(() => {
|
|
235
|
+
safeBodyCapture(this, span).catch(() => {});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return text;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Patch json() to cache and capture
|
|
244
|
+
Request.prototype.json = async function() {
|
|
245
|
+
// First get text
|
|
246
|
+
const text = await this.text();
|
|
247
|
+
// Then parse
|
|
248
|
+
return JSON.parse(text);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
console.log('[securenow] ✅ Auto-capture: Patched Next.js Request for automatic body capture');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Auto-patch when module is imported
|
|
255
|
+
if (isBodyCaptureEnabled()) {
|
|
256
|
+
try {
|
|
257
|
+
patchNextRequest();
|
|
258
|
+
console.log('[securenow] 📝 Automatic body capture: ENABLED');
|
|
259
|
+
console.log('[securenow] 💡 No code changes needed - bodies captured automatically!');
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.warn('[securenow] ⚠️ Auto-capture patch failed:', error.message);
|
|
262
|
+
console.warn('[securenow] 💡 Body capture disabled. Use manual approach if needed.');
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
console.log('[securenow] Automatic body capture: DISABLED (config.capture.body=false)');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = {
|
|
269
|
+
patchNextRequest,
|
|
270
|
+
safeBodyCapture,
|
|
271
|
+
redactSensitiveData,
|
|
272
|
+
isBodyCaptureEnabled,
|
|
273
|
+
};
|
|
274
|
+
|