securenow 8.23.0 → 8.25.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/cli/utils.js CHANGED
@@ -89,24 +89,36 @@ function cidrParse(args, flags) {
89
89
  }
90
90
 
91
91
  const parsed = cidrLib.parseCidr(cidr);
92
- if (!parsed) {
92
+ const parsed6 = parsed ? null : cidrLib.parseCidr6(cidr);
93
+ if (!parsed && !parsed6) {
93
94
  ui.error(`Invalid CIDR: ${cidr}`);
94
95
  process.exit(1);
95
96
  }
96
97
 
97
- const intToIp = (n) => [
98
- (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff,
99
- ].join('.');
100
- const broadcast = (parsed.network | (~parsed.mask >>> 0)) >>> 0;
101
- const size = (~parsed.mask >>> 0) + 1;
102
-
103
- const out = {
104
- cidr,
105
- network: intToIp(parsed.network),
106
- broadcast: intToIp(broadcast),
107
- mask: intToIp(parsed.mask),
108
- size,
109
- };
98
+ let out;
99
+ if (parsed) {
100
+ const { intToIp } = cidrLib;
101
+ const broadcast = (parsed.network | (~parsed.mask >>> 0)) >>> 0;
102
+ const size = (~parsed.mask >>> 0) + 1;
103
+ out = {
104
+ cidr,
105
+ network: intToIp(parsed.network),
106
+ broadcast: intToIp(broadcast),
107
+ mask: intToIp(parsed.mask),
108
+ size,
109
+ };
110
+ } else {
111
+ // IPv6 has no broadcast address: "broadcast" is the last address of the range.
112
+ const hostBits = 128n - BigInt(parsed6.bits);
113
+ const last = parsed6.network | ((1n << hostBits) - 1n);
114
+ out = {
115
+ cidr,
116
+ network: cidrLib.bigIntToIpv6(parsed6.network),
117
+ broadcast: cidrLib.bigIntToIpv6(last),
118
+ mask: `/${parsed6.bits}`,
119
+ size: (1n << hostBits).toString(),
120
+ };
121
+ }
110
122
 
111
123
  if (flags.json) {
112
124
  ui.json(out);
package/cli.js CHANGED
@@ -48,11 +48,13 @@ const COMMANDS = {
48
48
  environment: 'Alias for --env',
49
49
  key: 'Firewall API key to write to .securenow/runtime.json',
50
50
  'api-key': 'Alias for --key',
51
- typescript: 'Force TypeScript',
52
- javascript: 'Force JavaScript',
53
- src: 'Create in src/',
54
- root: 'Create in root',
55
- force: 'Overwrite existing',
51
+ ts: 'Next.js: write instrumentation.ts (default: TypeScript when tsconfig.json exists)',
52
+ typescript: 'Alias for --ts',
53
+ js: 'Next.js: write instrumentation.js',
54
+ javascript: 'Alias for --js',
55
+ src: 'Next.js: write the instrumentation file in src/ (default when app/ or pages/ is in src/)',
56
+ root: 'Next.js: write the instrumentation file at the project root',
57
+ force: 'Next.js: replace an existing instrumentation file (the old one is kept as .bak)',
56
58
  },
57
59
  run: (a, f) => require('./cli/init').init(a, f),
58
60
  },
@@ -152,13 +154,14 @@ const COMMANDS = {
152
154
  sub: {
153
155
  runtime: {
154
156
  desc: 'Write tokenless runtime credentials for production file-based deploys',
155
- usage: 'securenow credentials runtime [--env production] [--out .securenow/credentials.production.json] [--stdout]',
157
+ usage: 'securenow credentials runtime [--env production] [--out .securenow/credentials.production.json] [--stdout] [--format env]',
156
158
  flags: {
157
159
  env: 'Deployment environment for this runtime file (default: production)',
158
160
  environment: 'Alias for --env',
159
161
  out: 'Output path (default: .securenow/credentials.<env>.json)',
160
162
  output: 'Alias for --out',
161
163
  stdout: 'Print JSON to stdout instead of writing a file',
164
+ format: 'env: print SECURENOW_CREDENTIALS=<base64> for hosts without file mounts (Vercel, Railway, Render, Heroku, Fly)',
162
165
  },
163
166
  run: (a, f) => require('./cli/credentials').runtime(a, f),
164
167
  },
@@ -21,6 +21,12 @@
21
21
  * console.error('An error occurred');
22
22
  */
23
23
 
24
+ // SECURENOW_DISABLED=1: nothing to forward to, and no warnings about it.
25
+ if (require('./sdk-disabled').sdkDisabled('console instrumentation')) {
26
+ module.exports = {};
27
+ return;
28
+ }
29
+
24
30
  const tracing = require('./tracing');
25
31
 
26
32
  if (!tracing.isLoggingEnabled()) {
package/crash-capture.js CHANGED
@@ -122,4 +122,16 @@ function installCrashCapture({ getLogger, flush, isSdkError, onSdkError, write }
122
122
  });
123
123
  }
124
124
 
125
- module.exports = { installCrashCapture, describeError, unhandledRejectionMode };
125
+ // Force-flush the global tracer provider. A crash used to ship the FATAL log but drop every
126
+ // span still waiting in the batch processor, which are usually the requests that led to it.
127
+ function flushTracerProvider() {
128
+ try {
129
+ const { trace } = require('@opentelemetry/api');
130
+ const provider = trace.getTracerProvider();
131
+ const target = provider && typeof provider.getDelegate === 'function' ? provider.getDelegate() : provider;
132
+ if (target && typeof target.forceFlush === 'function') return Promise.resolve(target.forceFlush()).catch(() => {});
133
+ } catch (_) {}
134
+ return Promise.resolve();
135
+ }
136
+
137
+ module.exports = { installCrashCapture, describeError, unhandledRejectionMode, flushTracerProvider };
package/esm-detect.js CHANGED
@@ -38,7 +38,11 @@ function nearestPackageJson(startDir) {
38
38
 
39
39
  /** @returns {{ esm: boolean, reason: string|null }} */
40
40
  function detectEsmApp({ argv = process.argv, cwd = process.cwd(), env = process.env } = {}) {
41
- const entries = [argv && argv[1], env && env.pm_exec_path]
41
+ // A pm2 cluster worker only has pm2_env (JSON) at preload time, and its working directory is
42
+ // still the pm2 daemon's: take the app's folder and script from pm2 (see pm2-env.js).
43
+ const pm2 = require('./pm2-env').readPm2Env(env);
44
+ if (pm2 && pm2.cwd) cwd = pm2.cwd;
45
+ const entries = [argv && argv[1], pm2 && pm2.execPath]
42
46
  .filter((p) => typeof p === 'string' && p && !p.startsWith('-'))
43
47
  .map((p) => path.resolve(cwd, p));
44
48
 
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Fastify route and error capture.
5
+ *
6
+ * The OpenTelemetry bundle dropped its Fastify instrumentation (moved to @fastify/otel), so a
7
+ * Fastify request span had no http.route and a failing handler left no exception event. Every
8
+ * Fastify instance (4.x and 5.x) is announced on the 'fastify.initialization' diagnostics
9
+ * channel; these hooks give it what the Express, Koa and Hapi instrumentations give theirs:
10
+ * - http.route on the request's server span, and the "GET /users/:id" span name, through the
11
+ * request's RPC metadata (the HTTP instrumentation reads it when the response finishes);
12
+ * - the handler's error recorded as an exception event on that span.
13
+ */
14
+
15
+ let installed = false;
16
+
17
+ function routeOf(request) {
18
+ try {
19
+ return (request.routeOptions && request.routeOptions.url) || request.routerPath || null;
20
+ } catch (_) {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function onInstance(message) {
26
+ const fastify = message && message.fastify;
27
+ if (!fastify || typeof fastify.addHook !== 'function') return;
28
+ let api;
29
+ let core;
30
+ try {
31
+ api = require('@opentelemetry/api');
32
+ core = require('@opentelemetry/core');
33
+ } catch (_) {
34
+ return;
35
+ }
36
+
37
+ fastify.addHook('onRequest', function securenowRoute(request, reply, done) {
38
+ try {
39
+ const route = routeOf(request);
40
+ if (route) {
41
+ const meta = core.getRPCMetadata(api.context.active());
42
+ if (meta && meta.type === core.RPCType.HTTP) meta.route = route;
43
+ const span = api.trace.getActiveSpan();
44
+ if (span) span.setAttribute('http.route', route);
45
+ }
46
+ } catch (_) {}
47
+ done();
48
+ });
49
+
50
+ fastify.addHook('onError', function securenowError(request, reply, error, done) {
51
+ try {
52
+ const span = api.trace.getActiveSpan();
53
+ if (span && error) span.recordException(error);
54
+ } catch (_) {}
55
+ done();
56
+ });
57
+ }
58
+
59
+ function installFastifyHooks() {
60
+ if (installed) return;
61
+ installed = true;
62
+ try {
63
+ require('node:diagnostics_channel').subscribe('fastify.initialization', onInstance);
64
+ } catch (_) {}
65
+ }
66
+
67
+ module.exports = { installFastifyHooks, routeOf };
package/firewall-cloud.js CHANGED
@@ -11,14 +11,44 @@
11
11
  */
12
12
 
13
13
  const https = require('https');
14
+ const net = require('net');
14
15
 
15
16
  let _options = null;
16
17
  let _active = false;
17
18
  let _provider = null;
18
19
  let _lastPushTime = 0;
20
+ let _gcpRuleCount = 0;
21
+ const GCP_BASE_PRIORITY = 2000;
22
+ const GCP_RANGES_PER_RULE = 10;
23
+ const GCP_MAX_RULES = 100;
19
24
 
20
25
  const MIN_PUSH_INTERVAL_MS = 30000;
21
26
 
27
+ // Blocklist entries as CIDRs, per address family: an exact address becomes /32 (IPv4) or /128 (IPv6).
28
+ // (Appending "/32" to everything turned one IPv6 address into a /32 covering billions of addresses.)
29
+ function toCidrs(ips) {
30
+ const v4 = [];
31
+ const v6 = [];
32
+ for (const raw of ips || []) {
33
+ const entry = String(raw || '').trim();
34
+ if (!entry) continue;
35
+ const slash = entry.indexOf('/');
36
+ const addr = slash === -1 ? entry : entry.slice(0, slash);
37
+ const family = net.isIP(addr);
38
+ if (family === 4) v4.push(slash === -1 ? `${addr}/32` : entry);
39
+ else if (family === 6) v6.push(slash === -1 ? `${addr}/128` : entry);
40
+ }
41
+ return { v4, v6 };
42
+ }
43
+
44
+ function prefixLength(cidr) {
45
+ return Number(String(cidr).split('/')[1]);
46
+ }
47
+
48
+ function logCloud(...args) {
49
+ if (_options && _options.log) console.log(...args);
50
+ }
51
+
22
52
  // ────── Cloudflare ──────
23
53
 
24
54
  async function cloudflareSync(ips) {
@@ -40,7 +70,14 @@ async function cloudflareSync(ips) {
40
70
  list = created.result;
41
71
  }
42
72
 
43
- const items = ips.map(ip => ({ ip: ip.includes('/') ? ip : ip + '/32' }));
73
+ // Cloudflare lists take IPv6 ranges of /64 or wider, not single IPv6 addresses: those stay blocked by
74
+ // layer 1 in the app.
75
+ const { v4, v6 } = toCidrs(ips);
76
+ const v6Ranges = v6.filter((c) => prefixLength(c) <= 64);
77
+ if (v6Ranges.length < v6.length) {
78
+ logCloud('[securenow] Firewall cloud: %d IPv6 entries narrower than /64 not pushed to Cloudflare (blocked in the app)', v6.length - v6Ranges.length);
79
+ }
80
+ const items = [...v4, ...v6Ranges].map((ip) => ({ ip }));
44
81
 
45
82
  await cfApi('PUT', `/accounts/${accountId}/rules/lists/${list.id}/items`, token, items);
46
83
  }
@@ -95,7 +132,12 @@ async function awsSync(ips) {
95
132
  Id: ipSetId,
96
133
  }));
97
134
 
98
- const addresses = ips.map(ip => ip.includes('/') ? ip : ip + '/32');
135
+ // An IP set holds one address family: push the entries of the set's family.
136
+ const { v4, v6 } = toCidrs(ips);
137
+ const ipv6Set = IPSet && IPSet.IPAddressVersion === 'IPV6';
138
+ const addresses = ipv6Set ? v6 : v4;
139
+ const other = ipv6Set ? v4.length : v6.length;
140
+ if (other) logCloud('[securenow] Firewall cloud: %d %s entries not pushed to this %s IP set (blocked in the app)', other, ipv6Set ? 'IPv4' : 'IPv6', ipv6Set ? 'IPv6' : 'IPv4');
99
141
 
100
142
  await client.send(new WAFV2.UpdateIPSetCommand({
101
143
  Name: ipSetName,
@@ -122,38 +164,45 @@ async function gcpSync(ips) {
122
164
  if (!project || !policyName) throw new Error('Missing GCP_PROJECT_ID or GCP_SECURITY_POLICY');
123
165
 
124
166
  const client = new compute.SecurityPoliciesClient();
125
- const rulePriority = 2000;
126
-
127
- const srcRanges = ips.map(ip => ip.includes('/') ? ip : ip + '/32');
167
+ // A Cloud Armor rule matches at most 10 IP ranges, so the blocklist is spread over consecutive
168
+ // rules from priority 2000, and rules left over from a longer blocklist are removed. (One rule with
169
+ // every address was refused as soon as the blocklist passed 10 entries.)
170
+ const { v4, v6 } = toCidrs(ips);
171
+ const ranges = [...v4, ...v6].slice(0, GCP_MAX_RULES * GCP_RANGES_PER_RULE);
172
+ if (ranges.length < v4.length + v6.length) {
173
+ logCloud('[securenow] Firewall cloud: Cloud Armor gets the first %d blocklist entries (%d rules of %d); the rest are blocked in the app', ranges.length, GCP_MAX_RULES, GCP_RANGES_PER_RULE);
174
+ }
175
+ const chunks = [];
176
+ for (let i = 0; i < ranges.length; i += GCP_RANGES_PER_RULE) chunks.push(ranges.slice(i, i + GCP_RANGES_PER_RULE));
177
+
178
+ for (let i = 0; i < chunks.length; i++) {
179
+ const priority = GCP_BASE_PRIORITY + i;
180
+ const securityPolicyRuleResource = {
181
+ priority,
182
+ action: 'deny(403)',
183
+ match: { versionedExpr: 'SRC_IPS_V1', config: { srcIpRanges: chunks[i] } },
184
+ description: 'Managed by SecureNow firewall SDK',
185
+ };
186
+ try {
187
+ await client.patchRule({ project, securityPolicy: policyName, priority: String(priority), securityPolicyRuleResource });
188
+ } catch (e) {
189
+ if (e.code === 404 || e.code === 5 || /not found|does not exist/i.test(e.message || '')) {
190
+ await client.addRule({ project, securityPolicy: policyName, securityPolicyRuleResource });
191
+ } else {
192
+ throw e;
193
+ }
194
+ }
195
+ }
128
196
 
129
- try {
130
- await client.patchRule({
131
- project,
132
- securityPolicy: policyName,
133
- priority: String(rulePriority),
134
- securityPolicyRuleResource: {
135
- priority: rulePriority,
136
- action: 'deny(403)',
137
- match: { versionedExpr: 'SRC_IPS_V1', config: { srcIpRanges: srcRanges } },
138
- description: 'Managed by SecureNow firewall SDK',
139
- },
140
- });
141
- } catch (e) {
142
- if (e.code === 404 || e.message?.includes('not found')) {
143
- await client.addRule({
144
- project,
145
- securityPolicy: policyName,
146
- securityPolicyRuleResource: {
147
- priority: rulePriority,
148
- action: 'deny(403)',
149
- match: { versionedExpr: 'SRC_IPS_V1', config: { srcIpRanges: srcRanges } },
150
- description: 'Managed by SecureNow firewall SDK',
151
- },
152
- });
153
- } else {
154
- throw e;
197
+ const previous = Math.max(_gcpRuleCount, 1);
198
+ for (let i = chunks.length; i < previous; i++) {
199
+ try {
200
+ await client.removeRule({ project, securityPolicy: policyName, priority: String(GCP_BASE_PRIORITY + i) });
201
+ } catch (e) {
202
+ if (!(e.code === 404 || e.code === 5 || /not found|does not exist/i.test(e.message || ''))) throw e;
155
203
  }
156
204
  }
205
+ _gcpRuleCount = chunks.length;
157
206
  }
158
207
 
159
208
  // ────── Provider dispatch ──────
@@ -209,4 +258,4 @@ function shutdown() {
209
258
  _active = false;
210
259
  }
211
260
 
212
- module.exports = { init, sync, shutdown };
261
+ module.exports = { init, sync, shutdown, _test: { toCidrs, pushNow: (ips) => PROVIDERS[_provider](ips) } };
@@ -1,104 +1,162 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Layer 3: OS-level firewall via iptables/nftables.
5
- * Manages a dedicated SECURENOW_BLOCK chain — never touches customer rules.
4
+ * Layer 3: OS-level firewall via iptables (IPv4), ip6tables (IPv6) or, where neither exists, nftables.
5
+ * Manages its own chain (SECURENOW_BLOCK), or with nft its own table: never touches customer rules.
6
6
  * Linux only, requires root or CAP_NET_ADMIN. Falls back gracefully otherwise.
7
+ *
8
+ * Blocklist entries arrive over the network, so each one must be a literal IP or CIDR before it reaches
9
+ * a firewall command, and the binaries run without a shell. (Entries used to be pasted into a shell
10
+ * command run as root, and one IPv6 entry stopped the rest of the sync.) A sync is one batch
11
+ * (iptables-restore / nft -f), not one process per address.
7
12
  */
8
13
 
9
- const { execSync } = require('child_process');
14
+ const { execFileSync } = require('child_process');
15
+ const net = require('net');
10
16
  const os = require('os');
11
17
 
12
18
  const CHAIN_NAME = 'SECURENOW_BLOCK';
19
+ const NFT_TABLE = 'securenow';
13
20
 
14
21
  let _options = null;
15
22
  let _active = false;
16
- let _useNft = false;
23
+ let _backend = null; // 'iptables' | 'nft'
24
+ let _ip6 = false; // ip6tables available alongside iptables
17
25
 
18
- function exec(cmd) {
19
- return execSync(cmd, { stdio: 'pipe', timeout: 10000 }).toString().trim();
26
+ function run(cmd, args, input) {
27
+ return execFileSync(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000, input }).toString().trim();
20
28
  }
21
29
 
22
- function canRun(cmd) {
23
- try { exec(cmd); return true; } catch { return false; }
30
+ function canRun(cmd, args) {
31
+ try { run(cmd, args); return true; } catch { return false; }
32
+ }
33
+
34
+ function log(method, ...args) {
35
+ if (_options && _options.log) console[method](...args);
36
+ }
37
+
38
+ // A literal IPv4/IPv6 address or CIDR, or null.
39
+ function parseEntry(raw) {
40
+ const entry = String(raw == null ? '' : raw).trim();
41
+ const slash = entry.indexOf('/');
42
+ const addr = slash === -1 ? entry : entry.slice(0, slash);
43
+ const family = net.isIP(addr);
44
+ if (!family) return null;
45
+ if (slash !== -1) {
46
+ const bits = entry.slice(slash + 1);
47
+ if (!/^\d{1,3}$/.test(bits) || Number(bits) > (family === 4 ? 32 : 128)) return null;
48
+ }
49
+ return { family, value: entry };
50
+ }
51
+
52
+ function splitFamilies(ips) {
53
+ const v4 = [];
54
+ const v6 = [];
55
+ let rejected = 0;
56
+ for (const raw of ips || []) {
57
+ const parsed = parseEntry(raw);
58
+ if (!parsed) { rejected++; continue; }
59
+ (parsed.family === 4 ? v4 : v6).push(parsed.value);
60
+ }
61
+ return { v4, v6, rejected };
24
62
  }
25
63
 
26
64
  function detectBackend() {
27
- if (canRun('nft --version')) return 'nft';
28
- if (canRun('iptables --version')) return 'iptables';
65
+ // iptables first: iptables-legacy and iptables-nft both accept it, while bare nft rules depend on
66
+ // tables the host may not have.
67
+ if (canRun('iptables', ['--version'])) return 'iptables';
68
+ if (canRun('nft', ['--version'])) return 'nft';
29
69
  return null;
30
70
  }
31
71
 
32
- // ────── iptables backend ──────
72
+ // ────── iptables / ip6tables backend ──────
33
73
 
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}`);
74
+ function iptablesSetup(bin) {
75
+ try { run(bin, ['-N', CHAIN_NAME]); } catch (_) {} // chain may already exist
76
+ try { run(bin, ['-C', 'INPUT', '-j', CHAIN_NAME]); } catch (_) {
77
+ run(bin, ['-I', 'INPUT', '1', '-j', CHAIN_NAME]);
38
78
  }
39
79
  }
40
80
 
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
- }
81
+ // Input for iptables-restore --noflush: replaces the chain's rules in one step, leaves everything else.
82
+ function restoreScript(entries) {
83
+ return [
84
+ '*filter',
85
+ `:${CHAIN_NAME} - [0:0]`,
86
+ `-F ${CHAIN_NAME}`,
87
+ ...entries.map((entry) => `-A ${CHAIN_NAME} -s ${entry} -j DROP`),
88
+ 'COMMIT',
89
+ '',
90
+ ].join('\n');
46
91
  }
47
92
 
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 (_) {}
93
+ function iptablesSync(v4, v6) {
94
+ run('iptables-restore', ['--noflush'], restoreScript(v4));
95
+ if (_ip6) run('ip6tables-restore', ['--noflush'], restoreScript(v6));
52
96
  }
53
97
 
54
- // ────── nftables backend ──────
98
+ function iptablesCleanup(bin) {
99
+ try { run(bin, ['-D', 'INPUT', '-j', CHAIN_NAME]); } catch (_) {}
100
+ try { run(bin, ['-F', CHAIN_NAME]); } catch (_) {}
101
+ try { run(bin, ['-X', CHAIN_NAME]); } catch (_) {}
102
+ }
55
103
 
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 (_) {}
104
+ // ────── nftables backend (own table, own input hook) ──────
105
+
106
+ // One nft -f transaction: make sure the table exists, drop it, add it back with the current rules.
107
+ function nftScript(v4, v6) {
108
+ return [
109
+ `table inet ${NFT_TABLE}`,
110
+ `delete table inet ${NFT_TABLE}`,
111
+ `table inet ${NFT_TABLE} {`,
112
+ ' chain input {',
113
+ ' type filter hook input priority -10; policy accept;',
114
+ ...v4.map((entry) => ` ip saddr ${entry} drop`),
115
+ ...v6.map((entry) => ` ip6 saddr ${entry} drop`),
116
+ ' }',
117
+ '}',
118
+ '',
119
+ ].join('\n');
61
120
  }
62
121
 
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
- }
122
+ function nftSync(v4, v6) {
123
+ run('nft', ['-f', '-'], nftScript(v4, v6));
68
124
  }
69
125
 
70
126
  function nftCleanup() {
71
- try { exec(`nft flush chain ip filter ${CHAIN_NAME}`); } catch (_) {}
72
- try { exec(`nft delete chain ip filter ${CHAIN_NAME}`); } catch (_) {}
127
+ try { run('nft', ['delete', 'table', 'inet', NFT_TABLE]); } catch (_) {}
73
128
  }
74
129
 
75
130
  // ────── Public API ──────
76
131
 
77
- let _syncCallback = null;
78
-
79
132
  function init(options) {
80
133
  _options = options;
81
134
 
82
135
  if (os.platform() !== 'linux') {
83
- if (_options.log) console.warn('[securenow] Firewall iptables: only supported on Linux, skipping');
136
+ log('warn', '[securenow] Firewall iptables: only supported on Linux, skipping');
84
137
  return;
85
138
  }
86
139
 
87
- const backend = detectBackend();
88
- if (!backend) {
89
- if (_options.log) console.warn('[securenow] Firewall iptables: neither iptables nor nft found, skipping');
140
+ _backend = detectBackend();
141
+ if (!_backend) {
142
+ log('warn', '[securenow] Firewall iptables: neither iptables nor nft found, skipping');
90
143
  return;
91
144
  }
92
145
 
93
- _useNft = backend === 'nft';
94
- const label = _useNft ? 'nftables' : 'iptables';
95
-
96
146
  try {
97
- if (_useNft) nftSetup(); else iptablesSetup();
147
+ if (_backend === 'nft') {
148
+ nftSync([], []);
149
+ } else {
150
+ iptablesSetup('iptables');
151
+ if (canRun('ip6tables', ['--version'])) {
152
+ try { iptablesSetup('ip6tables'); _ip6 = true; } catch (_) { _ip6 = false; }
153
+ }
154
+ }
98
155
  _active = true;
99
- if (_options.log) console.log('[securenow] Firewall iptables: %s chain %s ready', label, CHAIN_NAME);
156
+ log('log', '[securenow] Firewall iptables: %s %s ready%s', _backend === 'nft' ? 'nftables table' : 'iptables chain',
157
+ _backend === 'nft' ? NFT_TABLE : CHAIN_NAME, _backend === 'iptables' && !_ip6 ? ' (IPv4 only: no ip6tables)' : '');
100
158
  } catch (e) {
101
- if (_options.log) console.warn('[securenow] Firewall iptables: setup failed (need root or CAP_NET_ADMIN):', e.message);
159
+ log('warn', '[securenow] Firewall iptables: setup failed (need root or CAP_NET_ADMIN):', e.message);
102
160
  }
103
161
  }
104
162
 
@@ -110,30 +168,33 @@ function sync(ips) {
110
168
  if (!_active) return;
111
169
 
112
170
  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
- }
171
+ const list = Array.isArray(ips) ? ips : [];
172
+ if (list.length > maxRules) log('warn', '[securenow] Firewall iptables: truncated to %d rules (max %d)', maxRules, maxRules);
173
+ const { v4, v6, rejected } = splitFamilies(list.slice(0, maxRules));
174
+ if (rejected) log('warn', '[securenow] Firewall iptables: ignored %d blocklist entries that are not IPs or CIDRs', rejected);
118
175
 
119
176
  try {
120
- if (_useNft) nftSync(limited); else iptablesSync(limited);
177
+ if (_backend === 'nft') nftSync(v4, v6);
178
+ else iptablesSync(v4, v6);
121
179
  } catch (e) {
122
- if (_options && _options.log) {
123
- console.warn('[securenow] Firewall iptables: sync failed:', e.message);
124
- }
180
+ log('warn', '[securenow] Firewall iptables: sync failed:', e.message);
125
181
  }
126
182
  }
127
183
 
128
184
  function shutdown() {
129
185
  if (!_active) return;
130
186
  try {
131
- if (_useNft) nftCleanup(); else iptablesCleanup();
132
- if (_options && _options.log) console.log('[securenow] Firewall iptables: chain %s cleaned up', CHAIN_NAME);
187
+ if (_backend === 'nft') {
188
+ nftCleanup();
189
+ } else {
190
+ iptablesCleanup('iptables');
191
+ if (_ip6) iptablesCleanup('ip6tables');
192
+ }
193
+ log('log', '[securenow] Firewall iptables: %s cleaned up', _backend === 'nft' ? `table ${NFT_TABLE}` : `chain ${CHAIN_NAME}`);
133
194
  } catch (e) {
134
- if (_options && _options.log) console.warn('[securenow] Firewall iptables: cleanup failed:', e.message);
195
+ log('warn', '[securenow] Firewall iptables: cleanup failed:', e.message);
135
196
  }
136
197
  _active = false;
137
198
  }
138
199
 
139
- module.exports = { init, sync, shutdown };
200
+ module.exports = { init, sync, shutdown, _test: { parseEntry, splitFamilies, restoreScript, nftScript } };
package/firewall-only.js CHANGED
@@ -41,6 +41,8 @@ if (firewallOptions.apiKey) {
41
41
  aws: firewallOptions.aws,
42
42
  gcp: firewallOptions.gcp,
43
43
  });
44
+ // Zero-code session revocation / session-theft capture (config.sessions.guard / .capture).
45
+ require('./sessions').installHttpGuard(appConfig.resolveSessionsOptions());
44
46
 
45
47
  const shutdown = () => { try { require('./firewall').shutdown(); } catch (_) {} };
46
48
  process.on('SIGINT', shutdown);