watchmyagents 1.4.12 → 1.4.13
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/package.json +1 -1
- package/src/fortress/url.js +80 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "watchmyagents",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.13",
|
|
4
4
|
"description": "Security observability + real-time policy enforcement for AI agents. Local-first NDJSON capture with a continuous Watch daemon that auto-uploads anonymized signals, Shield CLI that blocks policy violations live (with policies pulled from Fortress cloud), anonymizer producing signals-only payloads, bidirectional sync with WatchMyAgents Fortress, and one-command install as an always-on launchd/systemd service — closing the recursive Watch→Guardian→Shield security loop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
package/src/fortress/url.js
CHANGED
|
@@ -55,15 +55,20 @@ export function resolveFortressBase({ explicitUrl, explicitBase, env = process.e
|
|
|
55
55
|
// host, or a cloud metadata endpoint (169.254.169.254) and exfiltrate the key
|
|
56
56
|
// — the only prior check was `protocol === 'https:'` at the request layer.
|
|
57
57
|
//
|
|
58
|
-
// We require https, reject embedded credentials, and reject IP
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
58
|
+
// We require https, reject embedded credentials, and reject IP hosts that are
|
|
59
|
+
// not global-unicast. v1.4.13 (Codex P2) hardens this into a 3-way classify:
|
|
60
|
+
// - 'blocked' — loopback / link-local+metadata / multicast / reserved /
|
|
61
|
+
// broadcast / CGNAT / TEST-NET / benchmarking / 0/8. NEVER
|
|
62
|
+
// allowed, even with the opt-out flag (metadata exfil + the
|
|
63
|
+
// other non-routable ranges have no legitimate Fortress use).
|
|
64
|
+
// - 'private' — RFC1918 (10/8, 172.16/12, 192.168/16) + IPv6 ULA. Blocked by
|
|
65
|
+
// default; allowed ONLY with WMA_FORTRESS_ALLOW_PRIVATE_IPS=1
|
|
66
|
+
// (a legitimate self-hosted Fortress on a private network).
|
|
67
|
+
// - 'public' — global-unicast / public hostname. Allowed.
|
|
68
|
+
// We deliberately do NOT pin to *.supabase.co because operators self-host. The
|
|
69
|
+
// DNS-rebinding gap (a public hostname resolving to a non-global IP at connect
|
|
70
|
+
// time) is closed by guardedLookup (below) — wired into every Fortress request.
|
|
71
|
+
// Throws (fail-loud) so a misconfigured/hostile endpoint stops the run.
|
|
67
72
|
export function assertSafeFortressBase(base) {
|
|
68
73
|
let u;
|
|
69
74
|
try { u = new URL(base); }
|
|
@@ -75,46 +80,62 @@ export function assertSafeFortressBase(base) {
|
|
|
75
80
|
if (u.username || u.password) {
|
|
76
81
|
throw new Error('Fortress base URL must not embed credentials (user:pass@host).');
|
|
77
82
|
}
|
|
78
|
-
|
|
83
|
+
const cat = classifyHost(u.hostname);
|
|
84
|
+
if (cat === 'blocked') {
|
|
79
85
|
throw new Error(
|
|
80
|
-
`Fortress base URL host "${u.hostname}" is a
|
|
81
|
-
'Point WMA_FORTRESS_BASE_URL at
|
|
82
|
-
|
|
86
|
+
`Fortress base URL host "${u.hostname}" is a loopback/link-local/metadata/reserved address — refusing (SSRF guard). ` +
|
|
87
|
+
'This is ALWAYS blocked (the opt-out flag does not apply). Point WMA_FORTRESS_BASE_URL at a public endpoint.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (cat === 'private' && !privateFortressAllowed()) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Fortress base URL host "${u.hostname}" is a private-network (RFC1918/ULA) address — refusing (SSRF guard). ` +
|
|
93
|
+
'For a legitimate self-hosted Fortress on a private network, set WMA_FORTRESS_ALLOW_PRIVATE_IPS=1.',
|
|
83
94
|
);
|
|
84
95
|
}
|
|
85
96
|
return base;
|
|
86
97
|
}
|
|
87
98
|
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
99
|
+
// Classify a host (literal IP or hostname) into 'public' | 'private' | 'blocked'.
|
|
100
|
+
// 'blocked' — never a legitimate Fortress target (always refused).
|
|
101
|
+
// 'private' — RFC1918 / IPv6 ULA (refused unless the opt-out flag is set).
|
|
102
|
+
// 'public' — global-unicast / public hostname (allowed).
|
|
103
|
+
// Used by both the URL validator and the per-request DNS guard so a literal IP
|
|
104
|
+
// and a RESOLVED IP go through the EXACT same policy.
|
|
105
|
+
export function classifyHost(hostname) {
|
|
92
106
|
// Node keeps surrounding brackets on IPv6 hostnames (e.g. "[::1]"); strip them.
|
|
93
107
|
const h = String(hostname).toLowerCase().replace(/^\[/, '').replace(/\]$/, '');
|
|
94
|
-
if (h === 'localhost' || h.endsWith('.localhost')) return
|
|
108
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return 'blocked';
|
|
95
109
|
|
|
96
110
|
// IPv6 literal.
|
|
97
111
|
if (h.includes(':')) {
|
|
98
|
-
if (h === '::1' || h === '::') return
|
|
99
|
-
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return
|
|
100
|
-
if (h.startsWith('
|
|
112
|
+
if (h === '::1' || h === '::') return 'blocked'; // loopback / unspecified
|
|
113
|
+
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return 'blocked'; // fe80::/10 link-local
|
|
114
|
+
if (h.startsWith('ff')) return 'blocked'; // ff00::/8 multicast
|
|
115
|
+
if (h.startsWith('fc') || h.startsWith('fd')) return 'private'; // fc00::/7 ULA (RFC1918-equivalent)
|
|
101
116
|
// IPv4-mapped IPv6 (::ffff:a.b.c.d). CRITICAL: Node's URL parser normalizes
|
|
102
117
|
// the dotted form to HEX — new URL('https://[::ffff:127.0.0.1]/').hostname
|
|
103
118
|
// is '[::ffff:7f00:1]' — so a regex matching only the dotted form lets
|
|
104
119
|
// 127.0.0.1 / 169.254.169.254 (metadata!) slip past. Decode BOTH forms.
|
|
105
120
|
const mappedDotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
106
|
-
if (mappedDotted) return
|
|
121
|
+
if (mappedDotted) return classifyIpv4(mappedDotted[1]);
|
|
107
122
|
const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
108
123
|
if (mappedHex) {
|
|
109
124
|
const hi = parseInt(mappedHex[1], 16), lo = parseInt(mappedHex[2], 16);
|
|
110
|
-
return
|
|
125
|
+
return classifyIpv4(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
111
126
|
}
|
|
112
|
-
return
|
|
127
|
+
return 'public';
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
// IPv4 literal?
|
|
116
|
-
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return
|
|
117
|
-
return
|
|
131
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return classifyIpv4(h);
|
|
132
|
+
return 'public'; // public hostname (DNS rebinding closed by guardedLookup)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// True if a host is refused under the CURRENT policy (flag-aware).
|
|
136
|
+
function hostRefused(hostname) {
|
|
137
|
+
const cat = classifyHost(hostname);
|
|
138
|
+
return cat === 'blocked' || (cat === 'private' && !privateFortressAllowed());
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
// v1.4.11 (Codex P1/P2): per-request DNS guard — closes the DNS-rebinding gap
|
|
@@ -127,18 +148,20 @@ export function isBlockedHost(hostname) {
|
|
|
127
148
|
export function guardedLookup(hostname, options, callback) {
|
|
128
149
|
const cb = typeof options === 'function' ? options : callback;
|
|
129
150
|
const opts = typeof options === 'function' ? {} : (options || {});
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
151
|
+
// NOTE: we do NOT early-return when the opt-out flag is set. hostRefused()
|
|
152
|
+
// is flag-aware: it allows RFC1918 ('private') resolutions when the flag is
|
|
153
|
+
// set, but STILL blocks loopback / link-local / metadata / reserved
|
|
154
|
+
// ('blocked') even then — the flag must not open the cloud-metadata vector.
|
|
134
155
|
dnsLookup(hostname, opts, (err, address, family) => {
|
|
135
156
|
if (err) return cb(err);
|
|
136
157
|
const list = Array.isArray(address) ? address : [{ address, family }];
|
|
137
158
|
for (const a of list) {
|
|
138
|
-
if (
|
|
159
|
+
if (hostRefused(a.address)) {
|
|
160
|
+
const cat = classifyHost(a.address);
|
|
139
161
|
return cb(new Error(
|
|
140
|
-
`SSRF guard: "${hostname}" resolved to a
|
|
141
|
-
|
|
162
|
+
`SSRF guard: "${hostname}" resolved to a ${cat === 'blocked' ? 'loopback/link-local/metadata/reserved' : 'private-network'} ` +
|
|
163
|
+
`IP (${a.address}) — refusing the connection.` +
|
|
164
|
+
(cat === 'private' ? ' Set WMA_FORTRESS_ALLOW_PRIVATE_IPS=1 for a self-hosted Fortress on a private network.' : ''),
|
|
142
165
|
));
|
|
143
166
|
}
|
|
144
167
|
}
|
|
@@ -151,17 +174,31 @@ function privateFortressAllowed() {
|
|
|
151
174
|
return process.env.WMA_FORTRESS_ALLOW_PRIVATE_IPS === '1';
|
|
152
175
|
}
|
|
153
176
|
|
|
154
|
-
|
|
177
|
+
// Classify an IPv4 literal. 'private' = RFC1918 (opt-out-able). Everything
|
|
178
|
+
// non-global-unicast and non-RFC1918 is 'blocked' (never allowed): loopback,
|
|
179
|
+
// link-local/metadata, CGNAT, IETF protocol, TEST-NET-1/2/3, benchmarking,
|
|
180
|
+
// multicast, reserved/future, broadcast, this-network. (v1.4.13 Codex P2.)
|
|
181
|
+
function classifyIpv4(ip) {
|
|
155
182
|
const o = ip.split('.').map(Number);
|
|
156
|
-
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return
|
|
157
|
-
const [a, b] = o;
|
|
158
|
-
|
|
159
|
-
if (a === 10) return
|
|
160
|
-
if (a === 172 && b >= 16 && b <= 31) return
|
|
161
|
-
if (a === 192 && b === 168) return
|
|
162
|
-
|
|
163
|
-
if (a === 0) return
|
|
164
|
-
return
|
|
183
|
+
if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return 'blocked'; // malformed
|
|
184
|
+
const [a, b, c] = o;
|
|
185
|
+
// RFC1918 private — the only ranges the opt-out flag un-blocks.
|
|
186
|
+
if (a === 10) return 'private'; // 10.0.0.0/8
|
|
187
|
+
if (a === 172 && b >= 16 && b <= 31) return 'private'; // 172.16.0.0/12
|
|
188
|
+
if (a === 192 && b === 168) return 'private'; // 192.168.0.0/16
|
|
189
|
+
// Always blocked (not global-unicast; flag does NOT apply).
|
|
190
|
+
if (a === 0) return 'blocked'; // 0.0.0.0/8 this-network
|
|
191
|
+
if (a === 127) return 'blocked'; // 127.0.0.0/8 loopback
|
|
192
|
+
if (a === 169 && b === 254) return 'blocked'; // 169.254.0.0/16 link-local + cloud metadata
|
|
193
|
+
if (a === 100 && b >= 64 && b <= 127) return 'blocked'; // 100.64.0.0/10 CGNAT
|
|
194
|
+
if (a === 192 && b === 0 && c === 0) return 'blocked'; // 192.0.0.0/24 IETF protocol assignments
|
|
195
|
+
if (a === 192 && b === 0 && c === 2) return 'blocked'; // 192.0.2.0/24 TEST-NET-1
|
|
196
|
+
if (a === 198 && (b === 18 || b === 19)) return 'blocked'; // 198.18.0.0/15 benchmarking
|
|
197
|
+
if (a === 198 && b === 51 && c === 100) return 'blocked'; // 198.51.100.0/24 TEST-NET-2
|
|
198
|
+
if (a === 203 && b === 0 && c === 113) return 'blocked'; // 203.0.113.0/24 TEST-NET-3
|
|
199
|
+
if (a >= 224 && a <= 239) return 'blocked'; // 224.0.0.0/4 multicast
|
|
200
|
+
if (a >= 240) return 'blocked'; // 240.0.0.0/4 reserved + 255.255.255.255 broadcast
|
|
201
|
+
return 'public';
|
|
165
202
|
}
|
|
166
203
|
|
|
167
204
|
function stripTrailingSlash(s) {
|