securenow 7.7.7 → 7.7.9
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/cli/firewall.js +5 -4
- package/cli/rateLimits.js +245 -0
- package/cli.js +43 -1
- package/firewall.js +205 -46
- package/package.json +1 -1
package/cli/firewall.js
CHANGED
|
@@ -36,10 +36,11 @@ async function status(args, flags) {
|
|
|
36
36
|
['App scope', appKey || 'all apps'],
|
|
37
37
|
['Environment', data.environment || environment],
|
|
38
38
|
['Last updated', data.updatedAt || 'unknown'],
|
|
39
|
-
['Allowed IPs', data.allowlistCount != null ? `${data.allowlistCount} total (${data.allowlistExactCount} exact + ${data.allowlistCidrCount} CIDR ranges)` : '0'],
|
|
40
|
-
['Allowlist updated', data.allowlistUpdatedAt || 'never'],
|
|
41
|
-
['
|
|
42
|
-
|
|
39
|
+
['Allowed IPs', data.allowlistCount != null ? `${data.allowlistCount} total (${data.allowlistExactCount} exact + ${data.allowlistCidrCount} CIDR ranges)` : '0'],
|
|
40
|
+
['Allowlist updated', data.allowlistUpdatedAt || 'never'],
|
|
41
|
+
['Rate limit rules', String(data.rateLimitCount ?? 0)],
|
|
42
|
+
['Sync TTL', `${data.ttl || 60}s`],
|
|
43
|
+
]);
|
|
43
44
|
if (data.allowlistCount > 0) {
|
|
44
45
|
console.log('');
|
|
45
46
|
console.log(` ${ui.c.yellow('!')} Allowlist is active — only ${data.allowlistCount} IP(s) can reach your app`);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { api, requireAuth } = require('./client');
|
|
4
|
+
const config = require('./config');
|
|
5
|
+
const ui = require('./ui');
|
|
6
|
+
|
|
7
|
+
function resolveApp(flags) {
|
|
8
|
+
return flags.app || config.getDefaultApp();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolveEnvironment(flags, fallback = null) {
|
|
12
|
+
return flags.env || flags.environment || fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseWindow(value) {
|
|
16
|
+
if (value == null || value === '') return 60;
|
|
17
|
+
if (/^\d+$/.test(String(value))) return Number(value);
|
|
18
|
+
const raw = String(value).trim().toLowerCase();
|
|
19
|
+
const match = raw.match(/^(\d+)\s*(s|sec|second|seconds|m|min|minute|minutes|h|hr|hour|hours)$/);
|
|
20
|
+
if (!match) {
|
|
21
|
+
ui.error('Window must be seconds or look like 30s, 1m, or 1h.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const n = Number(match[1]);
|
|
25
|
+
const unit = match[2][0];
|
|
26
|
+
return unit === 'h' ? n * 3600 : unit === 'm' ? n * 60 : n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildQuery(flags) {
|
|
30
|
+
const query = {};
|
|
31
|
+
if (flags.page) query.page = flags.page;
|
|
32
|
+
if (flags.limit) query.limit = flags.limit;
|
|
33
|
+
if (flags.status) query.status = flags.status;
|
|
34
|
+
if (flags.search) query.search = flags.search;
|
|
35
|
+
const appKey = resolveApp(flags);
|
|
36
|
+
if (appKey) query.appKey = appKey;
|
|
37
|
+
const environment = resolveEnvironment(flags, null);
|
|
38
|
+
if (environment) query.environment = environment;
|
|
39
|
+
return query;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function describeTarget(rule) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
if (rule.ip) parts.push(rule.ip);
|
|
45
|
+
if (rule.method && rule.method !== 'ALL') parts.push(rule.method);
|
|
46
|
+
if (rule.pathPattern) parts.push(`${rule.pathMatchMode || 'prefix'}:${rule.pathPattern}`);
|
|
47
|
+
return parts.join(' ') || 'all traffic';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function list(args, flags) {
|
|
51
|
+
requireAuth();
|
|
52
|
+
const s = ui.spinner('Fetching rate limits');
|
|
53
|
+
try {
|
|
54
|
+
const data = await api.get('/rate-limits', { query: buildQuery(flags) });
|
|
55
|
+
const rules = data.rateLimits || [];
|
|
56
|
+
s.stop(`Found ${rules.length} rate-limit rule${rules.length !== 1 ? 's' : ''}${data.total ? ` (${data.total} total)` : ''}`);
|
|
57
|
+
|
|
58
|
+
if (flags.json) { ui.json(data); return; }
|
|
59
|
+
|
|
60
|
+
console.log('');
|
|
61
|
+
const rows = rules.map((r) => [
|
|
62
|
+
ui.c.dim(ui.truncate(r.id || r._id, 12)),
|
|
63
|
+
r.status === 'active' ? ui.statusBadge('active') : ui.statusBadge(r.status || 'unknown'),
|
|
64
|
+
describeTarget(r),
|
|
65
|
+
`${r.limit}/${r.windowSeconds}s`,
|
|
66
|
+
r.applicationKey || ui.c.dim('all apps'),
|
|
67
|
+
r.environment || ui.c.dim('all envs'),
|
|
68
|
+
r.expiresAt ? new Date(r.expiresAt).toLocaleString() : ui.c.dim('permanent'),
|
|
69
|
+
ui.timeAgo(r.createdAt),
|
|
70
|
+
]);
|
|
71
|
+
ui.table(['ID', 'Status', 'Target', 'Limit', 'App', 'Env', 'Expires', 'Created'], rows);
|
|
72
|
+
console.log('');
|
|
73
|
+
} catch (err) {
|
|
74
|
+
s.fail('Failed to fetch rate limits');
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function show(args, flags) {
|
|
80
|
+
requireAuth();
|
|
81
|
+
const id = args[0];
|
|
82
|
+
if (!id) {
|
|
83
|
+
ui.error('Usage: securenow ratelimit show <id>');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const s = ui.spinner('Fetching rate limit');
|
|
87
|
+
try {
|
|
88
|
+
const data = await api.get(`/rate-limits/${id}`);
|
|
89
|
+
const r = data.rateLimit;
|
|
90
|
+
s.stop('Rate limit loaded');
|
|
91
|
+
if (flags.json) { ui.json(r); return; }
|
|
92
|
+
console.log('');
|
|
93
|
+
ui.heading(r.name || 'Rate limit');
|
|
94
|
+
console.log('');
|
|
95
|
+
ui.keyValue([
|
|
96
|
+
['ID', r.id || r._id || id],
|
|
97
|
+
['Status', r.status || '-'],
|
|
98
|
+
['Target', describeTarget(r)],
|
|
99
|
+
['Limit', `${r.limit}/${r.windowSeconds}s`],
|
|
100
|
+
['Key by', r.keyBy || 'ip'],
|
|
101
|
+
['App', r.applicationKey || 'all apps'],
|
|
102
|
+
['Environment', r.environment || 'all envs'],
|
|
103
|
+
['Reason', r.reason || '-'],
|
|
104
|
+
['Expires', r.expiresAt || 'permanent'],
|
|
105
|
+
]);
|
|
106
|
+
console.log('');
|
|
107
|
+
} catch (err) {
|
|
108
|
+
s.fail('Failed to fetch rate limit');
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function add(args, flags) {
|
|
114
|
+
requireAuth();
|
|
115
|
+
const ip = args[0] || flags.ip || '';
|
|
116
|
+
const pathPattern = flags.route || flags.path || flags.pattern || '';
|
|
117
|
+
|
|
118
|
+
if (!ip && !pathPattern) {
|
|
119
|
+
ui.error('Provide an IP/CIDR, --route, or both.');
|
|
120
|
+
ui.info('Example: securenow ratelimit add 203.0.113.10 --limit 30 --window 1m --duration 24h');
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
if (!flags.limit) {
|
|
124
|
+
ui.error('Pass --limit <count>.');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const appKey = resolveApp(flags);
|
|
129
|
+
const environment = resolveEnvironment(flags, 'production');
|
|
130
|
+
const body = {
|
|
131
|
+
ip,
|
|
132
|
+
pathPattern,
|
|
133
|
+
pathMatchMode: flags.mode || flags['path-mode'] || (pathPattern ? 'prefix' : 'prefix'),
|
|
134
|
+
method: flags.method || 'ALL',
|
|
135
|
+
keyBy: flags['key-by'] || flags.keyBy || 'ip',
|
|
136
|
+
limit: Number(flags.limit),
|
|
137
|
+
windowSeconds: parseWindow(flags.window || flags['window-seconds']),
|
|
138
|
+
reason: flags.reason || '',
|
|
139
|
+
name: flags.name || '',
|
|
140
|
+
duration: flags.duration || '',
|
|
141
|
+
expiresAt: flags.expiresAt || flags.expires || '',
|
|
142
|
+
};
|
|
143
|
+
if (appKey) body.appKey = appKey;
|
|
144
|
+
if (environment) body.environment = environment;
|
|
145
|
+
|
|
146
|
+
const s = ui.spinner('Creating rate limit');
|
|
147
|
+
try {
|
|
148
|
+
const data = await api.post('/rate-limits', body);
|
|
149
|
+
s.stop('Rate limit created');
|
|
150
|
+
if (flags.json) { ui.json(data); return; }
|
|
151
|
+
const r = data.rateLimit;
|
|
152
|
+
console.log('');
|
|
153
|
+
console.log(` ${ui.c.green('ACTIVE')} ${describeTarget(r)} at ${r.limit}/${r.windowSeconds}s`);
|
|
154
|
+
console.log(` ${ui.c.dim('ID:')} ${r.id || r._id}`);
|
|
155
|
+
console.log('');
|
|
156
|
+
} catch (err) {
|
|
157
|
+
s.fail('Failed to create rate limit');
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function setStatus(args, flags, status) {
|
|
163
|
+
requireAuth();
|
|
164
|
+
const id = args[0];
|
|
165
|
+
if (!id) {
|
|
166
|
+
ui.error(`Usage: securenow ratelimit ${status === 'active' ? 'enable' : 'disable'} <id>`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
const s = ui.spinner(`${status === 'active' ? 'Enabling' : 'Disabling'} rate limit`);
|
|
170
|
+
try {
|
|
171
|
+
const data = await api.put(`/rate-limits/${id}`, { status });
|
|
172
|
+
s.stop(status === 'active' ? 'Rate limit enabled' : 'Rate limit disabled');
|
|
173
|
+
if (flags.json) ui.json(data);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
s.fail('Failed to update rate limit');
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function enable(args, flags) { return setStatus(args, flags, 'active'); }
|
|
181
|
+
async function disable(args, flags) { return setStatus(args, flags, 'disabled'); }
|
|
182
|
+
|
|
183
|
+
async function remove(args, flags) {
|
|
184
|
+
requireAuth();
|
|
185
|
+
const id = args[0];
|
|
186
|
+
if (!id) {
|
|
187
|
+
ui.error('Usage: securenow ratelimit remove <id> [--reason "..."]');
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
if (!flags.force && !flags.yes) {
|
|
191
|
+
const ok = await ui.confirm('Remove this rate-limit rule?');
|
|
192
|
+
if (!ok) { ui.info('Cancelled'); return; }
|
|
193
|
+
}
|
|
194
|
+
const s = ui.spinner('Removing rate limit');
|
|
195
|
+
try {
|
|
196
|
+
const opts = flags.reason ? { query: { reason: flags.reason } } : undefined;
|
|
197
|
+
const data = await api.delete(`/rate-limits/${id}`, opts);
|
|
198
|
+
s.stop('Rate limit removed');
|
|
199
|
+
if (flags.json) ui.json(data);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
s.fail('Failed to remove rate limit');
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function test(args, flags) {
|
|
207
|
+
requireAuth();
|
|
208
|
+
const ip = args[0] || flags.ip;
|
|
209
|
+
if (!ip) {
|
|
210
|
+
ui.error('Usage: securenow ratelimit test <ip> --path /api/login [--method POST]');
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
const query = {
|
|
214
|
+
ip,
|
|
215
|
+
path: flags.path || flags.route || '/',
|
|
216
|
+
method: flags.method || 'GET',
|
|
217
|
+
};
|
|
218
|
+
const appKey = resolveApp(flags);
|
|
219
|
+
if (appKey) query.appKey = appKey;
|
|
220
|
+
const environment = resolveEnvironment(flags, 'production');
|
|
221
|
+
if (environment) query.environment = environment;
|
|
222
|
+
|
|
223
|
+
const s = ui.spinner('Testing rate-limit match');
|
|
224
|
+
try {
|
|
225
|
+
const data = await api.get('/rate-limits/check', { query });
|
|
226
|
+
s.stop('Rate-limit check complete');
|
|
227
|
+
if (flags.json) { ui.json(data); return; }
|
|
228
|
+
console.log('');
|
|
229
|
+
if (data.limited) {
|
|
230
|
+
console.log(` ${ui.c.bold(ui.c.yellow('RATE LIMITED'))} - ${data.matches.length} matching rule${data.matches.length !== 1 ? 's' : ''}`);
|
|
231
|
+
for (const r of data.matches.slice(0, 5)) {
|
|
232
|
+
console.log(` ${ui.c.dim(r.id || r._id)} ${describeTarget(r)} ${r.limit}/${r.windowSeconds}s`);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
console.log(` ${ui.c.bold(ui.c.green('NOT LIMITED'))} - no matching rate-limit rules`);
|
|
236
|
+
}
|
|
237
|
+
console.log(` ${ui.c.dim(`App scope: ${appKey || 'all apps'} - Environment: ${data.environment || environment}`)}`);
|
|
238
|
+
console.log('');
|
|
239
|
+
} catch (err) {
|
|
240
|
+
s.fail('Failed to test rate limit');
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { list, show, add, enable, disable, remove, test };
|
package/cli.js
CHANGED
|
@@ -253,6 +253,48 @@ const COMMANDS = {
|
|
|
253
253
|
},
|
|
254
254
|
defaultSub: 'status',
|
|
255
255
|
},
|
|
256
|
+
ratelimit: {
|
|
257
|
+
desc: 'Manage soft rate-limit remediation rules',
|
|
258
|
+
usage: 'securenow ratelimit <list|add|show|test|enable|disable|remove> [options]',
|
|
259
|
+
flags: {
|
|
260
|
+
app: 'Scope to app key (defaults to logged-in app)',
|
|
261
|
+
env: 'Scope to environment (default for create/test: production)',
|
|
262
|
+
environment: 'Alias for --env',
|
|
263
|
+
json: 'Output as JSON',
|
|
264
|
+
limit: 'Allowed requests per window',
|
|
265
|
+
window: 'Window size, e.g. 30s, 1m, 1h',
|
|
266
|
+
duration: 'Expiry, e.g. 24h or 7d',
|
|
267
|
+
route: 'Path pattern such as /api/login',
|
|
268
|
+
path: 'Alias for --route',
|
|
269
|
+
mode: 'Path mode: exact, prefix, or regex',
|
|
270
|
+
method: 'HTTP method, or ALL',
|
|
271
|
+
reason: 'Audit reason',
|
|
272
|
+
},
|
|
273
|
+
sub: {
|
|
274
|
+
list: { desc: 'List rate-limit remediation rules', run: (a, f) => require('./cli/rateLimits').list(a, f) },
|
|
275
|
+
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) },
|
|
276
|
+
show: { desc: 'Show one rate-limit rule', usage: 'securenow ratelimit show <id>', run: (a, f) => require('./cli/rateLimits').show(a, f) },
|
|
277
|
+
test: { desc: 'Check whether a request would match rate-limit rules', usage: 'securenow ratelimit test <ip> --path /api/login --method POST', run: (a, f) => require('./cli/rateLimits').test(a, f) },
|
|
278
|
+
enable: { desc: 'Enable a rate-limit rule', usage: 'securenow ratelimit enable <id>', run: (a, f) => require('./cli/rateLimits').enable(a, f) },
|
|
279
|
+
disable: { desc: 'Disable a rate-limit rule', usage: 'securenow ratelimit disable <id>', run: (a, f) => require('./cli/rateLimits').disable(a, f) },
|
|
280
|
+
remove: { desc: 'Remove a rate-limit rule', usage: 'securenow ratelimit remove <id> [--reason "..."]', run: (a, f) => require('./cli/rateLimits').remove(a, f) },
|
|
281
|
+
},
|
|
282
|
+
defaultSub: 'list',
|
|
283
|
+
},
|
|
284
|
+
'rate-limit': {
|
|
285
|
+
desc: 'Alias for ratelimit',
|
|
286
|
+
usage: 'securenow rate-limit <list|add|show|test|enable|disable|remove> [options]',
|
|
287
|
+
sub: {
|
|
288
|
+
list: { desc: 'List rate-limit remediation rules', run: (a, f) => require('./cli/rateLimits').list(a, f) },
|
|
289
|
+
add: { desc: 'Create a rate-limit rule', usage: 'securenow rate-limit add [ip] --route /api/login --limit 5 --window 1m --duration 24h', run: (a, f) => require('./cli/rateLimits').add(a, f) },
|
|
290
|
+
show: { desc: 'Show one rate-limit rule', usage: 'securenow rate-limit show <id>', run: (a, f) => require('./cli/rateLimits').show(a, f) },
|
|
291
|
+
test: { desc: 'Check whether a request would match rate-limit rules', usage: 'securenow rate-limit test <ip> --path /api/login --method POST', run: (a, f) => require('./cli/rateLimits').test(a, f) },
|
|
292
|
+
enable: { desc: 'Enable a rate-limit rule', usage: 'securenow rate-limit enable <id>', run: (a, f) => require('./cli/rateLimits').enable(a, f) },
|
|
293
|
+
disable: { desc: 'Disable a rate-limit rule', usage: 'securenow rate-limit disable <id>', run: (a, f) => require('./cli/rateLimits').disable(a, f) },
|
|
294
|
+
remove: { desc: 'Remove a rate-limit rule', usage: 'securenow rate-limit remove <id> [--reason "..."]', run: (a, f) => require('./cli/rateLimits').remove(a, f) },
|
|
295
|
+
},
|
|
296
|
+
defaultSub: 'list',
|
|
297
|
+
},
|
|
256
298
|
automation: {
|
|
257
299
|
desc: 'Manage automation rules for blocklist actions',
|
|
258
300
|
usage: 'securenow automation <list|defaults|show|create|update|dry-run|execute|delete> [rule-id] [options]',
|
|
@@ -555,7 +597,7 @@ function showHelp(commandName) {
|
|
|
555
597
|
'Detect & Respond': ['human', 'notifications', 'alerts', 'fp'],
|
|
556
598
|
'Investigate': ['ip', 'forensics'],
|
|
557
599
|
'Firewall': ['firewall'],
|
|
558
|
-
'Remediation': ['automation', 'blocklist', 'allowlist', 'trusted'],
|
|
600
|
+
'Remediation': ['automation', 'ratelimit', 'blocklist', 'allowlist', 'trusted'],
|
|
559
601
|
'Telemetry': ['log', 'test-span'],
|
|
560
602
|
'Utilities': ['redact', 'cidr', 'doctor', 'env', 'mcp'],
|
|
561
603
|
'Settings': ['instances', 'config', 'version'],
|
package/firewall.js
CHANGED
|
@@ -17,7 +17,7 @@ let _initialized = false;
|
|
|
17
17
|
let _consecutiveErrors = 0;
|
|
18
18
|
let _layers = [];
|
|
19
19
|
let _rawIps = [];
|
|
20
|
-
let _stats = { syncs: 0, blocked: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
|
|
20
|
+
let _stats = { syncs: 0, blocked: 0, rateLimited: 0, allowed: 0, versionChecks: 0, errors: 0, suppressedDisabled: 0 };
|
|
21
21
|
let _localhostFallbackTried = false;
|
|
22
22
|
let _eventQueue = [];
|
|
23
23
|
let _eventTimer = null;
|
|
@@ -30,11 +30,17 @@ let _remoteEnabled = true;
|
|
|
30
30
|
let _lastRemoteEnabled = null;
|
|
31
31
|
|
|
32
32
|
// Allowlist state
|
|
33
|
-
let _allowlistMatcher = null;
|
|
34
|
-
let _allowlistRawIps = [];
|
|
35
|
-
let _lastAllowlistModified = null;
|
|
36
|
-
let _lastAllowlistVersion = null;
|
|
37
|
-
let _lastAllowlistSyncEtag = null;
|
|
33
|
+
let _allowlistMatcher = null;
|
|
34
|
+
let _allowlistRawIps = [];
|
|
35
|
+
let _lastAllowlistModified = null;
|
|
36
|
+
let _lastAllowlistVersion = null;
|
|
37
|
+
let _lastAllowlistSyncEtag = null;
|
|
38
|
+
|
|
39
|
+
// Rate-limit policy state is synced in Phase 1 for forward compatibility.
|
|
40
|
+
// Enforcement is intentionally added in the next SDK phase.
|
|
41
|
+
let _rateLimitRules = [];
|
|
42
|
+
let _lastRateLimitVersion = null;
|
|
43
|
+
let _rateLimitBuckets = new Map();
|
|
38
44
|
|
|
39
45
|
// Circuit breaker
|
|
40
46
|
const CIRCUIT_OPEN_THRESHOLD = 5;
|
|
@@ -295,6 +301,7 @@ function doUnifiedSync(callback) {
|
|
|
295
301
|
if (_options.environment) headers['X-SecureNow-Environment'] = _options.environment;
|
|
296
302
|
if (_lastVersion) headers['X-Blocklist-Version'] = _lastVersion;
|
|
297
303
|
if (_lastAllowlistVersion) headers['X-Allowlist-Version'] = _lastAllowlistVersion;
|
|
304
|
+
if (_lastRateLimitVersion) headers['X-Rate-Limit-Version'] = _lastRateLimitVersion;
|
|
298
305
|
if (_lastUnifiedEtag) headers['If-None-Match'] = _lastUnifiedEtag;
|
|
299
306
|
|
|
300
307
|
httpGet(url, headers, 8000, (err, res, data) => {
|
|
@@ -364,13 +371,25 @@ function doUnifiedSync(callback) {
|
|
|
364
371
|
}
|
|
365
372
|
}
|
|
366
373
|
|
|
367
|
-
if (body.allowlistIps) {
|
|
368
|
-
_allowlistRawIps = body.allowlistIps;
|
|
369
|
-
_allowlistMatcher = createMatcher(body.allowlistIps);
|
|
370
|
-
alChanged = true;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
+
if (body.allowlistIps) {
|
|
375
|
+
_allowlistRawIps = body.allowlistIps;
|
|
376
|
+
_allowlistMatcher = createMatcher(body.allowlistIps);
|
|
377
|
+
alChanged = true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (body.rateLimits) {
|
|
381
|
+
const newVer = body.rateLimits.version;
|
|
382
|
+
if (newVer !== _lastRateLimitVersion) {
|
|
383
|
+
_lastRateLimitVersion = newVer;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (Array.isArray(body.rateLimitRules)) {
|
|
388
|
+
_rateLimitRules = body.rateLimitRules;
|
|
389
|
+
pruneRateLimitBuckets(_rateLimitRules);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
callback(null, { blChanged, alChanged, rlChanged: Array.isArray(body.rateLimitRules) });
|
|
374
393
|
} catch (e) {
|
|
375
394
|
callback(new Error(`Failed to parse sync response: ${e.message}`));
|
|
376
395
|
}
|
|
@@ -557,11 +576,14 @@ function pollOnce(callback) {
|
|
|
557
576
|
const s = _matcher.stats();
|
|
558
577
|
fwLog('[securenow] Firewall: re-synced %d blocked IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
559
578
|
}
|
|
560
|
-
if (result.alChanged && _options.log && _allowlistMatcher) {
|
|
561
|
-
const s = _allowlistMatcher.stats();
|
|
562
|
-
fwLog('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
563
|
-
}
|
|
564
|
-
|
|
579
|
+
if (result.alChanged && _options.log && _allowlistMatcher) {
|
|
580
|
+
const s = _allowlistMatcher.stats();
|
|
581
|
+
fwLog('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
582
|
+
}
|
|
583
|
+
if (result.rlChanged && _options.log) {
|
|
584
|
+
fwLog('[securenow] Firewall: re-synced %d rate-limit rules (enforcement pending SDK phase 2)', _rateLimitRules.length);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
565
587
|
callback(null);
|
|
566
588
|
};
|
|
567
589
|
|
|
@@ -639,12 +661,15 @@ function startSyncLoop() {
|
|
|
639
661
|
const s = _matcher.stats();
|
|
640
662
|
fwLog('[securenow] Firewall: synced %d blocked IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
641
663
|
}
|
|
642
|
-
if (_options.log && _allowlistMatcher) {
|
|
643
|
-
const s = _allowlistMatcher.stats();
|
|
644
|
-
if (s.total > 0) fwLog('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
664
|
+
if (_options.log && _allowlistMatcher) {
|
|
665
|
+
const s = _allowlistMatcher.stats();
|
|
666
|
+
if (s.total > 0) fwLog('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
667
|
+
}
|
|
668
|
+
if (_options.log && _rateLimitRules.length > 0) {
|
|
669
|
+
fwLog('[securenow] Firewall: synced %d rate-limit rules (enforcement pending SDK phase 2)', _rateLimitRules.length);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
648
673
|
|
|
649
674
|
initialSync();
|
|
650
675
|
scheduleNextPoll();
|
|
@@ -655,14 +680,17 @@ function startSyncLoop() {
|
|
|
655
680
|
// Force a full re-fetch by clearing versions so unified endpoint returns full data
|
|
656
681
|
const savedBlVer = _lastVersion;
|
|
657
682
|
const savedAlVer = _lastAllowlistVersion;
|
|
683
|
+
const savedRlVer = _lastRateLimitVersion;
|
|
658
684
|
const savedUnifiedEtag = _lastUnifiedEtag;
|
|
659
685
|
_lastVersion = null;
|
|
660
686
|
_lastAllowlistVersion = null;
|
|
687
|
+
_lastRateLimitVersion = null;
|
|
661
688
|
_lastUnifiedEtag = null;
|
|
662
689
|
pollOnce((err) => {
|
|
663
690
|
if (err) {
|
|
664
691
|
_lastVersion = savedBlVer;
|
|
665
692
|
_lastAllowlistVersion = savedAlVer;
|
|
693
|
+
_lastRateLimitVersion = savedRlVer;
|
|
666
694
|
_lastUnifiedEtag = savedUnifiedEtag;
|
|
667
695
|
}
|
|
668
696
|
});
|
|
@@ -726,19 +754,125 @@ function wrapListener(originalListener) {
|
|
|
726
754
|
};
|
|
727
755
|
}
|
|
728
756
|
|
|
729
|
-
function sendBlockResponse(req, res, ip) {
|
|
730
|
-
const code = (_options && _options.statusCode) || 403;
|
|
731
|
-
const accept = req.headers['accept'] || '';
|
|
732
|
-
if (accept.includes('text/html')) {
|
|
757
|
+
function sendBlockResponse(req, res, ip) {
|
|
758
|
+
const code = (_options && _options.statusCode) || 403;
|
|
759
|
+
const accept = req.headers['accept'] || '';
|
|
760
|
+
if (accept.includes('text/html')) {
|
|
733
761
|
res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
734
762
|
res.end(blockedHtml(ip));
|
|
735
763
|
} else {
|
|
736
764
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
737
|
-
res.end(JSON.stringify({ error: 'Forbidden', ip }));
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function
|
|
765
|
+
res.end(JSON.stringify({ error: 'Forbidden', ip }));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function sendRateLimitResponse(req, res, ip, decision) {
|
|
770
|
+
const retryAfter = Math.max(1, decision.retryAfter || 1);
|
|
771
|
+
const headers = {
|
|
772
|
+
'Retry-After': String(retryAfter),
|
|
773
|
+
'X-RateLimit-Limit': String(decision.limit || ''),
|
|
774
|
+
'X-RateLimit-Window': String(decision.windowSeconds || ''),
|
|
775
|
+
'X-SecureNow-Rate-Limit-Rule': decision.ruleId || '',
|
|
776
|
+
};
|
|
777
|
+
const accept = req.headers['accept'] || '';
|
|
778
|
+
if (accept.includes('text/html')) {
|
|
779
|
+
res.writeHead(429, { ...headers, 'Content-Type': 'text/html; charset=utf-8' });
|
|
780
|
+
res.end('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Too Many Requests</title></head><body><h1>Too Many Requests</h1><p>Please retry later.</p></body></html>');
|
|
781
|
+
} else {
|
|
782
|
+
res.writeHead(429, { ...headers, 'Content-Type': 'application/json' });
|
|
783
|
+
res.end(JSON.stringify({
|
|
784
|
+
error: 'Too Many Requests',
|
|
785
|
+
ip,
|
|
786
|
+
retryAfter,
|
|
787
|
+
}));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function requestPath(req) {
|
|
792
|
+
try {
|
|
793
|
+
return new URL(req.url || '/', 'http://localhost').pathname || '/';
|
|
794
|
+
} catch (_) {
|
|
795
|
+
return req.url || '/';
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function ruleIpMatches(rule, ip) {
|
|
800
|
+
const target = String(rule.ip || '').trim();
|
|
801
|
+
if (!target) return true;
|
|
802
|
+
try {
|
|
803
|
+
return createMatcher([target]).isBlocked(ip);
|
|
804
|
+
} catch (_) {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function rulePathMatches(rule, path) {
|
|
810
|
+
const pattern = String(rule.pathPattern || '').trim();
|
|
811
|
+
if (!pattern) return true;
|
|
812
|
+
const mode = rule.pathMatchMode || 'prefix';
|
|
813
|
+
if (mode === 'exact') return path === pattern;
|
|
814
|
+
if (mode === 'regex') {
|
|
815
|
+
try { return new RegExp(pattern).test(path); } catch (_) { return false; }
|
|
816
|
+
}
|
|
817
|
+
return path.startsWith(pattern);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function rateLimitKey(rule, ip) {
|
|
821
|
+
const id = rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`;
|
|
822
|
+
const subject = rule.keyBy === 'global' ? 'global' : ip;
|
|
823
|
+
return `${id}|${subject}`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function checkRateLimitRules(req, ip) {
|
|
827
|
+
if (!_rateLimitRules || _rateLimitRules.length === 0) return null;
|
|
828
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
829
|
+
const path = requestPath(req);
|
|
830
|
+
const now = Date.now();
|
|
831
|
+
|
|
832
|
+
for (const rule of _rateLimitRules) {
|
|
833
|
+
if (!rule) continue;
|
|
834
|
+
const ruleMethod = String(rule.method || 'ALL').toUpperCase();
|
|
835
|
+
if (ruleMethod !== 'ALL' && ruleMethod !== method) continue;
|
|
836
|
+
if (!ruleIpMatches(rule, ip)) continue;
|
|
837
|
+
if (!rulePathMatches(rule, path)) continue;
|
|
838
|
+
|
|
839
|
+
const limit = Math.max(1, Number(rule.limit || 1) || 1);
|
|
840
|
+
const windowSeconds = Math.max(1, Number(rule.windowSeconds || 60) || 60);
|
|
841
|
+
const windowMs = windowSeconds * 1000;
|
|
842
|
+
const key = rateLimitKey(rule, ip);
|
|
843
|
+
let bucket = _rateLimitBuckets.get(key);
|
|
844
|
+
if (!bucket || now - bucket.windowStart >= windowMs) {
|
|
845
|
+
bucket = { windowStart: now, count: 0 };
|
|
846
|
+
_rateLimitBuckets.set(key, bucket);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (bucket.count >= limit) {
|
|
850
|
+
const retryAfter = Math.ceil((bucket.windowStart + windowMs - now) / 1000);
|
|
851
|
+
return {
|
|
852
|
+
rule,
|
|
853
|
+
ruleId: rule.id || rule._id || '',
|
|
854
|
+
limit,
|
|
855
|
+
windowSeconds,
|
|
856
|
+
retryAfter,
|
|
857
|
+
path,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
bucket.count++;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function pruneRateLimitBuckets(rules) {
|
|
867
|
+
const ids = new Set((rules || []).map((rule) => String(rule.id || rule._id || `${rule.ip || '*'}:${rule.method || 'ALL'}:${rule.pathPattern || '*'}`)));
|
|
868
|
+
for (const key of _rateLimitBuckets.keys()) {
|
|
869
|
+
const idx = key.indexOf('|');
|
|
870
|
+
const ruleKey = idx === -1 ? key : key.slice(0, idx);
|
|
871
|
+
if (!ids.has(ruleKey)) _rateLimitBuckets.delete(key);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function firewallRequestHandler(req, res) {
|
|
742
876
|
// Remote disable wins over everything: when the dashboard / CLI flips the
|
|
743
877
|
// toggle off, requests pass through the SDK as if the firewall weren't
|
|
744
878
|
// installed. The poll loop keeps running so we re-enable within seconds.
|
|
@@ -783,9 +917,29 @@ function firewallRequestHandler(req, res) {
|
|
|
783
917
|
sendBlockResponse(req, res, ip);
|
|
784
918
|
return true;
|
|
785
919
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
920
|
+
|
|
921
|
+
const rateLimitDecision = checkRateLimitRules(req, ip);
|
|
922
|
+
if (rateLimitDecision) {
|
|
923
|
+
_stats.rateLimited++;
|
|
924
|
+
if (_options && _options.log) {
|
|
925
|
+
fwLog('[securenow] Firewall: rate-limited %s via HTTP (rule=%s)', ip, rateLimitDecision.ruleId || 'unknown');
|
|
926
|
+
}
|
|
927
|
+
reportFirewallEvent({
|
|
928
|
+
action: 'rate_limited',
|
|
929
|
+
source: 'rate_limit',
|
|
930
|
+
statusCode: 429,
|
|
931
|
+
ip,
|
|
932
|
+
matchedEntry: rateLimitDecision.ruleId || '',
|
|
933
|
+
method: req.method || '',
|
|
934
|
+
path: rateLimitDecision.path || req.url || '',
|
|
935
|
+
userAgent: req.headers['user-agent'] || '',
|
|
936
|
+
});
|
|
937
|
+
sendRateLimitResponse(req, res, ip, rateLimitDecision);
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
789
943
|
|
|
790
944
|
const _origEmit = http.Server.prototype.emit;
|
|
791
945
|
let _emitPatched = false;
|
|
@@ -887,8 +1041,10 @@ function shutdown() {
|
|
|
887
1041
|
_circuitState = 'closed';
|
|
888
1042
|
_circuitOpenedAt = 0;
|
|
889
1043
|
_consecutiveErrors = 0;
|
|
890
|
-
_pollInflight = false;
|
|
891
|
-
_retryAfterUntil = 0;
|
|
1044
|
+
_pollInflight = false;
|
|
1045
|
+
_retryAfterUntil = 0;
|
|
1046
|
+
_rateLimitRules = [];
|
|
1047
|
+
_rateLimitBuckets = new Map();
|
|
892
1048
|
|
|
893
1049
|
_httpAgent.destroy();
|
|
894
1050
|
_httpsAgent.destroy();
|
|
@@ -912,9 +1068,11 @@ function shutdown() {
|
|
|
912
1068
|
function getStats() {
|
|
913
1069
|
return {
|
|
914
1070
|
..._stats,
|
|
915
|
-
matcher: _matcher ? _matcher.stats() : null,
|
|
916
|
-
allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
|
|
917
|
-
|
|
1071
|
+
matcher: _matcher ? _matcher.stats() : null,
|
|
1072
|
+
allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
|
|
1073
|
+
rateLimitRules: _rateLimitRules.length,
|
|
1074
|
+
rateLimitBuckets: _rateLimitBuckets.size,
|
|
1075
|
+
initialized: _initialized,
|
|
918
1076
|
circuitState: _circuitState,
|
|
919
1077
|
consecutiveErrors: _consecutiveErrors,
|
|
920
1078
|
unifiedSync: _useUnifiedSync,
|
|
@@ -927,8 +1085,9 @@ function getStats() {
|
|
|
927
1085
|
// Layers (TCP / iptables / cloud) read the matcher to populate kernel-level
|
|
928
1086
|
// rules. When the remote toggle is off, return null so they treat the policy
|
|
929
1087
|
// as "no IPs to block" without us mutating the cached matcher.
|
|
930
|
-
function getMatcher() { return _remoteEnabled === false ? null : _matcher; }
|
|
931
|
-
function getAllowlistMatcher() { return _remoteEnabled === false ? null : _allowlistMatcher; }
|
|
932
|
-
function
|
|
933
|
-
|
|
934
|
-
|
|
1088
|
+
function getMatcher() { return _remoteEnabled === false ? null : _matcher; }
|
|
1089
|
+
function getAllowlistMatcher() { return _remoteEnabled === false ? null : _allowlistMatcher; }
|
|
1090
|
+
function getRateLimitRules() { return _remoteEnabled === false ? [] : _rateLimitRules.slice(); }
|
|
1091
|
+
function isRemoteEnabled() { return _remoteEnabled !== false; }
|
|
1092
|
+
|
|
1093
|
+
module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getRateLimitRules, isRemoteEnabled };
|
package/package.json
CHANGED