securenow 5.16.2 → 5.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL-API.md CHANGED
@@ -460,6 +460,7 @@ Add custom fields via `SECURENOW_SENSITIVE_FIELDS=field1,field2`.
460
460
  | `SECURENOW_API_KEY` | API key (`snk_live_...`); activates firewall when set | — |
461
461
  | `SECURENOW_API_URL` | SecureNow API base URL | `https://api.securenow.ai` |
462
462
  | `SECURENOW_FIREWALL_ENABLED` | Master kill-switch (`0` to disable) | `1` |
463
+ | `SECURENOW_FIREWALL_VERSION_INTERVAL` | Seconds between lightweight version checks | `10` |
463
464
  | `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds | `300` |
464
465
  | `SECURENOW_FIREWALL_FAIL_MODE` | `open` (allow all when unavailable) or `closed` | `open` |
465
466
  | `SECURENOW_FIREWALL_STATUS_CODE` | HTTP status for blocked requests | `403` |
@@ -470,6 +471,8 @@ Add custom fields via `SECURENOW_SENSITIVE_FIELDS=field1,field2`.
470
471
  | `SECURENOW_FIREWALL_CLOUD_DRY_RUN` | `1` to log cloud pushes without applying | `0` |
471
472
  | `SECURENOW_TRUSTED_PROXIES` | Comma-separated trusted proxy IPs | — |
472
473
 
474
+ **Resilience:** The firewall SDK includes a circuit breaker (opens after 5 consecutive errors, 2-min cooldown), in-flight request guards (prevents overlapping requests), 429 Retry-After support, and exponential backoff on both version checks and initial sync retries.
475
+
473
476
  ### Cloud WAF Provider Variables
474
477
 
475
478
  | Provider | Variables |
package/SKILL-CLI.md CHANGED
@@ -336,7 +336,7 @@ securenow instances test <id> # test connection
336
336
 
337
337
  ```bash
338
338
  securenow notifications list --limit 5 --json
339
- securenow issues show <issue-id> --json
339
+ # IMPORTANT: parse the ipInvestigations array for each notification — see statuses below
340
340
  securenow ip <attacker-ip> --json
341
341
  securenow ip traces <attacker-ip> --json
342
342
  securenow traces show <trace-id> --json
@@ -345,6 +345,19 @@ securenow traces analyze <trace-id> --json
345
345
  securenow blocklist add <attacker-ip> --reason "Automated: SQL injection from issue #<id>"
346
346
  ```
347
347
 
348
+ #### Notification IP Investigation Statuses
349
+
350
+ Each notification contains an `ipInvestigations` array. **Every IP has its own `status` field** that you MUST check before taking action or raising flags. Do not rely only on top-level notification fields — always cross-reference each IP's individual investigation status.
351
+
352
+ | Status | Meaning | Action |
353
+ |--------|---------|--------|
354
+ | `open` | Still needs review — no determination yet | Investigate: run `securenow ip <ip>`, check traces, decide to block or dismiss |
355
+ | `false_positive` | Trusted/dismissed by exclusion rule, verified bot, or trusted IP list | **Skip** — greyed out in UI, no action needed |
356
+ | `blocked` | Already on the user's blocklist | **Skip** — already handled |
357
+ | `clean` | Pipeline analyzed and cleared as benign | **Skip** — verified safe |
358
+
359
+ **Critical workflow rule:** When investigating notifications, iterate `ipInvestigations[]` and **only flag or act on IPs with `status: "open"`**. IPs marked `false_positive`, `blocked`, or `clean` have already been triaged — do not re-flag them.
360
+
348
361
  ### Triage and Suppress a False Positive
349
362
 
350
363
  ```bash
package/cli/auth.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const http = require('http');
4
+ const crypto = require('crypto');
4
5
  const { execFileSync } = require('child_process');
5
6
  const config = require('./config');
6
7
  const { api, CLIError } = require('./client');
@@ -39,16 +40,18 @@ function decodeJwtPayload(token) {
39
40
 
40
41
  async function loginWithBrowser() {
41
42
  const appUrl = config.getAppUrl();
43
+ const nonce = crypto.randomBytes(24).toString('base64url');
42
44
 
43
45
  return new Promise((resolve, reject) => {
44
46
  let pendingToken = null;
45
47
 
46
48
  const server = http.createServer((req, res) => {
47
- const url = new URL(req.url, `http://localhost`);
49
+ const url = new URL(req.url, `http://127.0.0.1`);
48
50
 
49
51
  if (url.pathname === '/callback') {
50
52
  const token = url.searchParams.get('token');
51
53
  const error = url.searchParams.get('error');
54
+ const returnedState = url.searchParams.get('state');
52
55
 
53
56
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
54
57
 
@@ -59,13 +62,20 @@ async function loginWithBrowser() {
59
62
  return;
60
63
  }
61
64
 
65
+ if (returnedState !== nonce) {
66
+ res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2>Security Error</h2><p>State mismatch — this request may not have originated from your CLI. Please try again.</p></body></html>');
67
+ server.close();
68
+ reject(new CLIError('State mismatch on callback — possible CSRF. Please retry `securenow login`.'));
69
+ return;
70
+ }
71
+
62
72
  if (token) {
63
73
  pendingToken = token;
64
74
  const payload = decodeJwtPayload(token);
65
75
  const email = payload?.email || 'unknown account';
66
76
  const safeEmail = email.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
67
77
  const port = server.address().port;
68
- const switchUrl = `${appUrl}/cli/auth?callback=http://localhost:${port}/callback&force_login=1`;
78
+ const switchUrl = `${appUrl}/cli/auth?callback=http://127.0.0.1:${port}/callback&state=${encodeURIComponent(nonce)}&force_login=1`;
69
79
 
70
80
  res.end([
71
81
  '<!DOCTYPE html><html><head><meta charset="utf-8"><title>SecureNow CLI Login</title></head>',
@@ -91,7 +101,7 @@ async function loginWithBrowser() {
91
101
  '<script>',
92
102
  'document.getElementById("confirm-btn").addEventListener("click", function(){',
93
103
  ' this.disabled=true;this.textContent="Connecting\u2026";this.style.background="#86efac";this.style.cursor="default";',
94
- ` fetch("/confirm").then(function(){`,
104
+ ` fetch("/confirm?nonce=${encodeURIComponent(nonce)}").then(function(){`,
95
105
  ' document.getElementById("confirm-btn").style.display="none";',
96
106
  ' document.getElementById("done-msg").style.display="block";',
97
107
  ' });',
@@ -109,6 +119,11 @@ async function loginWithBrowser() {
109
119
  }
110
120
 
111
121
  if (url.pathname === '/confirm' && pendingToken) {
122
+ if (url.searchParams.get('nonce') !== nonce) {
123
+ res.writeHead(403, { 'Content-Type': 'application/json' });
124
+ res.end('{"error":"invalid nonce"}');
125
+ return;
126
+ }
112
127
  res.writeHead(200, { 'Content-Type': 'application/json' });
113
128
  res.end('{"ok":true}');
114
129
  const token = pendingToken;
@@ -124,7 +139,7 @@ async function loginWithBrowser() {
124
139
 
125
140
  server.listen(0, '127.0.0.1', () => {
126
141
  const port = server.address().port;
127
- const authUrl = `${appUrl}/cli/auth?callback=http://localhost:${port}/callback`;
142
+ const authUrl = `${appUrl}/cli/auth?callback=http://127.0.0.1:${port}/callback&state=${encodeURIComponent(nonce)}`;
128
143
 
129
144
  console.log('');
130
145
  ui.info('Opening browser for authentication...');
package/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
4
  const ui = require('./cli/ui');
@@ -345,6 +345,21 @@ The firewall uses the same trusted-proxy-aware IP resolution as SecureNow tracin
345
345
  - Store it in environment variables or `.env` — never commit it to source control
346
346
  - The key is hashed (SHA-256) on the server — SecureNow never stores the plaintext
347
347
 
348
+ ### Sync Architecture
349
+
350
+ The SDK uses a unified sync endpoint (`/firewall/sync`) that combines version checking and data fetching into a single request:
351
+
352
+ 1. **One request per poll** — The SDK sends its current blocklist and allowlist versions. The API responds with version info and only includes full IP lists for lists that have changed.
353
+ 2. **HTTP Keep-Alive** — TCP connections are reused across polls. TLS handshake happens once; subsequent requests reuse the socket (~5-10ms vs ~100-200ms per request).
354
+ 3. **Automatic fallback** — If the unified endpoint is not available (older API), the SDK falls back to legacy separate endpoints.
355
+
356
+ ### Circuit Breaker & Back-Pressure
357
+
358
+ - **Circuit breaker** — After 5 consecutive errors, the SDK pauses all polling for 2 minutes. After cooldown, a single probe is sent. If it succeeds, normal polling resumes.
359
+ - **In-flight guard** — Only one poll can be in-flight at a time. If the API is slow, the next scheduled poll is skipped.
360
+ - **429 Retry-After** — When the API returns HTTP 429, all polling pauses for the `Retry-After` duration.
361
+ - **Exponential backoff** — Poll interval doubles on each consecutive error (10s → 20s → 40s → 80s, capped at 120s).
362
+
348
363
  ### Cleanup on Shutdown
349
364
 
350
365
  All layers clean up on process exit (SIGINT/SIGTERM):
package/firewall.js CHANGED
@@ -8,7 +8,7 @@ const { resolveClientIp } = require('./resolve-ip');
8
8
  let _options = null;
9
9
  let _matcher = null;
10
10
  let _syncTimer = null;
11
- let _versionTimer = null;
11
+ let _pollTimer = null;
12
12
  let _lastModified = null;
13
13
  let _lastVersion = null;
14
14
  let _lastSyncEtag = null;
@@ -25,85 +25,89 @@ let _allowlistRawIps = [];
25
25
  let _lastAllowlistModified = null;
26
26
  let _lastAllowlistVersion = null;
27
27
  let _lastAllowlistSyncEtag = null;
28
- let _allowlistVersionTimer = null;
29
28
 
30
- // ────── Blocklist Sync ──────
29
+ // Circuit breaker
30
+ const CIRCUIT_OPEN_THRESHOLD = 5;
31
+ const CIRCUIT_OPEN_COOLDOWN_MS = 120_000;
32
+ let _circuitState = 'closed';
33
+ let _circuitOpenedAt = 0;
31
34
 
32
- function buildUrl(apiUrl, path) {
33
- return apiUrl.replace(/\/+$/, '') + '/api/v1' + path;
34
- }
35
+ // In-flight guard and 429 back-off
36
+ let _pollInflight = false;
37
+ let _retryAfterUntil = 0;
35
38
 
36
- function syncBlocklist(callback) {
37
- const url = buildUrl(_options.apiUrl, '/firewall/blocklist');
38
- const mod = url.startsWith('https') ? https : http;
39
- const parsed = new URL(url);
39
+ // Keep-alive agents — reuse TCP connections across polls (TLS handshake once)
40
+ const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
41
+ const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
40
42
 
41
- const reqOptions = {
42
- hostname: parsed.hostname,
43
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
44
- path: parsed.pathname + parsed.search,
45
- method: 'GET',
46
- headers: {
47
- 'Authorization': `Bearer ${_options.apiKey}`,
48
- 'User-Agent': 'securenow-firewall-sdk',
49
- },
50
- timeout: 10000,
51
- };
43
+ // Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
44
+ let _useUnifiedSync = true;
52
45
 
53
- if (_lastSyncEtag) {
54
- reqOptions.headers['If-None-Match'] = _lastSyncEtag;
55
- } else if (_lastModified) {
56
- reqOptions.headers['If-Modified-Since'] = _lastModified;
57
- }
46
+ // ────── Circuit Breaker ──────
58
47
 
59
- const req = mod.request(reqOptions, (res) => {
60
- if (res.statusCode === 304) {
61
- callback(null, false);
62
- return;
48
+ function maybeOpenCircuit() {
49
+ if (_circuitState === 'open') return;
50
+ if (_consecutiveErrors >= CIRCUIT_OPEN_THRESHOLD) {
51
+ _circuitState = 'open';
52
+ _circuitOpenedAt = Date.now();
53
+ if (_options && _options.log) {
54
+ console.warn('[securenow] Firewall: circuit breaker OPEN — pausing polling for %ds', CIRCUIT_OPEN_COOLDOWN_MS / 1000);
63
55
  }
56
+ }
57
+ }
64
58
 
65
- let data = '';
66
- res.on('data', (chunk) => { data += chunk; });
67
- res.on('end', () => {
68
- if (res.statusCode !== 200) {
69
- callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
70
- return;
71
- }
72
- try {
73
- const body = JSON.parse(data);
74
- const ips = body.ips || [];
75
- _rawIps = ips;
76
- _matcher = createMatcher(ips);
77
- _lastModified = res.headers['last-modified'] || null;
78
- if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
79
- _stats.syncs++;
80
- notifyLayers(ips);
81
- callback(null, true, _matcher.stats());
82
- } catch (e) {
83
- callback(new Error(`Failed to parse blocklist response: ${e.message}`));
84
- }
85
- });
86
- });
59
+ function resetCircuit() {
60
+ if (_circuitState !== 'closed') {
61
+ _circuitState = 'closed';
62
+ if (_options && _options.log) console.log('[securenow] Firewall: circuit breaker CLOSED — API healthy');
63
+ }
64
+ _consecutiveErrors = 0;
65
+ }
87
66
 
88
- req.on('error', (err) => callback(err));
89
- req.on('timeout', () => { req.destroy(); callback(new Error('Sync request timed out')); });
90
- req.end();
67
+ function shouldSkipRequest() {
68
+ if (Date.now() < _retryAfterUntil) return true;
69
+ if (_circuitState === 'closed') return false;
70
+ if (_circuitState === 'open') {
71
+ if (Date.now() - _circuitOpenedAt >= CIRCUIT_OPEN_COOLDOWN_MS) {
72
+ _circuitState = 'half-open';
73
+ return false;
74
+ }
75
+ return true;
76
+ }
77
+ return false; // half-open: allow one probe
91
78
  }
92
79
 
93
- function notifyLayers(ips) {
94
- for (const layer of _layers) {
95
- try { if (typeof layer.sync === 'function') layer.sync(ips); } catch (_) {}
80
+ function handleRetryAfter(res) {
81
+ const ra = res.headers['retry-after'];
82
+ if (!ra) return;
83
+ const secs = parseInt(ra, 10);
84
+ if (secs > 0 && secs <= 300) {
85
+ _retryAfterUntil = Date.now() + secs * 1000;
86
+ if (_options && _options.log) {
87
+ console.warn('[securenow] Firewall: API returned 429, backing off for %ds', secs);
88
+ }
96
89
  }
97
90
  }
98
91
 
99
- // ────── Allowlist Sync ──────
92
+ // ────── HTTP helpers ──────
100
93
 
101
- function syncAllowlist(callback) {
102
- const url = buildUrl(_options.apiUrl, '/firewall/allowlist');
94
+ function buildUrl(apiUrl, path) {
95
+ return apiUrl.replace(/\/+$/, '') + '/api/v1' + path;
96
+ }
97
+
98
+ function jitter(baseMs) {
99
+ return baseMs * (0.8 + Math.random() * 0.4);
100
+ }
101
+
102
+ function agentFor(url) {
103
+ return url.startsWith('https') ? _httpsAgent : _httpAgent;
104
+ }
105
+
106
+ function httpGet(url, extraHeaders, timeout, callback) {
103
107
  const mod = url.startsWith('https') ? https : http;
104
108
  const parsed = new URL(url);
105
109
 
106
- const reqOptions = {
110
+ const req = mod.request({
107
111
  hostname: parsed.hostname,
108
112
  port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
109
113
  path: parsed.pathname + parsed.search,
@@ -111,211 +115,315 @@ function syncAllowlist(callback) {
111
115
  headers: {
112
116
  'Authorization': `Bearer ${_options.apiKey}`,
113
117
  'User-Agent': 'securenow-firewall-sdk',
118
+ ...extraHeaders,
114
119
  },
115
- timeout: 10000,
116
- };
117
-
118
- if (_lastAllowlistSyncEtag) {
119
- reqOptions.headers['If-None-Match'] = _lastAllowlistSyncEtag;
120
- } else if (_lastAllowlistModified) {
121
- reqOptions.headers['If-Modified-Since'] = _lastAllowlistModified;
122
- }
123
-
124
- const req = mod.request(reqOptions, (res) => {
125
- if (res.statusCode === 304) {
126
- callback(null, false);
127
- return;
128
- }
129
-
120
+ timeout,
121
+ agent: agentFor(url),
122
+ }, (res) => {
130
123
  let data = '';
131
124
  res.on('data', (chunk) => { data += chunk; });
132
- res.on('end', () => {
133
- if (res.statusCode !== 200) {
134
- callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
135
- return;
136
- }
137
- try {
138
- const body = JSON.parse(data);
139
- const ips = body.ips || [];
140
- _allowlistRawIps = ips;
141
- _allowlistMatcher = createMatcher(ips);
142
- _lastAllowlistModified = res.headers['last-modified'] || null;
143
- if (res.headers['etag']) _lastAllowlistSyncEtag = res.headers['etag'];
144
- callback(null, true, _allowlistMatcher.stats());
145
- } catch (e) {
146
- callback(new Error(`Failed to parse allowlist response: ${e.message}`));
147
- }
148
- });
125
+ res.on('end', () => { callback(null, res, data); });
149
126
  });
150
127
 
151
128
  req.on('error', (err) => callback(err));
152
- req.on('timeout', () => { req.destroy(); callback(new Error('Allowlist sync request timed out')); });
129
+ req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
153
130
  req.end();
154
131
  }
155
132
 
156
- function checkAllowlistVersion(callback) {
157
- const url = buildUrl(_options.apiUrl, '/firewall/allowlist/version');
158
- const mod = url.startsWith('https') ? https : http;
159
- const parsed = new URL(url);
133
+ // ────── Unified Sync (v2 — single request for everything) ──────
160
134
 
161
- const headers = {
162
- 'Authorization': `Bearer ${_options.apiKey}`,
163
- 'User-Agent': 'securenow-firewall-sdk',
164
- };
165
- if (_lastAllowlistVersion) headers['If-None-Match'] = _lastAllowlistVersion;
135
+ function doUnifiedSync(callback) {
136
+ const url = buildUrl(_options.apiUrl, '/firewall/sync');
137
+ const headers = {};
138
+ if (_lastVersion) headers['X-Blocklist-Version'] = _lastVersion;
139
+ if (_lastAllowlistVersion) headers['X-Allowlist-Version'] = _lastAllowlistVersion;
140
+
141
+ httpGet(url, headers, 8000, (err, res, data) => {
142
+ if (err) return callback(err);
143
+
144
+ _stats.versionChecks++;
166
145
 
167
- const req = mod.request({
168
- hostname: parsed.hostname,
169
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
170
- path: parsed.pathname + parsed.search,
171
- method: 'GET',
172
- headers,
173
- timeout: 5000,
174
- }, (res) => {
175
146
  if (res.statusCode === 304) {
176
- res.resume();
177
- callback(null, false);
178
- return;
147
+ return callback(null, { blChanged: false, alChanged: false });
179
148
  }
180
149
 
181
- let data = '';
182
- res.on('data', (chunk) => { data += chunk; });
183
- res.on('end', () => {
184
- if (res.statusCode !== 200) {
185
- callback(null, false);
186
- return;
150
+ if (res.statusCode === 404) {
151
+ _useUnifiedSync = false;
152
+ if (_options.log) console.log('[securenow] Firewall: /sync not available, using legacy endpoints');
153
+ return callback(null, { blChanged: false, alChanged: false, useLegacy: true });
154
+ }
155
+
156
+ if (res.statusCode === 429) { handleRetryAfter(res); }
157
+
158
+ if (res.statusCode !== 200) {
159
+ return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
160
+ }
161
+
162
+ try {
163
+ const body = JSON.parse(data);
164
+
165
+ let blChanged = false;
166
+ let alChanged = false;
167
+
168
+ // Update blocklist version + data
169
+ if (body.blocklist) {
170
+ const newVer = body.blocklist.version;
171
+ if (newVer !== _lastVersion) {
172
+ _lastVersion = newVer;
173
+ blChanged = true;
174
+ }
187
175
  }
188
- try {
189
- const body = JSON.parse(data);
190
- const version = body.version || null;
191
- const changed = version !== _lastAllowlistVersion;
192
- if (changed) _lastAllowlistVersion = version;
193
- callback(null, changed);
194
- } catch (_e) { callback(null, false); }
195
- });
196
- });
197
176
 
198
- req.on('error', () => { callback(null, false); });
199
- req.on('timeout', () => { req.destroy(); callback(null, false); });
200
- req.end();
177
+ if (body.blocklistIps) {
178
+ _rawIps = body.blocklistIps;
179
+ _matcher = createMatcher(body.blocklistIps);
180
+ _stats.syncs++;
181
+ notifyLayers(body.blocklistIps);
182
+ blChanged = true;
183
+ }
184
+
185
+ // Update allowlist version + data
186
+ if (body.allowlist) {
187
+ const newVer = body.allowlist.version;
188
+ if (newVer !== _lastAllowlistVersion) {
189
+ _lastAllowlistVersion = newVer;
190
+ alChanged = true;
191
+ }
192
+ }
193
+
194
+ if (body.allowlistIps) {
195
+ _allowlistRawIps = body.allowlistIps;
196
+ _allowlistMatcher = createMatcher(body.allowlistIps);
197
+ alChanged = true;
198
+ }
199
+
200
+ callback(null, { blChanged, alChanged });
201
+ } catch (e) {
202
+ callback(new Error(`Failed to parse sync response: ${e.message}`));
203
+ }
204
+ });
201
205
  }
202
206
 
203
- function doFullAllowlistSync() {
204
- syncAllowlist((err, changed, stats) => {
205
- if (err) {
206
- if (_options.log) console.warn('[securenow] Firewall: allowlist sync failed:', err.message);
207
- } else if (changed && stats && _options.log) {
208
- console.log('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
207
+ // ────── Legacy Sync (v1 — separate endpoints, kept for backward compat) ──────
208
+
209
+ function legacyBlocklistSync(callback) {
210
+ const url = buildUrl(_options.apiUrl, '/firewall/blocklist');
211
+ const headers = {};
212
+ if (_lastSyncEtag) headers['If-None-Match'] = _lastSyncEtag;
213
+ else if (_lastModified) headers['If-Modified-Since'] = _lastModified;
214
+
215
+ httpGet(url, headers, 10000, (err, res, data) => {
216
+ if (err) return callback(err);
217
+ if (res.statusCode === 304) return callback(null, false);
218
+ if (res.statusCode === 429) { handleRetryAfter(res); }
219
+ if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
220
+
221
+ try {
222
+ const body = JSON.parse(data);
223
+ const ips = body.ips || [];
224
+ _rawIps = ips;
225
+ _matcher = createMatcher(ips);
226
+ _lastModified = res.headers['last-modified'] || null;
227
+ if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
228
+ _stats.syncs++;
229
+ notifyLayers(ips);
230
+ callback(null, true, _matcher.stats());
231
+ } catch (e) {
232
+ callback(new Error(`Failed to parse blocklist: ${e.message}`));
209
233
  }
210
234
  });
211
235
  }
212
236
 
213
- function scheduleNextAllowlistVersionCheck() {
214
- const baseMs = (_options.versionCheckInterval || 10) * 1000;
215
- const delayMs = jitter(baseMs);
237
+ function legacyAllowlistSync(callback) {
238
+ const url = buildUrl(_options.apiUrl, '/firewall/allowlist');
239
+ const headers = {};
240
+ if (_lastAllowlistSyncEtag) headers['If-None-Match'] = _lastAllowlistSyncEtag;
241
+ else if (_lastAllowlistModified) headers['If-Modified-Since'] = _lastAllowlistModified;
216
242
 
217
- _allowlistVersionTimer = setTimeout(() => {
218
- checkAllowlistVersion((_err, changed) => {
219
- if (changed) {
220
- if (_options.log) console.log('[securenow] Firewall: allowlist version changed, syncing…');
221
- doFullAllowlistSync();
222
- }
223
- scheduleNextAllowlistVersionCheck();
224
- });
225
- }, delayMs);
226
- if (_allowlistVersionTimer.unref) _allowlistVersionTimer.unref();
243
+ httpGet(url, headers, 10000, (err, res, data) => {
244
+ if (err) return callback(err);
245
+ if (res.statusCode === 304) return callback(null, false);
246
+ if (res.statusCode === 429) { handleRetryAfter(res); }
247
+ if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
248
+
249
+ try {
250
+ const body = JSON.parse(data);
251
+ const ips = body.ips || [];
252
+ _allowlistRawIps = ips;
253
+ _allowlistMatcher = createMatcher(ips);
254
+ _lastAllowlistModified = res.headers['last-modified'] || null;
255
+ if (res.headers['etag']) _lastAllowlistSyncEtag = res.headers['etag'];
256
+ callback(null, true, _allowlistMatcher.stats());
257
+ } catch (e) {
258
+ callback(new Error(`Failed to parse allowlist: ${e.message}`));
259
+ }
260
+ });
227
261
  }
228
262
 
229
- function checkVersion(callback) {
263
+ function legacyVersionCheck(callback) {
230
264
  const url = buildUrl(_options.apiUrl, '/firewall/blocklist/version');
231
- const mod = url.startsWith('https') ? https : http;
232
- const parsed = new URL(url);
233
-
234
- const headers = {
235
- 'Authorization': `Bearer ${_options.apiKey}`,
236
- 'User-Agent': 'securenow-firewall-sdk',
237
- };
265
+ const headers = {};
238
266
  if (_lastVersion) headers['If-None-Match'] = _lastVersion;
239
267
 
240
- const req = mod.request({
241
- hostname: parsed.hostname,
242
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
243
- path: parsed.pathname + parsed.search,
244
- method: 'GET',
245
- headers,
246
- timeout: 5000,
247
- }, (res) => {
268
+ httpGet(url, headers, 5000, (err, res, data) => {
269
+ if (err) return callback(err);
248
270
  _stats.versionChecks++;
271
+ if (res.statusCode === 304) return callback(null, false, false);
272
+ if (res.statusCode === 429) { handleRetryAfter(res); }
273
+ if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
249
274
 
250
- if (res.statusCode === 304) {
251
- _consecutiveErrors = 0;
252
- res.resume();
253
- callback(null, false);
254
- return;
275
+ try {
276
+ const body = JSON.parse(data);
277
+ const version = body.version || null;
278
+ const changed = version !== _lastVersion;
279
+ if (changed) _lastVersion = version;
280
+ callback(null, changed, false);
281
+ } catch (_e) { callback(null, false, false); }
282
+ });
283
+ }
284
+
285
+ function legacyAllowlistVersionCheck(callback) {
286
+ const url = buildUrl(_options.apiUrl, '/firewall/allowlist/version');
287
+ const headers = {};
288
+ if (_lastAllowlistVersion) headers['If-None-Match'] = _lastAllowlistVersion;
289
+
290
+ httpGet(url, headers, 5000, (err, res, data) => {
291
+ if (err) return callback(err);
292
+ if (res.statusCode === 304) return callback(null, false);
293
+ if (res.statusCode === 429) { handleRetryAfter(res); }
294
+ if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
295
+
296
+ try {
297
+ const body = JSON.parse(data);
298
+ const version = body.version || null;
299
+ const changed = version !== _lastAllowlistVersion;
300
+ if (changed) _lastAllowlistVersion = version;
301
+ callback(null, changed);
302
+ } catch (_e) { callback(null, false); }
303
+ });
304
+ }
305
+
306
+ function doLegacyPoll(callback) {
307
+ let pending = 2;
308
+ let blChanged = false;
309
+ let alChanged = false;
310
+ let firstError = null;
311
+
312
+ function checkDone() {
313
+ if (--pending > 0) return;
314
+ if (firstError) return callback(firstError);
315
+
316
+ let syncsPending = 0;
317
+ if (blChanged) syncsPending++;
318
+ if (alChanged) syncsPending++;
319
+ if (syncsPending === 0) return callback(null, { blChanged: false, alChanged: false });
320
+
321
+ function syncDone() {
322
+ if (--syncsPending > 0) return;
323
+ callback(null, { blChanged, alChanged });
255
324
  }
256
325
 
257
- let data = '';
258
- res.on('data', (chunk) => { data += chunk; });
259
- res.on('end', () => {
260
- if (res.statusCode !== 200) {
261
- _consecutiveErrors++;
262
- _stats.errors++;
263
- callback(null, false);
264
- return;
265
- }
266
- _consecutiveErrors = 0;
267
- try {
268
- const body = JSON.parse(data);
269
- const version = body.version || null;
270
- const changed = version !== _lastVersion;
271
- if (changed) _lastVersion = version;
272
- callback(null, changed);
273
- } catch (_e) { callback(null, false); }
274
- });
326
+ if (blChanged) {
327
+ legacyBlocklistSync((err, changed, stats) => {
328
+ if (err && _options.log) console.warn('[securenow] Firewall: sync failed (using stale list):', err.message);
329
+ else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
330
+ syncDone();
331
+ });
332
+ }
333
+ if (alChanged) {
334
+ legacyAllowlistSync((err, changed, stats) => {
335
+ if (err && _options.log) console.warn('[securenow] Firewall: allowlist sync failed:', err.message);
336
+ else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
337
+ syncDone();
338
+ });
339
+ }
340
+ }
341
+
342
+ legacyVersionCheck((err, changed) => {
343
+ if (err) firstError = firstError || err;
344
+ blChanged = changed;
345
+ checkDone();
275
346
  });
276
347
 
277
- req.on('error', () => { _consecutiveErrors++; _stats.errors++; callback(null, false); });
278
- req.on('timeout', () => { req.destroy(); _consecutiveErrors++; _stats.errors++; callback(null, false); });
279
- req.end();
348
+ legacyAllowlistVersionCheck((err, changed) => {
349
+ if (err) firstError = firstError || err;
350
+ alChanged = changed;
351
+ checkDone();
352
+ });
280
353
  }
281
354
 
282
- function doFullSync() {
283
- syncBlocklist((err, changed, stats) => {
355
+ // ────── Unified poll loop ──────
356
+
357
+ function notifyLayers(ips) {
358
+ for (const layer of _layers) {
359
+ try { if (typeof layer.sync === 'function') layer.sync(ips); } catch (_) {}
360
+ }
361
+ }
362
+
363
+ function pollOnce(callback) {
364
+ if (_pollInflight || shouldSkipRequest()) return callback(null);
365
+ _pollInflight = true;
366
+
367
+ const done = (err, result) => {
368
+ _pollInflight = false;
284
369
  if (err) {
285
- if (_options.log) console.warn('[securenow] Firewall: sync failed (using stale list):', err.message);
286
- } else if (changed && stats && _options.log) {
287
- console.log('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
370
+ _consecutiveErrors++;
371
+ _stats.errors++;
372
+ maybeOpenCircuit();
373
+ if (_options.log) console.warn('[securenow] Firewall: poll failed:', err.message);
374
+ return callback(err);
288
375
  }
289
- });
290
- }
376
+ _consecutiveErrors = 0;
377
+ resetCircuit();
378
+ if (result) {
379
+ if (result.blChanged && _options.log && _matcher) {
380
+ const s = _matcher.stats();
381
+ console.log('[securenow] Firewall: re-synced %d blocked IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
382
+ }
383
+ if (result.alChanged && _options.log && _allowlistMatcher) {
384
+ const s = _allowlistMatcher.stats();
385
+ console.log('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
386
+ }
387
+ }
388
+ callback(null);
389
+ };
291
390
 
292
- function jitter(baseMs) {
293
- return baseMs * (0.8 + Math.random() * 0.4);
391
+ if (_useUnifiedSync) {
392
+ doUnifiedSync((err, result) => {
393
+ if (err) return done(err);
394
+ if (result && result.useLegacy) {
395
+ doLegacyPoll(done);
396
+ } else {
397
+ done(null, result);
398
+ }
399
+ });
400
+ } else {
401
+ doLegacyPoll(done);
402
+ }
294
403
  }
295
404
 
296
- function scheduleNextVersionCheck() {
405
+ function scheduleNextPoll() {
297
406
  const baseMs = (_options.versionCheckInterval || 10) * 1000;
298
407
  const backoffMs = Math.min(baseMs * Math.pow(2, _consecutiveErrors), 120_000);
299
408
  const delayMs = jitter(backoffMs);
300
409
 
301
- _versionTimer = setTimeout(() => {
302
- checkVersion((_err, changed) => {
303
- if (changed) {
304
- if (_options.log) console.log('[securenow] Firewall: blocklist version changed, syncing…');
305
- doFullSync();
306
- }
307
- scheduleNextVersionCheck();
308
- });
410
+ _pollTimer = setTimeout(() => {
411
+ pollOnce(() => { scheduleNextPoll(); });
309
412
  }, delayMs);
310
- if (_versionTimer.unref) _versionTimer.unref();
413
+ if (_pollTimer.unref) _pollTimer.unref();
311
414
  }
312
415
 
313
416
  function startSyncLoop() {
314
417
  const fullSyncIntervalMs = (_options.syncInterval || 300) * 1000;
315
- const RETRY_DELAY = 5000;
418
+ const BASE_RETRY = 5000;
419
+ const MAX_RETRY = 120_000;
420
+ let _initAttempt = 0;
316
421
 
317
422
  function initialSync() {
318
- syncBlocklist((err, changed, stats) => {
423
+ // Use unified endpoint for initial sync too
424
+ const syncFn = _useUnifiedSync ? doUnifiedSync : (cb) => doLegacyPoll(cb);
425
+
426
+ syncFn((err, result) => {
319
427
  if (err) {
320
428
  const isConnErr = /ECONNREFUSED|ENOTFOUND|timed out/i.test(err.message);
321
429
  if (isConnErr && !_localhostFallbackTried && _options.apiUrl !== 'http://localhost:4000') {
@@ -327,42 +435,58 @@ function startSyncLoop() {
327
435
  if (retryTimer.unref) retryTimer.unref();
328
436
  return;
329
437
  }
438
+ if (result && result.useLegacy) {
439
+ const retryTimer = setTimeout(initialSync, 1000);
440
+ if (retryTimer.unref) retryTimer.unref();
441
+ return;
442
+ }
330
443
  if (_options.log) console.warn('[securenow] Firewall: initial sync failed:', err.message);
331
444
  if (_options.failMode === 'closed') {
332
445
  _matcher = { isBlocked: () => true, stats: () => ({ exact: 0, cidr: 0, total: 0 }) };
333
446
  }
334
- const retryTimer = setTimeout(initialSync, RETRY_DELAY);
447
+ _initAttempt++;
448
+ const delay = Math.min(BASE_RETRY * Math.pow(2, _initAttempt), MAX_RETRY);
449
+ const retryTimer = setTimeout(initialSync, jitter(delay));
335
450
  if (retryTimer.unref) retryTimer.unref();
336
451
  return;
337
452
  }
338
- if (changed && stats) {
339
- if (_options.log) console.log('[securenow] Firewall: synced %d blocked IPs (%d exact + %d CIDR ranges)', stats.total, stats.exact, stats.cidr);
340
- }
341
- _initialized = true;
342
- });
343
- }
344
453
 
345
- function initialAllowlistSync() {
346
- syncAllowlist((err, changed, stats) => {
347
- if (err) {
348
- if (_options.log) console.warn('[securenow] Firewall: initial allowlist sync failed:', err.message);
349
- const retryTimer = setTimeout(initialAllowlistSync, RETRY_DELAY);
454
+ if (result && result.useLegacy) {
455
+ const retryTimer = setTimeout(initialSync, 1000);
350
456
  if (retryTimer.unref) retryTimer.unref();
351
457
  return;
352
458
  }
353
- if (changed && stats && stats.total > 0) {
354
- if (_options.log) console.log('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', stats.total, stats.exact, stats.cidr);
459
+
460
+ _initialized = true;
461
+ if (_options.log && _matcher) {
462
+ const s = _matcher.stats();
463
+ console.log('[securenow] Firewall: synced %d blocked IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
464
+ }
465
+ if (_options.log && _allowlistMatcher) {
466
+ const s = _allowlistMatcher.stats();
467
+ if (s.total > 0) console.log('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
355
468
  }
356
469
  });
357
470
  }
358
471
 
359
472
  initialSync();
360
- initialAllowlistSync();
361
-
362
- scheduleNextVersionCheck();
363
- scheduleNextAllowlistVersionCheck();
364
-
365
- _syncTimer = setInterval(() => { doFullSync(); doFullAllowlistSync(); }, fullSyncIntervalMs);
473
+ scheduleNextPoll();
474
+
475
+ // Safety-net full sync timer (less frequent, uses same path)
476
+ _syncTimer = setInterval(() => {
477
+ if (shouldSkipRequest()) return;
478
+ // Force a full re-fetch by clearing versions so unified endpoint returns full data
479
+ const savedBlVer = _lastVersion;
480
+ const savedAlVer = _lastAllowlistVersion;
481
+ _lastVersion = null;
482
+ _lastAllowlistVersion = null;
483
+ pollOnce((err) => {
484
+ if (err) {
485
+ _lastVersion = savedBlVer;
486
+ _lastAllowlistVersion = savedAlVer;
487
+ }
488
+ });
489
+ }, fullSyncIntervalMs);
366
490
  if (_syncTimer.unref) _syncTimer.unref();
367
491
  }
368
492
 
@@ -445,7 +569,6 @@ function firewallRequestHandler(req, res) {
445
569
  sendBlockResponse(req, res, ip);
446
570
  return true;
447
571
  }
448
- // IP is on the allowlist — skip blocklist check, allow through
449
572
  return false;
450
573
  }
451
574
 
@@ -479,7 +602,6 @@ function patchHttpLayer() {
479
602
  if (_httpPatched) return;
480
603
  _httpPatched = true;
481
604
 
482
- // Patch Server.prototype.emit to intercept requests on already-created servers
483
605
  patchEmitLayer();
484
606
 
485
607
  http.createServer = function(...args) {
@@ -506,11 +628,9 @@ function init(options) {
506
628
 
507
629
  if (_options.log) console.log('[securenow] Firewall: ENABLED');
508
630
 
509
- // Layer 1: HTTP (always on)
510
631
  patchHttpLayer();
511
632
  if (_options.log) console.log('[securenow] Firewall: Layer 1 (HTTP 403) active');
512
633
 
513
- // Layer 2: TCP
514
634
  if (_options.tcp) {
515
635
  try {
516
636
  const tcpLayer = require('./firewall-tcp');
@@ -524,7 +644,6 @@ function init(options) {
524
644
  if (_options.log) console.log('[securenow] Firewall: Layer 2 (TCP drop) disabled (set SECURENOW_FIREWALL_TCP=1)');
525
645
  }
526
646
 
527
- // Layer 3: iptables
528
647
  if (_options.iptables) {
529
648
  try {
530
649
  const iptablesLayer = require('./firewall-iptables');
@@ -538,7 +657,6 @@ function init(options) {
538
657
  if (_options.log) console.log('[securenow] Firewall: Layer 3 (iptables) disabled (set SECURENOW_FIREWALL_IPTABLES=1)');
539
658
  }
540
659
 
541
- // Layer 4: Cloud WAF
542
660
  if (_options.cloud) {
543
661
  try {
544
662
  const cloudLayer = require('./firewall-cloud');
@@ -556,10 +674,18 @@ function init(options) {
556
674
  }
557
675
 
558
676
  function shutdown() {
559
- if (_versionTimer) { clearTimeout(_versionTimer); _versionTimer = null; }
560
- if (_allowlistVersionTimer) { clearTimeout(_allowlistVersionTimer); _allowlistVersionTimer = null; }
677
+ if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
561
678
  if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
562
679
 
680
+ _circuitState = 'closed';
681
+ _circuitOpenedAt = 0;
682
+ _consecutiveErrors = 0;
683
+ _pollInflight = false;
684
+ _retryAfterUntil = 0;
685
+
686
+ _httpAgent.destroy();
687
+ _httpsAgent.destroy();
688
+
563
689
  for (const layer of _layers) {
564
690
  try { if (typeof layer.shutdown === 'function') layer.shutdown(); } catch (_) {}
565
691
  }
@@ -582,6 +708,9 @@ function getStats() {
582
708
  matcher: _matcher ? _matcher.stats() : null,
583
709
  allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
584
710
  initialized: _initialized,
711
+ circuitState: _circuitState,
712
+ consecutiveErrors: _consecutiveErrors,
713
+ unifiedSync: _useUnifiedSync,
585
714
  };
586
715
  }
587
716
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "5.16.2",
3
+ "version": "5.17.0",
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",