securenow 8.13.0 → 8.14.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
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.
8
8
 
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 — 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.
10
10
 
11
11
  **Find it in code. Watch it in production.**
12
12
 
@@ -33,7 +33,7 @@ Agent: Apply the recommended blocks?
33
33
  You: Yes.
34
34
  ```
35
35
 
36
- 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`).
37
37
 
38
38
  ---
39
39
 
@@ -176,11 +176,25 @@ Everything the MCP server can do is also a command. Run `npx securenow help` for
176
176
  | `securenow human block <row> --yes --reason "..."` | Approve a proposed block |
177
177
  | `securenow blocklist add <ip> [--route /admin*] [--mode prefix] [--duration 24h] [--reason "..."]` | Block an IP globally or per-route |
178
178
  | `securenow blocklist unblock <id> --reason "..."` | Stop enforcement, keep the history |
179
- | `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 |
180
182
  | `securenow challenge add <ip> --route /login` | Proof-of-work challenge instead of a hard block |
181
183
  | `securenow revoke session <id>` / `revoke user <id>` | Kill stolen sessions (enforced via `securenow/sessions`) |
182
184
  | `securenow trusted add <ip>` | Mark an IP as trusted |
183
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
+
184
198
  ### Rules & detections
185
199
 
186
200
  | Command | Description |
@@ -235,7 +249,7 @@ It syncs your app's blocklist plus SecureNow's known-bad IP feed, refreshed hour
235
249
 
236
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.
237
251
  - **Fails open.** If SecureNow is unreachable, your app serves traffic normally (`config.firewall.failMode: "open"` is the default).
238
- - **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.
239
253
 
240
254
  More at [securenow.ai/security](https://securenow.ai/security).
241
255
 
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/automation.js CHANGED
@@ -82,7 +82,7 @@ async function list(args, flags) {
82
82
 
83
83
  console.log('');
84
84
  const rows = rules.map((rule) => [
85
- ui.c.dim(ui.truncate(rule._id || rule.id, 12)),
85
+ ui.c.dim(rule.id || rule._id || ''),
86
86
  rule.name || '-',
87
87
  ui.statusBadge(rule.status || 'active'),
88
88
  formatApplications(rule),
package/cli/challenges.js CHANGED
@@ -64,7 +64,7 @@ async function list(args, flags) {
64
64
 
65
65
  console.log('');
66
66
  const rows = rules.map((r) => [
67
- ui.c.dim(ui.truncate(r.id || r._id, 12)),
67
+ ui.c.dim(r.id || r._id || ''),
68
68
  r.status === 'active' ? ui.statusBadge('active') : ui.statusBadge(r.status || 'unknown'),
69
69
  describeTarget(r),
70
70
  `pow:${r.difficulty}`,
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/fp.js CHANGED
@@ -76,7 +76,7 @@ async function list(args, flags) {
76
76
  const rows = exclusions.map(e => {
77
77
  const scope = e.type === 'global' ? ui.c.cyan('All Rules') : `Rule: ${ui.truncate(e.ruleName || '?', 18)}`;
78
78
  return [
79
- ui.c.dim(ui.truncate(e._id, 12)),
79
+ ui.c.dim(e.id || e._id || ''),
80
80
  scope,
81
81
  ui.truncate(conditionsSummary(e.conditions, e.matchMode), 40),
82
82
  (e.matchMode || 'all').toUpperCase(),
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,
@@ -350,7 +351,7 @@ async function notificationsList(args, flags) {
350
351
 
351
352
  console.log('');
352
353
  const rows = notifications.map(n => [
353
- ui.c.dim(ui.truncate(n._id, 12)),
354
+ ui.c.dim(n.id || n._id || ''),
354
355
  ui.statusBadge(n.read ? 'read' : 'unread'),
355
356
  ui.truncate(n.title || n.message || n.type || '', 50),
356
357
  n.ip || '—',
package/cli/rateLimits.js CHANGED
@@ -47,6 +47,16 @@ function describeTarget(rule) {
47
47
  return parts.join(' ') || 'all traffic';
48
48
  }
49
49
 
50
+ // The bucket each request counts against: "ip", "global", "header x-api-key", "cookie session".
51
+ function describeKey(rule) {
52
+ const keyBy = rule.keyBy || 'ip';
53
+ return rule.keyName && (keyBy === 'header' || keyBy === 'cookie') ? `${keyBy} ${rule.keyName}` : keyBy;
54
+ }
55
+
56
+ function isExpired(rule) {
57
+ return Boolean(rule.expiresAt) && new Date(rule.expiresAt).getTime() <= Date.now();
58
+ }
59
+
50
60
  function naturalText(args, flags) {
51
61
  return String(flags.text || flags.prompt || flags.query || args.join(' ') || '').trim();
52
62
  }
@@ -62,6 +72,11 @@ function draftFromFlags(flags) {
62
72
  if (flags.mode || flags['path-mode']) draft.pathMatchMode = flags.mode || flags['path-mode'];
63
73
  if (flags.method) draft.method = flags.method;
64
74
  if (flags['key-by'] || flags.keyBy) draft.keyBy = flags['key-by'] || flags.keyBy;
75
+ else if (flags.header) draft.keyBy = 'header';
76
+ else if (flags.cookie) draft.keyBy = 'cookie';
77
+ if (flags['key-name'] || flags.keyName || flags.header || flags.cookie) {
78
+ draft.keyName = flags['key-name'] || flags.keyName || flags.header || flags.cookie;
79
+ }
65
80
  if (flags.limit) draft.limit = Number(flags.limit);
66
81
  if (flags.window || flags['window-seconds']) draft.windowSeconds = parseWindow(flags.window || flags['window-seconds']);
67
82
  if (flags.duration) draft.duration = flags.duration;
@@ -76,7 +91,7 @@ function printParsedDraft(data) {
76
91
  ['Name', draft.name || '-'],
77
92
  ['Target', describeTarget(draft)],
78
93
  ['Limit', `${draft.limit || '-'} / ${draft.windowSeconds || '-'}s`],
79
- ['Key by', draft.keyBy || 'ip'],
94
+ ['Key by', describeKey(draft)],
80
95
  ['App', draft.appKey || 'all'],
81
96
  ['Environment', draft.environment || 'all'],
82
97
  ['Duration', draft.duration || 'none'],
@@ -107,16 +122,17 @@ async function list(args, flags) {
107
122
 
108
123
  console.log('');
109
124
  const rows = rules.map((r) => [
110
- ui.c.dim(ui.truncate(r.id || r._id, 12)),
111
- r.status === 'active' ? ui.statusBadge('active') : ui.statusBadge(r.status || 'unknown'),
125
+ ui.c.dim(r.id || r._id || ''),
126
+ ui.statusBadge(r.status === 'active' && isExpired(r) ? 'expired' : (r.status || 'unknown')),
112
127
  describeTarget(r),
113
128
  `${r.limit}/${r.windowSeconds}s`,
129
+ describeKey(r),
114
130
  r.applicationKey || ui.c.dim('all apps'),
115
131
  r.environment || ui.c.dim('all envs'),
116
132
  r.expiresAt ? new Date(r.expiresAt).toLocaleString() : ui.c.dim('permanent'),
117
133
  ui.timeAgo(r.createdAt),
118
134
  ]);
119
- ui.table(['ID', 'Status', 'Target', 'Limit', 'App', 'Env', 'Expires', 'Created'], rows);
135
+ ui.table(['ID', 'Status', 'Target', 'Limit', 'Per', 'App', 'Env', 'Expires', 'Created'], rows);
120
136
  console.log('');
121
137
  } catch (err) {
122
138
  s.fail('Failed to fetch rate limits');
@@ -167,7 +183,7 @@ async function fromText(args, flags) {
167
183
  if (flags.json) { ui.json(data); return; }
168
184
  const r = data.rateLimit;
169
185
  console.log('');
170
- console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
186
+ console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
171
187
  console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
172
188
  console.log('');
173
189
  } catch (err) {
@@ -197,7 +213,7 @@ async function show(args, flags) {
197
213
  ['Status', r.status || '-'],
198
214
  ['Target', describeTarget(r)],
199
215
  ['Limit', `${r.limit}/${r.windowSeconds}s`],
200
- ['Key by', r.keyBy || 'ip'],
216
+ ['Key by', describeKey(r)],
201
217
  ['App', r.applicationKey || 'all apps'],
202
218
  ['Environment', r.environment || 'all envs'],
203
219
  ['Reason', r.reason || '-'],
@@ -232,7 +248,8 @@ async function add(args, flags) {
232
248
  pathPattern,
233
249
  pathMatchMode: flags.mode || flags['path-mode'] || (pathPattern ? 'prefix' : 'prefix'),
234
250
  method: flags.method || 'ALL',
235
- keyBy: flags['key-by'] || flags.keyBy || 'ip',
251
+ keyBy: flags['key-by'] || flags.keyBy || (flags.header ? 'header' : flags.cookie ? 'cookie' : 'ip'),
252
+ keyName: flags['key-name'] || flags.keyName || flags.header || flags.cookie || '',
236
253
  limit: Number(flags.limit),
237
254
  windowSeconds: parseWindow(flags.window || flags['window-seconds']),
238
255
  reason: flags.reason || '',
@@ -250,7 +267,7 @@ async function add(args, flags) {
250
267
  if (flags.json) { ui.json(data); return; }
251
268
  const r = data.rateLimit;
252
269
  console.log('');
253
- console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
270
+ console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
254
271
  console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
255
272
  console.log('');
256
273
  } catch (err) {
@@ -329,7 +346,7 @@ async function test(args, flags) {
329
346
  if (data.limited) {
330
347
  console.log(` ${ui.c.bold(ui.c.yellow('RATE LIMITED'))} - ${data.matches.length} matching rule${data.matches.length !== 1 ? 's' : ''}`);
331
348
  for (const r of data.matches.slice(0, 5)) {
332
- console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} ${r.limit}/${r.windowSeconds}s`);
349
+ console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} ${r.limit}/${r.windowSeconds}s per ${describeKey(r)}`);
333
350
  }
334
351
  } else {
335
352
  console.log(` ${ui.c.bold(ui.c.green('NOT LIMITED'))} - no matching rate-limit rules`);
package/cli/security.js CHANGED
@@ -190,7 +190,7 @@ async function alertRulesList(args, flags) {
190
190
 
191
191
  console.log('');
192
192
  const rows = rules.map((r) => [
193
- ui.c.dim(ui.truncate(r._id || r.id, 12)),
193
+ ui.c.dim(r.id || r._id || ''),
194
194
  r.name || '—',
195
195
  ruleStatusBadge(r),
196
196
  r.mode === 'test' ? ui.c.yellow('test') : ui.c.dim('prod'),
@@ -836,7 +836,7 @@ async function alertRuleExclusions(args, flags) {
836
836
  if (flags.json) { ui.json(data); return; }
837
837
  console.log('');
838
838
  const rows = exclusions.map((e) => [
839
- ui.c.dim(ui.truncate(e._id, 12)),
839
+ ui.c.dim(e.id || e._id || ''),
840
840
  e.isActive === false ? ui.statusBadge('disabled') : ui.statusBadge('active'),
841
841
  e.matchMode || 'all',
842
842
  ui.truncate(e.reason || e.pathPattern || JSON.stringify(e.conditions || []), 72),
@@ -862,7 +862,7 @@ async function alertChannelsList(args, flags) {
862
862
 
863
863
  console.log('');
864
864
  const rows = channels.map(ch => [
865
- ui.c.dim(ui.truncate(ch._id, 12)),
865
+ ui.c.dim(ch.id || ch._id || ''),
866
866
  ch.name || '—',
867
867
  ch.type || '—',
868
868
  ui.statusBadge(ch.enabled !== false ? 'enabled' : 'disabled'),
@@ -891,7 +891,7 @@ async function alertHistoryList(args, flags) {
891
891
 
892
892
  console.log('');
893
893
  const rows = history.map(h => [
894
- ui.c.dim(ui.truncate(h._id, 12)),
894
+ ui.c.dim(h.id || h._id || ''),
895
895
  h.ruleName || h.type || '—',
896
896
  h.severity ? ui.statusBadge(h.severity) : '—',
897
897
  ui.truncate(h.message || h.description || '', 40),
@@ -938,7 +938,7 @@ async function blocklistList(args, flags) {
938
938
 
939
939
  console.log('');
940
940
  const rows = items.map(b => [
941
- ui.c.dim(ui.truncate(b._id, 12)),
941
+ ui.c.dim(b.id || b._id || ''),
942
942
  ui.c.red(describeBlockTarget(b)),
943
943
  ui.truncate(b.reason || '', 40),
944
944
  b.source || '—',
@@ -1011,7 +1011,7 @@ async function revokeList(args, flags) {
1011
1011
  if (flags.json) { ui.json(entries); return; }
1012
1012
  console.log('');
1013
1013
  ui.table(['ID', 'Type', 'Value', 'Scope', 'Expires'], entries.map((r) => [
1014
- ui.c.dim(ui.truncate(r._id || r.id, 12)),
1014
+ ui.c.dim(r.id || r._id || ''),
1015
1015
  r.type,
1016
1016
  ui.truncate(r.value, 28),
1017
1017
  r.applicationKey || ui.c.dim('all'),
@@ -1191,7 +1191,7 @@ async function allowlistList(args, flags) {
1191
1191
 
1192
1192
  console.log('');
1193
1193
  const rows = items.map(a => [
1194
- ui.c.dim(ui.truncate(a._id, 12)),
1194
+ ui.c.dim(a.id || a._id || ''),
1195
1195
  ui.c.green(a.ip || '—'),
1196
1196
  a.label || ui.c.dim('—'),
1197
1197
  ui.truncate(a.reason || '', 40),
@@ -1307,7 +1307,7 @@ async function trustedList(args, flags) {
1307
1307
 
1308
1308
  console.log('');
1309
1309
  const rows = items.map(t => [
1310
- ui.c.dim(ui.truncate(t._id, 12)),
1310
+ ui.c.dim(t.id || t._id || ''),
1311
1311
  ui.c.green(t.ip || t.cidr || '—'),
1312
1312
  t.label || t.description || ui.c.dim('—'),
1313
1313
  t.applicationKey || ui.c.dim('all apps'),
@@ -1494,7 +1494,7 @@ async function forensicsLibrary(args, flags) {
1494
1494
 
1495
1495
  console.log('');
1496
1496
  const rows = queries.map(q => [
1497
- ui.c.dim(ui.truncate(q._id, 12)),
1497
+ ui.c.dim(q.id || q._id || ''),
1498
1498
  q.name || q.title || '—',
1499
1499
  ui.truncate(q.description || q.query || '', 50),
1500
1500
  ui.timeAgo(q.createdAt),
@@ -1756,7 +1756,7 @@ async function instancesList(args, flags) {
1756
1756
 
1757
1757
  console.log('');
1758
1758
  const rows = instances.map(inst => [
1759
- ui.c.dim(ui.truncate(inst._id, 12)),
1759
+ ui.c.dim(inst.id || inst._id || ''),
1760
1760
  inst.name || inst.host || '—',
1761
1761
  inst.host || '—',
1762
1762
  inst.port != null ? String(inst.port) : '—',
package/cli/ui.js CHANGED
@@ -354,6 +354,7 @@ function statusBadge(status) {
354
354
  trusted: c.green('■ trusted'),
355
355
  enabled: c.green('● enabled'),
356
356
  disabled: c.dim('○ disabled'),
357
+ expired: c.dim('○ expired'),
357
358
  paused: c.yellow('◆ paused'),
358
359
  critical: c.red('▲ critical'),
359
360
  high: c.red('▲ high'),
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) },
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: connecting to %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 };
package/mcp/catalog.js CHANGED
@@ -318,16 +318,18 @@ const TOOLS = [
318
318
  {
319
319
  name: 'securenow_firewall_test_ip',
320
320
  title: 'Test Firewall IP Decision',
321
- description: 'Check whether an IP would be blocked by the firewall.',
321
+ description: 'Check whether an IP would be blocked by the firewall. Pass path (and method) to evaluate route-scoped block rules the way the running app does, including case and trailing-slash aliases.',
322
322
  scope: 'firewall:read',
323
323
  readOnly: true,
324
324
  method: 'GET',
325
325
  endpoint: '/firewall/check/:ip',
326
326
  pathParams: ['ip'],
327
- queryFields: ['environment', 'appKey'],
327
+ queryFields: ['environment', 'appKey', 'path', 'method'],
328
328
  inputSchema: objectSchema({
329
329
  ip: string('IPv4 address to test.'),
330
330
  appKey: string('Optional application key UUID to test the app/environment toggle and scoped lists.'),
331
+ path: string('Optional request path, for example /api/auth/login. Without it, route-scoped rules are not evaluated.'),
332
+ method: string('Optional HTTP method for the path, default GET.'),
331
333
  ...environmentInput,
332
334
  }, ['ip']),
333
335
  },
@@ -1100,6 +1102,165 @@ const TOOLS = [
1100
1102
  ...confirmSchema,
1101
1103
  }, ['id', 'confirm', 'reason']),
1102
1104
  },
1105
+ {
1106
+ name: 'securenow_rate_limit_create',
1107
+ title: 'Create Rate Limit',
1108
+ description: 'Create a rate-limit rule from structured fields. The running app enforces it within about 10 seconds and answers 429 with Retry-After once the limit is reached. keyBy picks what each bucket counts: ip (default), global (one shared bucket), header (one bucket per value of keyName, e.g. x-api-key) or cookie (one bucket per value of keyName, e.g. session). Several rules can stack on one route (for example a burst limit and an hourly cap). Write action; requires confirmation.',
1109
+ scope: 'rate_limits:write',
1110
+ readOnly: false,
1111
+ confirm: true,
1112
+ method: 'POST',
1113
+ endpoint: '/rate-limits',
1114
+ bodyFields: ['name', 'appKey', 'environment', 'ip', 'pathPattern', 'pathMatchMode', 'method', 'keyBy', 'keyName', 'limit', 'windowSeconds', 'duration', 'expiresAt', 'status', 'reason'],
1115
+ inputSchema: objectSchema({
1116
+ name: string('Optional rule name.'),
1117
+ appKey: string('Optional application key. Omit for all apps.'),
1118
+ ip: string('Optional IPv4 address or CIDR the rule applies to. Omit for any IP.'),
1119
+ pathPattern: string('Route pattern, for example /api/auth/login or /api/. A rule needs a route, an IP, or both.'),
1120
+ pathMatchMode: { type: 'string', enum: ['exact', 'prefix', 'regex'], description: 'Path matching mode. Defaults to prefix.' },
1121
+ method: { type: 'string', enum: ['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], description: 'HTTP method scope. Defaults to ALL.' },
1122
+ keyBy: { type: 'string', enum: ['ip', 'global', 'header', 'cookie'], description: 'What each bucket counts. Defaults to ip.' },
1123
+ keyName: string('Header or cookie name when keyBy is header or cookie, for example x-api-key or session.'),
1124
+ limit: number('Requests allowed per window.', { minimum: 1, maximum: 1000000 }),
1125
+ windowSeconds: number('Window length in seconds (1 to 86400).', { minimum: 1, maximum: 86400 }),
1126
+ duration: string('Optional rule lifetime, for example 2h or 7d. Omit for no expiry.'),
1127
+ expiresAt: string('Optional expiry time as ISO 8601 instead of duration.'),
1128
+ status: { type: 'string', enum: ['active', 'disabled'], description: 'Create it disabled to stage it. Defaults to active.' },
1129
+ ...environmentInput,
1130
+ ...confirmSchema,
1131
+ }, ['limit', 'windowSeconds', 'confirm', 'reason']),
1132
+ },
1133
+ {
1134
+ name: 'securenow_rate_limit_update',
1135
+ title: 'Update Rate Limit',
1136
+ description: 'Change a rate-limit rule: enable or disable it (status), or change its limit, window, route, method, key or expiry. Only the fields you pass change. Write action; requires confirmation.',
1137
+ scope: 'rate_limits:write',
1138
+ readOnly: false,
1139
+ confirm: true,
1140
+ method: 'PUT',
1141
+ endpoint: '/rate-limits/:id',
1142
+ pathParams: ['id'],
1143
+ bodyFields: ['name', 'status', 'limit', 'windowSeconds', 'pathPattern', 'pathMatchMode', 'method', 'keyBy', 'keyName', 'ip', 'duration', 'expiresAt', 'reason'],
1144
+ inputSchema: objectSchema({
1145
+ id: string('Rate-limit rule id.'),
1146
+ name: string('Optional new name.'),
1147
+ status: { type: 'string', enum: ['active', 'disabled'], description: 'active enables the rule, disabled stops enforcing it.' },
1148
+ limit: number('New requests-per-window limit.', { minimum: 1, maximum: 1000000 }),
1149
+ windowSeconds: number('New window length in seconds.', { minimum: 1, maximum: 86400 }),
1150
+ pathPattern: string('New route pattern.'),
1151
+ pathMatchMode: { type: 'string', enum: ['exact', 'prefix', 'regex'] },
1152
+ method: { type: 'string', enum: ['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] },
1153
+ keyBy: { type: 'string', enum: ['ip', 'global', 'header', 'cookie'] },
1154
+ keyName: string('Header or cookie name for keyBy header/cookie.'),
1155
+ ip: string('New IPv4 address or CIDR filter ("" for any IP).'),
1156
+ duration: string('New lifetime from now, for example 2h.'),
1157
+ expiresAt: string('New expiry as ISO 8601.'),
1158
+ ...confirmSchema,
1159
+ }, ['id', 'confirm', 'reason']),
1160
+ },
1161
+ {
1162
+ name: 'securenow_firewall_hits',
1163
+ title: 'Firewall Hits',
1164
+ description: 'Who the firewall actually stopped: blocks (403), rate limits (429) and challenges, totalled per rule and per IP, plus the most recent hits. Use it to check that a new block or rate limit is working.',
1165
+ scope: 'firewall:read',
1166
+ readOnly: true,
1167
+ method: 'GET',
1168
+ endpoint: '/firewall/hits',
1169
+ queryFields: ['appKey', 'environment', 'action', 'source', 'ruleId', 'ip', 'since', 'from', 'to', 'limit'],
1170
+ inputSchema: objectSchema({
1171
+ appKey: string('Optional application key.'),
1172
+ action: { type: 'string', enum: ['blocked', 'rate_limited', 'captcha_challenged', 'captcha_passed', 'captcha_failed', 'allowed'], description: 'Optional action filter.' },
1173
+ source: { type: 'string', enum: ['blocklist', 'allowlist', 'rate_limit', 'challenge'], description: 'Optional source filter.' },
1174
+ ruleId: string('Optional rule id (rate limit, scoped block or challenge).'),
1175
+ ip: string('Optional client IP.'),
1176
+ since: string('Look-back window, for example 30m, 6h or 7d (default 24h, max 30d).'),
1177
+ from: string('Optional start time as ISO 8601 (instead of since).'),
1178
+ to: string('Optional end time as ISO 8601.'),
1179
+ limit: number('Recent hits to return.', { minimum: 1, maximum: 200 }),
1180
+ ...environmentInput,
1181
+ }),
1182
+ },
1183
+ {
1184
+ name: 'securenow_challenge_list',
1185
+ title: 'List Challenges',
1186
+ description: 'List proof-of-work challenge rules (an invisible check browsers pass and simple bots do not).',
1187
+ scope: 'challenges:read',
1188
+ readOnly: true,
1189
+ method: 'GET',
1190
+ endpoint: '/challenges',
1191
+ queryFields: ['status', 'appKey', 'environment'],
1192
+ inputSchema: objectSchema({
1193
+ status: string('Optional status filter: active or disabled.'),
1194
+ appKey: string('Optional application key.'),
1195
+ ...environmentInput,
1196
+ }),
1197
+ },
1198
+ {
1199
+ name: 'securenow_challenge_create',
1200
+ title: 'Create Challenge',
1201
+ description: 'Serve a proof-of-work challenge on a route (optionally for one IP/CIDR and method). Browsers solve it invisibly and keep a clearance cookie; clients that cannot solve it never reach the route. Write action; requires confirmation.',
1202
+ scope: 'challenges:write',
1203
+ readOnly: false,
1204
+ confirm: true,
1205
+ method: 'POST',
1206
+ endpoint: '/challenges',
1207
+ bodyFields: ['name', 'appKey', 'environment', 'ip', 'pathPattern', 'pathMatchMode', 'method', 'difficulty', 'clearanceTtlSeconds', 'duration', 'status', 'reason'],
1208
+ inputSchema: objectSchema({
1209
+ name: string('Optional rule name.'),
1210
+ appKey: string('Optional application key. Omit for all apps.'),
1211
+ ip: string('Optional IPv4 address or CIDR. Omit for any IP.'),
1212
+ pathPattern: string('Route pattern to protect, for example /api/signup.'),
1213
+ pathMatchMode: { type: 'string', enum: ['exact', 'prefix', 'regex'] },
1214
+ method: { type: 'string', enum: ['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] },
1215
+ difficulty: number('Proof-of-work difficulty bits (4 to 28, default 14).', { minimum: 4, maximum: 28 }),
1216
+ clearanceTtlSeconds: number('How long a solved challenge stays valid, in seconds (default 1800).', { minimum: 60 }),
1217
+ duration: string('Optional rule lifetime, for example 24h.'),
1218
+ status: { type: 'string', enum: ['active', 'disabled'] },
1219
+ ...environmentInput,
1220
+ ...confirmSchema,
1221
+ }, ['confirm', 'reason']),
1222
+ },
1223
+ {
1224
+ name: 'securenow_challenge_update',
1225
+ title: 'Update Challenge',
1226
+ description: 'Enable/disable a challenge rule or change its route, difficulty or expiry. Write action; requires confirmation.',
1227
+ scope: 'challenges:write',
1228
+ readOnly: false,
1229
+ confirm: true,
1230
+ method: 'PUT',
1231
+ endpoint: '/challenges/:id',
1232
+ pathParams: ['id'],
1233
+ bodyFields: ['name', 'status', 'pathPattern', 'pathMatchMode', 'method', 'ip', 'difficulty', 'clearanceTtlSeconds', 'duration', 'reason'],
1234
+ inputSchema: objectSchema({
1235
+ id: string('Challenge rule id.'),
1236
+ name: string('Optional new name.'),
1237
+ status: { type: 'string', enum: ['active', 'disabled'] },
1238
+ pathPattern: string('New route pattern.'),
1239
+ pathMatchMode: { type: 'string', enum: ['exact', 'prefix', 'regex'] },
1240
+ method: { type: 'string', enum: ['ALL', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] },
1241
+ ip: string('New IPv4 address or CIDR filter.'),
1242
+ difficulty: number('New difficulty bits.', { minimum: 4, maximum: 28 }),
1243
+ clearanceTtlSeconds: number('New clearance lifetime in seconds.', { minimum: 60 }),
1244
+ duration: string('New lifetime from now, for example 24h.'),
1245
+ ...confirmSchema,
1246
+ }, ['id', 'confirm', 'reason']),
1247
+ },
1248
+ {
1249
+ name: 'securenow_challenge_remove',
1250
+ title: 'Remove Challenge',
1251
+ description: 'Remove a proof-of-work challenge rule. Write action; requires confirmation.',
1252
+ scope: 'challenges:write',
1253
+ readOnly: false,
1254
+ confirm: true,
1255
+ method: 'DELETE',
1256
+ endpoint: '/challenges/:id',
1257
+ pathParams: ['id'],
1258
+ bodyFields: ['reason'],
1259
+ inputSchema: objectSchema({
1260
+ id: string('Challenge rule id.'),
1261
+ ...confirmSchema,
1262
+ }, ['id', 'confirm', 'reason']),
1263
+ },
1103
1264
  {
1104
1265
  name: 'securenow_rate_limit_parse',
1105
1266
  title: 'Parse Rate Limit Text',
@@ -1726,11 +1887,12 @@ const TOOLS = [
1726
1887
  confirm: true,
1727
1888
  method: 'POST',
1728
1889
  endpoint: '/blocklist',
1729
- bodyFields: ['ip', 'reason', 'expiresAt', 'metadata', 'appKey', 'environment', 'pathPattern', 'pathMatchMode', 'method'],
1890
+ bodyFields: ['ip', 'reason', 'expiresAt', 'duration', 'metadata', 'appKey', 'environment', 'pathPattern', 'pathMatchMode', 'method'],
1730
1891
  inputSchema: objectSchema({
1731
1892
  ip: string('IPv4 address or CIDR.'),
1732
1893
  reason: string('Reason for blocking.'),
1733
1894
  expiresAt: string('Optional expiry time as ISO 8601.'),
1895
+ duration: string('Optional block lifetime instead of expiresAt, for example 15m, 2h or 7d.'),
1734
1896
  metadata: { type: 'object', additionalProperties: true, description: 'Optional metadata.' },
1735
1897
  appKey: string('Optional application key to scope this block. Omit for all apps.'),
1736
1898
  pathPattern: string('Optional route/path pattern, for example /admin*. Omit to block all routes.'),
@@ -1905,6 +2067,11 @@ const DESTRUCTIVE_TOOLS = new Set([
1905
2067
  'securenow_automation_rule_delete',
1906
2068
  'securenow_rate_limit_remove',
1907
2069
  'securenow_rate_limit_create_from_text',
2070
+ 'securenow_rate_limit_create',
2071
+ 'securenow_rate_limit_update',
2072
+ 'securenow_challenge_create',
2073
+ 'securenow_challenge_update',
2074
+ 'securenow_challenge_remove',
1908
2075
  'securenow_alert_rule_rollback',
1909
2076
  'securenow_alert_rule_update',
1910
2077
  'securenow_alert_rule_query_update',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "8.13.0",
3
+ "version": "8.14.1",
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-crash-capture.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,6 +164,7 @@
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",
168
169
  "crash-capture.js",
169
170
  "sdk-disabled.js",
@@ -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 };