securenow 8.24.0 → 8.25.1
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/firewall-iptables.js
CHANGED
|
@@ -1,104 +1,162 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Layer 3: OS-level firewall via iptables
|
|
5
|
-
* Manages
|
|
4
|
+
* Layer 3: OS-level firewall via iptables (IPv4), ip6tables (IPv6) or, where neither exists, nftables.
|
|
5
|
+
* Manages its own chain (SECURENOW_BLOCK), or with nft its own table: never touches customer rules.
|
|
6
6
|
* Linux only, requires root or CAP_NET_ADMIN. Falls back gracefully otherwise.
|
|
7
|
+
*
|
|
8
|
+
* Blocklist entries arrive over the network, so each one must be a literal IP or CIDR before it reaches
|
|
9
|
+
* a firewall command, and the binaries run without a shell. (Entries used to be pasted into a shell
|
|
10
|
+
* command run as root, and one IPv6 entry stopped the rest of the sync.) A sync is one batch
|
|
11
|
+
* (iptables-restore / nft -f), not one process per address.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
|
-
const {
|
|
14
|
+
const { execFileSync } = require('child_process');
|
|
15
|
+
const net = require('net');
|
|
10
16
|
const os = require('os');
|
|
11
17
|
|
|
12
18
|
const CHAIN_NAME = 'SECURENOW_BLOCK';
|
|
19
|
+
const NFT_TABLE = 'securenow';
|
|
13
20
|
|
|
14
21
|
let _options = null;
|
|
15
22
|
let _active = false;
|
|
16
|
-
let
|
|
23
|
+
let _backend = null; // 'iptables' | 'nft'
|
|
24
|
+
let _ip6 = false; // ip6tables available alongside iptables
|
|
17
25
|
|
|
18
|
-
function
|
|
19
|
-
return
|
|
26
|
+
function run(cmd, args, input) {
|
|
27
|
+
return execFileSync(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000, input }).toString().trim();
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
function canRun(cmd) {
|
|
23
|
-
try {
|
|
30
|
+
function canRun(cmd, args) {
|
|
31
|
+
try { run(cmd, args); return true; } catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function log(method, ...args) {
|
|
35
|
+
if (_options && _options.log) console[method](...args);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A literal IPv4/IPv6 address or CIDR, or null.
|
|
39
|
+
function parseEntry(raw) {
|
|
40
|
+
const entry = String(raw == null ? '' : raw).trim();
|
|
41
|
+
const slash = entry.indexOf('/');
|
|
42
|
+
const addr = slash === -1 ? entry : entry.slice(0, slash);
|
|
43
|
+
const family = net.isIP(addr);
|
|
44
|
+
if (!family) return null;
|
|
45
|
+
if (slash !== -1) {
|
|
46
|
+
const bits = entry.slice(slash + 1);
|
|
47
|
+
if (!/^\d{1,3}$/.test(bits) || Number(bits) > (family === 4 ? 32 : 128)) return null;
|
|
48
|
+
}
|
|
49
|
+
return { family, value: entry };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitFamilies(ips) {
|
|
53
|
+
const v4 = [];
|
|
54
|
+
const v6 = [];
|
|
55
|
+
let rejected = 0;
|
|
56
|
+
for (const raw of ips || []) {
|
|
57
|
+
const parsed = parseEntry(raw);
|
|
58
|
+
if (!parsed) { rejected++; continue; }
|
|
59
|
+
(parsed.family === 4 ? v4 : v6).push(parsed.value);
|
|
60
|
+
}
|
|
61
|
+
return { v4, v6, rejected };
|
|
24
62
|
}
|
|
25
63
|
|
|
26
64
|
function detectBackend() {
|
|
27
|
-
|
|
28
|
-
|
|
65
|
+
// iptables first: iptables-legacy and iptables-nft both accept it, while bare nft rules depend on
|
|
66
|
+
// tables the host may not have.
|
|
67
|
+
if (canRun('iptables', ['--version'])) return 'iptables';
|
|
68
|
+
if (canRun('nft', ['--version'])) return 'nft';
|
|
29
69
|
return null;
|
|
30
70
|
}
|
|
31
71
|
|
|
32
|
-
// ────── iptables backend ──────
|
|
72
|
+
// ────── iptables / ip6tables backend ──────
|
|
33
73
|
|
|
34
|
-
function iptablesSetup() {
|
|
35
|
-
try {
|
|
36
|
-
try {
|
|
37
|
-
|
|
74
|
+
function iptablesSetup(bin) {
|
|
75
|
+
try { run(bin, ['-N', CHAIN_NAME]); } catch (_) {} // chain may already exist
|
|
76
|
+
try { run(bin, ['-C', 'INPUT', '-j', CHAIN_NAME]); } catch (_) {
|
|
77
|
+
run(bin, ['-I', 'INPUT', '1', '-j', CHAIN_NAME]);
|
|
38
78
|
}
|
|
39
79
|
}
|
|
40
80
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
81
|
+
// Input for iptables-restore --noflush: replaces the chain's rules in one step, leaves everything else.
|
|
82
|
+
function restoreScript(entries) {
|
|
83
|
+
return [
|
|
84
|
+
'*filter',
|
|
85
|
+
`:${CHAIN_NAME} - [0:0]`,
|
|
86
|
+
`-F ${CHAIN_NAME}`,
|
|
87
|
+
...entries.map((entry) => `-A ${CHAIN_NAME} -s ${entry} -j DROP`),
|
|
88
|
+
'COMMIT',
|
|
89
|
+
'',
|
|
90
|
+
].join('\n');
|
|
46
91
|
}
|
|
47
92
|
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
try { exec(`iptables -X ${CHAIN_NAME}`); } catch (_) {}
|
|
93
|
+
function iptablesSync(v4, v6) {
|
|
94
|
+
run('iptables-restore', ['--noflush'], restoreScript(v4));
|
|
95
|
+
if (_ip6) run('ip6tables-restore', ['--noflush'], restoreScript(v6));
|
|
52
96
|
}
|
|
53
97
|
|
|
54
|
-
|
|
98
|
+
function iptablesCleanup(bin) {
|
|
99
|
+
try { run(bin, ['-D', 'INPUT', '-j', CHAIN_NAME]); } catch (_) {}
|
|
100
|
+
try { run(bin, ['-F', CHAIN_NAME]); } catch (_) {}
|
|
101
|
+
try { run(bin, ['-X', CHAIN_NAME]); } catch (_) {}
|
|
102
|
+
}
|
|
55
103
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
// ────── nftables backend (own table, own input hook) ──────
|
|
105
|
+
|
|
106
|
+
// One nft -f transaction: make sure the table exists, drop it, add it back with the current rules.
|
|
107
|
+
function nftScript(v4, v6) {
|
|
108
|
+
return [
|
|
109
|
+
`table inet ${NFT_TABLE}`,
|
|
110
|
+
`delete table inet ${NFT_TABLE}`,
|
|
111
|
+
`table inet ${NFT_TABLE} {`,
|
|
112
|
+
' chain input {',
|
|
113
|
+
' type filter hook input priority -10; policy accept;',
|
|
114
|
+
...v4.map((entry) => ` ip saddr ${entry} drop`),
|
|
115
|
+
...v6.map((entry) => ` ip6 saddr ${entry} drop`),
|
|
116
|
+
' }',
|
|
117
|
+
'}',
|
|
118
|
+
'',
|
|
119
|
+
].join('\n');
|
|
61
120
|
}
|
|
62
121
|
|
|
63
|
-
function nftSync(
|
|
64
|
-
|
|
65
|
-
for (const ip of ips) {
|
|
66
|
-
exec(`nft add rule ip filter ${CHAIN_NAME} ip saddr ${ip} drop`);
|
|
67
|
-
}
|
|
122
|
+
function nftSync(v4, v6) {
|
|
123
|
+
run('nft', ['-f', '-'], nftScript(v4, v6));
|
|
68
124
|
}
|
|
69
125
|
|
|
70
126
|
function nftCleanup() {
|
|
71
|
-
try {
|
|
72
|
-
try { exec(`nft delete chain ip filter ${CHAIN_NAME}`); } catch (_) {}
|
|
127
|
+
try { run('nft', ['delete', 'table', 'inet', NFT_TABLE]); } catch (_) {}
|
|
73
128
|
}
|
|
74
129
|
|
|
75
130
|
// ────── Public API ──────
|
|
76
131
|
|
|
77
|
-
let _syncCallback = null;
|
|
78
|
-
|
|
79
132
|
function init(options) {
|
|
80
133
|
_options = options;
|
|
81
134
|
|
|
82
135
|
if (os.platform() !== 'linux') {
|
|
83
|
-
|
|
136
|
+
log('warn', '[securenow] Firewall iptables: only supported on Linux, skipping');
|
|
84
137
|
return;
|
|
85
138
|
}
|
|
86
139
|
|
|
87
|
-
|
|
88
|
-
if (!
|
|
89
|
-
|
|
140
|
+
_backend = detectBackend();
|
|
141
|
+
if (!_backend) {
|
|
142
|
+
log('warn', '[securenow] Firewall iptables: neither iptables nor nft found, skipping');
|
|
90
143
|
return;
|
|
91
144
|
}
|
|
92
145
|
|
|
93
|
-
_useNft = backend === 'nft';
|
|
94
|
-
const label = _useNft ? 'nftables' : 'iptables';
|
|
95
|
-
|
|
96
146
|
try {
|
|
97
|
-
if (
|
|
147
|
+
if (_backend === 'nft') {
|
|
148
|
+
nftSync([], []);
|
|
149
|
+
} else {
|
|
150
|
+
iptablesSetup('iptables');
|
|
151
|
+
if (canRun('ip6tables', ['--version'])) {
|
|
152
|
+
try { iptablesSetup('ip6tables'); _ip6 = true; } catch (_) { _ip6 = false; }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
98
155
|
_active = true;
|
|
99
|
-
|
|
156
|
+
log('log', '[securenow] Firewall iptables: %s %s ready%s', _backend === 'nft' ? 'nftables table' : 'iptables chain',
|
|
157
|
+
_backend === 'nft' ? NFT_TABLE : CHAIN_NAME, _backend === 'iptables' && !_ip6 ? ' (IPv4 only: no ip6tables)' : '');
|
|
100
158
|
} catch (e) {
|
|
101
|
-
|
|
159
|
+
log('warn', '[securenow] Firewall iptables: setup failed (need root or CAP_NET_ADMIN):', e.message);
|
|
102
160
|
}
|
|
103
161
|
}
|
|
104
162
|
|
|
@@ -110,30 +168,33 @@ function sync(ips) {
|
|
|
110
168
|
if (!_active) return;
|
|
111
169
|
|
|
112
170
|
const maxRules = (_options && _options.iptablesMax) || 10000;
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
171
|
+
const list = Array.isArray(ips) ? ips : [];
|
|
172
|
+
if (list.length > maxRules) log('warn', '[securenow] Firewall iptables: truncated to %d rules (max %d)', maxRules, maxRules);
|
|
173
|
+
const { v4, v6, rejected } = splitFamilies(list.slice(0, maxRules));
|
|
174
|
+
if (rejected) log('warn', '[securenow] Firewall iptables: ignored %d blocklist entries that are not IPs or CIDRs', rejected);
|
|
118
175
|
|
|
119
176
|
try {
|
|
120
|
-
if (
|
|
177
|
+
if (_backend === 'nft') nftSync(v4, v6);
|
|
178
|
+
else iptablesSync(v4, v6);
|
|
121
179
|
} catch (e) {
|
|
122
|
-
|
|
123
|
-
console.warn('[securenow] Firewall iptables: sync failed:', e.message);
|
|
124
|
-
}
|
|
180
|
+
log('warn', '[securenow] Firewall iptables: sync failed:', e.message);
|
|
125
181
|
}
|
|
126
182
|
}
|
|
127
183
|
|
|
128
184
|
function shutdown() {
|
|
129
185
|
if (!_active) return;
|
|
130
186
|
try {
|
|
131
|
-
if (
|
|
132
|
-
|
|
187
|
+
if (_backend === 'nft') {
|
|
188
|
+
nftCleanup();
|
|
189
|
+
} else {
|
|
190
|
+
iptablesCleanup('iptables');
|
|
191
|
+
if (_ip6) iptablesCleanup('ip6tables');
|
|
192
|
+
}
|
|
193
|
+
log('log', '[securenow] Firewall iptables: %s cleaned up', _backend === 'nft' ? `table ${NFT_TABLE}` : `chain ${CHAIN_NAME}`);
|
|
133
194
|
} catch (e) {
|
|
134
|
-
|
|
195
|
+
log('warn', '[securenow] Firewall iptables: cleanup failed:', e.message);
|
|
135
196
|
}
|
|
136
197
|
_active = false;
|
|
137
198
|
}
|
|
138
199
|
|
|
139
|
-
module.exports = { init, sync, shutdown };
|
|
200
|
+
module.exports = { init, sync, shutdown, _test: { parseEntry, splitFamilies, restoreScript, nftScript } };
|
package/firewall-only.js
CHANGED
|
@@ -41,6 +41,8 @@ if (firewallOptions.apiKey) {
|
|
|
41
41
|
aws: firewallOptions.aws,
|
|
42
42
|
gcp: firewallOptions.gcp,
|
|
43
43
|
});
|
|
44
|
+
// Zero-code session revocation / session-theft capture (config.sessions.guard / .capture).
|
|
45
|
+
require('./sessions').installHttpGuard(appConfig.resolveSessionsOptions());
|
|
44
46
|
|
|
45
47
|
const shutdown = () => { try { require('./firewall').shutdown(); } catch (_) {} };
|
|
46
48
|
process.on('SIGINT', shutdown);
|
package/firewall-tcp.js
CHANGED
|
@@ -18,12 +18,20 @@ let _options = null;
|
|
|
18
18
|
let _patched = false;
|
|
19
19
|
const _origListen = net.Server.prototype.listen;
|
|
20
20
|
|
|
21
|
+
const TCP_CHECKED = Symbol('securenow.tcpChecked');
|
|
22
|
+
|
|
23
|
+
// Returns true when it destroyed the socket. The firewall's emit guard calls it on every 'connection'
|
|
24
|
+
// of an HTTP/HTTPS/HTTP2 server, including servers that were already listening when the firewall
|
|
25
|
+
// started (the app usually calls listen() before the SDK's asynchronous start completes, so a listen()
|
|
26
|
+
// patch alone never saw them); the listen() patch below covers other net servers created later.
|
|
21
27
|
function onConnection(socket) {
|
|
28
|
+
if (!socket || socket[TCP_CHECKED] || !_getMatcher) return false;
|
|
29
|
+
socket[TCP_CHECKED] = true;
|
|
22
30
|
const ip = resolveSocketIp(socket);
|
|
23
31
|
|
|
24
32
|
// Skip if the connection is from a trusted proxy — Layer 1 will handle it
|
|
25
33
|
// with proper X-Forwarded-For resolution
|
|
26
|
-
if (isFromTrustedProxy(ip) || isFromTrustedProxy('::ffff:' + ip)) return;
|
|
34
|
+
if (isFromTrustedProxy(ip) || isFromTrustedProxy('::ffff:' + ip)) return false;
|
|
27
35
|
|
|
28
36
|
// Allowlist check: if active, only listed IPs pass
|
|
29
37
|
const allowlistMatcher = _getAllowlistMatcher ? _getAllowlistMatcher() : null;
|
|
@@ -33,21 +41,23 @@ function onConnection(socket) {
|
|
|
33
41
|
console.log('[securenow] Firewall: blocked %s via TCP (not in allowlist)', ip);
|
|
34
42
|
}
|
|
35
43
|
socket.destroy();
|
|
36
|
-
return;
|
|
44
|
+
return true;
|
|
37
45
|
}
|
|
38
|
-
return; // on allowlist — allow
|
|
46
|
+
return false; // on allowlist — allow
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
// Blocklist check
|
|
42
50
|
const matcher = _getMatcher();
|
|
43
|
-
if (!matcher) return;
|
|
51
|
+
if (!matcher) return false;
|
|
44
52
|
|
|
45
53
|
if (matcher.isBlocked(ip)) {
|
|
46
54
|
if (_options && _options.log) {
|
|
47
55
|
console.log('[securenow] Firewall: blocked %s via TCP (socket destroyed)', ip);
|
|
48
56
|
}
|
|
49
57
|
socket.destroy();
|
|
58
|
+
return true;
|
|
50
59
|
}
|
|
60
|
+
return false;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
function init(getMatcher, options, getAllowlistMatcher) {
|
|
@@ -71,4 +81,8 @@ function shutdown() {
|
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
|
|
74
|
-
|
|
84
|
+
function checkConnection(socket) {
|
|
85
|
+
return _patched ? onConnection(socket) : false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { init, shutdown, checkConnection };
|
package/firewall.js
CHANGED
|
@@ -1445,12 +1445,31 @@ const _origHttpsEmit = https.Server.prototype.emit;
|
|
|
1445
1445
|
const FIREWALL_CHECKED = Symbol('securenow.firewallChecked');
|
|
1446
1446
|
let _emitPatched = false;
|
|
1447
1447
|
|
|
1448
|
+
// Checks that run after the firewall lets a request through, independent of its remote toggle (the
|
|
1449
|
+
// zero-code session guard in sessions.js). Each returns true when it has answered the request itself.
|
|
1450
|
+
const _requestHooks = [];
|
|
1451
|
+
// Layer 2, when enabled: checked on each 'connection', before the server parses anything.
|
|
1452
|
+
let _tcpLayer = null;
|
|
1453
|
+
function addRequestHook(fn) {
|
|
1454
|
+
if (typeof fn === 'function' && !_requestHooks.includes(fn)) _requestHooks.push(fn);
|
|
1455
|
+
}
|
|
1456
|
+
function runRequestHooks(req, res) {
|
|
1457
|
+
for (const hook of _requestHooks) {
|
|
1458
|
+
try {
|
|
1459
|
+
if (hook(req, res)) return true;
|
|
1460
|
+
} catch (_) {}
|
|
1461
|
+
}
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1448
1465
|
// One 'request' guard for every server kind: the firewall runs once per request, then the server's own emit.
|
|
1449
1466
|
function guardedEmit(originalEmit) {
|
|
1450
1467
|
return function securenowGuardedEmit(event, req, res, ...rest) {
|
|
1468
|
+
if (event === 'connection' && _tcpLayer && req && _tcpLayer.checkConnection(req)) return true;
|
|
1451
1469
|
if (event === 'request' && req && res && !res.headersSent && !req[FIREWALL_CHECKED]) {
|
|
1452
1470
|
req[FIREWALL_CHECKED] = true;
|
|
1453
1471
|
if (firewallRequestHandler(req, res)) return true;
|
|
1472
|
+
if (_requestHooks.length && runRequestHooks(req, res)) return true;
|
|
1454
1473
|
}
|
|
1455
1474
|
return originalEmit.call(this, event, req, res, ...rest);
|
|
1456
1475
|
};
|
|
@@ -1528,6 +1547,7 @@ function init(options) {
|
|
|
1528
1547
|
const tcpLayer = require('./firewall-tcp');
|
|
1529
1548
|
tcpLayer.init(() => _matcher, _options, () => _allowlistMatcher);
|
|
1530
1549
|
_layers.push(tcpLayer);
|
|
1550
|
+
_tcpLayer = tcpLayer;
|
|
1531
1551
|
if (_options.log) fwLog('[securenow] Firewall: Layer 2 (TCP drop) active');
|
|
1532
1552
|
} catch (e) {
|
|
1533
1553
|
if (_options.log) fwWarn('[securenow] Firewall: Layer 2 (TCP drop) failed:', e.message);
|
|
@@ -1671,4 +1691,4 @@ const _test = {
|
|
|
1671
1691
|
identity: () => ({ sdkVersion: SDK_VERSION, instanceId: INSTANCE_ID }),
|
|
1672
1692
|
};
|
|
1673
1693
|
|
|
1674
|
-
module.exports = { init, shutdown, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, getChallengeRules, isRemoteEnabled, _test };
|
|
1694
|
+
module.exports = { init, shutdown, addRequestHook, getStats, getMatcher, getAllowlistMatcher, getBlocklistRules, getRateLimitRules, getChallengeRules, isRemoteEnabled, _test };
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Route templates (http.route) for frameworks the OpenTelemetry bundle does not instrument: Hono, h3,
|
|
5
|
+
* Feathers and Polka. Without these their request spans carried only the raw path (/users/42), not the
|
|
6
|
+
* route (/users/:id). Each hook reads the framework's own routing result and writes it to the request's
|
|
7
|
+
* server span; nothing changes how the app routes or responds.
|
|
8
|
+
*
|
|
9
|
+
* The same hooks record a handler's server error (5xx) as an exception event, with its stack, on that
|
|
10
|
+
* span: these frameworks catch the error and answer 500 themselves, so it never reached the HTTP layer.
|
|
11
|
+
*
|
|
12
|
+
* One OpenTelemetry instrumentation, so CommonJS and ESM (through the preload's ESM hook) are both patched.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { InstrumentationBase, InstrumentationNodeModuleDefinition } = require('@opentelemetry/instrumentation');
|
|
16
|
+
|
|
17
|
+
const PATCHED = Symbol.for('securenow.routePatched');
|
|
18
|
+
|
|
19
|
+
// The route goes on the request's server span twice: through the RPC metadata the HTTP instrumentation
|
|
20
|
+
// reads when the response finishes (it then names the span "GET /users/:id"), and as an attribute now.
|
|
21
|
+
function setRoute(route) {
|
|
22
|
+
if (!route || typeof route !== 'string') return;
|
|
23
|
+
try {
|
|
24
|
+
const api = require('@opentelemetry/api');
|
|
25
|
+
const core = require('@opentelemetry/core');
|
|
26
|
+
const meta = core.getRPCMetadata(api.context.active());
|
|
27
|
+
if (meta && meta.type === core.RPCType.HTTP) meta.route = route;
|
|
28
|
+
const span = api.trace.getActiveSpan();
|
|
29
|
+
if (span) span.setAttribute('http.route', route);
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function markPatched(fn) {
|
|
34
|
+
try { Object.defineProperty(fn, PATCHED, { value: true }); } catch (_) {}
|
|
35
|
+
return fn;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The HTTP status an error stands for: statusCode (h3), status (Hono HTTPException, http-errors), or a
|
|
39
|
+
// numeric code (Feathers errors). Anything else is a 500.
|
|
40
|
+
function errorStatus(err) {
|
|
41
|
+
if (!err || typeof err !== 'object') return 500;
|
|
42
|
+
const status = Number(err.statusCode || err.status || (typeof err.code === 'number' ? err.code : 0));
|
|
43
|
+
return Number.isInteger(status) && status >= 100 && status < 600 ? status : 500;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A 4xx is the client's doing (not found, unauthorized...) and stays off the span, as in OpenTelemetry's
|
|
47
|
+
// HTTP conventions; the status code itself still records it.
|
|
48
|
+
function recordServerError(err, status = errorStatus(err)) {
|
|
49
|
+
if (!err || status < 500) return;
|
|
50
|
+
try {
|
|
51
|
+
const span = require('@opentelemetry/api').trace.getActiveSpan();
|
|
52
|
+
if (span) span.recordException(err instanceof Error ? err : String(err && err.message ? err.message : err));
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- Hono 4: a '*' middleware added to every app reads the route that answered, after the handler ran.
|
|
57
|
+
function honoRouteOf(c) {
|
|
58
|
+
const req = c && c.req;
|
|
59
|
+
if (!req) return null;
|
|
60
|
+
try {
|
|
61
|
+
if (typeof req.routePath === 'string') return req.routePath;
|
|
62
|
+
} catch (_) {}
|
|
63
|
+
try {
|
|
64
|
+
const routes = req.matchedRoutes;
|
|
65
|
+
if (Array.isArray(routes) && routes[req.routeIndex]) return routes[req.routeIndex].path;
|
|
66
|
+
} catch (_) {}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function patchHono(exports) {
|
|
71
|
+
const Original = exports && exports.Hono;
|
|
72
|
+
if (typeof Original !== 'function' || Original[PATCHED]) return exports;
|
|
73
|
+
class Hono extends Original {
|
|
74
|
+
constructor(...args) {
|
|
75
|
+
super(...args);
|
|
76
|
+
try {
|
|
77
|
+
this.use('*', async function securenowRoute(c, next) {
|
|
78
|
+
try {
|
|
79
|
+
await next();
|
|
80
|
+
} finally {
|
|
81
|
+
const route = honoRouteOf(c);
|
|
82
|
+
if (route && route !== '*' && route !== '/*') setRoute(route);
|
|
83
|
+
// Hono's compose catches a handler's error, sets c.error and answers with app.onError.
|
|
84
|
+
if (c && c.error) recordServerError(c.error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
} catch (_) {}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try { Object.defineProperty(Hono, 'name', { value: 'Hono' }); } catch (_) {}
|
|
91
|
+
markPatched(Hono);
|
|
92
|
+
try { exports.Hono = Hono; } catch (_) {}
|
|
93
|
+
return exports;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- h3 1: the router records event.context.matchedRoute; an onBeforeResponse hook reads it.
|
|
97
|
+
function patchH3(exports) {
|
|
98
|
+
const original = exports && exports.createApp;
|
|
99
|
+
if (typeof original !== 'function' || original[PATCHED]) return exports;
|
|
100
|
+
const createApp = markPatched(function createApp(options) {
|
|
101
|
+
const opts = options || {};
|
|
102
|
+
const userHook = opts.onBeforeResponse;
|
|
103
|
+
const userOnError = opts.onError;
|
|
104
|
+
return original.call(this, {
|
|
105
|
+
...opts,
|
|
106
|
+
onBeforeResponse(event, response) {
|
|
107
|
+
try {
|
|
108
|
+
const matched = event && event.context && event.context.matchedRoute;
|
|
109
|
+
if (matched && matched.path) setRoute(matched.path);
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
return typeof userHook === 'function' ? userHook.call(this, event, response) : undefined;
|
|
112
|
+
},
|
|
113
|
+
// h3 wraps a thrown error in an H3Error (cause = the original, with its stack), calls onError, then
|
|
114
|
+
// still sends its default error response unless this hook handled the event.
|
|
115
|
+
onError(error, event) {
|
|
116
|
+
try {
|
|
117
|
+
recordServerError(error && error.cause instanceof Error ? error.cause : error, errorStatus(error));
|
|
118
|
+
} catch (_) {}
|
|
119
|
+
return typeof userOnError === 'function' ? userOnError.call(this, error, event) : undefined;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
try { exports.createApp = createApp; } catch (_) {}
|
|
124
|
+
return exports;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- Feathers 5: an app-wide around hook names external service calls (/users, /users/:id).
|
|
128
|
+
function feathersRoute(context) {
|
|
129
|
+
if (!context || typeof context.path !== 'string') return null;
|
|
130
|
+
const base = `/${context.path.replace(/^\/+/, '')}`;
|
|
131
|
+
return context.id !== undefined && context.id !== null ? `${base}/:id` : base;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function patchFeathers(exports) {
|
|
135
|
+
const original = exports && exports.feathers;
|
|
136
|
+
if (typeof original !== 'function' || original[PATCHED]) return exports;
|
|
137
|
+
const feathers = markPatched(function feathers(...args) {
|
|
138
|
+
const app = original.apply(this, args);
|
|
139
|
+
try {
|
|
140
|
+
app.hooks({
|
|
141
|
+
around: {
|
|
142
|
+
all: [async function securenowRoute(context, next) {
|
|
143
|
+
try {
|
|
144
|
+
await next();
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (context && context.params && context.params.provider) recordServerError(err);
|
|
147
|
+
throw err;
|
|
148
|
+
} finally {
|
|
149
|
+
// Only calls that came in through a transport (REST, sockets), not service-to-service calls.
|
|
150
|
+
if (context && context.params && context.params.provider) {
|
|
151
|
+
const route = feathersRoute(context);
|
|
152
|
+
if (route) setRoute(route);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}],
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
} catch (_) {}
|
|
159
|
+
return app;
|
|
160
|
+
});
|
|
161
|
+
try { exports.feathers = feathers; } catch (_) {}
|
|
162
|
+
return exports;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- Polka (trouter 2): find() returns the matched route's handlers; their registered pattern is the route.
|
|
166
|
+
// Polka passes next(err) errors to the app's own onError, which find() wraps the first time it runs.
|
|
167
|
+
function wrapPolkaOnError(app) {
|
|
168
|
+
const userOnError = app && app.onError;
|
|
169
|
+
if (typeof userOnError !== 'function' || userOnError[PATCHED]) return;
|
|
170
|
+
app.onError = markPatched(function onError(err, ...rest) {
|
|
171
|
+
recordServerError(err);
|
|
172
|
+
return userOnError.call(this, err, ...rest);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function patchTrouter(exports) {
|
|
177
|
+
const Trouter = exports && (typeof exports === 'function' ? exports : exports.default);
|
|
178
|
+
const proto = Trouter && Trouter.prototype;
|
|
179
|
+
if (!proto || typeof proto.find !== 'function' || proto.find[PATCHED]) return exports;
|
|
180
|
+
const original = proto.find;
|
|
181
|
+
proto.find = markPatched(function find(method, url) {
|
|
182
|
+
const result = original.call(this, method, url);
|
|
183
|
+
try { wrapPolkaOnError(this); } catch (_) {}
|
|
184
|
+
try {
|
|
185
|
+
if (result && result.handlers && this.handlers) {
|
|
186
|
+
for (const m of [method, '*']) {
|
|
187
|
+
const table = this.handlers[m];
|
|
188
|
+
if (!table) continue;
|
|
189
|
+
for (const pattern of Object.keys(table)) {
|
|
190
|
+
if (table[pattern] === result.handlers) {
|
|
191
|
+
setRoute(pattern);
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch (_) {}
|
|
198
|
+
return result;
|
|
199
|
+
});
|
|
200
|
+
return exports;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
class SecureNowRouteInstrumentation extends InstrumentationBase {
|
|
204
|
+
constructor(config = {}) {
|
|
205
|
+
super('securenow-framework-routes', '1.0.0', config);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
init() {
|
|
209
|
+
return [
|
|
210
|
+
new InstrumentationNodeModuleDefinition('hono', ['>=4.0.0 <5'], patchHono),
|
|
211
|
+
new InstrumentationNodeModuleDefinition('h3', ['>=1.0.0 <2'], patchH3),
|
|
212
|
+
new InstrumentationNodeModuleDefinition('@feathersjs/feathers', ['>=5.0.0 <6'], patchFeathers),
|
|
213
|
+
new InstrumentationNodeModuleDefinition('trouter', ['>=2.0.0 <3'], patchTrouter),
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
SecureNowRouteInstrumentation,
|
|
220
|
+
// For tests.
|
|
221
|
+
_test: { setRoute, patchHono, patchH3, patchFeathers, patchTrouter, honoRouteOf, feathersRoute, errorStatus, recordServerError },
|
|
222
|
+
};
|
package/log-format.js
CHANGED
|
@@ -19,6 +19,8 @@ function renderArg(arg) {
|
|
|
19
19
|
|
|
20
20
|
function formatLogArgs(args) {
|
|
21
21
|
if (!Array.isArray(args) || args.length === 0) return '';
|
|
22
|
+
// Credential fields in logged objects are redacted first (config.logging.redact, log-redaction.js).
|
|
23
|
+
try { args = require('./log-redaction').redactLogArgs(args); } catch (_) { /* format as is */ }
|
|
22
24
|
if (typeof args[0] === 'string' && PLACEHOLDER.test(args[0])) {
|
|
23
25
|
try { return util.format(...args); } catch (_) { /* fall through */ }
|
|
24
26
|
}
|