securenow 5.10.1 → 5.11.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.
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Layer 3: OS-level firewall via iptables/nftables.
5
+ * Manages a dedicated SECURENOW_BLOCK chain — never touches customer rules.
6
+ * Linux only, requires root or CAP_NET_ADMIN. Falls back gracefully otherwise.
7
+ */
8
+
9
+ const { execSync } = require('child_process');
10
+ const os = require('os');
11
+
12
+ const CHAIN_NAME = 'SECURENOW_BLOCK';
13
+
14
+ let _options = null;
15
+ let _active = false;
16
+ let _useNft = false;
17
+
18
+ function exec(cmd) {
19
+ return execSync(cmd, { stdio: 'pipe', timeout: 10000 }).toString().trim();
20
+ }
21
+
22
+ function canRun(cmd) {
23
+ try { exec(cmd); return true; } catch { return false; }
24
+ }
25
+
26
+ function detectBackend() {
27
+ if (canRun('nft --version')) return 'nft';
28
+ if (canRun('iptables --version')) return 'iptables';
29
+ return null;
30
+ }
31
+
32
+ // ────── iptables backend ──────
33
+
34
+ function iptablesSetup() {
35
+ try { exec(`iptables -N ${CHAIN_NAME}`); } catch (_) {} // chain may already exist
36
+ try { exec(`iptables -C INPUT -j ${CHAIN_NAME}`); } catch (_) {
37
+ exec(`iptables -I INPUT 1 -j ${CHAIN_NAME}`);
38
+ }
39
+ }
40
+
41
+ function iptablesSync(ips) {
42
+ exec(`iptables -F ${CHAIN_NAME}`);
43
+ for (const ip of ips) {
44
+ exec(`iptables -A ${CHAIN_NAME} -s ${ip} -j DROP`);
45
+ }
46
+ }
47
+
48
+ function iptablesCleanup() {
49
+ try { exec(`iptables -D INPUT -j ${CHAIN_NAME}`); } catch (_) {}
50
+ try { exec(`iptables -F ${CHAIN_NAME}`); } catch (_) {}
51
+ try { exec(`iptables -X ${CHAIN_NAME}`); } catch (_) {}
52
+ }
53
+
54
+ // ────── nftables backend ──────
55
+
56
+ function nftSetup() {
57
+ try { exec(`nft list chain ip filter ${CHAIN_NAME}`); } catch (_) {
58
+ exec(`nft add chain ip filter ${CHAIN_NAME}`);
59
+ }
60
+ try { exec(`nft insert rule ip filter INPUT jump ${CHAIN_NAME}`); } catch (_) {}
61
+ }
62
+
63
+ function nftSync(ips) {
64
+ exec(`nft flush chain ip filter ${CHAIN_NAME}`);
65
+ for (const ip of ips) {
66
+ exec(`nft add rule ip filter ${CHAIN_NAME} ip saddr ${ip} drop`);
67
+ }
68
+ }
69
+
70
+ function nftCleanup() {
71
+ try { exec(`nft flush chain ip filter ${CHAIN_NAME}`); } catch (_) {}
72
+ try { exec(`nft delete chain ip filter ${CHAIN_NAME}`); } catch (_) {}
73
+ }
74
+
75
+ // ────── Public API ──────
76
+
77
+ let _syncCallback = null;
78
+
79
+ function init(options) {
80
+ _options = options;
81
+
82
+ if (os.platform() !== 'linux') {
83
+ if (_options.log) console.warn('[securenow] Firewall iptables: only supported on Linux, skipping');
84
+ return;
85
+ }
86
+
87
+ const backend = detectBackend();
88
+ if (!backend) {
89
+ if (_options.log) console.warn('[securenow] Firewall iptables: neither iptables nor nft found, skipping');
90
+ return;
91
+ }
92
+
93
+ _useNft = backend === 'nft';
94
+ const label = _useNft ? 'nftables' : 'iptables';
95
+
96
+ try {
97
+ if (_useNft) nftSetup(); else iptablesSetup();
98
+ _active = true;
99
+ if (_options.log) console.log('[securenow] Firewall iptables: %s chain %s ready', label, CHAIN_NAME);
100
+ } catch (e) {
101
+ if (_options.log) console.warn('[securenow] Firewall iptables: setup failed (need root or CAP_NET_ADMIN):', e.message);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Called by the firewall core after each successful blocklist sync.
107
+ * @param {string[]} ips - Array of IPs and CIDRs to block
108
+ */
109
+ function sync(ips) {
110
+ if (!_active) return;
111
+
112
+ const maxRules = (_options && _options.iptablesMax) || 10000;
113
+ const limited = ips.length > maxRules ? ips.slice(0, maxRules) : ips;
114
+
115
+ if (ips.length > maxRules && _options.log) {
116
+ console.warn('[securenow] Firewall iptables: truncated to %d rules (max %d)', maxRules, maxRules);
117
+ }
118
+
119
+ try {
120
+ if (_useNft) nftSync(limited); else iptablesSync(limited);
121
+ } catch (e) {
122
+ if (_options && _options.log) {
123
+ console.warn('[securenow] Firewall iptables: sync failed:', e.message);
124
+ }
125
+ }
126
+ }
127
+
128
+ function shutdown() {
129
+ if (!_active) return;
130
+ try {
131
+ if (_useNft) nftCleanup(); else iptablesCleanup();
132
+ if (_options && _options.log) console.log('[securenow] Firewall iptables: chain %s cleaned up', CHAIN_NAME);
133
+ } catch (e) {
134
+ if (_options && _options.log) console.warn('[securenow] Firewall iptables: cleanup failed:', e.message);
135
+ }
136
+ _active = false;
137
+ }
138
+
139
+ module.exports = { init, sync, shutdown };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Layer 2: TCP-level blocking via net.Server connection event.
5
+ * Destroys the socket before HTTP parsing starts. Zero bytes sent back.
6
+ *
7
+ * Caveat: only sees socket.remoteAddress (direct connection IP).
8
+ * If behind a reverse proxy, the proxy IP is seen instead.
9
+ * Proxy IPs are skipped (let through to Layer 1 for proper header-based resolution).
10
+ */
11
+
12
+ const net = require('net');
13
+ const { resolveSocketIp, isFromTrustedProxy } = require('./resolve-ip');
14
+
15
+ let _getMatcher = null;
16
+ let _options = null;
17
+ let _patched = false;
18
+ const _origListen = net.Server.prototype.listen;
19
+
20
+ function onConnection(socket) {
21
+ const matcher = _getMatcher();
22
+ if (!matcher) return;
23
+
24
+ const ip = resolveSocketIp(socket);
25
+
26
+ // Skip if the connection is from a trusted proxy — Layer 1 will handle it
27
+ // with proper X-Forwarded-For resolution
28
+ if (isFromTrustedProxy(ip) || isFromTrustedProxy('::ffff:' + ip)) return;
29
+
30
+ if (matcher.isBlocked(ip)) {
31
+ if (_options && _options.log) {
32
+ console.log('[securenow] Firewall: blocked %s via TCP (socket destroyed)', ip);
33
+ }
34
+ socket.destroy();
35
+ }
36
+ }
37
+
38
+ function init(getMatcher, options) {
39
+ _getMatcher = getMatcher;
40
+ _options = options;
41
+
42
+ if (_patched) return;
43
+ _patched = true;
44
+
45
+ net.Server.prototype.listen = function(...args) {
46
+ this.on('connection', onConnection);
47
+ return _origListen.apply(this, args);
48
+ };
49
+ }
50
+
51
+ function shutdown() {
52
+ if (_patched) {
53
+ net.Server.prototype.listen = _origListen;
54
+ _patched = false;
55
+ }
56
+ }
57
+
58
+ module.exports = { init, shutdown };
package/firewall.js ADDED
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const { createMatcher } = require('./cidr');
6
+ const { resolveClientIp } = require('./resolve-ip');
7
+
8
+ let _options = null;
9
+ let _matcher = null;
10
+ let _syncTimer = null;
11
+ let _lastModified = null;
12
+ let _initialized = false;
13
+ let _layers = [];
14
+ let _rawIps = [];
15
+ let _stats = { syncs: 0, blocked: 0, allowed: 0 };
16
+
17
+ // ────── Blocklist Sync ──────
18
+
19
+ function buildUrl(apiUrl, path) {
20
+ return apiUrl.replace(/\/+$/, '') + '/api/v1' + path;
21
+ }
22
+
23
+ function syncBlocklist(callback) {
24
+ const url = buildUrl(_options.apiUrl, '/firewall/blocklist');
25
+ const mod = url.startsWith('https') ? https : http;
26
+ const parsed = new URL(url);
27
+
28
+ const reqOptions = {
29
+ hostname: parsed.hostname,
30
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
31
+ path: parsed.pathname + parsed.search,
32
+ method: 'GET',
33
+ headers: {
34
+ 'Authorization': `Bearer ${_options.apiKey}`,
35
+ 'User-Agent': 'securenow-firewall-sdk',
36
+ },
37
+ timeout: 10000,
38
+ };
39
+
40
+ if (_lastModified) {
41
+ reqOptions.headers['If-Modified-Since'] = _lastModified;
42
+ }
43
+
44
+ const req = mod.request(reqOptions, (res) => {
45
+ if (res.statusCode === 304) {
46
+ callback(null, false);
47
+ return;
48
+ }
49
+
50
+ let data = '';
51
+ res.on('data', (chunk) => { data += chunk; });
52
+ res.on('end', () => {
53
+ if (res.statusCode !== 200) {
54
+ callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
55
+ return;
56
+ }
57
+ try {
58
+ const body = JSON.parse(data);
59
+ const ips = body.ips || [];
60
+ _rawIps = ips;
61
+ _matcher = createMatcher(ips);
62
+ _lastModified = res.headers['last-modified'] || null;
63
+ _stats.syncs++;
64
+ notifyLayers(ips);
65
+ callback(null, true, _matcher.stats());
66
+ } catch (e) {
67
+ callback(new Error(`Failed to parse blocklist response: ${e.message}`));
68
+ }
69
+ });
70
+ });
71
+
72
+ req.on('error', (err) => callback(err));
73
+ req.on('timeout', () => { req.destroy(); callback(new Error('Sync request timed out')); });
74
+ req.end();
75
+ }
76
+
77
+ function notifyLayers(ips) {
78
+ for (const layer of _layers) {
79
+ try { if (typeof layer.sync === 'function') layer.sync(ips); } catch (_) {}
80
+ }
81
+ }
82
+
83
+ function startSyncLoop() {
84
+ const intervalMs = (_options.syncInterval || 60) * 1000;
85
+
86
+ syncBlocklist((err, changed, stats) => {
87
+ if (err) {
88
+ if (_options.log) console.warn('[securenow] Firewall: initial sync failed:', err.message);
89
+ if (_options.failMode === 'closed') {
90
+ _matcher = createMatcher([]); // block everything by default via empty matcher
91
+ _matcher = { isBlocked: () => true, stats: () => ({ exact: 0, cidr: 0, total: 0 }) };
92
+ }
93
+ } else if (changed && stats) {
94
+ if (_options.log) console.log('[securenow] Firewall: synced %d blocked IPs (%d exact + %d CIDR ranges)', stats.total, stats.exact, stats.cidr);
95
+ }
96
+ _initialized = true;
97
+ });
98
+
99
+ _syncTimer = setInterval(() => {
100
+ syncBlocklist((err, changed, stats) => {
101
+ if (err) {
102
+ if (_options.log) console.warn('[securenow] Firewall: sync failed (using stale list):', err.message);
103
+ } else if (changed && stats && _options.log) {
104
+ console.log('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
105
+ }
106
+ });
107
+ }, intervalMs);
108
+
109
+ if (_syncTimer.unref) _syncTimer.unref();
110
+ }
111
+
112
+ // ────── Layer 1: HTTP Handler ──────
113
+
114
+ const _origHttpCreate = http.createServer;
115
+ const _origHttpsCreate = https.createServer;
116
+ let _httpPatched = false;
117
+
118
+ function wrapListener(originalListener) {
119
+ return function firewallGuard(req, res) {
120
+ if (_matcher) {
121
+ const ip = resolveClientIp(req);
122
+ if (_matcher.isBlocked(ip)) {
123
+ _stats.blocked++;
124
+ if (_options.log) console.log('[securenow] Firewall: blocked %s via HTTP', ip);
125
+ const code = _options.statusCode || 403;
126
+ res.writeHead(code, { 'Content-Type': 'application/json' });
127
+ res.end(JSON.stringify({ error: 'Forbidden' }));
128
+ return;
129
+ }
130
+ }
131
+ _stats.allowed++;
132
+ return originalListener.call(this, req, res);
133
+ };
134
+ }
135
+
136
+ function patchHttpLayer() {
137
+ if (_httpPatched) return;
138
+ _httpPatched = true;
139
+
140
+ http.createServer = function(...args) {
141
+ if (typeof args[args.length - 1] === 'function') {
142
+ args[args.length - 1] = wrapListener(args[args.length - 1]);
143
+ }
144
+ return _origHttpCreate.apply(this, args);
145
+ };
146
+ Object.assign(http.createServer, _origHttpCreate);
147
+
148
+ https.createServer = function(...args) {
149
+ if (typeof args[args.length - 1] === 'function') {
150
+ args[args.length - 1] = wrapListener(args[args.length - 1]);
151
+ }
152
+ return _origHttpsCreate.apply(this, args);
153
+ };
154
+ Object.assign(https.createServer, _origHttpsCreate);
155
+ }
156
+
157
+ // ────── Init ──────
158
+
159
+ function init(options) {
160
+ _options = options;
161
+
162
+ if (_options.log) console.log('[securenow] Firewall: ENABLED');
163
+
164
+ // Layer 1: HTTP (always on)
165
+ patchHttpLayer();
166
+ if (_options.log) console.log('[securenow] Firewall: Layer 1 (HTTP 403) active');
167
+
168
+ // Layer 2: TCP
169
+ if (_options.tcp) {
170
+ try {
171
+ const tcpLayer = require('./firewall-tcp');
172
+ tcpLayer.init(() => _matcher, _options);
173
+ _layers.push(tcpLayer);
174
+ if (_options.log) console.log('[securenow] Firewall: Layer 2 (TCP drop) active');
175
+ } catch (e) {
176
+ if (_options.log) console.warn('[securenow] Firewall: Layer 2 (TCP drop) failed:', e.message);
177
+ }
178
+ } else {
179
+ if (_options.log) console.log('[securenow] Firewall: Layer 2 (TCP drop) disabled (set SECURENOW_FIREWALL_TCP=1)');
180
+ }
181
+
182
+ // Layer 3: iptables
183
+ if (_options.iptables) {
184
+ try {
185
+ const iptablesLayer = require('./firewall-iptables');
186
+ iptablesLayer.init(_options);
187
+ _layers.push(iptablesLayer);
188
+ if (_options.log) console.log('[securenow] Firewall: Layer 3 (iptables) active');
189
+ } catch (e) {
190
+ if (_options.log) console.warn('[securenow] Firewall: Layer 3 (iptables) failed:', e.message);
191
+ }
192
+ } else {
193
+ if (_options.log) console.log('[securenow] Firewall: Layer 3 (iptables) disabled (set SECURENOW_FIREWALL_IPTABLES=1)');
194
+ }
195
+
196
+ // Layer 4: Cloud WAF
197
+ if (_options.cloud) {
198
+ try {
199
+ const cloudLayer = require('./firewall-cloud');
200
+ cloudLayer.init(_options);
201
+ _layers.push(cloudLayer);
202
+ if (_options.log) console.log('[securenow] Firewall: Layer 4 (Cloud WAF) active (%s)', _options.cloud);
203
+ } catch (e) {
204
+ if (_options.log) console.warn('[securenow] Firewall: Layer 4 (Cloud WAF) failed:', e.message);
205
+ }
206
+ } else {
207
+ if (_options.log) console.log('[securenow] Firewall: Layer 4 (Cloud WAF) disabled (set SECURENOW_FIREWALL_CLOUD=cloudflare|aws|gcp)');
208
+ }
209
+
210
+ startSyncLoop();
211
+ }
212
+
213
+ function shutdown() {
214
+ if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
215
+
216
+ for (const layer of _layers) {
217
+ try { if (typeof layer.shutdown === 'function') layer.shutdown(); } catch (_) {}
218
+ }
219
+ _layers = [];
220
+
221
+ // Restore original createServer
222
+ if (_httpPatched) {
223
+ http.createServer = _origHttpCreate;
224
+ https.createServer = _origHttpsCreate;
225
+ _httpPatched = false;
226
+ }
227
+ }
228
+
229
+ function getStats() {
230
+ return { ..._stats, matcher: _matcher ? _matcher.stats() : null, initialized: _initialized };
231
+ }
232
+
233
+ function getMatcher() { return _matcher; }
234
+
235
+ module.exports = { init, shutdown, getStats, getMatcher };