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/register-vite.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// node_modules/securenow/register-vite.js (CommonJS, for -r)
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
// Trace the Node process
|
|
5
|
-
|
|
4
|
+
// Trace and protect the Node process that serves a Vite app (vite dev / vite preview, or a
|
|
5
|
+
// Vite SSR server): the same runtime as securenow/register.
|
|
6
|
+
// node -r securenow/register-vite node_modules/vite/bin/vite.js preview
|
|
7
|
+
// (It used to load the browser tracer from web-vite.mjs, which sends nothing since browser
|
|
8
|
+
// ingestion was disabled in 8.0, so this preload captured nothing at all.)
|
|
9
|
+
module.exports = require('./register');
|
package/register.d.ts
CHANGED
|
@@ -1,61 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SecureNow Register Module TypeScript Declarations
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Preload for Node.js servers (Express, Fastify, NestJS, Koa, Hapi, Hono, Polka, Feathers, h3,
|
|
5
|
+
* raw node:http): `node -r securenow/register <entry>`. It starts tracing, logs, body capture,
|
|
6
|
+
* the firewall and (when configured) session revocation before the app loads.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* Configuration:
|
|
10
|
-
*
|
|
11
|
-
* Local development reads .securenow/credentials.json. Run
|
|
12
|
-
* `npx securenow login` to choose/create an app, then `npx securenow init`
|
|
13
|
-
* to ensure secure defaults and explanations are present.
|
|
14
|
-
*
|
|
15
|
-
* Production uses the same file shape. Run
|
|
16
|
-
* `npx securenow credentials runtime --env production`, then mount/copy the
|
|
17
|
-
* generated JSON to .securenow/credentials.json in the running app.
|
|
10
|
+
* Configuration: .securenow/runtime.json, written by `npx securenow login`; `npx securenow init`
|
|
11
|
+
* adds the secure defaults with explanations.
|
|
18
12
|
*
|
|
19
|
-
*
|
|
13
|
+
* Production: `npx securenow credentials runtime --env production`, then mount/copy the file to
|
|
14
|
+
* .securenow/credentials.json in the running app, or store the `--format env` output
|
|
15
|
+
* (SECURENOW_CREDENTIALS) as a secret environment variable on hosts without file mounts.
|
|
16
|
+
* SECURENOW_DISABLED=1 turns the SDK off (tests).
|
|
20
17
|
*/
|
|
21
18
|
|
|
22
19
|
// This module has side effects (initializes OpenTelemetry)
|
|
23
|
-
// It's typically
|
|
20
|
+
// It's typically preloaded with -r, not imported
|
|
24
21
|
export {};
|
|
25
22
|
|
|
26
23
|
/**
|
|
27
24
|
* @example
|
|
28
25
|
* ```bash
|
|
29
|
-
* #
|
|
30
|
-
*
|
|
26
|
+
* # Preload (CommonJS, ESM and TypeScript alike)
|
|
27
|
+
* node -r securenow/register app.js
|
|
28
|
+
* node -r securenow/register --import tsx src/index.ts
|
|
29
|
+
* NODE_OPTIONS="-r securenow/register" npm start
|
|
31
30
|
* ```
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* ```bash
|
|
35
|
-
* # Using with PM2
|
|
36
|
-
* pm2 start app.js --node-args="-r securenow/register"
|
|
37
|
-
* ```
|
|
38
|
-
*
|
|
31
|
+
*
|
|
39
32
|
* @example
|
|
40
33
|
* ```javascript
|
|
41
|
-
* // ecosystem.config.
|
|
34
|
+
* // ecosystem.config.cjs (PM2, fork or cluster mode)
|
|
42
35
|
* module.exports = {
|
|
43
36
|
* apps: [{
|
|
44
37
|
* name: 'my-app',
|
|
45
38
|
* script: './app.js',
|
|
46
|
-
*
|
|
47
|
-
* //
|
|
39
|
+
* // An absolute path: a bare '-r securenow/register' is resolved from the PM2 daemon's
|
|
40
|
+
* // folder in cluster mode, and the workers crash-loop with "Cannot find module".
|
|
41
|
+
* node_args: ['-r', require.resolve('securenow/register')],
|
|
42
|
+
* instances: 'max',
|
|
43
|
+
* exec_mode: 'cluster',
|
|
48
44
|
* }]
|
|
49
45
|
* };
|
|
50
46
|
* ```
|
|
51
|
-
*
|
|
47
|
+
*
|
|
52
48
|
* @example
|
|
53
|
-
* ```
|
|
54
|
-
* //
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* import express from 'express';
|
|
58
|
-
* const app = express();
|
|
59
|
-
* // Your app code...
|
|
49
|
+
* ```javascript
|
|
50
|
+
* // First line of a CommonJS entry (alternative to -r)
|
|
51
|
+
* require('securenow/register');
|
|
52
|
+
* const express = require('express');
|
|
60
53
|
* ```
|
|
61
54
|
*/
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Request capture shared by every SecureNow HTTP integration: the Node preload
|
|
5
|
+
* (tracing.js), Next.js (nextjs.js) and Nuxt (through the preload). Detection rules
|
|
6
|
+
* read the same attributes whatever the framework: http.request.headers (redacted)
|
|
7
|
+
* and http.request.body (redacted, size-capped, multipart as metadata only).
|
|
8
|
+
*
|
|
9
|
+
* The body is observed, never consumed: the request's own emit('data') is wrapped,
|
|
10
|
+
* so the application reads its body exactly as it would without SecureNow.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { OTP_SENSITIVE_FIELDS, isOtpCodeField, matchesSensitiveField, redactOtpGraphQL, redactOtpQuery } = require('./otp-redaction');
|
|
14
|
+
const { createHeaderNameRedactor } = require('./header-redaction');
|
|
15
|
+
|
|
16
|
+
// Default sensitive fields to redact from request bodies.
|
|
17
|
+
// OTP/code aliases use exact normalized matching; other fields are matched
|
|
18
|
+
// substring-wise against lowercased keys (see redactSensitiveData),
|
|
19
|
+
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
20
|
+
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
21
|
+
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
22
|
+
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
23
|
+
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
24
|
+
const DEFAULT_SENSITIVE_FIELDS = [
|
|
25
|
+
...OTP_SENSITIVE_FIELDS,
|
|
26
|
+
// credentials / auth
|
|
27
|
+
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
28
|
+
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
29
|
+
'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
|
|
30
|
+
'cookie', 'set-cookie',
|
|
31
|
+
// financial
|
|
32
|
+
'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
|
|
33
|
+
'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
|
|
34
|
+
// PII
|
|
35
|
+
'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
|
|
36
|
+
'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
|
|
37
|
+
'passport', 'license',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Conservative value-shape redactors. Key-name matching misses secrets that
|
|
41
|
+
// land in free-form string values (GraphQL bodies, message fields, etc.), so
|
|
42
|
+
// as a second layer we scrub string VALUES that *look like* a secret/PII.
|
|
43
|
+
// These are intentionally precise/bounded so they don't garble normal prose,
|
|
44
|
+
// and they only ever transform captured telemetry strings (read-only) — never
|
|
45
|
+
// the actual request/response stream. Compiled once at module load.
|
|
46
|
+
const VALUE_REDACTORS = [
|
|
47
|
+
// The JWT and email patterns only start where a run of their characters starts (the
|
|
48
|
+
// lookbehind), so a long run that holds no match is scanned once, not once per position:
|
|
49
|
+
// a crafted 10KB value costs microseconds, not 50ms+ of event loop.
|
|
50
|
+
// JWT: three base64url segments. Anchored to the eyJ header so it won't
|
|
51
|
+
// match arbitrary dotted tokens.
|
|
52
|
+
{ name: 'jwt', hint: 'eyJ', re: /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
|
|
53
|
+
// Bearer/Basic auth header value embedded in a body string.
|
|
54
|
+
{ name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
|
|
55
|
+
// Stripe-style / SecureNow live+test API keys.
|
|
56
|
+
{ name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
|
|
57
|
+
// Email addresses (bounded local/domain parts: 64 and 253 characters at most).
|
|
58
|
+
{ name: 'email', hint: '@', re: /(?<![\w.%+-])[\w.%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}\b/g },
|
|
59
|
+
// Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
|
|
60
|
+
// space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
|
|
61
|
+
{ name: 'card', re: /\b(?:\d[ -]?){12,18}\d\b/g, luhn: true },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Skip the value-shape scan on very large strings to bound per-body cost.
|
|
65
|
+
const MAX_VALUE_SCAN_LENGTH = 16384;
|
|
66
|
+
|
|
67
|
+
function luhnValid(digits) {
|
|
68
|
+
let sum = 0;
|
|
69
|
+
let alt = false;
|
|
70
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
71
|
+
let d = digits.charCodeAt(i) - 48;
|
|
72
|
+
if (d < 0 || d > 9) return false;
|
|
73
|
+
if (alt) { d *= 2; if (d > 9) d -= 9; }
|
|
74
|
+
sum += d;
|
|
75
|
+
alt = !alt;
|
|
76
|
+
}
|
|
77
|
+
return sum % 10 === 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Redact obvious secret/PII shapes inside a captured string VALUE.
|
|
82
|
+
* Returns the input unchanged when nothing matches (cheap common case).
|
|
83
|
+
* maxScanLength: longer values skip the shape scan (log lines pass a larger bound).
|
|
84
|
+
*/
|
|
85
|
+
function redactSensitiveValue(value, maxScanLength = MAX_VALUE_SCAN_LENGTH) {
|
|
86
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
87
|
+
// Credential assignments in GraphQL/query strings need field-aware
|
|
88
|
+
// redaction even though a short numeric OTP has no unique value shape.
|
|
89
|
+
value = redactOtpQuery(redactOtpGraphQL(value));
|
|
90
|
+
if (value.length > maxScanLength) return value; // bound shape scans
|
|
91
|
+
let out = value;
|
|
92
|
+
for (const r of VALUE_REDACTORS) {
|
|
93
|
+
if (r.hint && !out.includes(r.hint)) continue;
|
|
94
|
+
r.re.lastIndex = 0;
|
|
95
|
+
if (r.luhn) {
|
|
96
|
+
out = out.replace(r.re, (m) => {
|
|
97
|
+
const digits = m.replace(/[ -]/g, '');
|
|
98
|
+
if (digits.length < 13 || digits.length > 19) return m;
|
|
99
|
+
return luhnValid(digits) ? '[REDACTED]' : m;
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
out = out.replace(r.re, '[REDACTED]');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function escapeRegex(str) {
|
|
109
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Redact sensitive fields from an object
|
|
114
|
+
*/
|
|
115
|
+
function redactSensitiveData(obj, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
116
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
117
|
+
|
|
118
|
+
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
119
|
+
|
|
120
|
+
for (const key of Object.keys(redacted)) {
|
|
121
|
+
|
|
122
|
+
if (matchesSensitiveField(key, sensitiveFields)) {
|
|
123
|
+
redacted[key] = '[REDACTED]';
|
|
124
|
+
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
125
|
+
redacted[key] = redactSensitiveData(redacted[key], sensitiveFields);
|
|
126
|
+
} else if (typeof redacted[key] === 'string') {
|
|
127
|
+
// Second layer: even if the key name looks benign, scrub values that
|
|
128
|
+
// *look like* a secret/PII (JWT, bearer token, API key, email, card).
|
|
129
|
+
redacted[key] = redactSensitiveValue(redacted[key]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return redacted;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Redact sensitive data from GraphQL query strings
|
|
138
|
+
*/
|
|
139
|
+
function redactGraphQLQuery(query, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
140
|
+
if (!query || typeof query !== 'string') return query;
|
|
141
|
+
|
|
142
|
+
let redacted = query;
|
|
143
|
+
|
|
144
|
+
// Redact sensitive fields in GraphQL arguments and variables
|
|
145
|
+
sensitiveFields.filter(field => field === 'otp' || !isOtpCodeField(field)).forEach(field => {
|
|
146
|
+
const escaped = escapeRegex(field);
|
|
147
|
+
const patterns = [
|
|
148
|
+
// Quoted values: password: "..." keeps its quotes.
|
|
149
|
+
new RegExp(`(${escaped}\\s*:\\s*["'])([^"']+)(["'])`, 'gi'),
|
|
150
|
+
// Unquoted values (variables, numbers). A value starting with a quote was handled above.
|
|
151
|
+
new RegExp(`(${escaped}\\s*:\\s*)([^\\s,})\n"'][^\\s,})\n]*)`, 'gi'),
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
patterns.forEach(pattern => {
|
|
155
|
+
// With two groups the third callback argument is the match offset, not a suffix: testing
|
|
156
|
+
// it for truthiness appended the offset to the redacted value ("[REDACTED]17").
|
|
157
|
+
redacted = redacted.replace(pattern, (match, prefix, value, suffix) => (
|
|
158
|
+
typeof suffix === 'string' ? `${prefix}[REDACTED]${suffix}` : `${prefix}[REDACTED]`
|
|
159
|
+
));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Second layer: scrub secret/PII *shapes* anywhere in the (free-form) query
|
|
164
|
+
// body — catches values the key-name pass above can't see.
|
|
165
|
+
redacted = redactOtpGraphQL(redactSensitiveValue(redacted));
|
|
166
|
+
|
|
167
|
+
return redacted;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Redact a captured body text by content type: the same rules as the HTTP-layer capture,
|
|
172
|
+
* for integrations that only see the body as text (Next.js Request.text()).
|
|
173
|
+
*/
|
|
174
|
+
function redactBodyText(bodyText, contentType, sensitiveFields = DEFAULT_SENSITIVE_FIELDS) {
|
|
175
|
+
const ct = String(contentType || '').toLowerCase();
|
|
176
|
+
if (ct.includes('application/graphql')) {
|
|
177
|
+
return { body: redactGraphQLQuery(bodyText, sensitiveFields), type: 'graphql' };
|
|
178
|
+
}
|
|
179
|
+
if (ct.includes('application/json')) {
|
|
180
|
+
return { body: JSON.stringify(redactSensitiveData(JSON.parse(bodyText), sensitiveFields)), type: 'json' };
|
|
181
|
+
}
|
|
182
|
+
if (ct.includes('application/x-www-form-urlencoded')) {
|
|
183
|
+
return { body: JSON.stringify(redactSensitiveData(Object.fromEntries(new URLSearchParams(bodyText)), sensitiveFields)), type: 'form' };
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// -------- Multipart streaming parser --------
|
|
189
|
+
// Streams through the request without buffering file content.
|
|
190
|
+
// Only part headers and text-field values are kept in memory,
|
|
191
|
+
// so memory stays bounded (~few KB) regardless of upload size.
|
|
192
|
+
|
|
193
|
+
function extractBoundary(contentType) {
|
|
194
|
+
const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/i);
|
|
195
|
+
return match ? (match[1] || match[2]) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createMultipartMetaCollector(contentType, sensitiveFields, maxTextFieldSize, onComplete) {
|
|
199
|
+
const boundary = extractBoundary(contentType);
|
|
200
|
+
if (!boundary) { onComplete({ error: 'BOUNDARY_NOT_FOUND' }); return null; }
|
|
201
|
+
|
|
202
|
+
const result = { fields: Object.create(null), files: [] };
|
|
203
|
+
let totalSize = 0;
|
|
204
|
+
let buf = Buffer.alloc(0);
|
|
205
|
+
|
|
206
|
+
const MAX_PARTS = 100;
|
|
207
|
+
let partCount = 0;
|
|
208
|
+
|
|
209
|
+
const FIRST_DELIM = Buffer.from('--' + boundary);
|
|
210
|
+
const DELIM = Buffer.from('\r\n--' + boundary);
|
|
211
|
+
const HDR_END = Buffer.from('\r\n\r\n');
|
|
212
|
+
|
|
213
|
+
let initialized = false;
|
|
214
|
+
let inHeaders = true;
|
|
215
|
+
let isFile = false;
|
|
216
|
+
let fldName = '';
|
|
217
|
+
let fName = '';
|
|
218
|
+
let pCT = '';
|
|
219
|
+
let bodyBytes = 0;
|
|
220
|
+
let textVal = '';
|
|
221
|
+
|
|
222
|
+
function flushPart() {
|
|
223
|
+
if (!fldName || fldName === '__proto__' || fldName === 'constructor' || fldName === 'prototype') return;
|
|
224
|
+
if (isFile) {
|
|
225
|
+
result.files.push({ field: fldName, filename: fName, contentType: pCT || 'unknown', size: bodyBytes });
|
|
226
|
+
} else {
|
|
227
|
+
const redact = matchesSensitiveField(fldName, sensitiveFields);
|
|
228
|
+
// Name-match redaction first; otherwise scrub secret/PII *shapes* in the value.
|
|
229
|
+
result.fields[fldName] = redact
|
|
230
|
+
? '[REDACTED]'
|
|
231
|
+
: redactSensitiveValue(textVal.substring(0, maxTextFieldSize));
|
|
232
|
+
}
|
|
233
|
+
fldName = ''; bodyBytes = 0; textVal = ''; partCount++;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function drain() {
|
|
237
|
+
if (!initialized) {
|
|
238
|
+
const i = buf.indexOf(FIRST_DELIM);
|
|
239
|
+
if (i === -1) {
|
|
240
|
+
if (buf.length > FIRST_DELIM.length + 4) buf = buf.slice(buf.length - FIRST_DELIM.length - 4);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
buf = buf.slice(i + FIRST_DELIM.length);
|
|
244
|
+
initialized = true;
|
|
245
|
+
inHeaders = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let guard = 200;
|
|
249
|
+
while (buf.length > 0 && guard-- > 0 && partCount < MAX_PARTS) {
|
|
250
|
+
if (inHeaders) {
|
|
251
|
+
if (buf.length >= 2 && buf[0] === 0x2D && buf[1] === 0x2D) { buf = Buffer.alloc(0); return; }
|
|
252
|
+
if (buf.length >= 2 && buf[0] === 0x0D && buf[1] === 0x0A) { buf = buf.slice(2); continue; }
|
|
253
|
+
|
|
254
|
+
const hi = buf.indexOf(HDR_END);
|
|
255
|
+
if (hi === -1) return;
|
|
256
|
+
|
|
257
|
+
const hdr = buf.slice(0, hi).toString('latin1');
|
|
258
|
+
buf = buf.slice(hi + 4);
|
|
259
|
+
|
|
260
|
+
const nm = hdr.match(/name="([^"]+)"/);
|
|
261
|
+
const fn = hdr.match(/filename="([^"]*)"/);
|
|
262
|
+
const ct = hdr.match(/Content-Type:\s*(.+)/i);
|
|
263
|
+
fldName = nm ? nm[1] : '';
|
|
264
|
+
fName = fn ? fn[1] : '';
|
|
265
|
+
pCT = ct ? ct[1].trim() : '';
|
|
266
|
+
isFile = !!fn;
|
|
267
|
+
bodyBytes = 0;
|
|
268
|
+
textVal = '';
|
|
269
|
+
inHeaders = false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const di = buf.indexOf(DELIM);
|
|
273
|
+
if (di === -1) {
|
|
274
|
+
const safe = Math.max(0, buf.length - DELIM.length - 2);
|
|
275
|
+
if (safe > 0) {
|
|
276
|
+
bodyBytes += safe;
|
|
277
|
+
if (!isFile && textVal.length < maxTextFieldSize) {
|
|
278
|
+
textVal += buf.slice(0, safe).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
279
|
+
}
|
|
280
|
+
buf = buf.slice(safe);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
bodyBytes += di;
|
|
286
|
+
if (!isFile && textVal.length < maxTextFieldSize) {
|
|
287
|
+
textVal += buf.slice(0, di).toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
288
|
+
}
|
|
289
|
+
flushPart();
|
|
290
|
+
buf = buf.slice(di + DELIM.length);
|
|
291
|
+
inHeaders = true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function onData(chunk) {
|
|
296
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
297
|
+
totalSize += data.length;
|
|
298
|
+
buf = Buffer.concat([buf, data]);
|
|
299
|
+
drain();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function onEnd() {
|
|
303
|
+
try {
|
|
304
|
+
if (!inHeaders && fldName) {
|
|
305
|
+
bodyBytes += buf.length;
|
|
306
|
+
if (!isFile) textVal += buf.toString('utf8').substring(0, maxTextFieldSize - textVal.length);
|
|
307
|
+
flushPart();
|
|
308
|
+
}
|
|
309
|
+
onComplete({ parsed: result, totalSize });
|
|
310
|
+
} catch (e) {
|
|
311
|
+
onComplete({ error: 'PARSE_ERROR' });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { onData, onEnd };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const BODY_CAPTURE_PATCH = Symbol.for('securenow.bodyCapture.emitPatch');
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* @param {{ captureBody?: boolean, captureMultipart?: boolean, maxBodySize?: number, customSensitiveFields?: string[] }} options
|
|
322
|
+
*/
|
|
323
|
+
function createRequestCapture(options = {}) {
|
|
324
|
+
const captureBody = options.captureBody !== false;
|
|
325
|
+
const captureMultipart = options.captureMultipart !== false;
|
|
326
|
+
const maxBodySize = options.maxBodySize || 10240;
|
|
327
|
+
const customSensitiveFields = options.customSensitiveFields || [];
|
|
328
|
+
const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
|
|
329
|
+
|
|
330
|
+
// Credential-bearing request headers (cookies, auth, CSRF/XSRF, session, API keys, and any
|
|
331
|
+
// configured capture.sensitiveFields) are redacted by name before capture.
|
|
332
|
+
const isSensitiveHeaderName = createHeaderNameRedactor(customSensitiveFields);
|
|
333
|
+
|
|
334
|
+
function captureHeaders(span, request) {
|
|
335
|
+
if (!request || !request.headers) return;
|
|
336
|
+
const safe = {};
|
|
337
|
+
for (const [k, v] of Object.entries(request.headers)) {
|
|
338
|
+
if (isSensitiveHeaderName(k)) { safe[k] = '[REDACTED]'; continue; }
|
|
339
|
+
safe[k] = redactSensitiveValue(redactOtpQuery(typeof v === 'string' ? v : String(v))).substring(0, 500);
|
|
340
|
+
}
|
|
341
|
+
const serialized = JSON.stringify(safe);
|
|
342
|
+
if (serialized.length <= 8192) {
|
|
343
|
+
span.setAttribute('http.request.headers', serialized);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function installRequestBodyObserver(span, request, contentType) {
|
|
348
|
+
if (!request || request[BODY_CAPTURE_PATCH]) return;
|
|
349
|
+
|
|
350
|
+
const normalizedContentType = String(contentType || '').toLowerCase();
|
|
351
|
+
const isStructuredBody = captureBody && (
|
|
352
|
+
normalizedContentType.includes('application/json') ||
|
|
353
|
+
normalizedContentType.includes('application/graphql') ||
|
|
354
|
+
normalizedContentType.includes('application/x-www-form-urlencoded')
|
|
355
|
+
);
|
|
356
|
+
const isMultipartBody = normalizedContentType.includes('multipart/form-data');
|
|
357
|
+
|
|
358
|
+
if (!isStructuredBody && !isMultipartBody) return;
|
|
359
|
+
|
|
360
|
+
if (isMultipartBody && !captureMultipart) {
|
|
361
|
+
span.setAttribute('http.request.body', '[MULTIPART - NOT CAPTURED]');
|
|
362
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
363
|
+
span.setAttribute('http.request.body.note', 'Multipart capture disabled by config.capture.multipart=false');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let chunks = [];
|
|
368
|
+
let size = 0;
|
|
369
|
+
let structuredDone = false;
|
|
370
|
+
|
|
371
|
+
// The boundary is case-sensitive: browsers send mixed-case ones (----WebKitFormBoundaryAbC...),
|
|
372
|
+
// so it is read from the header as sent, not from the lowercased copy (which found no part).
|
|
373
|
+
const multipartCollector = isMultipartBody && captureMultipart
|
|
374
|
+
? createMultipartMetaCollector(String(contentType || ''), allSensitiveFields, 1000, ({ error, parsed, totalSize }) => {
|
|
375
|
+
try {
|
|
376
|
+
if (error === 'BOUNDARY_NOT_FOUND') {
|
|
377
|
+
span.setAttribute('http.request.body', '[MULTIPART - BOUNDARY NOT FOUND]');
|
|
378
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (error) {
|
|
382
|
+
span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
|
|
383
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
384
|
+
span.setAttribute('http.request.body.parse_error', true);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
span.setAttributes({
|
|
388
|
+
'http.request.body': JSON.stringify(parsed).substring(0, maxBodySize),
|
|
389
|
+
'http.request.body.type': 'multipart',
|
|
390
|
+
'http.request.body.size': totalSize,
|
|
391
|
+
'http.request.body.fields_count': Object.keys(parsed.fields).length,
|
|
392
|
+
'http.request.body.files_count': parsed.files.length,
|
|
393
|
+
});
|
|
394
|
+
} catch (e) {
|
|
395
|
+
span.setAttribute('http.request.body', '[MULTIPART - PARSE ERROR]');
|
|
396
|
+
span.setAttribute('http.request.body.type', 'multipart');
|
|
397
|
+
span.setAttribute('http.request.body.parse_error', true);
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
: null;
|
|
401
|
+
|
|
402
|
+
if (isMultipartBody && captureMultipart && !multipartCollector) return;
|
|
403
|
+
|
|
404
|
+
function finishStructuredCapture() {
|
|
405
|
+
if (!isStructuredBody || structuredDone) return;
|
|
406
|
+
structuredDone = true;
|
|
407
|
+
|
|
408
|
+
if (size > maxBodySize) {
|
|
409
|
+
span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
|
|
410
|
+
span.setAttribute('http.request.body.size', size);
|
|
411
|
+
chunks = [];
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (chunks.length === 0) return;
|
|
416
|
+
|
|
417
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
418
|
+
chunks = [];
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
if (normalizedContentType.includes('application/graphql')) {
|
|
422
|
+
const redacted = redactGraphQLQuery(body, allSensitiveFields);
|
|
423
|
+
span.setAttributes({
|
|
424
|
+
'http.request.body': redacted.substring(0, maxBodySize),
|
|
425
|
+
'http.request.body.type': 'graphql',
|
|
426
|
+
'http.request.body.size': size,
|
|
427
|
+
});
|
|
428
|
+
} else if (normalizedContentType.includes('application/json')) {
|
|
429
|
+
const parsed = JSON.parse(body);
|
|
430
|
+
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
431
|
+
span.setAttributes({
|
|
432
|
+
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
433
|
+
'http.request.body.type': 'json',
|
|
434
|
+
'http.request.body.size': size,
|
|
435
|
+
});
|
|
436
|
+
} else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
|
|
437
|
+
const parsed = Object.fromEntries(new URLSearchParams(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': 'form',
|
|
442
|
+
'http.request.body.size': size,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
} catch (e) {
|
|
446
|
+
span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
|
|
447
|
+
span.setAttribute('http.request.body.parse_error', true);
|
|
448
|
+
span.setAttribute('http.request.body.size', size);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const originalEmit = request.emit;
|
|
453
|
+
request[BODY_CAPTURE_PATCH] = true;
|
|
454
|
+
request.emit = function securenowObservedEmit(event, ...args) {
|
|
455
|
+
try {
|
|
456
|
+
if (event === 'data' && args.length > 0) {
|
|
457
|
+
const chunk = Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]);
|
|
458
|
+
if (isStructuredBody) {
|
|
459
|
+
size += chunk.length;
|
|
460
|
+
if (size <= maxBodySize) chunks.push(chunk);
|
|
461
|
+
}
|
|
462
|
+
if (multipartCollector) multipartCollector.onData(chunk);
|
|
463
|
+
} else if (event === 'end') {
|
|
464
|
+
finishStructuredCapture();
|
|
465
|
+
if (multipartCollector) multipartCollector.onEnd();
|
|
466
|
+
}
|
|
467
|
+
} catch (_) {
|
|
468
|
+
// Body capture must never interfere with the application request stream.
|
|
469
|
+
}
|
|
470
|
+
return originalEmit.apply(this, [event, ...args]);
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function observeBody(span, request) {
|
|
475
|
+
if (!(captureBody || captureMultipart) || !request || !request.method) return;
|
|
476
|
+
if (!['POST', 'PUT', 'PATCH'].includes(request.method)) return;
|
|
477
|
+
const contentType = (request.headers && request.headers['content-type']) || '';
|
|
478
|
+
installRequestBodyObserver(span, request, contentType);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { captureBody, captureMultipart, maxBodySize, allSensitiveFields, captureHeaders, observeBody };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
module.exports = {
|
|
485
|
+
DEFAULT_SENSITIVE_FIELDS,
|
|
486
|
+
redactSensitiveValue,
|
|
487
|
+
redactSensitiveData,
|
|
488
|
+
redactGraphQLQuery,
|
|
489
|
+
redactBodyText,
|
|
490
|
+
createMultipartMetaCollector,
|
|
491
|
+
createRequestCapture,
|
|
492
|
+
};
|
package/resolve-ip.js
CHANGED
|
@@ -7,17 +7,19 @@ const { createMatcher } = require('./cidr');
|
|
|
7
7
|
const LOOPBACK_RE = /^(127\.|::1$|::ffff:127\.)/;
|
|
8
8
|
const PRIVATE_IP_RE = /^(127\.|::1$|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.|f[cd][0-9a-f]{2}:)/i;
|
|
9
9
|
|
|
10
|
-
// Named proxy presets for config.networking.trustedProxies. Cloudflare's published
|
|
11
|
-
// (https://www.cloudflare.com/ips-v4): an app behind Cloudflare trusts them with "cloudflare".
|
|
10
|
+
// Named proxy presets for config.networking.trustedProxies. Cloudflare's published edge ranges
|
|
11
|
+
// (https://www.cloudflare.com/ips-v4 and /ips-v6): an app behind Cloudflare trusts them with "cloudflare".
|
|
12
12
|
const PROXY_PRESETS = {
|
|
13
13
|
cloudflare: [
|
|
14
14
|
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18',
|
|
15
15
|
'108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17',
|
|
16
16
|
'162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
|
|
17
|
+
'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', '2405:8100::/32',
|
|
18
|
+
'2a06:98c0::/29', '2c0f:f248::/32',
|
|
17
19
|
],
|
|
18
20
|
};
|
|
19
21
|
|
|
20
|
-
// Exact addresses (IPv4 or IPv6),
|
|
22
|
+
// Exact addresses and CIDR ranges (IPv4 or IPv6), and presets.
|
|
21
23
|
function trustedProxyMatcher(entries) {
|
|
22
24
|
const expanded = [];
|
|
23
25
|
for (const entry of entries) {
|
|
@@ -93,14 +95,30 @@ function getHeader(headers, name) {
|
|
|
93
95
|
return firstHeaderValue(headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()]);
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// A request that came over a Unix domain socket or a Windows named pipe has no remote address, and only a
|
|
99
|
+
// process on this machine can connect to one: nginx `proxy_pass http://unix:/run/app.sock`, Nuxt's dev
|
|
100
|
+
// proxy, a local sidecar. Such a connection is a trusted local proxy, so its forwarding headers count
|
|
101
|
+
// (before this, requests over a socket had no client IP at all and IP blocking never applied to them).
|
|
102
|
+
function arrivedOverLocalSocket(request) {
|
|
103
|
+
const socket = request && request.socket;
|
|
104
|
+
if (!socket || socket.remoteAddress) return false;
|
|
105
|
+
try {
|
|
106
|
+
const server = socket.server || socket._server;
|
|
107
|
+
return !!server && typeof server.address === 'function' && typeof server.address() === 'string';
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
function resolveClientIpWithDetails(request) {
|
|
97
114
|
const socketIp = normalizeIp(request.socket?.remoteAddress || '');
|
|
98
115
|
const headers = request.headers || {};
|
|
99
|
-
const
|
|
116
|
+
const viaLocalSocket = !socketIp && arrivedOverLocalSocket(request);
|
|
117
|
+
const trustedProxy = viaLocalSocket || isFromTrustedProxy(socketIp);
|
|
100
118
|
|
|
101
119
|
const details = {
|
|
102
120
|
ip: socketIp,
|
|
103
|
-
source: socketIp ? 'socket' : 'unknown',
|
|
121
|
+
source: socketIp ? 'socket' : (viaLocalSocket ? 'unix-socket' : 'unknown'),
|
|
104
122
|
socketIp,
|
|
105
123
|
trustedProxy,
|
|
106
124
|
forwardedFor: String(getHeader(headers, 'x-forwarded-for') || ''),
|