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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.12",
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": [
@@ -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-LITERAL hosts
59
- // in private / loopback / link-local / ULA ranges (the direct metadata-
60
- // endpoint vector). We deliberately do NOT pin to *.supabase.co because
61
- // operators self-host Fortress; any PUBLIC host is allowed. Residual:
62
- // DNS-rebinding (a public hostname resolving to a private IP) is not caught
63
- // here — that needs per-request resolved-IP pinning, out of scope for a sync
64
- // URL validator. Throws (fail-loud) on a bad URL rather than returning null,
65
- // so a misconfigured/hostile endpoint stops the run instead of silently
66
- // disabling upload/enforcement.
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
- if (!privateFortressAllowed() && isBlockedHost(u.hostname)) {
83
+ const cat = classifyHost(u.hostname);
84
+ if (cat === 'blocked') {
79
85
  throw new Error(
80
- `Fortress base URL host "${u.hostname}" is a private/loopback/link-local address — refusing (SSRF guard). ` +
81
- 'Point WMA_FORTRESS_BASE_URL at your public Fortress endpoint, or set ' +
82
- 'WMA_FORTRESS_ALLOW_PRIVATE_IPS=1 for a self-hosted Fortress on a private network.',
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
- // True if the host is an IP literal in a private/loopback/link-local/ULA range,
89
- // or localhost. Public hostnames and public IPs pass. Exported so the per-request
90
- // DNS guard (guardedLookup) can check RESOLVED ips with the exact same logic.
91
- export function isBlockedHost(hostname) {
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 true;
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 true; // loopback / unspecified
99
- if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // fe80::/10 link-local
100
- if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 unique-local
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 isBlockedIpv4(mappedDotted[1]);
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 isBlockedIpv4(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
125
+ return classifyIpv4(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
111
126
  }
112
- return false;
127
+ return 'public';
113
128
  }
114
129
 
115
130
  // IPv4 literal?
116
- if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return isBlockedIpv4(h);
117
- return false;
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
- // Escape hatch for a LEGITIMATE self-hosted Fortress on a private network
131
- // (hostname 10.x/192.168.x/etc). Off by default the secure posture is to
132
- // refuse private resolutions. Same flag gates assertSafeFortressBase.
133
- if (privateFortressAllowed()) return dnsLookup(hostname, opts, cb);
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 (isBlockedHost(a.address)) {
159
+ if (hostRefused(a.address)) {
160
+ const cat = classifyHost(a.address);
139
161
  return cb(new Error(
140
- `SSRF guard: "${hostname}" resolved to a private/loopback/link-local IP (${a.address}) — refusing the connection. ` +
141
- 'Set WMA_FORTRESS_ALLOW_PRIVATE_IPS=1 to allow a self-hosted Fortress on a private network.',
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
- function isBlockedIpv4(ip) {
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 true; // malformed → block
157
- const [a, b] = o;
158
- if (a === 127) return true; // 127.0.0.0/8 loopback
159
- if (a === 10) return true; // 10.0.0.0/8 private
160
- if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
161
- if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
162
- if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (cloud metadata)
163
- if (a === 0) return true; // 0.0.0.0/8
164
- return false;
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) {