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/app-config.js
CHANGED
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* Named runtime files such as ./.securenow/credentials.staging.json and
|
|
9
9
|
* ./.securenow/credentials.production.json are also accepted when the
|
|
10
10
|
* canonical file is not present. Filename selection is deterministic and does
|
|
11
|
-
* not
|
|
11
|
+
* not depend on NODE_ENV.
|
|
12
12
|
* Every SDK setting has a file-backed equivalent so customers do not need
|
|
13
|
-
* .env files.
|
|
13
|
+
* .env files. Hosts that cannot mount a file (Vercel, Railway, Render, Heroku,
|
|
14
|
+
* Fly) pass the same JSON in one variable, SECURENOW_CREDENTIALS.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
const fs = require('fs');
|
|
17
18
|
const path = require('path');
|
|
18
19
|
const os = require('os');
|
|
20
|
+
const { readPm2Env } = require('./pm2-env');
|
|
19
21
|
|
|
20
22
|
// Shared SecureNow Cloud ingestion gateway (production collector for every
|
|
21
23
|
// paying account and for the 14-day trial).
|
|
@@ -42,6 +44,7 @@ const ADMIN_CREDENTIALS_FILENAME = 'admin.json';
|
|
|
42
44
|
const DEFAULT_CONFIG = Object.freeze({
|
|
43
45
|
logging: {
|
|
44
46
|
enabled: true,
|
|
47
|
+
redact: true,
|
|
45
48
|
},
|
|
46
49
|
capture: {
|
|
47
50
|
body: true,
|
|
@@ -94,6 +97,11 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
94
97
|
networking: {
|
|
95
98
|
trustedProxies: [],
|
|
96
99
|
},
|
|
100
|
+
sessions: {
|
|
101
|
+
guard: false,
|
|
102
|
+
capture: false,
|
|
103
|
+
cookieNames: [],
|
|
104
|
+
},
|
|
97
105
|
});
|
|
98
106
|
|
|
99
107
|
const CONFIG_EXPLANATIONS = Object.freeze({
|
|
@@ -103,6 +111,7 @@ const CONFIG_EXPLANATIONS = Object.freeze({
|
|
|
103
111
|
'app.name': 'Human-readable app label shown in CLI output.',
|
|
104
112
|
'app.routing': 'Telemetry uses the default SecureNow ingestion gateway and routes by app.key; runtime credentials do not expose per-instance collector URLs.',
|
|
105
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.',
|
|
106
115
|
'config.capture.body': 'Secure default: JSON, GraphQL, and form request bodies are captured with redaction.',
|
|
107
116
|
'config.capture.multipart': 'Secure default: multipart text fields and file metadata are captured. File content is never buffered.',
|
|
108
117
|
'config.capture.maxBodySize': 'Maximum body bytes captured per request. Default 10KB limits memory and sensitive data exposure.',
|
|
@@ -133,7 +142,10 @@ const CONFIG_EXPLANATIONS = Object.freeze({
|
|
|
133
142
|
'config.firewall.cloudflare': 'Cloudflare API token/account id for Layer 4 WAF. Secrets: do not commit.',
|
|
134
143
|
'config.firewall.aws': 'AWS WAF IP set configuration for Layer 4 WAF.',
|
|
135
144
|
'config.firewall.gcp': 'GCP Cloud Armor policy configuration for Layer 4 WAF.',
|
|
136
|
-
'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.',
|
|
137
149
|
});
|
|
138
150
|
|
|
139
151
|
function clone(value) {
|
|
@@ -240,7 +252,20 @@ function credentialRelativePaths() {
|
|
|
240
252
|
return runtimeCredentialRelativePaths();
|
|
241
253
|
}
|
|
242
254
|
|
|
243
|
-
|
|
255
|
+
// Where to look for .securenow/ files and package.json. A PM2 process is searched from the app
|
|
256
|
+
// folder PM2 recorded and nowhere else: in cluster mode the worker's working directory is still
|
|
257
|
+
// the PM2 daemon's when the preload runs, so searching from it found nothing, or another
|
|
258
|
+
// project's credentials if the daemon was started inside that project.
|
|
259
|
+
function searchStartDirs() {
|
|
260
|
+
const pm2 = readPm2Env();
|
|
261
|
+
if (pm2 && (pm2.cwd || pm2.execPath)) {
|
|
262
|
+
const appDir = pm2.cwd ? path.resolve(pm2.cwd) : null;
|
|
263
|
+
return uniq([
|
|
264
|
+
appDir,
|
|
265
|
+
pm2.execPath ? path.dirname(path.resolve(appDir || '.', pm2.execPath)) : null,
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
|
|
244
269
|
const starts = [];
|
|
245
270
|
try {
|
|
246
271
|
if (typeof process !== 'undefined' && process.cwd) starts.push(process.cwd());
|
|
@@ -250,8 +275,12 @@ function resolveLocalFileFromRelativePaths(relativePaths) {
|
|
|
250
275
|
if (process.argv && process.argv[1]) starts.push(path.dirname(process.argv[1]));
|
|
251
276
|
if (require.main && require.main.filename) starts.push(path.dirname(require.main.filename));
|
|
252
277
|
|
|
278
|
+
return uniq(starts);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveLocalFileFromRelativePaths(relativePaths) {
|
|
253
282
|
const candidates = relativePaths;
|
|
254
|
-
for (const start of
|
|
283
|
+
for (const start of searchStartDirs()) {
|
|
255
284
|
const found = findUpFirstFile(start, candidates);
|
|
256
285
|
if (found) return found;
|
|
257
286
|
}
|
|
@@ -314,16 +343,7 @@ function runtimeCredentialsFromDocument(credentials) {
|
|
|
314
343
|
}
|
|
315
344
|
|
|
316
345
|
function resolvePackageJsonFile() {
|
|
317
|
-
const
|
|
318
|
-
try {
|
|
319
|
-
if (typeof process !== 'undefined' && process.cwd) starts.push(process.cwd());
|
|
320
|
-
} catch {}
|
|
321
|
-
|
|
322
|
-
if (process.env.INIT_CWD) starts.push(process.env.INIT_CWD);
|
|
323
|
-
if (process.argv && process.argv[1]) starts.push(path.dirname(process.argv[1]));
|
|
324
|
-
if (require.main && require.main.filename) starts.push(path.dirname(require.main.filename));
|
|
325
|
-
|
|
326
|
-
for (const start of uniq(starts)) {
|
|
346
|
+
for (const start of searchStartDirs()) {
|
|
327
347
|
const found = findUpFile(start, 'package.json');
|
|
328
348
|
if (found) return found;
|
|
329
349
|
}
|
|
@@ -347,7 +367,50 @@ function loadGlobalCredentials() {
|
|
|
347
367
|
}
|
|
348
368
|
}
|
|
349
369
|
|
|
370
|
+
const ENV_CREDENTIALS_VAR = 'SECURENOW_CREDENTIALS';
|
|
371
|
+
let _envCredentials = { raw: undefined, value: null };
|
|
372
|
+
let _warnedBadEnvCredentials = false;
|
|
373
|
+
|
|
374
|
+
// Runtime credentials passed in the environment, for hosts that cannot mount a file. The value is
|
|
375
|
+
// the JSON written by `npx securenow credentials runtime`, raw or base64-encoded (`--format env`
|
|
376
|
+
// prints it ready to paste). When set it takes precedence over credential files.
|
|
377
|
+
function loadEnvCredentials() {
|
|
378
|
+
const raw = typeof process !== 'undefined' && process.env ? process.env[ENV_CREDENTIALS_VAR] : undefined;
|
|
379
|
+
if (raw !== _envCredentials.raw) {
|
|
380
|
+
let value = null;
|
|
381
|
+
const text = raw == null ? '' : String(raw).trim();
|
|
382
|
+
if (text) {
|
|
383
|
+
try {
|
|
384
|
+
const json = text.startsWith('{') ? text : Buffer.from(text, 'base64').toString('utf8');
|
|
385
|
+
value = withCredentialDefaults(runtimeCredentialsFromDocument(JSON.parse(json.replace(/^\uFEFF/, ''))));
|
|
386
|
+
} catch {
|
|
387
|
+
value = null;
|
|
388
|
+
}
|
|
389
|
+
if (!value && !_warnedBadEnvCredentials) {
|
|
390
|
+
_warnedBadEnvCredentials = true;
|
|
391
|
+
try {
|
|
392
|
+
console.warn('[securenow] %s is set but is not runtime credentials JSON (raw or base64); ignoring it.', ENV_CREDENTIALS_VAR);
|
|
393
|
+
} catch {}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
_envCredentials = { raw, value };
|
|
397
|
+
}
|
|
398
|
+
return _envCredentials.value ? clone(_envCredentials.value) : null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Where the runtime credentials came from, for the startup log: never their content.
|
|
402
|
+
function describeCredentialsSource() {
|
|
403
|
+
try {
|
|
404
|
+
if (loadEnvCredentials()) return `env:${ENV_CREDENTIALS_VAR}`;
|
|
405
|
+
return resolveLocalCredentialsFile() || resolveGlobalCredentialsFile() || 'none';
|
|
406
|
+
} catch {
|
|
407
|
+
return 'none';
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
350
411
|
function loadCredentials() {
|
|
412
|
+
const fromEnv = loadEnvCredentials();
|
|
413
|
+
if (fromEnv) return fromEnv;
|
|
351
414
|
try {
|
|
352
415
|
const localFile = resolveLocalCredentialsFile();
|
|
353
416
|
if (localFile) {
|
|
@@ -692,6 +755,13 @@ function resolveEndpoints(options = {}) {
|
|
|
692
755
|
};
|
|
693
756
|
}
|
|
694
757
|
|
|
758
|
+
function resolveSessionsOptions() {
|
|
759
|
+
return {
|
|
760
|
+
guard: boolConfig('sessions.guard', false),
|
|
761
|
+
capture: boolConfig('sessions.capture', false),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
695
765
|
function resolveFirewallEnabled() {
|
|
696
766
|
return true;
|
|
697
767
|
}
|
|
@@ -764,6 +834,7 @@ module.exports = {
|
|
|
764
834
|
resolveOtlpHeaderString,
|
|
765
835
|
resolveEndpoints,
|
|
766
836
|
resolveFirewallEnabled,
|
|
837
|
+
resolveSessionsOptions,
|
|
767
838
|
normalizeFirewallApiUrl,
|
|
768
839
|
resolveFirewallApiUrl,
|
|
769
840
|
resolveFirewallApiFallbacks,
|
|
@@ -781,6 +852,10 @@ module.exports = {
|
|
|
781
852
|
resolveLocalAdminCredentialsFile,
|
|
782
853
|
resolveGlobalAdminCredentialsFile,
|
|
783
854
|
loadCredentials,
|
|
855
|
+
loadEnvCredentials,
|
|
856
|
+
describeCredentialsSource,
|
|
857
|
+
searchStartDirs,
|
|
858
|
+
ENV_CREDENTIALS_VAR,
|
|
784
859
|
loadLocalCredentials,
|
|
785
860
|
loadGlobalCredentials,
|
|
786
861
|
loadPackageJsonName,
|
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/credentials.js
CHANGED
|
@@ -73,6 +73,17 @@ async function runtime(_args, flags) {
|
|
|
73
73
|
warn('Runtime API key is missing. Run `npx securenow app connect` or `npx securenow api-key create` before generating production runtime credentials.');
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// For hosts that inject environment variables but cannot mount a file (Vercel, Railway,
|
|
77
|
+
// Render, Heroku, Fly): one line to paste as a secret. The SDK reads SECURENOW_CREDENTIALS
|
|
78
|
+
// (raw JSON or base64) before any credentials file.
|
|
79
|
+
if (flags.format === 'env' || flags['env-var']) {
|
|
80
|
+
const { ENV_CREDENTIALS_VAR } = appConfig;
|
|
81
|
+
const encoded = Buffer.from(JSON.stringify(creds), 'utf8').toString('base64');
|
|
82
|
+
console.log(`${ENV_CREDENTIALS_VAR}=${encoded}`);
|
|
83
|
+
console.error(`! This line contains the runtime API key. Store it as a secret variable named ${ENV_CREDENTIALS_VAR}; do not commit it.`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
if (flags.stdout) {
|
|
77
88
|
console.log(JSON.stringify(creds, null, 2));
|
|
78
89
|
return;
|
package/cli/init.js
CHANGED
|
@@ -5,7 +5,26 @@ const path = require('path');
|
|
|
5
5
|
const ui = require('./ui');
|
|
6
6
|
const config = require('./config');
|
|
7
7
|
|
|
8
|
+
// The import sits inside the nodejs block, without webpackIgnore: Next.js drops the block from the
|
|
9
|
+
// Edge build and traces securenow and its dependencies into standalone output (webpackIgnore hid
|
|
10
|
+
// them from tracing, so a standalone image shipped without OpenTelemetry and every request failed).
|
|
11
|
+
// The try/catch keeps the app serving if SecureNow cannot start.
|
|
8
12
|
const INSTRUMENTATION = `export async function register() {
|
|
13
|
+
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
14
|
+
try {
|
|
15
|
+
const securenowNext = await import('securenow/nextjs');
|
|
16
|
+
const registerSecureNow = securenowNext.registerSecureNow || securenowNext.default?.registerSecureNow;
|
|
17
|
+
registerSecureNow({ captureBody: true });
|
|
18
|
+
await import('securenow/nextjs-auto-capture');
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error('[securenow] SecureNow did not start; the app is running without it:', error);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
// What init wrote before 8.24.0: replaced automatically when a file still holds exactly this.
|
|
27
|
+
const LEGACY_INSTRUMENTATION = `export async function register() {
|
|
9
28
|
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
|
|
10
29
|
|
|
11
30
|
const securenowNext = await import(/* webpackIgnore: true */ 'securenow/nextjs');
|
|
@@ -15,6 +34,10 @@ const INSTRUMENTATION = `export async function register() {
|
|
|
15
34
|
}
|
|
16
35
|
`;
|
|
17
36
|
|
|
37
|
+
function isLegacyInstrumentation(content) {
|
|
38
|
+
return String(content || '').replace(/\r\n/g, '\n').trim() === LEGACY_INSTRUMENTATION.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
function detectProject(dir) {
|
|
19
42
|
const pkgPath = path.join(dir, 'package.json');
|
|
20
43
|
if (!fs.existsSync(pkgPath)) return { framework: 'unknown' };
|
|
@@ -39,6 +62,22 @@ function findInstrumentationFile(dir) {
|
|
|
39
62
|
return null;
|
|
40
63
|
}
|
|
41
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
|
+
|
|
42
81
|
function findNextConfig(dir) {
|
|
43
82
|
for (const name of ['next.config.js', 'next.config.mjs', 'next.config.ts']) {
|
|
44
83
|
const p = path.join(dir, name);
|
|
@@ -78,6 +117,7 @@ async function init(_args, flags) {
|
|
|
78
117
|
} else {
|
|
79
118
|
initNode(project);
|
|
80
119
|
}
|
|
120
|
+
checkPm2Ecosystem(dir);
|
|
81
121
|
|
|
82
122
|
console.log('');
|
|
83
123
|
ui.success('Setup complete.');
|
|
@@ -108,17 +148,49 @@ function initCredentials(flags) {
|
|
|
108
148
|
}
|
|
109
149
|
|
|
110
150
|
async function initNextJs(dir, project, flags) {
|
|
111
|
-
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);
|
|
112
152
|
const ext = useTs ? 'ts' : 'js';
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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);
|
|
162
|
+
if (existing && isLegacyInstrumentation(fs.readFileSync(existing, 'utf8'))) {
|
|
163
|
+
fs.writeFileSync(existing, INSTRUMENTATION, 'utf8');
|
|
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`);
|
|
169
|
+
} else if (existing) {
|
|
170
|
+
ui.info(`instrumentation file already exists: ${rel(existing)}`);
|
|
171
|
+
printAgentPrompt('instrumentation', rel(existing), nextMajor(project), project);
|
|
118
172
|
} else {
|
|
119
|
-
const filePath = path.join(
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|
|
122
194
|
}
|
|
123
195
|
|
|
124
196
|
const configPath = findNextConfig(dir);
|
|
@@ -134,7 +206,12 @@ async function initNextJs(dir, project, flags) {
|
|
|
134
206
|
}
|
|
135
207
|
} else {
|
|
136
208
|
const newConfigPath = path.join(dir, 'next.config.mjs');
|
|
137
|
-
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} */
|
|
138
215
|
const nextConfig = {
|
|
139
216
|
serverExternalPackages: ['securenow'],
|
|
140
217
|
outputFileTracingIncludes: {
|
|
@@ -143,10 +220,22 @@ const nextConfig = {
|
|
|
143
220
|
};
|
|
144
221
|
|
|
145
222
|
export default nextConfig;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
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
|
+
`;
|
|
150
239
|
|
|
151
240
|
function patchNextConfig(configPath, major) {
|
|
152
241
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
@@ -239,13 +328,13 @@ function printAgentPrompt(kind, filename, major, project) {
|
|
|
239
328
|
? `Use the project scripts for verification when appropriate: ${commands}. Ask before starting long-running dev/start servers, and ask which command to use if these scripts are not the right customer workflow.`
|
|
240
329
|
: 'Ask the customer which command starts, builds, and tests this app because package.json does not expose an obvious script.',
|
|
241
330
|
kind === 'instrumentation'
|
|
242
|
-
? `Merge this into ${filename}:
|
|
331
|
+
? `Merge this into ${filename}: inside register(), in an if (process.env.NEXT_RUNTIME === "nodejs") { ... } block, wrapped in try/catch so the app keeps running if SecureNow cannot start, dynamically import "securenow/nextjs" (call its registerSecureNow({ captureBody: true })) and then "securenow/nextjs-auto-capture". Do not add webpackIgnore comments and do not use an early return: keeping the imports inside the nodejs block lets Next.js drop them from the Edge build and trace securenow's dependencies into standalone output. Preserve all existing instrumentation.`
|
|
243
332
|
: null,
|
|
244
333
|
kind === 'next-config' && major >= 15
|
|
245
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/**/*"] }.`
|
|
246
335
|
: null,
|
|
247
336
|
kind === 'next-config' && major < 15
|
|
248
|
-
? `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.`
|
|
249
338
|
: null,
|
|
250
339
|
'Verify with: npx securenow env, npx securenow test-span, npx securenow status, plus the selected build/test command when available.',
|
|
251
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.',
|
|
@@ -267,6 +356,19 @@ function initNuxt(dir) {
|
|
|
267
356
|
}
|
|
268
357
|
}
|
|
269
358
|
|
|
359
|
+
// In PM2 cluster mode Node resolves a bare `-r securenow/register` from the PM2 daemon's folder,
|
|
360
|
+
// so workers started from elsewhere (pm2 resurrect at boot) crash with "Cannot find module".
|
|
361
|
+
function checkPm2Ecosystem(dir) {
|
|
362
|
+
for (const name of ['ecosystem.config.cjs', 'ecosystem.config.js', 'ecosystem.config.mjs']) {
|
|
363
|
+
const file = path.join(dir, name);
|
|
364
|
+
if (!fs.existsSync(file)) continue;
|
|
365
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
366
|
+
if (content.includes('securenow/register') && !/require\.resolve\(\s*['"]securenow\/register['"]\s*\)/.test(content)) {
|
|
367
|
+
ui.warn(`${name}: pass the preload as an absolute path, node_args: ['-r', require.resolve('securenow/register')]. A bare '-r securenow/register' is resolved from the PM2 daemon's folder in cluster mode, and the workers crash when the daemon starts elsewhere (pm2 resurrect at boot).`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
270
372
|
function initNode(project) {
|
|
271
373
|
const scripts = project.pkg.scripts || {};
|
|
272
374
|
const startScript = scripts.start || '';
|
|
@@ -293,4 +395,6 @@ function initNode(project) {
|
|
|
293
395
|
}
|
|
294
396
|
}
|
|
295
397
|
|
|
296
|
-
module.exports = {
|
|
398
|
+
module.exports = {
|
|
399
|
+
INSTRUMENTATION,
|
|
400
|
+
isLegacyInstrumentation, init };
|