securenow 8.12.4 → 8.14.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/mcp/server.js CHANGED
@@ -14,6 +14,9 @@ const {
14
14
  promptMessages,
15
15
  assertConfirmed,
16
16
  buildApiRequest,
17
+ pollToolResult,
18
+ fitToolResult,
19
+ summarizeToolResult,
17
20
  resourceContent,
18
21
  jsonText,
19
22
  maskSecret,
@@ -160,27 +163,32 @@ async function callApiTool(tool, args) {
160
163
  throw new Error('Admin auth is missing. Run `npx securenow admin login` from the project root, then retry. Runtime app credentials cannot grant admin/control-plane permissions.');
161
164
  }
162
165
 
163
- const request = buildApiRequest(tool, finalArgs);
164
- const options = {
165
- token,
166
- ...(Object.keys(request.query || {}).length > 0 ? { query: request.query } : {}),
167
- };
166
+ const send = (request) => {
167
+ const options = {
168
+ token,
169
+ ...(Object.keys(request.query || {}).length > 0 ? { query: request.query } : {}),
170
+ };
171
+
172
+ if (request.method === 'GET') return api.get(request.endpoint, options);
173
+ if (request.method === 'POST') return api.post(request.endpoint, request.body || {}, options);
174
+ if (request.method === 'PUT') return api.put(request.endpoint, request.body || {}, options);
175
+ if (request.method === 'PATCH') return api.patch(request.endpoint, request.body || {}, options);
176
+ if (request.method === 'DELETE') return api.delete(request.endpoint, options);
168
177
 
169
- if (request.method === 'GET') return api.get(request.endpoint, options);
170
- if (request.method === 'POST') return api.post(request.endpoint, request.body || {}, options);
171
- if (request.method === 'PUT') return api.put(request.endpoint, request.body || {}, options);
172
- if (request.method === 'PATCH') return api.patch(request.endpoint, request.body || {}, options);
173
- if (request.method === 'DELETE') return api.delete(request.endpoint, options);
178
+ throw new Error(`Unsupported method for ${tool.name}: ${request.method}`);
179
+ };
174
180
 
175
- throw new Error(`Unsupported method for ${tool.name}: ${request.method}`);
181
+ const result = await send(buildApiRequest(tool, finalArgs));
182
+ return summarizeToolResult(tool, await pollToolResult(tool, result, send));
176
183
  }
177
184
 
178
- function asToolResult(result) {
185
+ function asToolResult(rawResult) {
186
+ const result = fitToolResult(rawResult);
179
187
  return {
180
188
  content: [
181
189
  {
182
190
  type: 'text',
183
- text: typeof result === 'string' ? result : jsonText(result),
191
+ text: typeof result === 'string' ? result : JSON.stringify(result),
184
192
  },
185
193
  ],
186
194
  structuredContent: typeof result === 'object' && result !== null ? result : { value: result },
package/nextjs.js CHANGED
@@ -514,19 +514,14 @@ function registerSecureNow(options = {}) {
514
514
  _formatOtlpError(err)
515
515
  );
516
516
  }
517
- process.on('uncaughtException', (err, origin) => {
518
- if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
519
- _reportSuppressedOtlpError('error', err, origin);
520
- return;
521
- }
522
- throw err;
523
- });
524
- process.on('unhandledRejection', (reason) => {
525
- if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
526
- _reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
527
- return;
528
- }
529
- throw reason;
517
+ // Crash capture (see crash-capture.js). The logger provider only exists on
518
+ // self-hosted deployments; on Vercel crashes keep Node's default behaviour.
519
+ let _crashLoggerProvider = null;
520
+ require('./crash-capture').installCrashCapture({
521
+ getLogger: () => (_crashLoggerProvider ? _crashLoggerProvider.getLogger('securenow.crash', '1.0.0') : null),
522
+ flush: () => (_crashLoggerProvider ? _crashLoggerProvider.forceFlush() : undefined),
523
+ isSdkError: (e) => _isOtlpTransientError(e) && (_looksLikeOtlpStack(e) || _looksLikeConfiguredOtlpEndpoint(e)),
524
+ onSdkError: _reportSuppressedOtlpError,
530
525
  });
531
526
 
532
527
  if (isVercel) {
@@ -606,6 +601,7 @@ function registerSecureNow(options = {}) {
606
601
  // sdk-logs >= 0.220 takes a single options object ({ exporter }).
607
602
  processors: [new BatchLogRecordProcessor({ exporter: logExporter })],
608
603
  });
604
+ _crashLoggerProvider = loggerProvider;
609
605
 
610
606
  // Patch console to forward logs as OTLP log records
611
607
  const logger = loggerProvider.getLogger('console', '1.0.0');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.12.4",
3
+ "version": "8.14.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-log-format.js && node test-sdk-disabled.js && node test-internal-thread.js && node test-package-contents.js"
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-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-package-contents.js"
15
15
  },
16
16
  "keywords": [
17
17
  "opentelemetry",
@@ -164,7 +164,9 @@
164
164
  "otp-redaction.js",
165
165
  "header-redaction.js",
166
166
  "firewall-paths.js",
167
+ "rate-limit-store.js",
167
168
  "log-format.js",
169
+ "crash-capture.js",
168
170
  "sdk-disabled.js",
169
171
  "rate-limits.js",
170
172
  "rate-limits.d.ts",
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ // Optional shared counters for rate limits, so every worker and every server counts against
4
+ // one bucket instead of each process counting on its own.
5
+ //
6
+ // .securenow/runtime.json → "config": { "firewall": { "rateLimitStore": "redis://host:6379" } }
7
+ // (rediss:// for TLS; user:password@ and /db are supported)
8
+ //
9
+ // The firewall never waits on Redis: it decides from what it already knows (its own count plus
10
+ // the latest shared count Redis returned) and reports each counted request in the background.
11
+ // At most a request or two per process can slip past the limit while a reply is in flight.
12
+ // If Redis is unreachable the firewall keeps counting in-process (fail-open) and reconnects.
13
+ //
14
+ // No dependency: a minimal RESP client that only sends AUTH / SELECT / INCR / PEXPIRE.
15
+
16
+ const net = require('net');
17
+ const tls = require('tls');
18
+
19
+ const COMMAND_TIMEOUT_MS = 1000;
20
+ const MAX_BACKOFF_MS = 30000;
21
+
22
+ function encodeCommand(args) {
23
+ let out = `*${args.length}\r\n`;
24
+ for (const arg of args) {
25
+ const s = String(arg);
26
+ out += `$${Buffer.byteLength(s)}\r\n${s}\r\n`;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ // Parses as many complete RESP replies as the buffer holds. Returns { replies, rest }.
32
+ function parseReplies(buffer) {
33
+ const replies = [];
34
+ let offset = 0;
35
+ while (offset < buffer.length) {
36
+ const end = buffer.indexOf('\r\n', offset);
37
+ if (end === -1) break;
38
+ const type = buffer[offset];
39
+ const line = buffer.toString('utf8', offset + 1, end);
40
+ if (type === 0x2b /* + */) { replies.push({ ok: true, value: line }); offset = end + 2; continue; }
41
+ if (type === 0x2d /* - */) { replies.push({ ok: false, error: line }); offset = end + 2; continue; }
42
+ if (type === 0x3a /* : */) { replies.push({ ok: true, value: Number(line) }); offset = end + 2; continue; }
43
+ if (type === 0x24 /* $ */) {
44
+ const len = Number(line);
45
+ if (len === -1) { replies.push({ ok: true, value: null }); offset = end + 2; continue; }
46
+ if (buffer.length < end + 2 + len + 2) break;
47
+ replies.push({ ok: true, value: buffer.toString('utf8', end + 2, end + 2 + len) });
48
+ offset = end + 2 + len + 2;
49
+ continue;
50
+ }
51
+ // Arrays and other types are never requested; treat as a protocol error.
52
+ replies.push({ ok: false, error: `unexpected reply type ${String.fromCharCode(type)}` });
53
+ offset = end + 2;
54
+ }
55
+ return { replies, rest: buffer.subarray(offset) };
56
+ }
57
+
58
+ class RedisCounterStore {
59
+ constructor(url, { log } = {}) {
60
+ const parsed = new URL(url);
61
+ if (!['redis:', 'rediss:'].includes(parsed.protocol)) throw new Error('rateLimitStore must be a redis:// or rediss:// URL');
62
+ this.host = parsed.hostname || '127.0.0.1';
63
+ this.port = Number(parsed.port || 6379);
64
+ this.tls = parsed.protocol === 'rediss:';
65
+ this.username = decodeURIComponent(parsed.username || '');
66
+ this.password = decodeURIComponent(parsed.password || '');
67
+ this.db = Number((parsed.pathname || '/').slice(1) || 0) || 0;
68
+ this.log = typeof log === 'function' ? log : () => {};
69
+ this.socket = null;
70
+ this.ready = false;
71
+ this.pending = [];
72
+ this.buffer = Buffer.alloc(0);
73
+ this.backoff = 1000;
74
+ this.closed = false;
75
+ this.connect();
76
+ }
77
+
78
+ describe() {
79
+ return `${this.tls ? 'rediss' : 'redis'}://${this.host}:${this.port}/${this.db}`;
80
+ }
81
+
82
+ connect() {
83
+ if (this.closed) return;
84
+ const onConnect = () => {
85
+ const setup = [];
86
+ if (this.password) setup.push(this.username ? ['AUTH', this.username, this.password] : ['AUTH', this.password]);
87
+ if (this.db) setup.push(['SELECT', this.db]);
88
+ Promise.all(setup.map((cmd) => this.send(cmd, true)))
89
+ .then(() => {
90
+ this.ready = true;
91
+ this.backoff = 1000;
92
+ this.log('[securenow] Firewall: shared rate-limit counters ON (%s)', this.describe());
93
+ })
94
+ .catch((err) => {
95
+ this.log('[securenow] Firewall: rate-limit store setup failed (%s); counting in-process', err.message);
96
+ this.socket.destroy();
97
+ });
98
+ };
99
+ const socket = this.tls
100
+ ? tls.connect({ host: this.host, port: this.port, servername: this.host }, onConnect)
101
+ : net.connect({ host: this.host, port: this.port }, onConnect);
102
+ socket.setNoDelay(true);
103
+ socket.unref();
104
+ socket.on('data', (chunk) => this.onData(chunk));
105
+ socket.on('error', () => { /* handled by close */ });
106
+ socket.on('close', () => this.onClose());
107
+ this.socket = socket;
108
+ }
109
+
110
+ onData(chunk) {
111
+ this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
112
+ const { replies, rest } = parseReplies(this.buffer);
113
+ this.buffer = rest;
114
+ for (const reply of replies) {
115
+ const waiter = this.pending.shift();
116
+ if (!waiter) continue;
117
+ clearTimeout(waiter.timer);
118
+ if (reply.ok) waiter.resolve(reply.value);
119
+ else waiter.reject(new Error(reply.error));
120
+ }
121
+ }
122
+
123
+ onClose() {
124
+ const wasReady = this.ready;
125
+ this.ready = false;
126
+ this.buffer = Buffer.alloc(0);
127
+ const pending = this.pending;
128
+ this.pending = [];
129
+ for (const waiter of pending) { clearTimeout(waiter.timer); waiter.reject(new Error('connection closed')); }
130
+ if (this.closed) return;
131
+ if (wasReady) this.log('[securenow] Firewall: rate-limit store unreachable; counting in-process until it is back');
132
+ const delay = this.backoff;
133
+ this.backoff = Math.min(MAX_BACKOFF_MS, this.backoff * 2);
134
+ const timer = setTimeout(() => this.connect(), delay);
135
+ if (timer.unref) timer.unref();
136
+ }
137
+
138
+ send(args, allowBeforeReady = false) {
139
+ if (!this.socket || (!this.ready && !allowBeforeReady)) return Promise.reject(new Error('store not ready'));
140
+ return new Promise((resolve, reject) => {
141
+ const waiter = { resolve, reject, timer: null };
142
+ waiter.timer = setTimeout(() => {
143
+ reject(new Error('store timeout'));
144
+ // A lost reply desynchronizes the pipeline: drop the connection and start clean.
145
+ if (this.socket) this.socket.destroy();
146
+ }, COMMAND_TIMEOUT_MS);
147
+ if (waiter.timer.unref) waiter.timer.unref();
148
+ this.pending.push(waiter);
149
+ this.socket.write(encodeCommand(args));
150
+ });
151
+ }
152
+
153
+ // Count one request in a fixed window key; resolves with the shared total for that window.
154
+ incr(key, ttlMs) {
155
+ if (!this.ready) return Promise.reject(new Error('store not ready'));
156
+ const counted = this.send(['INCR', key]);
157
+ this.send(['PEXPIRE', key, Math.max(1000, Math.round(ttlMs))]).catch(() => {});
158
+ return counted;
159
+ }
160
+
161
+ close() {
162
+ this.closed = true;
163
+ this.ready = false;
164
+ if (this.socket) this.socket.destroy();
165
+ }
166
+ }
167
+
168
+ function createRateLimitStore(spec, options = {}) {
169
+ if (!spec) return null;
170
+ const url = typeof spec === 'string' ? spec : spec.url;
171
+ if (!url) return null;
172
+ return new RedisCounterStore(url, options);
173
+ }
174
+
175
+ module.exports = { createRateLimitStore, RedisCounterStore, encodeCommand, parseReplies };
package/tracing.js CHANGED
@@ -9,7 +9,7 @@ if (require('./sdk-disabled').sdkDisabled('tracing')) return;
9
9
  * Preload with: node --require securenow/register app.js
10
10
  *
11
11
  * Works for both CJS and ESM apps. On Node >=20.6 the ESM loader hook is
12
- * auto-registered via module.register() — no --import flag needed.
12
+ * auto-registered via module.register() — no --import flag needed.
13
13
  * On Node 18 with "type": "module", add the hook manually:
14
14
  * node --import @opentelemetry/instrumentation/hook.mjs --require securenow/register app.js
15
15
  *
@@ -348,7 +348,7 @@ function collectMultipartMeta(request, contentType, sensitiveFields, maxTextFiel
348
348
  const hasCliHook = execArgv.includes('hook.mjs') || execArgv.includes('import-in-the-middle');
349
349
  const hasModuleRegister = typeof require('node:module').register === 'function';
350
350
  if (!hasCliHook && !hasModuleRegister) {
351
- console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
351
+ console.warn('[securenow] ⚠️ ESM app detected ("type": "module") but no ESM loader hook available.');
352
352
  console.warn('[securenow] Upgrade to Node >=20.6 (recommended) or add: --import @opentelemetry/instrumentation/hook.mjs');
353
353
  }
354
354
  }
@@ -384,7 +384,7 @@ const headers = resolvedEndpoints.headers;
384
384
  const rawBase = (resolvedApp.appId || '').trim().replace(/^['"]|['"]$/g, '');
385
385
  const baseName = rawBase || null;
386
386
  // Auto-disables the per-worker suffix when we resolved a routing UUID from
387
- // credentials — the dashboard does exact-match IN on service.name, so any
387
+ // credentials — the dashboard does exact-match IN on service.name, so any
388
388
  // suffix breaks routing. config.runtime.noUuid can override.
389
389
  const noUuid = appConfig.resolveNoUuid();
390
390
  const strict = appConfig.boolConfig('runtime.strict', false);
@@ -411,7 +411,7 @@ const instancePrefix = baseName || 'securenow';
411
411
  const serviceInstanceId = `${instancePrefix}-${randomUUID()}`;
412
412
 
413
413
  // Loud line per worker to prove what was used
414
- console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s → service.name=%s instance.id=%s',
414
+ console.log('[securenow] pid=%d appId=%s instance=%s apiKey=%s → service.name=%s instance.id=%s',
415
415
  process.pid,
416
416
  JSON.stringify(baseName),
417
417
  JSON.stringify(endpointBase),
@@ -739,21 +739,15 @@ function _reportSuppressedOtlpError(kind, err, origin) {
739
739
  );
740
740
  }
741
741
 
742
- process.on('uncaughtException', (err, origin) => {
743
- if (_isOtlpTransientError(err) && (_looksLikeOtlpStack(err) || _looksLikeConfiguredOtlpEndpoint(err))) {
744
- _reportSuppressedOtlpError('error', err, origin);
745
- return; // swallow - do not crash
746
- }
747
- // Not ours — re-throw so the default handler (or the app's own handler) fires
748
- throw err;
749
- });
750
- process.on('unhandledRejection', (reason) => {
751
- if (_isOtlpTransientError(reason) && (_looksLikeOtlpStack(reason) || _looksLikeConfiguredOtlpEndpoint(reason))) {
752
- _reportSuppressedOtlpError('rejection', reason, 'unhandledRejection');
753
- return; // swallow
754
- }
755
- // Not ours — re-throw as unhandled so Node's default behaviour applies
756
- throw reason;
742
+ // Crashes: OTLP exporter blips are swallowed (never crash the host app); every
743
+ // other uncaught exception / unhandled rejection is recorded as a FATAL log
744
+ // record, flushed, and the process exits like Node's default (code 1). If the
745
+ // app has its own handler, the error is recorded and that handler decides.
746
+ require('./crash-capture').installCrashCapture({
747
+ getLogger: () => (loggerProvider ? loggerProvider.getLogger('securenow.crash', '1.0.0') : null),
748
+ flush: () => (loggerProvider ? loggerProvider.forceFlush() : undefined),
749
+ isSdkError: (e) => _isOtlpTransientError(e) && (_looksLikeOtlpStack(e) || _looksLikeConfiguredOtlpEndpoint(e)),
750
+ onSdkError: _reportSuppressedOtlpError,
757
751
  });
758
752
 
759
753
  // -------- SDK --------
@@ -779,17 +773,17 @@ const sdk = new NodeSDK({
779
773
  (async () => {
780
774
  try {
781
775
  await Promise.resolve(sdk.start?.());
782
- console.log('[securenow] OTel SDK started → %s', tracesUrl);
776
+ console.log('[securenow] OTel SDK started → %s', tracesUrl);
783
777
  if (loggingEnabled) {
784
- console.log('[securenow] 📋 Logging: ENABLED → %s', logsUrl);
778
+ console.log('[securenow] 📋 Logging: ENABLED → %s', logsUrl);
785
779
  } else {
786
780
  console.log('[securenow] Logging: DISABLED (config.logging.enabled=false)');
787
781
  }
788
782
  if (captureBody) {
789
- console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
783
+ console.log('[securenow] 📝 Request body capture: ENABLED (max: %d bytes, redacting %d sensitive fields)', maxBodySize, allSensitiveFields.length);
790
784
  }
791
785
  if (captureMultipart) {
792
- console.log('[securenow] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
786
+ console.log('[securenow] 📎 Multipart body capture: ENABLED (streaming — file content not buffered)');
793
787
  }
794
788
  if (appConfig.boolConfig('runtime.testSpan', false)) {
795
789
  const api = require('@opentelemetry/api');
@@ -797,9 +791,9 @@ const sdk = new NodeSDK({
797
791
  const span = tracer.startSpan('securenow.startup.smoke'); span.end();
798
792
  }
799
793
 
800
- // Firewall — auto-activates only when a real snk_live_ key is resolvable.
794
+ // Firewall — auto-activates only when a real snk_live_ key is resolvable.
801
795
  // resolveApiKey() enforces the prefix, so we skip cleanly when the app has
802
- // only an app-routing UUID (or nothing at all) — no 401 polling loops.
796
+ // only an app-routing UUID (or nothing at all) — no 401 polling loops.
803
797
  const firewallOptions = appConfig.resolveFirewallOptions();
804
798
  if (firewallOptions.apiKey) {
805
799
  require('./firewall').init({