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/NPM_README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # SecureNow - Complete OpenTelemetry Observability for Node.js
2
2
 
3
+ > **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
4
+
3
5
  OpenTelemetry instrumentation library for Node.js, Next.js, and Nuxt applications. Send distributed traces and logs to any OTLP-compatible observability backend.
4
6
 
5
7
  **Features:**
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # SecureNow
2
2
 
3
+ > **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
4
+
3
5
  **Production security for AI-native development teams.**
4
6
 
5
7
  Your coding agent knows your code. SecureNow shows it what's happening in production. One Node.js package, CLI, and MCP server connect Claude Code, Codex, and Cursor to your live application security — attacks, routes, users, traces, IP intelligence, and response controls.
6
8
 
7
- Your agent already sees the repository, the dependencies, the auth implementation, the API handlers, the database queries. SecureNow adds the half it can't see: live requests, routes, users and sessions, attack patterns, IP intelligence, traces and logs, security history, and block / rate-limit controls — with human approval on every mutation.
9
+ Your agent already sees the repository, the dependencies, the auth implementation, the API handlers, the database queries. SecureNow adds the half it can't see: live requests, routes, users and sessions, attack patterns, IP intelligence, traces and logs, security history, and block / rate-limit controls — every write confirmed by you and recorded.
8
10
 
9
11
  **Find it in code. Watch it in production.**
10
12
 
@@ -31,7 +33,7 @@ Agent: Apply the recommended blocks?
31
33
  You: Yes.
32
34
  ```
33
35
 
34
- Detection and investigation are automatic. Actions are proposed, a human approves, then they execute — and every action is reversible (unblock keeps the history, revocations can be restored, rate limits expire).
36
+ Detection and investigation are automatic. Your agent proposes actions and asks before it acts: every write needs an explicit `confirm: true` and a reason, applies to your running apps in about 10 seconds, and is reversible (unblock keeps the history, revocations can be restored, rate limits expire). Some automated AI blocks wait in an approval queue (`securenow human`).
35
37
 
36
38
  ---
37
39
 
@@ -94,7 +96,7 @@ export default defineNuxtConfig({
94
96
  });
95
97
  ```
96
98
 
97
- **What's captured automatically:** HTTP spans (Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt, raw `http`), database spans (Postgres, MySQL, MongoDB, Redis), `console.*` as logs with trace correlation, request bodies with auto-redaction, multipart metadata (never file content), plus the firewall. Tune everything in `.securenow/runtime.json` under `config`.
99
+ **What's captured automatically:** HTTP spans (Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt, raw `http`), database spans (Postgres, MySQL, MongoDB, Redis), `console.*` as logs with trace correlation, crashes (uncaught exceptions and unhandled rejections) as `FATAL` logs with their stack, request bodies with auto-redaction, multipart metadata (never file content), plus the firewall. Tune everything in `.securenow/runtime.json` under `config`.
98
100
 
99
101
  **Production** — same file structure, no env vars. Generate a tokenless runtime credentials file and mount it as a deployment secret:
100
102
 
@@ -174,11 +176,25 @@ Everything the MCP server can do is also a command. Run `npx securenow help` for
174
176
  | `securenow human block <row> --yes --reason "..."` | Approve a proposed block |
175
177
  | `securenow blocklist add <ip> [--route /admin*] [--mode prefix] [--duration 24h] [--reason "..."]` | Block an IP globally or per-route |
176
178
  | `securenow blocklist unblock <id> --reason "..."` | Stop enforcement, keep the history |
177
- | `securenow ratelimit from-text "rate limit /api/login to 5 per minute" --yes` | Rate-limit rule from natural language |
179
+ | `securenow ratelimit from-text "5 per minute per IP on POST /api/login for 2h" --yes` | Rate-limit rule from natural language |
180
+ | `securenow ratelimit add --route /api/ --limit 100 --window 1m --header x-api-key` | Per-API-key (or `--cookie session` per-session) limit |
181
+ | `securenow firewall hits --since 1h` | Who your blocks, rate limits and challenges actually stopped |
178
182
  | `securenow challenge add <ip> --route /login` | Proof-of-work challenge instead of a hard block |
179
183
  | `securenow revoke session <id>` / `revoke user <id>` | Kill stolen sessions (enforced via `securenow/sessions`) |
180
184
  | `securenow trusted add <ip>` | Mark an IP as trusted |
181
185
 
186
+ ### Rate limits, outside your code
187
+
188
+ Rate-limit rules live in SecureNow, not in your code: create them from the dashboard, the CLI or your agent (MCP), and every running instance picks them up in about 10 seconds, with no deploy. A rule matches a route (exact, prefix or regex), a method, optionally an IP/CIDR, an app and an environment, and counts per IP, globally, per request header value (`--header x-api-key`) or per cookie value (`--cookie session`). Header and cookie values are hashed; requests without them are counted per IP. Rules can stack (a burst limit and an hourly cap). Over the limit, the app answers `429` with `Retry-After`.
189
+
190
+ Counters live in each process by default. To share one counter across workers and servers, point the firewall at your Redis:
191
+
192
+ ```json
193
+ { "config": { "firewall": { "rateLimitStore": "redis://your-redis:6379" } } }
194
+ ```
195
+
196
+ The firewall never waits on Redis: it decides from what it already knows and reports each hit in the background (at most about one extra request per process can get through while a reply is in flight). If Redis is unreachable, counting falls back to in-process.
197
+
182
198
  ### Rules & detections
183
199
 
184
200
  | Command | Description |
@@ -233,7 +249,7 @@ It syncs your app's blocklist plus SecureNow's known-bad IP feed, refreshed hour
233
249
 
234
250
  SecureNow 8.11.2 redacts sign-in credentials named `code`, `otpCode`, `oneTimeCode`, `verificationCode`, and `passcode` by default, including case and separator variants. JSON, form, GraphQL, and multipart text capture use the same rule; URL query parameters and captured headers are also protected. Credential names match exactly after normalization, so `status_code` and `error_code` remain available for investigation. Redaction changes exported telemetry only.
235
251
  - **Fails open.** If SecureNow is unreachable, your app serves traffic normally (`config.firewall.failMode: "open"` is the default).
236
- - **Read/write split.** MCP read tools are read-only; write tools require explicit `confirm: true` plus a reason, and human approval is the designed path for mutations — blocks, rate limits, and revocations are proposed, approved, executed, and reversible.
252
+ - **Read/write split.** MCP read tools are read-only; write tools require explicit `confirm: true` plus a reason, so your agent asks you before it blocks, rate-limits or revokes anything; every write is recorded and reversible.
237
253
 
238
254
  More at [securenow.ai/security](https://securenow.ai/security).
239
255
 
package/SKILL-API.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # SecureNow SDK — Agent Skill
2
2
 
3
+ > **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
4
+
3
5
  Instrument any Node.js application with OpenTelemetry tracing, structured logging, request body capture, and a multi-layer IP firewall. Supports Express, Fastify, NestJS, Koa, Hapi, Next.js, Nuxt 3, Vite (browser), and raw `http.createServer` — with zero code changes for most setups.
4
6
 
5
7
  **CLI parity:** every capability exposed below (redaction, CIDR matching, log/span emission, firewall preload, config inspection) has an equivalent `securenow` CLI command. See [SKILL-CLI.md](./SKILL-CLI.md) for the terminal surface.
@@ -670,7 +672,7 @@ Make sure an API key is resolvable by running `npx securenow app connect` first.
670
672
  ### Instrument a Docker Container
671
673
 
672
674
  ```dockerfile
673
- FROM node:20-slim
675
+ FROM node:24.22.1-slim
674
676
  WORKDIR /app
675
677
  COPY package*.json ./
676
678
  RUN npm ci --omit=dev
package/SKILL-CLI.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # SecureNow CLI — Agent Skill
2
2
 
3
+ > **Node.js security:** Use a supported, patched runtime: Node.js 22.23.4+, 24.22.1+, or 26.12.1+ on its respective release line. Earlier patches are affected by [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). Upgrading the SecureNow package does not patch Node.js; upgrade the runtime and restart every application process.
4
+
3
5
  Use the `securenow` CLI to perform security DevOps from the terminal: manage apps, investigate threats, control the firewall, analyze traces, handle false positives, and run instrumented Node.js processes. Every command supports `--json` for machine-readable output.
4
6
 
5
7
  ## Installation
package/app-config.js CHANGED
@@ -75,6 +75,7 @@ const DEFAULT_CONFIG = Object.freeze({
75
75
  iptables: false,
76
76
  cloud: null,
77
77
  cloudDryRun: false,
78
+ rateLimitStore: null,
78
79
  cloudflare: {
79
80
  apiToken: null,
80
81
  accountId: null,
@@ -126,6 +127,7 @@ const CONFIG_EXPLANATIONS = Object.freeze({
126
127
  'config.firewall.iptables': 'Layer 3 OS firewall. Default false because it requires Linux root/CAP_NET_ADMIN.',
127
128
  'config.firewall.cloud': 'Layer 4 WAF provider: cloudflare, aws, gcp, or null.',
128
129
  'config.firewall.cloudDryRun': 'Log intended cloud WAF pushes without applying them.',
130
+ 'config.firewall.rateLimitStore': 'Optional shared rate-limit counters: a redis:// or rediss:// URL. Unset = each process counts on its own. If the store is unreachable, counting falls back to in-process.',
129
131
  'config.firewall.cloudflare': 'Cloudflare API token/account id for Layer 4 WAF. Secrets: do not commit.',
130
132
  'config.firewall.aws': 'AWS WAF IP set configuration for Layer 4 WAF.',
131
133
  'config.firewall.gcp': 'GCP Cloud Armor policy configuration for Layer 4 WAF.',
@@ -710,6 +712,8 @@ function resolveFirewallOptions() {
710
712
  iptables: boolConfig('firewall.iptables', false),
711
713
  cloud: configValue('firewall.cloud', null) || null,
712
714
  cloudDryRun: boolConfig('firewall.cloudDryRun', false),
715
+ // Optional shared rate-limit counters: "redis://host:6379" (or rediss://). Unset = per process.
716
+ rateLimitStore: configValue('firewall.rateLimitStore', null) || null,
713
717
  cloudflare: {
714
718
  apiToken: resolveConfigPath('firewall.cloudflare.apiToken', [], null) || null,
715
719
  accountId: resolveConfigPath('firewall.cloudflare.accountId', [], null) || null,
package/cli/firewall.js CHANGED
@@ -216,4 +216,50 @@ async function appsList(args, flags) {
216
216
  }
217
217
  }
218
218
 
219
- module.exports = { status, testIp, enable, disable, appsList };
219
+ // securenow firewall hits — who the firewall actually stopped (blocks, 429s, challenges),
220
+ // per rule and per IP, plus the latest hits. The quickest "is my rule working?" check.
221
+ async function hits(args, flags) {
222
+ requireAuth();
223
+ const s = ui.spinner('Loading firewall hits');
224
+ try {
225
+ const query = { since: flags.since || '24h' };
226
+ const appKey = await resolveAppKey(flags);
227
+ if (appKey) query.appKey = appKey;
228
+ if (flags.env || flags.environment) query.environment = flags.env || flags.environment;
229
+ if (flags.action) query.action = flags.action === 'rate-limited' ? 'rate_limited' : flags.action;
230
+ if (flags.source) query.source = flags.source === 'rate-limit' ? 'rate_limit' : flags.source;
231
+ if (flags.rule) query.ruleId = flags.rule;
232
+ if (flags.ip) query.ip = flags.ip;
233
+ if (flags.limit) query.limit = flags.limit;
234
+ const data = await api.get('/firewall/hits', { query });
235
+ s.stop('Firewall hits loaded');
236
+ if (flags.json) { ui.json(data); return; }
237
+
238
+ const label = { blocked: 'blocked', rate_limited: '429', captcha_challenged: 'challenged', captcha_passed: 'passed', captcha_failed: 'failed', allowed: 'allowed' };
239
+ const totals = Object.entries(data.totals || {}).map(([k, v]) => `${label[k] || k} ${v}`).join(' · ') || 'none';
240
+ console.log('');
241
+ console.log(` ${ui.c.bold('Hits')} ${ui.c.dim(`${new Date(data.from).toLocaleString()} → ${new Date(data.to).toLocaleString()}`)} ${totals}`);
242
+ console.log('');
243
+ if (!(data.byRule || []).length) {
244
+ console.log(` ${ui.c.dim('No blocks, rate limits or challenges in this window.')}`);
245
+ console.log('');
246
+ return;
247
+ }
248
+ console.log(` ${ui.c.bold('By rule')}`);
249
+ for (const r of data.byRule.slice(0, 15)) {
250
+ const what = r.rule || (r.ruleId ? `rule ${r.ruleId}` : r.matchedEntry ? `block ${r.matchedEntry}` : r.source);
251
+ console.log(` ${String(r.hits).padStart(6)} ${(label[r.action] || r.action).padEnd(10)} ${what} ${ui.c.dim(`${r.distinctIps} IP${r.distinctIps === 1 ? '' : 's'}${r.ruleId ? ` · ${r.ruleId}` : ''}`)}`);
252
+ }
253
+ console.log('');
254
+ console.log(` ${ui.c.bold('Top IPs')}`);
255
+ for (const r of (data.byIp || []).slice(0, 10)) {
256
+ console.log(` ${String(r.hits).padStart(6)} ${(label[r.action] || r.action).padEnd(10)} ${r.ip}`);
257
+ }
258
+ console.log('');
259
+ } catch (err) {
260
+ s.fail('Failed to load firewall hits');
261
+ throw err;
262
+ }
263
+ }
264
+
265
+ module.exports = { status, testIp, enable, disable, appsList, hits };
package/cli/monitor.js CHANGED
@@ -269,7 +269,7 @@ async function logsList(args, flags) {
269
269
 
270
270
  console.log('');
271
271
  for (const log of logs) {
272
- const level = (log.severityText || log.level || 'INFO').toUpperCase();
272
+ const level = (log.severity_text || log.severityText || log.level || 'INFO').toUpperCase();
273
273
  const levelColor = {
274
274
  ERROR: ui.c.red, FATAL: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
275
275
  INFO: ui.c.cyan, DEBUG: ui.c.dim, TRACE: ui.c.dim,
@@ -277,13 +277,14 @@ async function logsList(args, flags) {
277
277
 
278
278
  const parsedTime = ui.parseTimestamp(log.timestamp);
279
279
  const time = ui.c.dim(parsedTime ? parsedTime.toLocaleTimeString() : '');
280
- const body = log.body || log.message || log.severityText || '';
280
+ const body = log.body || log.message || '';
281
281
 
282
282
  const env = log.environment ? ` ${ui.c.dim(`[${log.environment}]`)}` : '';
283
283
  console.log(` ${time} ${levelColor(level.padEnd(7))}${env} ${body}`);
284
284
 
285
- if (log.traceId && flags.verbose) {
286
- console.log(` ${ui.c.dim(` trace=${log.traceId} span=${log.spanId || ''}`)}`);
285
+ const traceId = log.trace_id || log.traceId;
286
+ if (traceId && flags.verbose) {
287
+ console.log(` ${ui.c.dim(` trace=${traceId} span=${log.span_id || log.spanId || ''}`)}`);
287
288
  }
288
289
  }
289
290
  console.log('');
@@ -315,7 +316,7 @@ async function logsTrace(args, flags) {
315
316
 
316
317
  console.log('');
317
318
  for (const log of logs) {
318
- const level = (log.severityText || log.level || 'INFO').toUpperCase();
319
+ const level = (log.severity_text || log.severityText || log.level || 'INFO').toUpperCase();
319
320
  const levelColor = {
320
321
  ERROR: ui.c.red, FATAL: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
321
322
  INFO: ui.c.cyan, DEBUG: ui.c.dim, TRACE: ui.c.dim,
package/cli/rateLimits.js CHANGED
@@ -197,7 +197,7 @@ async function show(args, flags) {
197
197
  ['Status', r.status || '-'],
198
198
  ['Target', describeTarget(r)],
199
199
  ['Limit', `${r.limit}/${r.windowSeconds}s`],
200
- ['Key by', r.keyBy || 'ip'],
200
+ ['Key by', r.keyName ? `${r.keyBy} ${r.keyName}` : (r.keyBy || 'ip')],
201
201
  ['App', r.applicationKey || 'all apps'],
202
202
  ['Environment', r.environment || 'all envs'],
203
203
  ['Reason', r.reason || '-'],
@@ -232,7 +232,8 @@ async function add(args, flags) {
232
232
  pathPattern,
233
233
  pathMatchMode: flags.mode || flags['path-mode'] || (pathPattern ? 'prefix' : 'prefix'),
234
234
  method: flags.method || 'ALL',
235
- keyBy: flags['key-by'] || flags.keyBy || 'ip',
235
+ keyBy: flags['key-by'] || flags.keyBy || (flags.header ? 'header' : flags.cookie ? 'cookie' : 'ip'),
236
+ keyName: flags['key-name'] || flags.keyName || flags.header || flags.cookie || '',
236
237
  limit: Number(flags.limit),
237
238
  windowSeconds: parseWindow(flags.window || flags['window-seconds']),
238
239
  reason: flags.reason || '',
package/cli.js CHANGED
@@ -387,6 +387,7 @@ const COMMANDS = {
387
387
  enable: { desc: 'Turn the firewall ON for an app environment', usage: 'securenow firewall enable [--app <key>] [--env production]', flags: { app: 'App key (defaults to logged-in app)', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').enable(a, f) },
388
388
  disable: { desc: 'Turn the firewall OFF for an app environment', usage: 'securenow firewall disable [--app <key>] [--env local]', flags: { app: 'App key (defaults to logged-in app)', env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').disable(a, f) },
389
389
  'test-ip': { desc: 'Check if an IP would be blocked', usage: 'securenow firewall test-ip <ip> [--path /admin/users] [--method GET] [--env production]', flags: { env: 'Environment (default: production)', environment: 'Alias for --env' }, run: (a, f) => require('./cli/firewall').testIp(a, f) },
390
+ hits: { desc: 'Who the firewall stopped: blocks, 429s and challenges per rule and per IP', usage: 'securenow firewall hits [--since 24h] [--action blocked|rate_limited|captcha_challenged] [--rule <id>] [--ip <ip>] [--env production] [--json]', flags: { since: 'Look-back window, e.g. 30m, 6h, 7d (default 24h)', action: 'blocked | rate_limited | captcha_challenged', source: 'blocklist | allowlist | rate_limit | challenge', rule: 'Rule id', ip: 'Client IP', env: 'Environment', limit: 'Recent hits to fetch' }, run: (a, f) => require('./cli/firewall').hits(a, f) },
390
391
  },
391
392
  defaultSub: 'status',
392
393
  },
@@ -410,7 +411,7 @@ const COMMANDS = {
410
411
  },
411
412
  sub: {
412
413
  list: { desc: 'List rate-limit remediation rules', run: (a, f) => require('./cli/rateLimits').list(a, f) },
413
- add: { desc: 'Create a rate-limit rule', usage: 'securenow ratelimit add [ip] --route /api/login --limit 5 --window 1m --duration 24h', run: (a, f) => require('./cli/rateLimits').add(a, f) },
414
+ add: { desc: 'Create a rate-limit rule', usage: 'securenow ratelimit add [ip] --route /api/login --limit 5 --window 1m --duration 24h [--key-by ip|global|header|cookie] [--key-name x-api-key | --header x-api-key | --cookie session]', run: (a, f) => require('./cli/rateLimits').add(a, f) },
414
415
  parse: { desc: 'Parse natural language into a rate-limit draft', usage: 'securenow ratelimit parse "rate limit /api/login to 2 attempts per minute"', run: (a, f) => require('./cli/rateLimits').parseText(a, f) },
415
416
  'from-text': { desc: 'Create a rate-limit rule from natural language', usage: 'securenow ratelimit from-text "rate limit /api/login to 2 attempts per minute" --yes', run: (a, f) => require('./cli/rateLimits').fromText(a, f) },
416
417
  show: { desc: 'Show one rate-limit rule', usage: 'securenow ratelimit show <id>', run: (a, f) => require('./cli/rateLimits').show(a, f) },
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // Crash capture: an uncaught exception or an unhandled promise rejection is
4
+ // recorded as a FATAL log record (with exception.type/message/stacktrace), the
5
+ // log pipeline is flushed, and the process then exits the way Node would have
6
+ // (stack on stderr, exit code 1). When the app registered its own handler, the
7
+ // error is recorded as ERROR and the app's handler decides what happens next.
8
+ // Transient OTLP exporter errors are still swallowed so telemetry outages never
9
+ // crash the host app.
10
+
11
+ const util = require('util');
12
+
13
+ const FLUSH_TIMEOUT_MS = 2000;
14
+
15
+ function unhandledRejectionMode() {
16
+ const args = [...process.execArgv, ...String(process.env.NODE_OPTIONS || '').split(/\s+/)];
17
+ for (const a of args) {
18
+ const m = /^--unhandled-rejections=(\S+)$/.exec(a);
19
+ if (m) return m[1];
20
+ }
21
+ return 'throw';
22
+ }
23
+
24
+ function describeError(err) {
25
+ if (err instanceof Error) {
26
+ const type = err.name || (err.constructor && err.constructor.name) || 'Error';
27
+ const message = err.message || '';
28
+ return { type, message, stack: err.stack || `${type}: ${message}` };
29
+ }
30
+ const text = util.inspect(err, { depth: 3, breakLength: Infinity });
31
+ const type = err && typeof err === 'object' ? (err.constructor && err.constructor.name) || 'Object' : typeof err;
32
+ return { type, message: text, stack: text };
33
+ }
34
+
35
+ function installCrashCapture({ getLogger, flush, isSdkError, onSdkError, write } = {}) {
36
+ const writeErr = write || ((s) => { try { process.stderr.write(s); } catch (_) {} });
37
+ let crashing = false;
38
+
39
+ function record(err, origin, fatal) {
40
+ let logger = null;
41
+ try { logger = getLogger ? getLogger() : null; } catch (_) {}
42
+ if (!logger) return;
43
+ const d = describeError(err);
44
+ const what = origin === 'unhandledRejection' ? 'Unhandled promise rejection' : 'Uncaught exception';
45
+ try {
46
+ logger.emit({
47
+ severityNumber: fatal ? 21 : 17,
48
+ severityText: fatal ? 'FATAL' : 'ERROR',
49
+ body: `${what}${fatal ? ', process crashed' : ' (handled by the app)'}: ${d.stack}`,
50
+ attributes: {
51
+ 'log.source': 'crash',
52
+ 'exception.type': d.type,
53
+ 'exception.message': d.message.slice(0, 4000),
54
+ 'exception.stacktrace': d.stack.slice(0, 16000),
55
+ 'exception.origin': origin || 'uncaughtException',
56
+ 'exception.escaped': Boolean(fatal),
57
+ 'process.pid': process.pid,
58
+ 'process.uptime_s': Math.round(process.uptime()),
59
+ },
60
+ });
61
+ } catch (_) {}
62
+ }
63
+
64
+ function flushQuietly() {
65
+ try {
66
+ return Promise.resolve(flush ? flush() : undefined).catch(() => {});
67
+ } catch (_) {
68
+ return Promise.resolve();
69
+ }
70
+ }
71
+
72
+ // Print like Node's default handler, give the exporter a bounded window to
73
+ // ship the crash record, then exit with code 1.
74
+ function crash(err, origin) {
75
+ if (crashing) process.exit(1);
76
+ crashing = true;
77
+ const d = describeError(err);
78
+ if (origin === 'unhandledRejection' && !(err instanceof Error)) {
79
+ writeErr(`[UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "${d.message}".]\n`);
80
+ } else {
81
+ writeErr(`${d.stack}\n`);
82
+ }
83
+ writeErr(`\nNode.js ${process.version}\n`);
84
+ const hardStop = setTimeout(() => process.exit(1), FLUSH_TIMEOUT_MS + 250);
85
+ Promise.race([flushQuietly(), new Promise((r) => setTimeout(r, FLUSH_TIMEOUT_MS))])
86
+ .then(() => { clearTimeout(hardStop); process.exit(1); });
87
+ }
88
+
89
+ process.on('uncaughtException', function securenowUncaughtException(err, origin) {
90
+ if (isSdkError && isSdkError(err)) {
91
+ if (onSdkError) onSdkError('error', err, origin);
92
+ return;
93
+ }
94
+ const appHandles = process.listenerCount('uncaughtException') > 1;
95
+ record(err, origin, !appHandles);
96
+ if (appHandles) {
97
+ flushQuietly();
98
+ return;
99
+ }
100
+ crash(err, origin);
101
+ });
102
+
103
+ process.on('unhandledRejection', function securenowUnhandledRejection(reason) {
104
+ if (isSdkError && isSdkError(reason)) {
105
+ if (onSdkError) onSdkError('rejection', reason, 'unhandledRejection');
106
+ return;
107
+ }
108
+ const appHandles = process.listenerCount('unhandledRejection') > 1;
109
+ const mode = unhandledRejectionMode();
110
+ if (appHandles || mode === 'warn' || mode === 'none' || mode === 'warn-with-error-code') {
111
+ record(reason, 'unhandledRejection', false);
112
+ if (!appHandles && mode === 'warn-with-error-code') {
113
+ writeErr(`(node:${process.pid}) UnhandledPromiseRejectionWarning: ${describeError(reason).stack}\n`);
114
+ process.exitCode = 1;
115
+ }
116
+ flushQuietly();
117
+ return;
118
+ }
119
+ // Default (--unhandled-rejections=throw): an unhandled rejection crashes.
120
+ record(reason, 'unhandledRejection', true);
121
+ crash(reason, 'unhandledRejection');
122
+ });
123
+ }
124
+
125
+ module.exports = { installCrashCapture, describeError, unhandledRejectionMode };
package/firewall.js CHANGED
@@ -44,6 +44,10 @@ let _lastAllowlistSyncEtag = null;
44
44
  let _rateLimitRules = [];
45
45
  let _lastRateLimitVersion = null;
46
46
  let _rateLimitBuckets = new Map();
47
+ // Optional shared counters (config.firewall.rateLimitStore = "redis://…"). When it is ready,
48
+ // rate limits count across every worker and server; otherwise each process counts on its own.
49
+ let _rateLimitStore = null;
50
+ let _sharedBuckets = new Map();
47
51
 
48
52
  // CAPTCHA / proof-of-work challenge state. _challengeConfig.secret signs the
49
53
  // challenge tokens + clearance cookies (delivered per-app over sync). When a
@@ -914,10 +918,43 @@ function ruleIpMatches(rule, ip) {
914
918
  // Matches the raw path and its canonical aliases (case, trailing/duplicate slashes, percent-encoding).
915
919
  const { rulePathMatches } = require('./firewall-paths');
916
920
 
917
- function rateLimitKey(rule, ip) {
918
- const id = rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
919
- const subject = rule.keyBy === 'global' ? 'global' : ip;
920
- return `${id}|${subject}`;
921
+ function readCookie(req, name) {
922
+ const header = req && req.headers && req.headers.cookie;
923
+ if (!header || !name) return '';
924
+ for (const part of String(header).split(';')) {
925
+ const idx = part.indexOf('=');
926
+ if (idx === -1) continue;
927
+ if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
928
+ }
929
+ return '';
930
+ }
931
+
932
+ function hashSubject(value) {
933
+ return require('crypto').createHash('sha256').update(String(value)).digest('hex').slice(0, 24);
934
+ }
935
+
936
+ // What one bucket counts. keyBy "header"/"cookie" counts per value of that header (an API key,
937
+ // a user or tenant id) or cookie (a session), hashed so no raw credential is ever kept or sent
938
+ // to a shared store. A request without the header/cookie is counted per IP instead.
939
+ function rateLimitSubject(rule, ip, req) {
940
+ if (rule.keyBy === 'global') return 'global';
941
+ if (rule.keyBy === 'header' || rule.keyBy === 'cookie') {
942
+ const name = String(rule.keyName || '').trim();
943
+ const raw = rule.keyBy === 'header'
944
+ ? (req && req.headers ? req.headers[name.toLowerCase()] : '')
945
+ : readCookie(req, name);
946
+ const value = Array.isArray(raw) ? raw.join(',') : String(raw || '').trim();
947
+ if (value) return `${rule.keyBy}:${hashSubject(value)}`;
948
+ }
949
+ return ip;
950
+ }
951
+
952
+ function rateLimitRuleId(rule) {
953
+ return rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
954
+ }
955
+
956
+ function rateLimitKey(rule, ip, req) {
957
+ return `${rateLimitRuleId(rule)}|${rateLimitSubject(rule, ip, req)}`;
921
958
  }
922
959
 
923
960
  function checkBlocklistRules(req, ip) {
@@ -958,7 +995,39 @@ function checkRateLimitRules(req, ip) {
958
995
  const limit = Math.max(1, Number(rule.limit || 1) || 1);
959
996
  const windowSeconds = Math.max(1, Number(rule.windowSeconds || 60) || 60);
960
997
  const windowMs = windowSeconds * 1000;
961
- const key = rateLimitKey(rule, ip);
998
+ const key = rateLimitKey(rule, ip, req);
999
+
1000
+ if (_rateLimitStore && _rateLimitStore.ready) {
1001
+ // Shared counters: windows aligned to the clock so every process agrees on the bucket.
1002
+ // Decide from what is already known (this process's count, or the latest shared total
1003
+ // plus requests still in flight) and report the hit to the store in the background.
1004
+ const windowIndex = Math.floor(now / windowMs);
1005
+ let shared = _sharedBuckets.get(key);
1006
+ if (!shared || shared.windowIndex !== windowIndex) {
1007
+ shared = { windowIndex, local: 0, shared: 0, inflight: 0 };
1008
+ _sharedBuckets.set(key, shared);
1009
+ }
1010
+ if (Math.max(shared.local, shared.shared + shared.inflight) >= limit) {
1011
+ return {
1012
+ rule,
1013
+ ruleId: rule.id || rule._id || '',
1014
+ limit,
1015
+ windowSeconds,
1016
+ retryAfter: Math.max(1, Math.ceil(((windowIndex + 1) * windowMs - now) / 1000)),
1017
+ path,
1018
+ shared: true,
1019
+ };
1020
+ }
1021
+ shared.local++;
1022
+ shared.inflight++;
1023
+ const entry = shared;
1024
+ _rateLimitStore.incr(`securenow:rl:${key}:${windowIndex}`, windowMs + 1000)
1025
+ .then((total) => { if (Number.isFinite(total)) entry.shared = Math.max(entry.shared, total); })
1026
+ .catch(() => {})
1027
+ .finally(() => { entry.inflight = Math.max(0, entry.inflight - 1); });
1028
+ continue;
1029
+ }
1030
+
962
1031
  let bucket = _rateLimitBuckets.get(key);
963
1032
  if (!bucket || now - bucket.windowStart >= windowMs) {
964
1033
  bucket = { windowStart: now, count: 0 };
@@ -983,11 +1052,21 @@ function checkRateLimitRules(req, ip) {
983
1052
  }
984
1053
 
985
1054
  function pruneRateLimitBuckets(rules) {
986
- const ids = new Set((rules || []).map((rule) => String(rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`)));
987
- for (const key of _rateLimitBuckets.keys()) {
1055
+ const ids = new Set((rules || []).map((rule) => String(rateLimitRuleId(rule))));
1056
+ for (const buckets of [_rateLimitBuckets, _sharedBuckets]) {
1057
+ for (const key of buckets.keys()) {
1058
+ const idx = key.indexOf('|');
1059
+ const ruleKey = idx === -1 ? key : key.slice(0, idx);
1060
+ if (!ids.has(ruleKey)) buckets.delete(key);
1061
+ }
1062
+ }
1063
+ // Drop shared entries from past windows so the map cannot grow without bound.
1064
+ const now = Date.now();
1065
+ const windowMsById = new Map((rules || []).map((r) => [String(rateLimitRuleId(r)), Math.max(1, Number(r.windowSeconds) || 60) * 1000]));
1066
+ for (const [key, entry] of _sharedBuckets) {
988
1067
  const idx = key.indexOf('|');
989
- const ruleKey = idx === -1 ? key : key.slice(0, idx);
990
- if (!ids.has(ruleKey)) _rateLimitBuckets.delete(key);
1068
+ const windowMs = windowMsById.get(idx === -1 ? key : key.slice(0, idx)) || 60000;
1069
+ if (entry.windowIndex < Math.floor(now / windowMs)) _sharedBuckets.delete(key);
991
1070
  }
992
1071
  }
993
1072
 
@@ -1100,6 +1179,7 @@ function sendChallengeResponse(req, res, ip, decision) {
1100
1179
  source: 'challenge',
1101
1180
  statusCode: 403,
1102
1181
  ip,
1182
+ ruleId: String(decision.ruleId || ''),
1103
1183
  matchedEntry: rid,
1104
1184
  method: req.method || '',
1105
1185
  path: decision.path || req.url || '',
@@ -1249,6 +1329,7 @@ function firewallRequestHandler(req, res) {
1249
1329
  reportFirewallEvent({
1250
1330
  source: 'blocklist',
1251
1331
  ip,
1332
+ ruleId: String(blockRuleDecision.ruleId || ''),
1252
1333
  matchedEntry: blockRuleDecision.matchedEntry || ip,
1253
1334
  method: req.method || '',
1254
1335
  path: blockRuleDecision.path || req.url || '',
@@ -1282,6 +1363,8 @@ function firewallRequestHandler(req, res) {
1282
1363
  source: 'rate_limit',
1283
1364
  statusCode: 429,
1284
1365
  ip,
1366
+ ruleId: String(rateLimitDecision.ruleId || ''),
1367
+ keyBy: (rateLimitDecision.rule && rateLimitDecision.rule.keyBy) || 'ip',
1285
1368
  matchedEntry: rateLimitDecision.ruleId || '',
1286
1369
  method: req.method || '',
1287
1370
  path: rateLimitDecision.path || req.url || '',
@@ -1387,10 +1470,27 @@ function init(options) {
1387
1470
  if (_options.log) fwLog('[securenow] Firewall: Layer 4 (Cloud WAF) disabled (set config.firewall.cloud=cloudflare|aws|gcp)');
1388
1471
  }
1389
1472
 
1473
+ const storeSpec = _options.rateLimitStore !== undefined
1474
+ ? _options.rateLimitStore
1475
+ : require('./app-config').resolveFirewallOptions().rateLimitStore;
1476
+ if (storeSpec) {
1477
+ try {
1478
+ _rateLimitStore = require('./rate-limit-store').createRateLimitStore(storeSpec, {
1479
+ log: (...args) => { if (_options.log) fwLog(...args); },
1480
+ });
1481
+ if (_options.log && _rateLimitStore) fwLog('[securenow] Firewall: shared rate-limit counters → %s', _rateLimitStore.describe());
1482
+ } catch (e) {
1483
+ _rateLimitStore = null;
1484
+ if (_options.log) fwWarn('[securenow] Firewall: rate-limit store disabled (%s); counting in-process', e.message);
1485
+ }
1486
+ }
1487
+
1390
1488
  startSyncLoop();
1391
1489
  }
1392
1490
 
1393
1491
  function shutdown() {
1492
+ if (_rateLimitStore) { try { _rateLimitStore.close(); } catch (_) {} _rateLimitStore = null; }
1493
+ _sharedBuckets = new Map();
1394
1494
  if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
1395
1495
  if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
1396
1496
  if (_eventTimer) { clearTimeout(_eventTimer); _eventTimer = null; }
@@ -1459,4 +1559,15 @@ function isRemoteEnabled() { return _remoteEnabled !== false; }
1459
1559
 
1460
1560
  function getChallengeRules() { return _challengeRules; }
1461
1561
 
1462
- module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, getChallengeRules, isRemoteEnabled };
1562
+ // Test hooks (not part of the public API): drive rate-limit decisions without a server or sync.
1563
+ const _test = {
1564
+ setRateLimitRules(rules) { _rateLimitRules = Array.isArray(rules) ? rules : []; _rateLimitBuckets = new Map(); _sharedBuckets = new Map(); },
1565
+ setRateLimitStore(store) { _rateLimitStore = store || null; _sharedBuckets = new Map(); },
1566
+ checkRateLimitRules,
1567
+ rateLimitSubject,
1568
+ rateLimitKey,
1569
+ pruneRateLimitBuckets,
1570
+ sharedBucketCount: () => _sharedBuckets.size,
1571
+ };
1572
+
1573
+ module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, getChallengeRules, isRemoteEnabled, _test };