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 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
- ['Sync TTL', `${data.ttl || 60}s`],
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
- callback(null, { blChanged, alChanged });
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 firewallRequestHandler(req, res) {
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
- return false;
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
- initialized: _initialized,
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 isRemoteEnabled() { return _remoteEnabled !== false; }
933
-
934
- module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, isRemoteEnabled };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "7.7.7",
3
+ "version": "7.7.9",
4
4
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",