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/NPM_README.md +730 -333
- package/README.md +91 -28
- package/SKILL-API.md +657 -530
- package/SKILL-CLI.md +293 -663
- package/app-config.js +90 -15
- package/cidr.js +139 -20
- package/cli/credentials.js +11 -0
- package/cli/init.js +121 -17
- package/cli/utils.js +26 -14
- package/cli.js +9 -6
- package/console-instrumentation.js +6 -0
- package/crash-capture.js +13 -1
- package/esm-detect.js +5 -1
- package/fastify-hooks.js +67 -0
- package/firewall-cloud.js +81 -32
- package/firewall-iptables.js +123 -62
- package/firewall-only.js +2 -0
- package/firewall-tcp.js +19 -5
- package/firewall.js +21 -1
- package/framework-routes.js +222 -0
- package/log-format.js +2 -0
- package/log-redaction.js +135 -0
- package/mcp/catalog.js +14 -9
- package/next-route.js +61 -0
- package/nextjs-auto-capture.d.ts +5 -2
- package/nextjs-auto-capture.js +77 -220
- package/nextjs-middleware.js +8 -4
- package/nextjs-wrapper.js +8 -4
- package/nextjs.d.ts +35 -67
- package/nextjs.js +75 -182
- package/nuxt-server-plugin.mjs +30 -478
- package/nuxt.mjs +15 -14
- package/package.json +8 -2
- package/pm2-env.js +47 -0
- package/register-vite.js +6 -2
- package/register.d.ts +27 -34
- package/request-capture.js +492 -0
- package/resolve-ip.js +23 -5
- package/sessions.d.ts +27 -0
- package/sessions.js +71 -10
- package/tracing.d.ts +40 -121
- package/tracing.js +39 -447
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/fastify-hooks.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
securityPolicy: policyName,
|
|
133
|
-
|
|
134
|
-
|
|
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) } };
|
package/firewall-iptables.js
CHANGED
|
@@ -1,104 +1,162 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Layer 3: OS-level firewall via iptables
|
|
5
|
-
* Manages
|
|
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 {
|
|
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
|
|
23
|
+
let _backend = null; // 'iptables' | 'nft'
|
|
24
|
+
let _ip6 = false; // ip6tables available alongside iptables
|
|
17
25
|
|
|
18
|
-
function
|
|
19
|
-
return
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
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 {
|
|
36
|
-
try {
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
64
|
-
|
|
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 {
|
|
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
|
-
|
|
136
|
+
log('warn', '[securenow] Firewall iptables: only supported on Linux, skipping');
|
|
84
137
|
return;
|
|
85
138
|
}
|
|
86
139
|
|
|
87
|
-
|
|
88
|
-
if (!
|
|
89
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 (
|
|
177
|
+
if (_backend === 'nft') nftSync(v4, v6);
|
|
178
|
+
else iptablesSync(v4, v6);
|
|
121
179
|
} catch (e) {
|
|
122
|
-
|
|
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 (
|
|
132
|
-
|
|
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
|
-
|
|
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);
|