securenow 8.24.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 +693 -305
- package/README.md +76 -28
- package/SKILL-API.md +648 -525
- package/SKILL-CLI.md +293 -663
- package/app-config.js +19 -1
- package/cidr.js +139 -20
- package/cli/init.js +77 -15
- package/cli/utils.js +26 -14
- package/cli.js +7 -5
- package/console-instrumentation.js +6 -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-middleware.js +8 -4
- package/nextjs-wrapper.js +8 -4
- package/nextjs.d.ts +35 -67
- package/nextjs.js +29 -6
- package/package.json +5 -2
- package/register.d.ts +27 -34
- package/request-capture.js +11 -6
- 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 +12 -4
package/app-config.js
CHANGED
|
@@ -44,6 +44,7 @@ const ADMIN_CREDENTIALS_FILENAME = 'admin.json';
|
|
|
44
44
|
const DEFAULT_CONFIG = Object.freeze({
|
|
45
45
|
logging: {
|
|
46
46
|
enabled: true,
|
|
47
|
+
redact: true,
|
|
47
48
|
},
|
|
48
49
|
capture: {
|
|
49
50
|
body: true,
|
|
@@ -96,6 +97,11 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
96
97
|
networking: {
|
|
97
98
|
trustedProxies: [],
|
|
98
99
|
},
|
|
100
|
+
sessions: {
|
|
101
|
+
guard: false,
|
|
102
|
+
capture: false,
|
|
103
|
+
cookieNames: [],
|
|
104
|
+
},
|
|
99
105
|
});
|
|
100
106
|
|
|
101
107
|
const CONFIG_EXPLANATIONS = Object.freeze({
|
|
@@ -105,6 +111,7 @@ const CONFIG_EXPLANATIONS = Object.freeze({
|
|
|
105
111
|
'app.name': 'Human-readable app label shown in CLI output.',
|
|
106
112
|
'app.routing': 'Telemetry uses the default SecureNow ingestion gateway and routes by app.key; runtime credentials do not expose per-instance collector URLs.',
|
|
107
113
|
'config.logging.enabled': 'Secure default: console log forwarding is on. Set false to disable OTLP logs.',
|
|
114
|
+
'config.logging.redact': 'Secure default: exported log lines are redacted. Credential fields (password, token, apiKey, authorization, cookie, OTP codes, card numbers...) in logged objects, JSON and key=value text, and secret shapes anywhere (JWTs, Bearer/Basic credentials, sk_/pk_/rk_/snk_ keys, card numbers, emails) become [REDACTED]; the terminal still shows the line as written. Set false to export lines as written.',
|
|
108
115
|
'config.capture.body': 'Secure default: JSON, GraphQL, and form request bodies are captured with redaction.',
|
|
109
116
|
'config.capture.multipart': 'Secure default: multipart text fields and file metadata are captured. File content is never buffered.',
|
|
110
117
|
'config.capture.maxBodySize': 'Maximum body bytes captured per request. Default 10KB limits memory and sensitive data exposure.',
|
|
@@ -135,7 +142,10 @@ const CONFIG_EXPLANATIONS = Object.freeze({
|
|
|
135
142
|
'config.firewall.cloudflare': 'Cloudflare API token/account id for Layer 4 WAF. Secrets: do not commit.',
|
|
136
143
|
'config.firewall.aws': 'AWS WAF IP set configuration for Layer 4 WAF.',
|
|
137
144
|
'config.firewall.gcp': 'GCP Cloud Armor policy configuration for Layer 4 WAF.',
|
|
138
|
-
'config.networking.trustedProxies': 'Additional proxies whose X-Forwarded-For headers should be trusted: IPs, IPv4 ranges (CIDR) or "cloudflare".',
|
|
145
|
+
'config.networking.trustedProxies': 'Additional proxies whose X-Forwarded-For headers should be trusted: IPs, IPv4 ranges (CIDR) or "cloudflare". Private, loopback and Unix-socket connections are trusted by default.',
|
|
146
|
+
'config.sessions.guard': 'If true, every request whose session or user was revoked gets a 401, in any framework, with no code (session = SHA-256 of the Bearer token or session cookie; user = JWT sub).',
|
|
147
|
+
'config.sessions.capture': 'If true, requests with a session send session.seen (deduplicated per session and IP every 5 minutes) so SecureNow can detect a stolen session.',
|
|
148
|
+
'config.sessions.cookieNames': 'Session cookie names to read besides next-auth, connect.sid, session and sid.',
|
|
139
149
|
});
|
|
140
150
|
|
|
141
151
|
function clone(value) {
|
|
@@ -745,6 +755,13 @@ function resolveEndpoints(options = {}) {
|
|
|
745
755
|
};
|
|
746
756
|
}
|
|
747
757
|
|
|
758
|
+
function resolveSessionsOptions() {
|
|
759
|
+
return {
|
|
760
|
+
guard: boolConfig('sessions.guard', false),
|
|
761
|
+
capture: boolConfig('sessions.capture', false),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
748
765
|
function resolveFirewallEnabled() {
|
|
749
766
|
return true;
|
|
750
767
|
}
|
|
@@ -817,6 +834,7 @@ module.exports = {
|
|
|
817
834
|
resolveOtlpHeaderString,
|
|
818
835
|
resolveEndpoints,
|
|
819
836
|
resolveFirewallEnabled,
|
|
837
|
+
resolveSessionsOptions,
|
|
820
838
|
normalizeFirewallApiUrl,
|
|
821
839
|
resolveFirewallApiUrl,
|
|
822
840
|
resolveFirewallApiFallbacks,
|
package/cidr.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Bitmask-based CIDR matching. No regex on user input — prevents ReDoS.
|
|
5
|
-
*
|
|
5
|
+
* IPv4 addresses are unsigned 32-bit integers; IPv6 addresses are 128-bit BigInts.
|
|
6
|
+
* An IPv4-mapped IPv6 address (::ffff:1.2.3.4) is treated as the IPv4 address it carries.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
function ipToInt(ip) {
|
|
@@ -10,13 +11,99 @@ function ipToInt(ip) {
|
|
|
10
11
|
if (parts.length !== 4) return null;
|
|
11
12
|
let result = 0;
|
|
12
13
|
for (let i = 0; i < 4; i++) {
|
|
14
|
+
if (!/^\d{1,3}$/.test(parts[i])) return null;
|
|
13
15
|
const n = parseInt(parts[i], 10);
|
|
14
|
-
if (
|
|
16
|
+
if (n > 255) return null;
|
|
15
17
|
result = (result << 8) + n;
|
|
16
18
|
}
|
|
17
19
|
return result >>> 0;
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
function intToIp(n) {
|
|
23
|
+
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const V6_BITS = 128n;
|
|
27
|
+
const V6_ALL = (1n << V6_BITS) - 1n;
|
|
28
|
+
|
|
29
|
+
// '2001:db8::1' -> BigInt. Accepts '::' compression, an IPv4 tail ('::ffff:1.2.3.4'),
|
|
30
|
+
// brackets and a zone id ('fe80::1%eth0'). Returns null for anything else.
|
|
31
|
+
function ipv6ToBigInt(ip) {
|
|
32
|
+
let s = String(ip || '').trim();
|
|
33
|
+
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
|
|
34
|
+
const zone = s.indexOf('%');
|
|
35
|
+
if (zone !== -1) s = s.slice(0, zone);
|
|
36
|
+
if (!s.includes(':') || s.length > 45) return null;
|
|
37
|
+
|
|
38
|
+
// An IPv4 tail ('::ffff:1.2.3.4') is the last two groups.
|
|
39
|
+
let tail = [];
|
|
40
|
+
const lastColon = s.lastIndexOf(':');
|
|
41
|
+
if (s.indexOf('.', lastColon) !== -1) {
|
|
42
|
+
const v4 = ipToInt(s.slice(lastColon + 1));
|
|
43
|
+
if (v4 === null) return null;
|
|
44
|
+
tail = [(v4 >>> 16) & 0xffff, v4 & 0xffff];
|
|
45
|
+
s = s.slice(0, lastColon + 1);
|
|
46
|
+
if (!s.endsWith('::')) s = s.slice(0, -1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const halves = s.split('::');
|
|
50
|
+
if (halves.length > 2) return null;
|
|
51
|
+
const groupsOf = (part) => {
|
|
52
|
+
if (part === '') return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const g of part.split(':')) {
|
|
55
|
+
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
|
|
56
|
+
out.push(parseInt(g, 16));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
};
|
|
60
|
+
const head = groupsOf(halves[0]);
|
|
61
|
+
const rest = halves.length === 2 ? groupsOf(halves[1]) : [];
|
|
62
|
+
if (!head || !rest) return null;
|
|
63
|
+
const total = head.length + rest.length + tail.length;
|
|
64
|
+
// '::' stands for at least one zero group.
|
|
65
|
+
if (halves.length === 2 ? total > 7 : total !== 8) return null;
|
|
66
|
+
|
|
67
|
+
const all = head.concat(new Array(8 - total).fill(0), rest, tail);
|
|
68
|
+
let n = 0n;
|
|
69
|
+
for (const g of all) n = (n << 16n) | BigInt(g);
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function bigIntToIpv6(n) {
|
|
74
|
+
const groups = [];
|
|
75
|
+
for (let i = 7; i >= 0; i--) groups.push(Number((n >> BigInt(i * 16)) & 0xffffn).toString(16));
|
|
76
|
+
// Compress the longest run of zero groups (RFC 5952).
|
|
77
|
+
let best = -1; let bestLen = 0;
|
|
78
|
+
for (let i = 0; i < 8;) {
|
|
79
|
+
if (groups[i] !== '0') { i++; continue; }
|
|
80
|
+
let j = i;
|
|
81
|
+
while (j < 8 && groups[j] === '0') j++;
|
|
82
|
+
if (j - i > bestLen && j - i > 1) { best = i; bestLen = j - i; }
|
|
83
|
+
i = j;
|
|
84
|
+
}
|
|
85
|
+
if (best === -1) return groups.join(':');
|
|
86
|
+
return `${groups.slice(0, best).join(':')}::${groups.slice(best + bestLen).join(':')}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Classifies an address: { family: 4, int } or { family: 6, big }, with a canonical `key`
|
|
91
|
+
* for exact lookups. An IPv4-mapped IPv6 address comes back as family 4. Null if not an IP.
|
|
92
|
+
*/
|
|
93
|
+
function parseIp(ip) {
|
|
94
|
+
const s = String(ip || '').trim();
|
|
95
|
+
if (!s) return null;
|
|
96
|
+
const v4 = ipToInt(s);
|
|
97
|
+
if (v4 !== null) return { family: 4, int: v4, key: intToIp(v4) };
|
|
98
|
+
const big = ipv6ToBigInt(s);
|
|
99
|
+
if (big === null) return null;
|
|
100
|
+
if ((big >> 32n) === 0xffffn) {
|
|
101
|
+
const int = Number(big & 0xffffffffn) >>> 0;
|
|
102
|
+
return { family: 4, int, key: intToIp(int) };
|
|
103
|
+
}
|
|
104
|
+
return { family: 6, big, key: bigIntToIpv6(big) };
|
|
105
|
+
}
|
|
106
|
+
|
|
20
107
|
function parseCidr(cidr) {
|
|
21
108
|
const slash = cidr.indexOf('/');
|
|
22
109
|
if (slash === -1) return null;
|
|
@@ -29,44 +116,72 @@ function parseCidr(cidr) {
|
|
|
29
116
|
return { network: (network & mask) >>> 0, mask };
|
|
30
117
|
}
|
|
31
118
|
|
|
119
|
+
/** '2001:db8::/32' -> { network, mask, bits } as BigInts. Null if not an IPv6 CIDR. */
|
|
120
|
+
function parseCidr6(cidr) {
|
|
121
|
+
const slash = String(cidr || '').indexOf('/');
|
|
122
|
+
if (slash === -1) return null;
|
|
123
|
+
const suffix = cidr.slice(slash + 1).trim();
|
|
124
|
+
if (!/^\d{1,3}$/.test(suffix)) return null;
|
|
125
|
+
const bits = parseInt(suffix, 10);
|
|
126
|
+
if (bits > 128) return null;
|
|
127
|
+
const big = ipv6ToBigInt(cidr.slice(0, slash));
|
|
128
|
+
if (big === null) return null;
|
|
129
|
+
const mask = bits === 0 ? 0n : (V6_ALL << (V6_BITS - BigInt(bits))) & V6_ALL;
|
|
130
|
+
return { network: big & mask, mask, bits };
|
|
131
|
+
}
|
|
132
|
+
|
|
32
133
|
function matchesCidr(ipInt, cidrEntry) {
|
|
33
134
|
return ((ipInt & cidrEntry.mask) >>> 0) === cidrEntry.network;
|
|
34
135
|
}
|
|
35
136
|
|
|
36
137
|
/**
|
|
37
|
-
* Create a matcher from a list of
|
|
138
|
+
* Create a matcher from a list of IPv4/IPv6 addresses and CIDRs.
|
|
38
139
|
* Returns { isBlocked(ip), stats() }.
|
|
39
|
-
* Exact IPs use a Set for O(1) lookup
|
|
140
|
+
* Exact IPs use a Set of canonical forms for O(1) lookup ('2001:DB8:0::1' and '2001:db8::1'
|
|
141
|
+
* are the same entry); CIDRs use an array scan (typically < 100 entries).
|
|
40
142
|
*/
|
|
41
143
|
function createMatcher(ipList) {
|
|
42
144
|
const exactSet = new Set();
|
|
43
|
-
const
|
|
145
|
+
const cidr4 = [];
|
|
146
|
+
const cidr6 = [];
|
|
44
147
|
|
|
45
|
-
for (const entry of ipList) {
|
|
148
|
+
for (const entry of ipList || []) {
|
|
46
149
|
const trimmed = (entry || '').trim();
|
|
47
150
|
if (!trimmed) continue;
|
|
48
151
|
|
|
49
152
|
if (trimmed.includes('/')) {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
153
|
+
const v4 = parseCidr(trimmed);
|
|
154
|
+
if (v4) { cidr4.push(v4); continue; }
|
|
155
|
+
const v6 = parseCidr6(trimmed);
|
|
156
|
+
if (!v6) continue;
|
|
157
|
+
// ::ffff:0:0/96 and narrower are IPv4 ranges written in IPv6 form.
|
|
158
|
+
if (v6.bits >= 96 && (v6.network >> 32n) === 0xffffn) {
|
|
159
|
+
const bits = v6.bits - 96;
|
|
160
|
+
const mask = bits === 0 ? 0 : (~((1 << (32 - bits)) - 1)) >>> 0;
|
|
161
|
+
cidr4.push({ network: (Number(v6.network & 0xffffffffn) & mask) >>> 0, mask });
|
|
162
|
+
} else {
|
|
163
|
+
cidr6.push(v6);
|
|
164
|
+
}
|
|
52
165
|
} else {
|
|
53
|
-
const
|
|
54
|
-
exactSet.add(
|
|
166
|
+
const parsed = parseIp(trimmed);
|
|
167
|
+
exactSet.add(parsed ? parsed.key : trimmed);
|
|
55
168
|
}
|
|
56
169
|
}
|
|
57
170
|
|
|
58
171
|
function isBlocked(ip) {
|
|
59
172
|
if (!ip) return false;
|
|
60
|
-
const
|
|
173
|
+
const parsed = parseIp(ip);
|
|
174
|
+
if (!parsed) return exactSet.has(String(ip).trim());
|
|
61
175
|
|
|
62
|
-
if (exactSet.has(
|
|
176
|
+
if (exactSet.has(parsed.key)) return true;
|
|
63
177
|
|
|
64
|
-
if (
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
178
|
+
if (parsed.family === 4) {
|
|
179
|
+
for (const cidr of cidr4) {
|
|
180
|
+
if (matchesCidr(parsed.int, cidr)) return true;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
for (const cidr of cidr6) {
|
|
184
|
+
if ((parsed.big & cidr.mask) === cidr.network) return true;
|
|
70
185
|
}
|
|
71
186
|
}
|
|
72
187
|
|
|
@@ -74,10 +189,14 @@ function createMatcher(ipList) {
|
|
|
74
189
|
}
|
|
75
190
|
|
|
76
191
|
function stats() {
|
|
77
|
-
|
|
192
|
+
const cidr = cidr4.length + cidr6.length;
|
|
193
|
+
return { exact: exactSet.size, cidr, total: exactSet.size + cidr };
|
|
78
194
|
}
|
|
79
195
|
|
|
80
196
|
return { isBlocked, stats };
|
|
81
197
|
}
|
|
82
198
|
|
|
83
|
-
module.exports = {
|
|
199
|
+
module.exports = {
|
|
200
|
+
ipToInt, intToIp, parseCidr, matchesCidr, createMatcher,
|
|
201
|
+
ipv6ToBigInt, bigIntToIpv6, parseCidr6, parseIp,
|
|
202
|
+
};
|
package/cli/init.js
CHANGED
|
@@ -62,6 +62,22 @@ function findInstrumentationFile(dir) {
|
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Next.js loads instrumentation.* from the folder that holds app/ or pages/: src/ in a src layout,
|
|
66
|
+
// where a file at the project root is never loaded.
|
|
67
|
+
function usesSrcDir(dir) {
|
|
68
|
+
const inSrc = ['src/app', 'src/pages'].some((d) => fs.existsSync(path.join(dir, d)));
|
|
69
|
+
const inRoot = ['app', 'pages'].some((d) => fs.existsSync(path.join(dir, d)));
|
|
70
|
+
return inSrc && !inRoot;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function instrumentationIn(folder) {
|
|
74
|
+
for (const name of ['instrumentation.ts', 'instrumentation.js']) {
|
|
75
|
+
const p = path.join(folder, name);
|
|
76
|
+
if (fs.existsSync(p)) return p;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
function findNextConfig(dir) {
|
|
66
82
|
for (const name of ['next.config.js', 'next.config.mjs', 'next.config.ts']) {
|
|
67
83
|
const p = path.join(dir, name);
|
|
@@ -132,20 +148,49 @@ function initCredentials(flags) {
|
|
|
132
148
|
}
|
|
133
149
|
|
|
134
150
|
async function initNextJs(dir, project, flags) {
|
|
135
|
-
const useTs = flags.javascript ? false : flags.typescript ? true : hasTypeScript(dir);
|
|
151
|
+
const useTs = (flags.javascript || flags.js) ? false : (flags.typescript || flags.ts) ? true : hasTypeScript(dir);
|
|
136
152
|
const ext = useTs ? 'ts' : 'js';
|
|
137
|
-
|
|
138
|
-
const
|
|
153
|
+
const inSrc = flags.root ? false : flags.src ? true : usesSrcDir(dir);
|
|
154
|
+
const folder = inSrc ? path.join(dir, 'src') : dir;
|
|
155
|
+
const otherFolder = inSrc ? dir : path.join(dir, 'src');
|
|
156
|
+
const rel = (p) => path.relative(dir, p).split(path.sep).join('/');
|
|
157
|
+
|
|
158
|
+
// In a src/ layout, Next.js 14 and 15 load only src/instrumentation.*; 16 also loads a root file.
|
|
159
|
+
const major = nextMajor(project);
|
|
160
|
+
const existing = instrumentationIn(folder) || (inSrc && major >= 16 ? instrumentationIn(otherFolder) : null);
|
|
161
|
+
const misplaced = existing ? null : instrumentationIn(otherFolder);
|
|
139
162
|
if (existing && isLegacyInstrumentation(fs.readFileSync(existing, 'utf8'))) {
|
|
140
163
|
fs.writeFileSync(existing, INSTRUMENTATION, 'utf8');
|
|
141
|
-
ui.success(`Updated ${
|
|
164
|
+
ui.success(`Updated ${rel(existing)}: standalone builds now include SecureNow's dependencies`);
|
|
165
|
+
} else if (existing && flags.force) {
|
|
166
|
+
fs.copyFileSync(existing, `${existing}.bak`);
|
|
167
|
+
fs.writeFileSync(existing, INSTRUMENTATION, 'utf8');
|
|
168
|
+
ui.success(`Replaced ${rel(existing)} (--force); the previous file is in ${rel(existing)}.bak`);
|
|
142
169
|
} else if (existing) {
|
|
143
|
-
ui.info(`instrumentation file already exists: ${
|
|
144
|
-
printAgentPrompt('instrumentation',
|
|
170
|
+
ui.info(`instrumentation file already exists: ${rel(existing)}`);
|
|
171
|
+
printAgentPrompt('instrumentation', rel(existing), nextMajor(project), project);
|
|
145
172
|
} else {
|
|
146
|
-
const filePath = path.join(
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
const filePath = path.join(folder, `instrumentation.${ext}`);
|
|
174
|
+
if (misplaced) {
|
|
175
|
+
// Next.js never loads a file in the other folder, so SecureNow would not start.
|
|
176
|
+
const ours = isLegacyInstrumentation(fs.readFileSync(misplaced, 'utf8'))
|
|
177
|
+
|| fs.readFileSync(misplaced, 'utf8') === INSTRUMENTATION;
|
|
178
|
+
if (ours || flags.force) {
|
|
179
|
+
if (!ours) fs.copyFileSync(misplaced, `${misplaced}.bak`);
|
|
180
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
181
|
+
fs.writeFileSync(filePath, INSTRUMENTATION, 'utf8');
|
|
182
|
+
fs.unlinkSync(misplaced);
|
|
183
|
+
ui.success(`Moved ${rel(misplaced)} to ${rel(filePath)}: Next.js loads instrumentation from ${inSrc ? 'src/' : 'the project root'} in this project${ours ? '' : ` (the previous file is in ${rel(misplaced)}.bak)`}`);
|
|
184
|
+
} else {
|
|
185
|
+
ui.warn(`${rel(misplaced)} is in the wrong folder: this project keeps app/ or pages/ ${inSrc ? 'in src/' : 'at the root'}, and Next.js loads instrumentation from there (${rel(filePath)}), so SecureNow would not start.`);
|
|
186
|
+
ui.info('Move it there (or rerun with --force to write SecureNow\'s file there and keep yours as .bak).');
|
|
187
|
+
printAgentPrompt('instrumentation', rel(filePath), major, project);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
191
|
+
fs.writeFileSync(filePath, INSTRUMENTATION, 'utf8');
|
|
192
|
+
ui.success(`Created ${rel(filePath)}`);
|
|
193
|
+
}
|
|
149
194
|
}
|
|
150
195
|
|
|
151
196
|
const configPath = findNextConfig(dir);
|
|
@@ -161,7 +206,12 @@ async function initNextJs(dir, project, flags) {
|
|
|
161
206
|
}
|
|
162
207
|
} else {
|
|
163
208
|
const newConfigPath = path.join(dir, 'next.config.mjs');
|
|
164
|
-
fs.writeFileSync(newConfigPath,
|
|
209
|
+
fs.writeFileSync(newConfigPath, nextMajor(project) < 15 ? NEXT14_CONFIG : NEXT_CONFIG, 'utf8');
|
|
210
|
+
ui.success('Created next.config.mjs');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const NEXT_CONFIG = `/** @type {import('next').NextConfig} */
|
|
165
215
|
const nextConfig = {
|
|
166
216
|
serverExternalPackages: ['securenow'],
|
|
167
217
|
outputFileTracingIncludes: {
|
|
@@ -170,10 +220,22 @@ const nextConfig = {
|
|
|
170
220
|
};
|
|
171
221
|
|
|
172
222
|
export default nextConfig;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
223
|
+
`;
|
|
224
|
+
|
|
225
|
+
// Next.js 14 keeps these under experimental and needs the instrumentation hook switched on.
|
|
226
|
+
const NEXT14_CONFIG = `/** @type {import('next').NextConfig} */
|
|
227
|
+
const nextConfig = {
|
|
228
|
+
experimental: {
|
|
229
|
+
instrumentationHook: true,
|
|
230
|
+
serverComponentsExternalPackages: ['securenow'],
|
|
231
|
+
outputFileTracingIncludes: {
|
|
232
|
+
'/*': ['./node_modules/securenow/**/*'],
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export default nextConfig;
|
|
238
|
+
`;
|
|
177
239
|
|
|
178
240
|
function patchNextConfig(configPath, major) {
|
|
179
241
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
@@ -272,7 +334,7 @@ function printAgentPrompt(kind, filename, major, project) {
|
|
|
272
334
|
? `Update ${filename} while preserving existing config: add securenow to serverExternalPackages, e.g. serverExternalPackages: [...(existing || []), "securenow"], and include SecureNow runtime files for standalone output with outputFileTracingIncludes: { "/*": ["./node_modules/securenow/**/*"] }.`
|
|
273
335
|
: null,
|
|
274
336
|
kind === 'next-config' && major < 15
|
|
275
|
-
? `Update ${filename} while preserving existing config:
|
|
337
|
+
? `Update ${filename} while preserving existing config (Next.js 14): set experimental.instrumentationHook to true, add securenow to experimental.serverComponentsExternalPackages, and add experimental.outputFileTracingIncludes: { "/*": ["./node_modules/securenow/**/*"] } for standalone output.`
|
|
276
338
|
: null,
|
|
277
339
|
'Verify with: npx securenow env, npx securenow test-span, npx securenow status, plus the selected build/test command when available.',
|
|
278
340
|
'Only smoke-test an HTTP URL when the user provides a local or production URL, the running app prints one, or project config clearly defines one. Do not invent localhost ports or production domains; ask for the URL and skip URL probes if none is available.',
|
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
|
},
|
|
@@ -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/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) } };
|