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/nuxt-server-plugin.mjs
CHANGED
|
@@ -1,505 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SecureNow Nitro Server Plugin
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* to create spans, capture metadata, and forward logs.
|
|
4
|
+
* Registered by the Nuxt module (nuxt.mjs) via addServerPlugin.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from '@opentelemetry/api';
|
|
17
|
-
import { createRequire } from 'node:module';
|
|
18
|
-
import { randomUUID } from 'node:crypto';
|
|
19
|
-
|
|
20
|
-
const nodeRequire = createRequire(import.meta.url);
|
|
21
|
-
const appConfig = nodeRequire('./app-config');
|
|
22
|
-
const { resolveClientIpWithDetails } = nodeRequire('./resolve-ip');
|
|
23
|
-
const { nodeSdkDefaultTelemetryOptions } = nodeRequire('./otel-defaults');
|
|
24
|
-
const nodeSdkTelemetryOptions = nodeSdkDefaultTelemetryOptions();
|
|
25
|
-
|
|
26
|
-
const { NodeSDK } = nodeRequire('@opentelemetry/sdk-node');
|
|
27
|
-
const { OTLPTraceExporter } = nodeRequire('@opentelemetry/exporter-trace-otlp-http');
|
|
28
|
-
const { HttpInstrumentation } = nodeRequire('@opentelemetry/instrumentation-http');
|
|
29
|
-
// instrumentation-http >= 0.221 only emits stable HTTP semconv; this restores the
|
|
30
|
-
// legacy http.method/http.target/http.status_code/... attributes SecureNow queries.
|
|
31
|
-
const { withLegacyHttpSemconv } = nodeRequire('./legacy-semconv');
|
|
32
|
-
|
|
33
|
-
// ── Helpers ──
|
|
34
|
-
|
|
35
|
-
function createResource(attributes) {
|
|
36
|
-
if (typeof otelResources.resourceFromAttributes === 'function') {
|
|
37
|
-
return otelResources.resourceFromAttributes(attributes);
|
|
38
|
-
}
|
|
39
|
-
if (typeof otelResources.Resource === 'function') {
|
|
40
|
-
return new otelResources.Resource(attributes);
|
|
41
|
-
}
|
|
42
|
-
throw new Error('Unsupported @opentelemetry/resources version');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const { OTP_SENSITIVE_FIELDS, matchesSensitiveField, redactOtpQuery, withOtpRedactionExporter, redactOtpGraphQL } = nodeRequire('./otp-redaction');
|
|
46
|
-
|
|
47
|
-
// Default sensitive fields to redact from request bodies.
|
|
48
|
-
// OTP/code aliases use exact normalized matching; other fields are matched
|
|
49
|
-
// substring-wise against lowercased keys (see redactSensitiveData),
|
|
50
|
-
// so e.g. 'card' also catches 'creditCard' and 'account' also catches
|
|
51
|
-
// 'accountNumber'. Over-redaction here is intentional: a falsely-redacted
|
|
52
|
-
// telemetry value is always safer than a leaked secret. Entries are kept
|
|
53
|
-
// specific enough to avoid nuking broad benign keys (e.g. we use
|
|
54
|
-
// 'firstname'/'lastname'/'fullname', never bare 'name').
|
|
55
|
-
const DEFAULT_SENSITIVE_FIELDS = [
|
|
56
|
-
...OTP_SENSITIVE_FIELDS,
|
|
57
|
-
// credentials / auth
|
|
58
|
-
'password', 'passwd', 'pwd', 'secret', 'token', 'api_key', 'apikey',
|
|
59
|
-
'access_token', 'auth', 'authorization', 'bearer', 'credentials',
|
|
60
|
-
'mysql_pwd', 'otp', 'mfa', 'totp', 'sessionid', 'session_id',
|
|
61
|
-
'cookie', 'set-cookie',
|
|
62
|
-
// financial
|
|
63
|
-
'stripeToken', 'card', 'cardnumber', 'ccv', 'cvc', 'cvv',
|
|
64
|
-
'iban', 'account', 'accountnumber', 'routing', 'sortcode', 'taxid',
|
|
65
|
-
// PII
|
|
66
|
-
'ssn', 'pin', 'email', 'e_mail', 'phone', 'mobile', 'dob', 'birthdate',
|
|
67
|
-
'firstname', 'lastname', 'fullname', 'address', 'postcode', 'zip',
|
|
68
|
-
'passport', 'license',
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
// Conservative value-shape redactors. Key-name matching misses secrets that
|
|
72
|
-
// land in free-form string values (GraphQL bodies, message fields, etc.), so
|
|
73
|
-
// as a second layer we scrub string VALUES that *look like* a secret/PII.
|
|
74
|
-
// These are intentionally precise/bounded so they don't garble normal prose,
|
|
75
|
-
// and they only ever transform captured telemetry strings (read-only) — never
|
|
76
|
-
// the actual request/response stream. Compiled once at module load.
|
|
77
|
-
const VALUE_REDACTORS = [
|
|
78
|
-
// JWT: three base64url segments. Anchored to the eyJ header so it won't
|
|
79
|
-
// match arbitrary dotted tokens.
|
|
80
|
-
{ name: 'jwt', re: /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g },
|
|
81
|
-
// Bearer/Basic auth header value embedded in a body string.
|
|
82
|
-
{ name: 'bearer', re: /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi },
|
|
83
|
-
// Stripe-style / SecureNow live+test API keys.
|
|
84
|
-
{ name: 'apikey', re: /\b(?:sk|pk|rk|snk)_(?:live|test)_[A-Za-z0-9]{8,}/g },
|
|
85
|
-
// Email addresses (bounded local/domain parts).
|
|
86
|
-
{ name: 'email', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}\b/g },
|
|
87
|
-
// Credit-card-like: 13–19 digit runs that pass a Luhn check, allowing
|
|
88
|
-
// space/dash grouping. Luhn-gated to avoid clobbering ordinary long numbers.
|
|
89
|
-
{ name: 'card', re: /\b(?:\d[ -]?){13,19}\b/g, luhn: true },
|
|
90
|
-
];
|
|
91
|
-
|
|
92
|
-
// Skip the value-shape scan on very large strings to bound per-body cost.
|
|
93
|
-
const MAX_VALUE_SCAN_LENGTH = 16384;
|
|
94
|
-
|
|
95
|
-
function luhnValid(digits) {
|
|
96
|
-
let sum = 0;
|
|
97
|
-
let alt = false;
|
|
98
|
-
for (let i = digits.length - 1; i >= 0; i--) {
|
|
99
|
-
let d = digits.charCodeAt(i) - 48;
|
|
100
|
-
if (d < 0 || d > 9) return false;
|
|
101
|
-
if (alt) { d *= 2; if (d > 9) d -= 9; }
|
|
102
|
-
sum += d;
|
|
103
|
-
alt = !alt;
|
|
104
|
-
}
|
|
105
|
-
return sum % 10 === 0;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Redact obvious secret/PII shapes inside a captured string VALUE.
|
|
110
|
-
* Returns the input unchanged when nothing matches (cheap common case).
|
|
6
|
+
* It loads the same runtime as `node -r securenow/register`: tracing with header and body
|
|
7
|
+
* capture, console logs, crash capture, the firewall, rate limits and challenges. The import
|
|
8
|
+
* is static and by package name, so Nitro keeps securenow external and copies it, with its
|
|
9
|
+
* dependencies, into .output/server/node_modules. (The previous plugin rebuilt the SDK here
|
|
10
|
+
* with relative requires; once Nitro bundled the plugin those paths no longer existed and the
|
|
11
|
+
* production server crashed at startup.)
|
|
12
|
+
*
|
|
13
|
+
* On top of that runtime the Nitro hooks below add what the HTTP layer cannot see: the matched
|
|
14
|
+
* h3 route as http.route, and a handler's error as an exception on the request's span.
|
|
111
15
|
*/
|
|
112
|
-
function redactSensitiveValue(value) {
|
|
113
|
-
if (typeof value !== 'string' || value.length === 0) return value;
|
|
114
|
-
// Credential assignments in GraphQL/query strings need field-aware
|
|
115
|
-
// redaction even though a short numeric OTP has no unique value shape.
|
|
116
|
-
value = redactOtpQuery(redactOtpGraphQL(value));
|
|
117
|
-
if (value.length > MAX_VALUE_SCAN_LENGTH) return value; // bound shape scans
|
|
118
|
-
let out = value;
|
|
119
|
-
for (const r of VALUE_REDACTORS) {
|
|
120
|
-
r.re.lastIndex = 0;
|
|
121
|
-
if (r.luhn) {
|
|
122
|
-
out = out.replace(r.re, (m) => {
|
|
123
|
-
const digits = m.replace(/[ -]/g, '');
|
|
124
|
-
if (digits.length < 13 || digits.length > 19) return m;
|
|
125
|
-
return luhnValid(digits) ? '[REDACTED]' : m;
|
|
126
|
-
});
|
|
127
|
-
} else {
|
|
128
|
-
out = out.replace(r.re, '[REDACTED]');
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return out;
|
|
132
|
-
}
|
|
133
16
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const redacted = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
137
|
-
for (const key of Object.keys(redacted)) {
|
|
138
|
-
if (matchesSensitiveField(key, fields)) {
|
|
139
|
-
redacted[key] = '[REDACTED]';
|
|
140
|
-
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
141
|
-
redacted[key] = redactSensitiveData(redacted[key], fields);
|
|
142
|
-
} else if (typeof redacted[key] === 'string') {
|
|
143
|
-
// Second layer: even if the key name looks benign, scrub values that
|
|
144
|
-
// *look like* a secret/PII (JWT, bearer token, API key, email, card).
|
|
145
|
-
redacted[key] = redactSensitiveValue(redacted[key]);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return redacted;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ── Runtime config helpers ──
|
|
152
|
-
|
|
153
|
-
function getRuntimeOptions() {
|
|
154
|
-
try {
|
|
155
|
-
const cfg = useRuntimeConfig();
|
|
156
|
-
return cfg.securenow || {};
|
|
157
|
-
} catch {
|
|
158
|
-
return {};
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// ── Plugin ──
|
|
163
|
-
|
|
164
|
-
export default defineNitroPlugin(async (nitroApp) => {
|
|
165
|
-
const opts = getRuntimeOptions();
|
|
166
|
-
|
|
167
|
-
// Resolution order: opts -> .securenow/credentials.json -> package.json#name
|
|
168
|
-
const resolvedApp = appConfig.resolveAll();
|
|
169
|
-
|
|
170
|
-
// ── Naming ──
|
|
171
|
-
const rawBase = (opts.serviceName || resolvedApp.appId || '').trim().replace(/^['"]|['"]$/g, '');
|
|
172
|
-
const baseName = rawBase || null;
|
|
173
|
-
// Default: auto-disable per-worker suffix when logged in (appId is the
|
|
174
|
-
// routing UUID and the dashboard does exact match). opts.noUuid or
|
|
175
|
-
// config.runtime.noUuid or opts.noUuid override.
|
|
176
|
-
const noUuid = appConfig.resolveNoUuid({ noUuid: opts.noUuid });
|
|
177
|
-
const deploymentEnvironment = appConfig.normalizeDeploymentEnvironment(
|
|
178
|
-
opts.environment || resolvedApp.deploymentEnvironment
|
|
179
|
-
);
|
|
17
|
+
import 'securenow/register';
|
|
18
|
+
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
|
180
19
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
console.warn(
|
|
187
|
-
'[securenow] ⚠️ No app identity resolved. Using fallback: %s',
|
|
188
|
-
serviceName,
|
|
189
|
-
);
|
|
190
|
-
console.warn(
|
|
191
|
-
'[securenow] Run `npx securenow login` or set app.key in .securenow/credentials.json',
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const serviceInstanceId = `${baseName || 'securenow'}-${randomUUID()}`;
|
|
196
|
-
|
|
197
|
-
// ── Endpoints ──
|
|
198
|
-
const resolvedEndpoints = appConfig.resolveEndpoints({ endpoint: opts.endpoint || resolvedApp.instance });
|
|
199
|
-
const tracesUrl = resolvedEndpoints.tracesUrl;
|
|
200
|
-
const logsUrl = resolvedEndpoints.logsUrl;
|
|
201
|
-
const headers = resolvedEndpoints.headers;
|
|
202
|
-
|
|
203
|
-
// ── Resource ──
|
|
204
|
-
const resource = createResource({
|
|
205
|
-
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
|
206
|
-
[SemanticResourceAttributes.SERVICE_INSTANCE_ID]: serviceInstanceId,
|
|
207
|
-
[SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: deploymentEnvironment,
|
|
208
|
-
[SemanticResourceAttributes.SERVICE_VERSION]:
|
|
209
|
-
process.env.npm_package_version || undefined,
|
|
210
|
-
'framework': 'nuxt',
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
// ── Body capture config ──
|
|
214
|
-
// Opt-out default: set config.capture.body=false (or opts.captureBody=false) to disable.
|
|
215
|
-
const captureBody =
|
|
216
|
-
opts.captureBody ??
|
|
217
|
-
appConfig.boolConfig('capture.body', true);
|
|
218
|
-
const maxBodySize = appConfig.numberConfig('capture.maxBodySize', 10240, 1024);
|
|
219
|
-
const customSensitiveFields = appConfig.listConfig('capture.sensitiveFields');
|
|
220
|
-
const allSensitiveFields = [...DEFAULT_SENSITIVE_FIELDS, ...customSensitiveFields];
|
|
221
|
-
|
|
222
|
-
// ── HTTP instrumentation ──
|
|
223
|
-
const httpInstrumentation = new HttpInstrumentation(withLegacyHttpSemconv({
|
|
224
|
-
requestHook: (span, request) => {
|
|
225
|
-
try {
|
|
226
|
-
const hdrs = request.headers || {};
|
|
227
|
-
const ipDetails = resolveClientIpWithDetails(request);
|
|
228
|
-
const clientIp = ipDetails.ip || 'unknown';
|
|
229
|
-
|
|
230
|
-
span.setAttributes({
|
|
231
|
-
'http.client_ip': clientIp,
|
|
232
|
-
'http.client_ip.source': ipDetails.source,
|
|
233
|
-
'http.socket_ip': ipDetails.socketIp || '',
|
|
234
|
-
'http.forwarded_for': ipDetails.forwardedFor || '',
|
|
235
|
-
'http.real_ip': ipDetails.realIp || '',
|
|
236
|
-
'http.proxy.trusted': String(!!ipDetails.trustedProxy),
|
|
237
|
-
'http.request.header.x_forwarded_for': ipDetails.forwardedFor || '',
|
|
238
|
-
'http.request.header.x_real_ip': ipDetails.realIp || '',
|
|
239
|
-
'http.request.header.cf_connecting_ip': ipDetails.cfConnectingIp || '',
|
|
240
|
-
'http.request.header.true_client_ip': ipDetails.trueClientIp || '',
|
|
241
|
-
'http.request.header.x_client_ip': ipDetails.clientIp || '',
|
|
242
|
-
'http.user_agent': hdrs['user-agent'] || '',
|
|
243
|
-
'http.host': hdrs['x-forwarded-host'] || hdrs['host'] || '',
|
|
244
|
-
'http.scheme':
|
|
245
|
-
hdrs['x-forwarded-proto'] ||
|
|
246
|
-
(request.socket?.encrypted ? 'https' : 'http'),
|
|
247
|
-
'http.referer': hdrs['referer'] || '',
|
|
248
|
-
'http.origin': hdrs['origin'] || '',
|
|
249
|
-
'http.request_id':
|
|
250
|
-
hdrs['x-request-id'] || hdrs['x-trace-id'] || '',
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
if (hdrs['authorization']) {
|
|
254
|
-
span.setAttribute('http.security.auth_present', 'true');
|
|
255
|
-
}
|
|
256
|
-
if (hdrs['cookie']) {
|
|
257
|
-
span.setAttribute('http.security.cookies_present', 'true');
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Body capture via stream listener (same approach as tracing.js)
|
|
261
|
-
if (
|
|
262
|
-
captureBody &&
|
|
263
|
-
request.method &&
|
|
264
|
-
['POST', 'PUT', 'PATCH'].includes(request.method)
|
|
265
|
-
) {
|
|
266
|
-
const ct = hdrs['content-type'] || '';
|
|
267
|
-
if (
|
|
268
|
-
ct.includes('application/json') ||
|
|
269
|
-
ct.includes('application/graphql') ||
|
|
270
|
-
ct.includes('application/x-www-form-urlencoded')
|
|
271
|
-
) {
|
|
272
|
-
const chunks = [];
|
|
273
|
-
let size = 0;
|
|
274
|
-
request.on('data', (chunk) => {
|
|
275
|
-
size += chunk.length;
|
|
276
|
-
if (size <= maxBodySize) chunks.push(chunk);
|
|
277
|
-
});
|
|
278
|
-
request.on('end', () => {
|
|
279
|
-
if (size > maxBodySize) {
|
|
280
|
-
span.setAttribute('http.request.body', `[TOO LARGE: ${size} bytes]`);
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
if (chunks.length === 0) return;
|
|
284
|
-
const raw = Buffer.concat(chunks).toString('utf8');
|
|
285
|
-
try {
|
|
286
|
-
const parsed = JSON.parse(raw);
|
|
287
|
-
const redacted = redactSensitiveData(parsed, allSensitiveFields);
|
|
288
|
-
span.setAttributes({
|
|
289
|
-
'http.request.body': JSON.stringify(redacted).substring(0, maxBodySize),
|
|
290
|
-
'http.request.body.type': ct.includes('graphql') ? 'graphql' : 'json',
|
|
291
|
-
'http.request.body.size': size,
|
|
292
|
-
});
|
|
293
|
-
} catch {
|
|
294
|
-
span.setAttribute('http.request.body', '[UNPARSEABLE - REDACTED FOR SAFETY]');
|
|
295
|
-
span.setAttribute('http.request.body.parse_error', true);
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
} catch {
|
|
301
|
-
// never break the request
|
|
302
|
-
}
|
|
303
|
-
},
|
|
304
|
-
}));
|
|
305
|
-
|
|
306
|
-
// ── Trace exporter + SDK ──
|
|
307
|
-
const traceExporter = withOtpRedactionExporter(new OTLPTraceExporter({ url: tracesUrl, headers }));
|
|
308
|
-
|
|
309
|
-
const sdk = new NodeSDK({
|
|
310
|
-
...nodeSdkTelemetryOptions,
|
|
311
|
-
traceExporter,
|
|
312
|
-
resource,
|
|
313
|
-
instrumentations: [httpInstrumentation],
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
sdk.start();
|
|
317
|
-
console.log('[securenow] 🚀 Nuxt OTel SDK started → %s', tracesUrl);
|
|
318
|
-
console.log(
|
|
319
|
-
'[securenow] service.name=%s instance.id=%s',
|
|
320
|
-
serviceName,
|
|
321
|
-
serviceInstanceId,
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
// ── Logging ──
|
|
325
|
-
// Opt-out default: set config.logging.enabled=false (or opts.logging=false) to disable.
|
|
326
|
-
const loggingEnabled =
|
|
327
|
-
opts.logging ??
|
|
328
|
-
appConfig.boolConfig('logging.enabled', true);
|
|
329
|
-
|
|
330
|
-
let loggerProvider = null;
|
|
331
|
-
|
|
332
|
-
if (loggingEnabled) {
|
|
333
|
-
try {
|
|
334
|
-
const { OTLPLogExporter } = await import(
|
|
335
|
-
'@opentelemetry/exporter-logs-otlp-http'
|
|
336
|
-
);
|
|
337
|
-
const { LoggerProvider, BatchLogRecordProcessor } = await import(
|
|
338
|
-
'@opentelemetry/sdk-logs'
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
const logExporter = new OTLPLogExporter({ url: logsUrl, headers });
|
|
342
|
-
loggerProvider = new LoggerProvider({
|
|
343
|
-
resource,
|
|
344
|
-
// sdk-logs >= 0.220 takes a single options object ({ exporter }).
|
|
345
|
-
processors: [new BatchLogRecordProcessor({ exporter: logExporter })],
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
const logger = loggerProvider.getLogger('console', '1.0.0');
|
|
349
|
-
const SEV = { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 };
|
|
350
|
-
const orig = {
|
|
351
|
-
log: console.log,
|
|
352
|
-
info: console.info,
|
|
353
|
-
warn: console.warn,
|
|
354
|
-
error: console.error,
|
|
355
|
-
debug: console.debug,
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
function emitLog(sn, st, args) {
|
|
359
|
-
try {
|
|
360
|
-
const ctx = otelContext.active();
|
|
361
|
-
const spanCtx = otelTrace.getSpanContext(ctx);
|
|
362
|
-
logger.emit({
|
|
363
|
-
severityNumber: sn,
|
|
364
|
-
severityText: st,
|
|
365
|
-
body: args
|
|
366
|
-
.map((a) =>
|
|
367
|
-
typeof a === 'object' && a !== null ? JSON.stringify(a) : String(a),
|
|
368
|
-
)
|
|
369
|
-
.join(' '),
|
|
370
|
-
attributes: { 'log.source': 'console', 'log.method': st.toLowerCase() },
|
|
371
|
-
...(spanCtx && { context: ctx }),
|
|
372
|
-
});
|
|
373
|
-
} catch {
|
|
374
|
-
// swallow
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
console.log = (...a) => { emitLog(SEV.INFO, 'INFO', a); orig.log.apply(console, a); };
|
|
379
|
-
console.info = (...a) => { emitLog(SEV.INFO, 'INFO', a); orig.info.apply(console, a); };
|
|
380
|
-
console.warn = (...a) => { emitLog(SEV.WARN, 'WARN', a); orig.warn.apply(console, a); };
|
|
381
|
-
console.error = (...a) => { emitLog(SEV.ERROR, 'ERROR', a); orig.error.apply(console, a); };
|
|
382
|
-
console.debug = (...a) => { emitLog(SEV.DEBUG, 'DEBUG', a); orig.debug.apply(console, a); };
|
|
383
|
-
|
|
384
|
-
console.log('[securenow] 📋 Logging: ENABLED → %s', logsUrl);
|
|
385
|
-
} catch (e) {
|
|
386
|
-
console.warn('[securenow] ⚠️ Logging setup failed:', e.message);
|
|
387
|
-
}
|
|
388
|
-
} else {
|
|
389
|
-
console.log(
|
|
390
|
-
'[securenow] Logging: DISABLED (config.logging.enabled=false)',
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (captureBody) {
|
|
395
|
-
console.log(
|
|
396
|
-
'[securenow] 📝 Body capture: ENABLED (max %d bytes, redacting %d fields)',
|
|
397
|
-
maxBodySize,
|
|
398
|
-
allSensitiveFields.length,
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// ── Firewall — runs independently from OTel ──
|
|
403
|
-
const firewallOptions = appConfig.resolveFirewallOptions();
|
|
404
|
-
if (firewallOptions.apiKey) {
|
|
405
|
-
try {
|
|
406
|
-
const { init: fwInit } = await import('./firewall.js');
|
|
407
|
-
fwInit({
|
|
408
|
-
apiKey: firewallOptions.apiKey,
|
|
409
|
-
appKey: firewallOptions.appKey,
|
|
410
|
-
environment: deploymentEnvironment || firewallOptions.environment,
|
|
411
|
-
apiUrl: firewallOptions.apiUrl,
|
|
412
|
-
apiUrlFallbacks: firewallOptions.apiUrlFallbacks,
|
|
413
|
-
versionCheckInterval: firewallOptions.versionCheckInterval,
|
|
414
|
-
syncInterval: firewallOptions.syncInterval,
|
|
415
|
-
failMode: firewallOptions.failMode,
|
|
416
|
-
statusCode: firewallOptions.statusCode,
|
|
417
|
-
log: firewallOptions.log,
|
|
418
|
-
tcp: firewallOptions.tcp,
|
|
419
|
-
iptables: firewallOptions.iptables,
|
|
420
|
-
cloud: firewallOptions.cloud,
|
|
421
|
-
cloudDryRun: firewallOptions.cloudDryRun,
|
|
422
|
-
cloudflare: firewallOptions.cloudflare,
|
|
423
|
-
aws: firewallOptions.aws,
|
|
424
|
-
gcp: firewallOptions.gcp,
|
|
425
|
-
});
|
|
426
|
-
} catch (e) {
|
|
427
|
-
console.warn('[securenow] Firewall init failed:', e.message);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// ── Graceful shutdown ──
|
|
432
|
-
const shutdown = async (sig) => {
|
|
433
|
-
try {
|
|
434
|
-
await sdk.shutdown?.();
|
|
435
|
-
if (loggerProvider) await loggerProvider.shutdown?.();
|
|
436
|
-
try { const fw = await import('./firewall.js'); fw.shutdown?.(); } catch {}
|
|
437
|
-
console.log(`[securenow] Shut down on ${sig}`);
|
|
438
|
-
} catch {
|
|
439
|
-
// swallow
|
|
440
|
-
}
|
|
441
|
-
};
|
|
442
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
443
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
444
|
-
|
|
445
|
-
// ── Nitro request hooks for span enrichment ──
|
|
446
|
-
const tracer = otelTrace.getTracer('securenow-nuxt', '1.0.0');
|
|
447
|
-
const spanMap = new WeakMap();
|
|
20
|
+
// A plain function, not defineNitroPlugin(...): Nitro auto-imports that helper into the project's
|
|
21
|
+
// own files only, so inside this package it was undefined and the server failed at startup.
|
|
22
|
+
// (defineNitroPlugin only returns the function it is given.)
|
|
23
|
+
export default function securenowNitroPlugin(nitroApp) {
|
|
24
|
+
const spans = new WeakMap();
|
|
448
25
|
|
|
449
26
|
nitroApp.hooks.hook('request', (event) => {
|
|
450
27
|
try {
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
const path = event.path || req.url || '/';
|
|
454
|
-
|
|
455
|
-
const span = tracer.startSpan(`${method} ${path}`, {
|
|
456
|
-
attributes: {
|
|
457
|
-
'http.method': method,
|
|
458
|
-
'http.target': path,
|
|
459
|
-
'http.url': `${req.headers?.['x-forwarded-proto'] || 'http'}://${req.headers?.host || 'localhost'}${path}`,
|
|
460
|
-
'component': 'nuxt-nitro',
|
|
461
|
-
},
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
spanMap.set(event, span);
|
|
28
|
+
const span = trace.getActiveSpan();
|
|
29
|
+
if (span) spans.set(event, span);
|
|
465
30
|
} catch {
|
|
466
31
|
// never break the request
|
|
467
32
|
}
|
|
468
33
|
});
|
|
469
34
|
|
|
470
|
-
nitroApp.hooks.hook('
|
|
35
|
+
nitroApp.hooks.hook('beforeResponse', (event) => {
|
|
471
36
|
try {
|
|
472
|
-
const span =
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const status = event.node.res.statusCode || 200;
|
|
476
|
-
span.setAttribute('http.status_code', status);
|
|
477
|
-
|
|
478
|
-
if (status >= 500) {
|
|
479
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${status}` });
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
span.end();
|
|
483
|
-
spanMap.delete(event);
|
|
37
|
+
const span = spans.get(event);
|
|
38
|
+
const route = event && event.context && event.context.matchedRoute && event.context.matchedRoute.path;
|
|
39
|
+
if (span && route) span.setAttribute('http.route', route);
|
|
484
40
|
} catch {
|
|
485
|
-
//
|
|
41
|
+
// never break the request
|
|
486
42
|
}
|
|
487
43
|
});
|
|
488
44
|
|
|
489
|
-
nitroApp.hooks.hook('error', (error,
|
|
45
|
+
nitroApp.hooks.hook('error', (error, ctx) => {
|
|
490
46
|
try {
|
|
491
|
-
const
|
|
492
|
-
|
|
47
|
+
const event = ctx && ctx.event;
|
|
48
|
+
const span = event ? spans.get(event) : null;
|
|
49
|
+
if (span && error) {
|
|
493
50
|
span.recordException(error);
|
|
494
|
-
span.setStatus({
|
|
495
|
-
code: SpanStatusCode.ERROR,
|
|
496
|
-
message: error.message || 'Internal Server Error',
|
|
497
|
-
});
|
|
498
|
-
span.end();
|
|
499
|
-
spanMap.delete(event);
|
|
51
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message || 'Internal Server Error' });
|
|
500
52
|
}
|
|
501
53
|
} catch {
|
|
502
54
|
// swallow
|
|
503
55
|
}
|
|
504
56
|
});
|
|
505
|
-
}
|
|
57
|
+
}
|
package/nuxt.mjs
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SecureNow Nuxt 3
|
|
2
|
+
* SecureNow Nuxt Module (Nuxt 3 and 4)
|
|
3
3
|
*
|
|
4
4
|
* Usage in nuxt.config.ts:
|
|
5
5
|
*
|
|
6
6
|
* export default defineNuxtConfig({
|
|
7
7
|
* modules: ['securenow/nuxt'],
|
|
8
|
-
* securenow: { // optional overrides
|
|
9
|
-
* serviceName: 'my-nuxt-app',
|
|
10
|
-
* },
|
|
11
8
|
* });
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Configuration comes from the same place as every SecureNow integration:
|
|
11
|
+
* .securenow/runtime.json (or SECURENOW_CREDENTIALS on hosts without file mounts).
|
|
12
|
+
* The server plugin loads the same runtime as `node -r securenow/register`.
|
|
15
13
|
*/
|
|
16
14
|
|
|
17
15
|
import { defineNuxtModule, createResolver, addServerPlugin } from '@nuxt/kit';
|
|
@@ -56,16 +54,19 @@ export default defineNuxtModule({
|
|
|
56
54
|
...(nitroConfig.externals.external || []),
|
|
57
55
|
...OTEL_EXTERNALS,
|
|
58
56
|
];
|
|
57
|
+
// Nitro's production build treats every package as side-effect free and drops a bare
|
|
58
|
+
// import: without this, the plugin's `import 'securenow/register'` vanished from .output
|
|
59
|
+
// (nuxt dev, which does not tree-shake, still worked).
|
|
60
|
+
nitroConfig.moduleSideEffects = [...(nitroConfig.moduleSideEffects || []), 'securenow/'];
|
|
59
61
|
});
|
|
60
62
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
};
|
|
63
|
+
// The server plugin runs the shared SecureNow runtime, which reads its settings from
|
|
64
|
+
// .securenow/runtime.json: module options set here are not applied (service.name has to be
|
|
65
|
+
// the app key for the dashboard to find the app, so serviceName was never safe anyway).
|
|
66
|
+
const ignored = Object.keys(options || {}).filter((key) => options[key] !== undefined);
|
|
67
|
+
if (ignored.length) {
|
|
68
|
+
console.warn('[securenow] nuxt.config securenow.%s is no longer used: set it in .securenow/runtime.json (config.capture.body, config.logging.enabled, config.otel.endpoint, config.runtime.noUuid).', ignored.join(', securenow.'));
|
|
69
|
+
}
|
|
69
70
|
|
|
70
71
|
// ── Register Nitro server plugin ──
|
|
71
72
|
addServerPlugin(resolve('./nuxt-server-plugin'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "securenow",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.25.0",
|
|
4
4
|
"mcpName": "ai.securenow/securenow",
|
|
5
5
|
"description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"securenow-mcp": "mcp/server.js"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "node test-app-config.js && node test-resolve-ip.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-firewall-guards.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-crash-capture.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-esm-detect.js && node test-package-contents.js"
|
|
14
|
+
"test": "node test-app-config.js && node test-resolve-ip.js && node test-cidr.js && node test-otel-defaults.js && node test-telemetry.js && node test-legacy-semconv.js && node test-otp-redaction.js && node test-header-redaction.js && node test-firewall-paths.js && node test-firewall-guards.js && node test-rate-limits.js && node test-rate-limit-redis.js && node test-log-format.js && node test-log-redaction.js && node test-crash-capture.js && node test-crash-flush.js && node test-request-capture.js && node test-fastify-hooks.js && node test-pm2-credentials.js && node test-env-credentials.js && node test-framework-wiring.js && node test-framework-routes.js && node test-sessions-everywhere.js && node test-local-socket.js && node test-firewall-cloud.js && node test-firewall-layers.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-esm-detect.js && node test-package-contents.js"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"opentelemetry",
|
|
@@ -167,7 +167,13 @@
|
|
|
167
167
|
"firewall-paths.js",
|
|
168
168
|
"rate-limit-store.js",
|
|
169
169
|
"log-format.js",
|
|
170
|
+
"log-redaction.js",
|
|
170
171
|
"crash-capture.js",
|
|
172
|
+
"request-capture.js",
|
|
173
|
+
"pm2-env.js",
|
|
174
|
+
"fastify-hooks.js",
|
|
175
|
+
"framework-routes.js",
|
|
176
|
+
"next-route.js",
|
|
171
177
|
"sdk-disabled.js",
|
|
172
178
|
"rate-limits.js",
|
|
173
179
|
"rate-limits.d.ts",
|
package/pm2-env.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PM2's view of this process, as far as it is known when the SecureNow preload runs.
|
|
5
|
+
*
|
|
6
|
+
* Fork mode exports pm_cwd, pm_exec_path and NODE_APP_INSTANCE before the app starts.
|
|
7
|
+
* Cluster mode does not: at preload time a worker only has the JSON in pm2_env, and its
|
|
8
|
+
* working directory is still the PM2 daemon's (PM2's ProcessContainer changes directory
|
|
9
|
+
* afterwards). Anything that looks for the app's files from process.cwd() in a cluster
|
|
10
|
+
* worker therefore looks wherever the daemon happened to be started.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ cwd: string|null, execPath: string|null, instance: string|null, id: string|null, execMode: string|null, name: string|null } | null}
|
|
13
|
+
*/
|
|
14
|
+
function readPm2Env(env = process.env) {
|
|
15
|
+
if (!env) return null;
|
|
16
|
+
|
|
17
|
+
let fromJson = null;
|
|
18
|
+
if (typeof env.pm2_env === 'string' && env.pm2_env) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(env.pm2_env);
|
|
21
|
+
if (parsed && typeof parsed === 'object') fromJson = parsed;
|
|
22
|
+
} catch (_) {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pick = (key) => {
|
|
26
|
+
const flat = env[key];
|
|
27
|
+
if (flat != null && flat !== '') return String(flat);
|
|
28
|
+
const nested = fromJson && fromJson[key];
|
|
29
|
+
return nested != null && nested !== '' ? String(nested) : null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const cwd = pick('pm_cwd');
|
|
33
|
+
const execPath = pick('pm_exec_path');
|
|
34
|
+
const id = pick('pm_id');
|
|
35
|
+
if (!cwd && !execPath && id == null) return null;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
cwd,
|
|
39
|
+
execPath,
|
|
40
|
+
instance: pick('NODE_APP_INSTANCE'),
|
|
41
|
+
id,
|
|
42
|
+
execMode: pick('exec_mode'),
|
|
43
|
+
name: fromJson ? pick('name') : (env.name ? String(env.name) : null),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { readPm2Env };
|