securenow 5.16.2 → 5.16.3

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-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');
package/firewall.js CHANGED
@@ -26,6 +26,76 @@ let _lastAllowlistModified = null;
26
26
  let _lastAllowlistVersion = null;
27
27
  let _lastAllowlistSyncEtag = null;
28
28
  let _allowlistVersionTimer = null;
29
+ let _allowlistConsecutiveErrors = 0;
30
+
31
+ // Circuit breaker: stops all outbound polling when the API is persistently down.
32
+ // Opens after CIRCUIT_OPEN_THRESHOLD consecutive errors across both lists,
33
+ // stays open for CIRCUIT_OPEN_COOLDOWN_MS, then half-opens (single probe).
34
+ const CIRCUIT_OPEN_THRESHOLD = 5;
35
+ const CIRCUIT_OPEN_COOLDOWN_MS = 120_000;
36
+ let _circuitState = 'closed'; // 'closed' | 'open' | 'half-open'
37
+ let _circuitOpenedAt = 0;
38
+
39
+ // In-flight guards — prevent overlapping requests when the API is slow
40
+ let _versionCheckInflight = false;
41
+ let _allowlistVersionCheckInflight = false;
42
+ let _blocklistSyncInflight = false;
43
+ let _allowlistSyncInflight = false;
44
+
45
+ // 429 global back-off: if the API returns 429 with Retry-After, all polling
46
+ // pauses until this timestamp.
47
+ let _retryAfterUntil = 0;
48
+
49
+ // ────── Circuit Breaker ──────
50
+
51
+ function totalConsecutiveErrors() {
52
+ return _consecutiveErrors + _allowlistConsecutiveErrors;
53
+ }
54
+
55
+ function maybeOpenCircuit() {
56
+ if (_circuitState === 'open') return;
57
+ if (totalConsecutiveErrors() >= CIRCUIT_OPEN_THRESHOLD) {
58
+ _circuitState = 'open';
59
+ _circuitOpenedAt = Date.now();
60
+ if (_options && _options.log) {
61
+ console.warn('[securenow] Firewall: circuit breaker OPEN — pausing polling for %ds', CIRCUIT_OPEN_COOLDOWN_MS / 1000);
62
+ }
63
+ }
64
+ }
65
+
66
+ function resetCircuit() {
67
+ if (_circuitState !== 'closed') {
68
+ _circuitState = 'closed';
69
+ if (_options && _options.log) console.log('[securenow] Firewall: circuit breaker CLOSED — API healthy');
70
+ }
71
+ _consecutiveErrors = 0;
72
+ _allowlistConsecutiveErrors = 0;
73
+ }
74
+
75
+ function shouldSkipRequest() {
76
+ if (Date.now() < _retryAfterUntil) return true;
77
+ if (_circuitState === 'closed') return false;
78
+ if (_circuitState === 'open') {
79
+ if (Date.now() - _circuitOpenedAt >= CIRCUIT_OPEN_COOLDOWN_MS) {
80
+ _circuitState = 'half-open';
81
+ return false;
82
+ }
83
+ return true;
84
+ }
85
+ return false; // half-open: allow one probe
86
+ }
87
+
88
+ function handleRetryAfter(res) {
89
+ const ra = res.headers['retry-after'];
90
+ if (!ra) return;
91
+ const secs = parseInt(ra, 10);
92
+ if (secs > 0 && secs <= 300) {
93
+ _retryAfterUntil = Date.now() + secs * 1000;
94
+ if (_options && _options.log) {
95
+ console.warn('[securenow] Firewall: API returned 429, backing off for %ds', secs);
96
+ }
97
+ }
98
+ }
29
99
 
30
100
  // ────── Blocklist Sync ──────
31
101
 
@@ -34,6 +104,11 @@ function buildUrl(apiUrl, path) {
34
104
  }
35
105
 
36
106
  function syncBlocklist(callback) {
107
+ if (_blocklistSyncInflight) { callback(null, false); return; }
108
+ _blocklistSyncInflight = true;
109
+
110
+ const done = (...args) => { _blocklistSyncInflight = false; callback(...args); };
111
+
37
112
  const url = buildUrl(_options.apiUrl, '/firewall/blocklist');
38
113
  const mod = url.startsWith('https') ? https : http;
39
114
  const parsed = new URL(url);
@@ -58,15 +133,16 @@ function syncBlocklist(callback) {
58
133
 
59
134
  const req = mod.request(reqOptions, (res) => {
60
135
  if (res.statusCode === 304) {
61
- callback(null, false);
136
+ done(null, false);
62
137
  return;
63
138
  }
139
+ if (res.statusCode === 429) { handleRetryAfter(res); }
64
140
 
65
141
  let data = '';
66
142
  res.on('data', (chunk) => { data += chunk; });
67
143
  res.on('end', () => {
68
144
  if (res.statusCode !== 200) {
69
- callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
145
+ done(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
70
146
  return;
71
147
  }
72
148
  try {
@@ -78,15 +154,15 @@ function syncBlocklist(callback) {
78
154
  if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
79
155
  _stats.syncs++;
80
156
  notifyLayers(ips);
81
- callback(null, true, _matcher.stats());
157
+ done(null, true, _matcher.stats());
82
158
  } catch (e) {
83
- callback(new Error(`Failed to parse blocklist response: ${e.message}`));
159
+ done(new Error(`Failed to parse blocklist response: ${e.message}`));
84
160
  }
85
161
  });
86
162
  });
87
163
 
88
- req.on('error', (err) => callback(err));
89
- req.on('timeout', () => { req.destroy(); callback(new Error('Sync request timed out')); });
164
+ req.on('error', (err) => done(err));
165
+ req.on('timeout', () => { req.destroy(); done(new Error('Sync request timed out')); });
90
166
  req.end();
91
167
  }
92
168
 
@@ -99,6 +175,11 @@ function notifyLayers(ips) {
99
175
  // ────── Allowlist Sync ──────
100
176
 
101
177
  function syncAllowlist(callback) {
178
+ if (_allowlistSyncInflight) { callback(null, false); return; }
179
+ _allowlistSyncInflight = true;
180
+
181
+ const done = (...args) => { _allowlistSyncInflight = false; callback(...args); };
182
+
102
183
  const url = buildUrl(_options.apiUrl, '/firewall/allowlist');
103
184
  const mod = url.startsWith('https') ? https : http;
104
185
  const parsed = new URL(url);
@@ -123,15 +204,16 @@ function syncAllowlist(callback) {
123
204
 
124
205
  const req = mod.request(reqOptions, (res) => {
125
206
  if (res.statusCode === 304) {
126
- callback(null, false);
207
+ done(null, false);
127
208
  return;
128
209
  }
210
+ if (res.statusCode === 429) { handleRetryAfter(res); }
129
211
 
130
212
  let data = '';
131
213
  res.on('data', (chunk) => { data += chunk; });
132
214
  res.on('end', () => {
133
215
  if (res.statusCode !== 200) {
134
- callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
216
+ done(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
135
217
  return;
136
218
  }
137
219
  try {
@@ -141,19 +223,24 @@ function syncAllowlist(callback) {
141
223
  _allowlistMatcher = createMatcher(ips);
142
224
  _lastAllowlistModified = res.headers['last-modified'] || null;
143
225
  if (res.headers['etag']) _lastAllowlistSyncEtag = res.headers['etag'];
144
- callback(null, true, _allowlistMatcher.stats());
226
+ done(null, true, _allowlistMatcher.stats());
145
227
  } catch (e) {
146
- callback(new Error(`Failed to parse allowlist response: ${e.message}`));
228
+ done(new Error(`Failed to parse allowlist response: ${e.message}`));
147
229
  }
148
230
  });
149
231
  });
150
232
 
151
- req.on('error', (err) => callback(err));
152
- req.on('timeout', () => { req.destroy(); callback(new Error('Allowlist sync request timed out')); });
233
+ req.on('error', (err) => done(err));
234
+ req.on('timeout', () => { req.destroy(); done(new Error('Allowlist sync request timed out')); });
153
235
  req.end();
154
236
  }
155
237
 
156
238
  function checkAllowlistVersion(callback) {
239
+ if (_allowlistVersionCheckInflight || shouldSkipRequest()) { callback(null, false); return; }
240
+ _allowlistVersionCheckInflight = true;
241
+
242
+ const done = (...args) => { _allowlistVersionCheckInflight = false; callback(...args); };
243
+
157
244
  const url = buildUrl(_options.apiUrl, '/firewall/allowlist/version');
158
245
  const mod = url.startsWith('https') ? https : http;
159
246
  const parsed = new URL(url);
@@ -173,30 +260,38 @@ function checkAllowlistVersion(callback) {
173
260
  timeout: 5000,
174
261
  }, (res) => {
175
262
  if (res.statusCode === 304) {
263
+ _allowlistConsecutiveErrors = 0;
264
+ resetCircuit();
176
265
  res.resume();
177
- callback(null, false);
266
+ done(null, false);
178
267
  return;
179
268
  }
269
+ if (res.statusCode === 429) { handleRetryAfter(res); }
180
270
 
181
271
  let data = '';
182
272
  res.on('data', (chunk) => { data += chunk; });
183
273
  res.on('end', () => {
184
274
  if (res.statusCode !== 200) {
185
- callback(null, false);
275
+ _allowlistConsecutiveErrors++;
276
+ _stats.errors++;
277
+ maybeOpenCircuit();
278
+ done(null, false);
186
279
  return;
187
280
  }
281
+ _allowlistConsecutiveErrors = 0;
282
+ resetCircuit();
188
283
  try {
189
284
  const body = JSON.parse(data);
190
285
  const version = body.version || null;
191
286
  const changed = version !== _lastAllowlistVersion;
192
287
  if (changed) _lastAllowlistVersion = version;
193
- callback(null, changed);
194
- } catch (_e) { callback(null, false); }
288
+ done(null, changed);
289
+ } catch (_e) { done(null, false); }
195
290
  });
196
291
  });
197
292
 
198
- req.on('error', () => { callback(null, false); });
199
- req.on('timeout', () => { req.destroy(); callback(null, false); });
293
+ req.on('error', () => { _allowlistConsecutiveErrors++; _stats.errors++; maybeOpenCircuit(); done(null, false); });
294
+ req.on('timeout', () => { req.destroy(); _allowlistConsecutiveErrors++; _stats.errors++; maybeOpenCircuit(); done(null, false); });
200
295
  req.end();
201
296
  }
202
297
 
@@ -212,7 +307,8 @@ function doFullAllowlistSync() {
212
307
 
213
308
  function scheduleNextAllowlistVersionCheck() {
214
309
  const baseMs = (_options.versionCheckInterval || 10) * 1000;
215
- const delayMs = jitter(baseMs);
310
+ const backoffMs = Math.min(baseMs * Math.pow(2, _allowlistConsecutiveErrors), 120_000);
311
+ const delayMs = jitter(backoffMs);
216
312
 
217
313
  _allowlistVersionTimer = setTimeout(() => {
218
314
  checkAllowlistVersion((_err, changed) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "5.16.2",
3
+ "version": "5.16.3",
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",