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 +18 -4
- package/app-config.js +4 -0
- package/cli/automation.js +1 -1
- package/cli/challenges.js +1 -1
- package/cli/firewall.js +47 -1
- package/cli/fp.js +1 -1
- package/cli/monitor.js +7 -6
- package/cli/rateLimits.js +26 -9
- package/cli/security.js +10 -10
- package/cli/ui.js +1 -0
- package/cli.js +2 -1
- package/firewall.js +121 -10
- package/mcp/catalog.js +170 -3
- package/package.json +3 -2
- package/rate-limit-store.js +175 -0
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 —
|
|
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.
|
|
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 "
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 ||
|
|
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
|
-
|
|
286
|
-
|
|
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(
|
|
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
|
|
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(
|
|
111
|
-
r.status === 'active' ?
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
987
|
-
for (const
|
|
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
|
|
990
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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 };
|