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/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
- * All operations use unsigned 32-bit integers for IPv4 addresses.
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 (isNaN(n) || n < 0 || n > 255) return null;
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 IPs and CIDRs.
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; CIDRs use array scan (typically < 100 entries).
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 cidrRanges = [];
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 parsed = parseCidr(trimmed);
51
- if (parsed) cidrRanges.push(parsed);
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 normalized = trimmed.replace(/^::ffff:/, '');
54
- exactSet.add(normalized);
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 normalized = ip.replace(/^::ffff:/, '');
173
+ const parsed = parseIp(ip);
174
+ if (!parsed) return exactSet.has(String(ip).trim());
61
175
 
62
- if (exactSet.has(normalized)) return true;
176
+ if (exactSet.has(parsed.key)) return true;
63
177
 
64
- if (cidrRanges.length > 0) {
65
- const ipInt = ipToInt(normalized);
66
- if (ipInt !== null) {
67
- for (const cidr of cidrRanges) {
68
- if (matchesCidr(ipInt, cidr)) return true;
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
- return { exact: exactSet.size, cidr: cidrRanges.length, total: exactSet.size + cidrRanges.length };
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 = { ipToInt, parseCidr, matchesCidr, createMatcher };
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 existing = findInstrumentationFile(dir);
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 ${path.relative(dir, existing)}: standalone builds now include SecureNow's dependencies`);
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: ${path.relative(dir, existing)}`);
144
- printAgentPrompt('instrumentation', path.basename(existing), nextMajor(project), project);
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(dir, `instrumentation.${ext}`);
147
- fs.writeFileSync(filePath, INSTRUMENTATION, 'utf8');
148
- ui.success(`Created instrumentation.${ext}`);
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, `/** @type {import('next').NextConfig} */
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
- `, 'utf8');
174
- ui.success('Created next.config.mjs');
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: enable experimental.instrumentationHook and add securenow to experimental.serverComponentsExternalPackages.`
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
- 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
  },
@@ -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
- 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) } };