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 +14 -1
- package/cli/auth.js +19 -4
- package/cli.js +1 -1
- package/firewall.js +115 -19
- package/package.json +1 -1
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
|
-
|
|
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://
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
67
77
|
const port = server.address().port;
|
|
68
|
-
const switchUrl = `${appUrl}/cli/auth?callback=http://
|
|
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://
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
done(null, true, _matcher.stats());
|
|
82
158
|
} catch (e) {
|
|
83
|
-
|
|
159
|
+
done(new Error(`Failed to parse blocklist response: ${e.message}`));
|
|
84
160
|
}
|
|
85
161
|
});
|
|
86
162
|
});
|
|
87
163
|
|
|
88
|
-
req.on('error', (err) =>
|
|
89
|
-
req.on('timeout', () => { req.destroy();
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
done(null, true, _allowlistMatcher.stats());
|
|
145
227
|
} catch (e) {
|
|
146
|
-
|
|
228
|
+
done(new Error(`Failed to parse allowlist response: ${e.message}`));
|
|
147
229
|
}
|
|
148
230
|
});
|
|
149
231
|
});
|
|
150
232
|
|
|
151
|
-
req.on('error', (err) =>
|
|
152
|
-
req.on('timeout', () => { req.destroy();
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
} catch (_e) {
|
|
288
|
+
done(null, changed);
|
|
289
|
+
} catch (_e) { done(null, false); }
|
|
195
290
|
});
|
|
196
291
|
});
|
|
197
292
|
|
|
198
|
-
req.on('error', () => {
|
|
199
|
-
req.on('timeout', () => { req.destroy();
|
|
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
|
|
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