securenow 5.16.1 → 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 +68 -14
- 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,20 @@ 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) => {
|
|
46
|
+
let pendingToken = null;
|
|
47
|
+
|
|
44
48
|
const server = http.createServer((req, res) => {
|
|
45
|
-
const url = new URL(req.url, `http://
|
|
49
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
46
50
|
|
|
47
51
|
if (url.pathname === '/callback') {
|
|
48
52
|
const token = url.searchParams.get('token');
|
|
49
53
|
const error = url.searchParams.get('error');
|
|
54
|
+
const returnedState = url.searchParams.get('state');
|
|
50
55
|
|
|
51
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
56
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
52
57
|
|
|
53
58
|
if (error) {
|
|
54
59
|
res.end('<html><body style="font-family:system-ui;text-align:center;padding:60px"><h2>Authentication Failed</h2><p>You can close this window.</p></body></html>');
|
|
@@ -57,19 +62,53 @@ async function loginWithBrowser() {
|
|
|
57
62
|
return;
|
|
58
63
|
}
|
|
59
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
|
+
|
|
60
72
|
if (token) {
|
|
73
|
+
pendingToken = token;
|
|
61
74
|
const payload = decodeJwtPayload(token);
|
|
62
|
-
const email = payload?.email || '';
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
const email = payload?.email || 'unknown account';
|
|
76
|
+
const safeEmail = email.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
77
|
+
const port = server.address().port;
|
|
78
|
+
const switchUrl = `${appUrl}/cli/auth?callback=http://127.0.0.1:${port}/callback&state=${encodeURIComponent(nonce)}&force_login=1`;
|
|
79
|
+
|
|
80
|
+
res.end([
|
|
81
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8"><title>SecureNow CLI Login</title></head>',
|
|
82
|
+
'<body style="font-family:system-ui,sans-serif;text-align:center;padding:60px;margin:0;background:#fafafa">',
|
|
83
|
+
'<div style="max-width:420px;margin:0 auto;background:#fff;border-radius:12px;padding:40px 32px;box-shadow:0 2px 12px rgba(0,0,0,.08)">',
|
|
84
|
+
'<div style="width:56px;height:56px;margin:0 auto 20px;background:#f0fdf4;border-radius:50%;display:flex;align-items:center;justify-content:center">',
|
|
85
|
+
'<svg width="28" height="28" fill="none" viewBox="0 0 24 24"><path d="M12 2a5 5 0 015 5v1a2 2 0 012 2v8a2 2 0 01-2 2H7a2 2 0 01-2-2v-8a2 2 0 012-2V7a5 5 0 015-5zm0 2a3 3 0 00-3 3v1h6V7a3 3 0 00-3-3z" fill="#22c55e"/></svg>',
|
|
86
|
+
'</div>',
|
|
87
|
+
'<h2 style="margin:0 0 8px;font-size:22px;color:#111">Connect to SecureNow CLI</h2>',
|
|
88
|
+
`<p style="margin:0 0 24px;color:#666;font-size:15px">You are signing in as</p>`,
|
|
89
|
+
`<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px 18px;margin:0 0 28px">`,
|
|
90
|
+
`<span style="font-size:17px;font-weight:600;color:#0f172a">${safeEmail}</span>`,
|
|
91
|
+
'</div>',
|
|
92
|
+
'<button id="confirm-btn" style="width:100%;padding:13px 24px;font-size:16px;font-weight:600;color:#fff;background:#22c55e;border:none;border-radius:8px;cursor:pointer;transition:background .15s" ',
|
|
93
|
+
'onmouseover="this.style.background=\'#16a34a\'" onmouseout="this.style.background=\'#22c55e\'">',
|
|
94
|
+
'Confirm & Continue</button>',
|
|
95
|
+
`<p style="margin:20px 0 0"><a href="${switchUrl}" style="color:#6366f1;font-size:14px;text-decoration:none" `,
|
|
96
|
+
'onmouseover="this.style.textDecoration=\'underline\'" onmouseout="this.style.textDecoration=\'none\'">Use a different account</a></p>',
|
|
97
|
+
'<div id="done-msg" style="display:none;margin-top:24px">',
|
|
98
|
+
'<p style="color:#22c55e;font-weight:600;font-size:17px">\u2713 Connected! You can close this window.</p>',
|
|
99
|
+
'</div>',
|
|
100
|
+
'</div>',
|
|
101
|
+
'<script>',
|
|
102
|
+
'document.getElementById("confirm-btn").addEventListener("click", function(){',
|
|
103
|
+
' this.disabled=true;this.textContent="Connecting\u2026";this.style.background="#86efac";this.style.cursor="default";',
|
|
104
|
+
` fetch("/confirm?nonce=${encodeURIComponent(nonce)}").then(function(){`,
|
|
105
|
+
' document.getElementById("confirm-btn").style.display="none";',
|
|
106
|
+
' document.getElementById("done-msg").style.display="block";',
|
|
107
|
+
' });',
|
|
108
|
+
'});',
|
|
109
|
+
'</script>',
|
|
110
|
+
'</body></html>',
|
|
111
|
+
].join(''));
|
|
73
112
|
return;
|
|
74
113
|
}
|
|
75
114
|
|
|
@@ -79,13 +118,28 @@ async function loginWithBrowser() {
|
|
|
79
118
|
return;
|
|
80
119
|
}
|
|
81
120
|
|
|
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
|
+
}
|
|
127
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
128
|
+
res.end('{"ok":true}');
|
|
129
|
+
const token = pendingToken;
|
|
130
|
+
pendingToken = null;
|
|
131
|
+
server.close();
|
|
132
|
+
resolve(token);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
82
136
|
res.writeHead(404);
|
|
83
137
|
res.end();
|
|
84
138
|
});
|
|
85
139
|
|
|
86
140
|
server.listen(0, '127.0.0.1', () => {
|
|
87
141
|
const port = server.address().port;
|
|
88
|
-
const authUrl = `${appUrl}/cli/auth?callback=http://
|
|
142
|
+
const authUrl = `${appUrl}/cli/auth?callback=http://127.0.0.1:${port}/callback&state=${encodeURIComponent(nonce)}`;
|
|
89
143
|
|
|
90
144
|
console.log('');
|
|
91
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