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