@hasna/uptime 0.1.11 → 0.1.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/dist/index.js CHANGED
@@ -1,12 +1,239 @@
1
1
  // @bun
2
2
  // src/checks.ts
3
+ import dns from "dns/promises";
4
+ import http from "http";
5
+ import https from "https";
6
+ import net2 from "net";
7
+
8
+ // src/target-policy.ts
3
9
  import net from "net";
10
+ var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
11
+ var DENIED_IPV4_CIDRS = [
12
+ ["0.0.0.0", 8],
13
+ ["10.0.0.0", 8],
14
+ ["100.64.0.0", 10],
15
+ ["127.0.0.0", 8],
16
+ ["169.254.0.0", 16],
17
+ ["172.16.0.0", 12],
18
+ ["192.0.0.0", 24],
19
+ ["192.0.2.0", 24],
20
+ ["192.88.99.0", 24],
21
+ ["192.168.0.0", 16],
22
+ ["198.18.0.0", 15],
23
+ ["198.51.100.0", 24],
24
+ ["203.0.113.0", 24],
25
+ ["224.0.0.0", 4],
26
+ ["240.0.0.0", 4]
27
+ ];
28
+ var DENIED_IPV6_CIDRS = [
29
+ ["::", 128],
30
+ ["::1", 128],
31
+ ["64:ff9b::", 96],
32
+ ["64:ff9b:1::", 48],
33
+ ["100::", 64],
34
+ ["100:0:0:1::", 64],
35
+ ["2001::", 23],
36
+ ["2001:db8::", 32],
37
+ ["2002::", 16],
38
+ ["2620:4f:8000::", 48],
39
+ ["3fff::", 20],
40
+ ["5f00::", 16],
41
+ ["fc00::", 7],
42
+ ["fe80::", 10],
43
+ ["ff00::", 8]
44
+ ];
45
+ function assertHostedTargetAllowed(target) {
46
+ if (target.kind === "http" || target.kind === "browser_page") {
47
+ if (!target.url)
48
+ throw new Error("HTTP monitors require url");
49
+ assertHostedHttpUrlAllowed(target.url);
50
+ return;
51
+ }
52
+ if (target.kind === "tcp") {
53
+ if (!target.host)
54
+ throw new Error("TCP monitors require host");
55
+ assertHostedHostAllowed(target.host, "TCP host");
56
+ if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
57
+ throw new Error("TCP monitors require a port from 1 to 65535");
58
+ }
59
+ return;
60
+ }
61
+ throw new Error("Monitor kind must be http, tcp, or browser_page");
62
+ }
63
+ function assertHostedHttpUrlAllowed(value) {
64
+ const parsed = new URL(value);
65
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
66
+ throw new Error("HTTP monitor url must use http or https");
67
+ }
68
+ if (parsed.username || parsed.password) {
69
+ throw new Error("hosted target URLs must not contain userinfo");
70
+ }
71
+ for (const key of parsed.searchParams.keys()) {
72
+ if (SECRET_PARAM_PATTERN.test(key)) {
73
+ throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
74
+ }
75
+ }
76
+ if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
77
+ throw new Error("hosted target URL fragment contains secret-like data");
78
+ }
79
+ assertHostedHostAllowed(parsed.hostname, "HTTP host");
80
+ }
81
+ function assertHostedHostAllowed(hostname, label = "host") {
82
+ const host = normalizeHostedHost(hostname);
83
+ if (!host)
84
+ throw new Error(`${label} is required`);
85
+ if (host === "localhost" || host.endsWith(".localhost")) {
86
+ throw new Error(`${label} is not allowed in hosted mode: localhost`);
87
+ }
88
+ if (host.endsWith(".local") || host.endsWith(".internal")) {
89
+ throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
90
+ }
91
+ const ipVersion = net.isIP(host);
92
+ if (ipVersion === 4 && isDeniedIpv4(host)) {
93
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
94
+ }
95
+ if (ipVersion === 6 && isDeniedIpv6(host)) {
96
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
97
+ }
98
+ }
99
+ function assertHostedResolvedAddressesAllowed(hostname, addresses, label = "resolved address") {
100
+ if (addresses.length === 0) {
101
+ throw new Error(`${label} is not allowed in hosted mode: DNS returned no addresses for ${normalizeHostedHost(hostname) || "host"}`);
102
+ }
103
+ for (const entry of addresses) {
104
+ assertHostedAddressAllowed(entry.address, label);
105
+ }
106
+ }
107
+ function assertHostedAddressAllowed(address, label = "resolved address") {
108
+ const host = normalizeHostedHost(address);
109
+ const ipVersion = net.isIP(host);
110
+ if (ipVersion === 4 && isDeniedIpv4(host)) {
111
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
112
+ }
113
+ if (ipVersion === 6 && isDeniedIpv6(host)) {
114
+ throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
115
+ }
116
+ if (ipVersion === 0) {
117
+ throw new Error(`${label} is not allowed in hosted mode: DNS returned a non-IP address`);
118
+ }
119
+ }
120
+ function normalizeHostedHost(hostname) {
121
+ return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
122
+ }
123
+ function isDeniedIpv4(ip) {
124
+ const parts = parseIpv4Words(ip);
125
+ if (!parts)
126
+ return true;
127
+ return DENIED_IPV4_CIDRS.some(([base, prefix]) => ipv4MatchesCidr(parts, parseIpv4Words(base), prefix));
128
+ }
129
+ function isDeniedIpv6(ip) {
130
+ const normalized = ip.toLowerCase();
131
+ const words = parseIpv6Words(normalized);
132
+ if (!words)
133
+ return true;
134
+ const mappedIpv4 = ipv4FromMappedIpv6Words(words);
135
+ if (mappedIpv4)
136
+ return isDeniedIpv4(mappedIpv4);
137
+ return isIpv4CompatibleIpv6(words) || DENIED_IPV6_CIDRS.some(([base, prefix]) => ipv6MatchesCidr(words, parseIpv6Words(base), prefix));
138
+ }
139
+ function isIpv4CompatibleIpv6(words) {
140
+ if (!words)
141
+ return false;
142
+ if (!words.slice(0, 6).every((word) => word === 0))
143
+ return false;
144
+ if (words[6] === 0 && (words[7] === 0 || words[7] === 1))
145
+ return false;
146
+ return true;
147
+ }
148
+ function ipv4FromMappedIpv6Words(words) {
149
+ if (words[0] !== 0 || words[1] !== 0 || words[2] !== 0 || words[3] !== 0 || words[4] !== 0 || words[5] !== 65535) {
150
+ return null;
151
+ }
152
+ return ipv4FromWords(words[6], words[7]);
153
+ }
154
+ function ipv4FromWords(high, low) {
155
+ return [
156
+ high >> 8,
157
+ high & 255,
158
+ low >> 8,
159
+ low & 255
160
+ ].join(".");
161
+ }
162
+ function ipv4MatchesCidr(parts, base, prefix) {
163
+ const mask = prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0;
164
+ return (ipv4ToNumber(parts) & mask) >>> 0 === (ipv4ToNumber(base) & mask) >>> 0;
165
+ }
166
+ function ipv4ToNumber(parts) {
167
+ return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
168
+ }
169
+ function ipv6MatchesCidr(words, base, prefix) {
170
+ const fullWords = Math.floor(prefix / 16);
171
+ for (let index = 0;index < fullWords; index += 1) {
172
+ if (words[index] !== base[index])
173
+ return false;
174
+ }
175
+ const remainingBits = prefix % 16;
176
+ if (remainingBits === 0)
177
+ return true;
178
+ const mask = 65535 << 16 - remainingBits & 65535;
179
+ return (words[fullWords] & mask) === (base[fullWords] & mask);
180
+ }
181
+ function parseIpv6Words(value) {
182
+ let ip = value.toLowerCase();
183
+ const zoneIndex = ip.indexOf("%");
184
+ if (zoneIndex >= 0)
185
+ ip = ip.slice(0, zoneIndex);
186
+ if (ip.includes(".")) {
187
+ const lastColon = ip.lastIndexOf(":");
188
+ if (lastColon < 0)
189
+ return null;
190
+ const ipv4 = parseIpv4Words(ip.slice(lastColon + 1));
191
+ if (!ipv4)
192
+ return null;
193
+ ip = `${ip.slice(0, lastColon)}:${(ipv4[0] << 8 | ipv4[1]).toString(16)}:${(ipv4[2] << 8 | ipv4[3]).toString(16)}`;
194
+ }
195
+ const compressed = ip.split("::");
196
+ if (compressed.length > 2)
197
+ return null;
198
+ const left = parseIpv6Side(compressed[0]);
199
+ const right = compressed.length === 2 ? parseIpv6Side(compressed[1]) : [];
200
+ if (!left || !right)
201
+ return null;
202
+ if (compressed.length === 1)
203
+ return left.length === 8 ? left : null;
204
+ const missing = 8 - left.length - right.length;
205
+ if (missing < 1)
206
+ return null;
207
+ return [...left, ...Array(missing).fill(0), ...right];
208
+ }
209
+ function parseIpv6Side(value) {
210
+ if (!value)
211
+ return [];
212
+ const words = value.split(":");
213
+ if (words.some((word) => !/^[0-9a-f]{1,4}$/.test(word)))
214
+ return null;
215
+ return words.map((word) => Number.parseInt(word, 16));
216
+ }
217
+ function parseIpv4Words(value) {
218
+ const words = value.split(".").map((part) => Number(part));
219
+ if (words.length !== 4 || words.some((word) => !Number.isInteger(word) || word < 0 || word > 255)) {
220
+ return null;
221
+ }
222
+ return words;
223
+ }
224
+
225
+ // src/checks.ts
4
226
  async function runMonitorCheck(monitor, options = {}) {
5
227
  if (!monitor.enabled) {
6
228
  return { status: "down", latencyMs: null, error: "monitor is disabled" };
7
229
  }
8
- if (monitor.kind === "http")
9
- return runHttpCheck(monitor, options.fetch ?? fetch);
230
+ if (monitor.kind === "http") {
231
+ return options.hostedTargetPolicy ? runHostedHttpCheck(monitor, {
232
+ resolveHost: options.resolveHost,
233
+ request: options.hostedHttpRequest,
234
+ maxRedirects: options.maxRedirects
235
+ }) : runHttpCheck(monitor, options.fetch ?? fetch);
236
+ }
10
237
  if (monitor.kind === "browser_page")
11
238
  return runBrowserPageCheck(monitor, { fetch: options.fetch, runner: options.browserPage });
12
239
  if (monitor.kind === "tcp")
@@ -44,12 +271,87 @@ async function runHttpCheck(monitor, fetchImpl = fetch) {
44
271
  clearTimeout(timeout);
45
272
  }
46
273
  }
274
+ async function runHostedHttpCheck(monitor, options = {}) {
275
+ if (!monitor.url)
276
+ return { status: "down", latencyMs: null, error: "missing url" };
277
+ const resolver = options.resolveHost ?? resolveHostedHost;
278
+ const request = options.request ?? requestHostedHttpPinned;
279
+ const maxRedirects = options.maxRedirects ?? 5;
280
+ const controller = new AbortController;
281
+ const timeout = setTimeout(() => controller.abort(), monitor.timeoutMs);
282
+ const started = performance.now();
283
+ const decisions = [];
284
+ let currentUrl;
285
+ let redirectCount = 0;
286
+ try {
287
+ currentUrl = new URL(monitor.url);
288
+ } catch (error) {
289
+ clearTimeout(timeout);
290
+ return {
291
+ status: "down",
292
+ latencyMs: 0,
293
+ statusCode: null,
294
+ error: error instanceof Error ? error.message : String(error),
295
+ evidence: hostedHttpEvidence(null, redirectCount, decisions)
296
+ };
297
+ }
298
+ try {
299
+ while (true) {
300
+ throwIfAborted(controller.signal);
301
+ const stage = redirectCount === 0 ? "request" : "redirect";
302
+ const address = await resolveAndRecordHostedHttpDecision(currentUrl, stage, resolver, decisions);
303
+ const response = await request({
304
+ url: currentUrl,
305
+ method: monitor.method || "GET",
306
+ timeoutMs: monitor.timeoutMs,
307
+ address,
308
+ signal: controller.signal
309
+ });
310
+ const location = redirectLocation(response.headers);
311
+ if (isRedirectStatus(response.status) && location) {
312
+ if (redirectCount >= maxRedirects) {
313
+ const latencyMs2 = elapsed(started);
314
+ return {
315
+ status: "down",
316
+ latencyMs: latencyMs2,
317
+ statusCode: response.status,
318
+ error: `too many redirects after ${maxRedirects}`,
319
+ evidence: hostedHttpEvidence(currentUrl, redirectCount, decisions)
320
+ };
321
+ }
322
+ currentUrl = new URL(location, currentUrl);
323
+ redirectCount += 1;
324
+ continue;
325
+ }
326
+ const latencyMs = elapsed(started);
327
+ const ok = monitor.expectedStatus == null ? response.status >= 200 && response.status < 400 : response.status === monitor.expectedStatus;
328
+ return {
329
+ status: ok ? "up" : "down",
330
+ latencyMs,
331
+ statusCode: response.status,
332
+ error: ok ? null : `unexpected status ${response.status}`,
333
+ evidence: hostedHttpEvidence(currentUrl, redirectCount, decisions)
334
+ };
335
+ }
336
+ } catch (error) {
337
+ const latencyMs = elapsed(started);
338
+ return {
339
+ status: "down",
340
+ latencyMs,
341
+ statusCode: null,
342
+ error: error instanceof Error ? error.message : String(error),
343
+ evidence: hostedHttpEvidence(currentUrl, redirectCount, decisions)
344
+ };
345
+ } finally {
346
+ clearTimeout(timeout);
347
+ }
348
+ }
47
349
  async function runTcpCheck(monitor) {
48
350
  if (!monitor.host || !monitor.port)
49
351
  return { status: "down", latencyMs: null, error: "missing host or port" };
50
352
  const started = performance.now();
51
353
  return new Promise((resolve) => {
52
- const socket = net.createConnection({ host: monitor.host, port: monitor.port, timeout: monitor.timeoutMs });
354
+ const socket = net2.createConnection({ host: monitor.host, port: monitor.port, timeout: monitor.timeoutMs });
53
355
  let settled = false;
54
356
  const finish = (result) => {
55
357
  if (settled)
@@ -137,6 +439,43 @@ function normalizeBrowserEvidence(sourceUrl, raw) {
137
439
  retentionClass: "short"
138
440
  };
139
441
  }
442
+ function normalizeHttpTargetPolicyEvidence(raw) {
443
+ if (!isHttpTargetPolicyEvidence(raw))
444
+ throw new Error("HTTP target-policy evidence is invalid");
445
+ return {
446
+ kind: "http_target_policy",
447
+ mode: "hosted",
448
+ finalUrl: raw.finalUrl ? redactUrl(raw.finalUrl) : null,
449
+ redirectCount: Math.max(0, Math.min(20, Math.trunc(raw.redirectCount))),
450
+ decisions: raw.decisions.slice(0, 20).map((decision) => ({
451
+ stage: decision.stage,
452
+ decision: decision.decision,
453
+ url: redactUrl(decision.url),
454
+ host: redactText(normalizeHostedHost(decision.host)),
455
+ targetClass: "public_http",
456
+ probeClass: "public",
457
+ protocol: decision.protocol,
458
+ resolvedAddresses: decision.resolvedAddresses.slice(0, 20).map((address) => ({
459
+ address: normalizeHostedHost(address.address),
460
+ family: address.family
461
+ })),
462
+ ruleId: redactText(decision.ruleId),
463
+ reason: decision.reason ? redactText(decision.reason) : null
464
+ })),
465
+ redacted: true,
466
+ redactionStatus: "redacted",
467
+ retentionClass: "short"
468
+ };
469
+ }
470
+ function isBrowserPageEvidence(value) {
471
+ return Boolean(value && typeof value === "object" && value.kind === "browser_page");
472
+ }
473
+ function isHttpTargetPolicyEvidence(value) {
474
+ if (!value || typeof value !== "object" || value.kind !== "http_target_policy")
475
+ return false;
476
+ const evidence = value;
477
+ return evidence.mode === "hosted" && (evidence.finalUrl === null || typeof evidence.finalUrl === "string") && Number.isInteger(evidence.redirectCount) && evidence.redacted === true && evidence.redactionStatus === "redacted" && evidence.retentionClass === "short" && Array.isArray(evidence.decisions) && evidence.decisions.every((decision) => decision && (decision.stage === "request" || decision.stage === "redirect") && (decision.decision === "allowed" || decision.decision === "blocked") && (decision.protocol === "http:" || decision.protocol === "https:") && decision.targetClass === "public_http" && decision.probeClass === "public" && typeof decision.url === "string" && typeof decision.host === "string" && typeof decision.ruleId === "string" && (decision.reason === null || typeof decision.reason === "string") && Array.isArray(decision.resolvedAddresses) && decision.resolvedAddresses.every((address) => address && typeof address.address === "string" && (address.family === 4 || address.family === 6)));
478
+ }
140
479
  function validateBrowserPageUrl(value) {
141
480
  const parsed = new URL(value);
142
481
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -193,6 +532,130 @@ function redactText(value) {
193
532
  function isSecretKey(value) {
194
533
  return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
195
534
  }
535
+ async function resolveAndRecordHostedHttpDecision(url, stage, resolver, decisions) {
536
+ let addresses = [];
537
+ try {
538
+ assertHostedHttpUrlAllowed(url.toString());
539
+ addresses = normalizeResolvedAddresses(await resolver(normalizeHostedHost(url.hostname)));
540
+ assertHostedResolvedAddressesAllowed(url.hostname, addresses, "HTTP resolved address");
541
+ decisions.push({
542
+ stage,
543
+ decision: "allowed",
544
+ url: sanitizePolicyUrl(url),
545
+ host: normalizeHostedHost(url.hostname),
546
+ targetClass: "public_http",
547
+ probeClass: "public",
548
+ protocol: url.protocol,
549
+ resolvedAddresses: addresses,
550
+ ruleId: "hosted-http-runtime-target-policy",
551
+ reason: null
552
+ });
553
+ return addresses[0];
554
+ } catch (error) {
555
+ decisions.push({
556
+ stage,
557
+ decision: "blocked",
558
+ url: sanitizePolicyUrl(url),
559
+ host: normalizeHostedHost(url.hostname),
560
+ targetClass: "public_http",
561
+ probeClass: "public",
562
+ protocol: url.protocol === "http:" || url.protocol === "https:" ? url.protocol : "http:",
563
+ resolvedAddresses: addresses,
564
+ ruleId: "hosted-http-runtime-target-policy",
565
+ reason: error instanceof Error ? error.message : String(error)
566
+ });
567
+ throw error;
568
+ }
569
+ }
570
+ async function resolveHostedHost(hostname) {
571
+ const host = normalizeHostedHost(hostname);
572
+ const ipVersion = net2.isIP(host);
573
+ if (ipVersion === 4 || ipVersion === 6)
574
+ return [{ address: host, family: ipVersion }];
575
+ return dns.lookup(host, { all: true, verbatim: true });
576
+ }
577
+ function normalizeResolvedAddresses(addresses) {
578
+ return addresses.map((entry) => {
579
+ const address = normalizeHostedHost(entry.address);
580
+ const detected = net2.isIP(address);
581
+ const family = entry.family === 4 || entry.family === 6 ? entry.family : detected;
582
+ if (family !== 4 && family !== 6) {
583
+ throw new Error("HTTP resolved address is not allowed in hosted mode: DNS returned a non-IP address");
584
+ }
585
+ return { address, family };
586
+ });
587
+ }
588
+ function hostedHttpEvidence(finalUrl, redirectCount, decisions) {
589
+ return {
590
+ kind: "http_target_policy",
591
+ mode: "hosted",
592
+ finalUrl: finalUrl ? sanitizePolicyUrl(finalUrl) : null,
593
+ redirectCount,
594
+ decisions,
595
+ redacted: true,
596
+ redactionStatus: "redacted",
597
+ retentionClass: "short"
598
+ };
599
+ }
600
+ function sanitizePolicyUrl(url) {
601
+ const copy = new URL(url.toString());
602
+ copy.username = "";
603
+ copy.password = "";
604
+ copy.hash = "";
605
+ for (const key of copy.searchParams.keys()) {
606
+ if (isSecretKey(key))
607
+ copy.searchParams.set(key, "[redacted]");
608
+ }
609
+ return copy.toString();
610
+ }
611
+ function redirectLocation(headers) {
612
+ if (!headers)
613
+ return null;
614
+ if (headers instanceof Headers)
615
+ return headers.get("location");
616
+ const raw = headers.location ?? headers.Location;
617
+ if (Array.isArray(raw))
618
+ return raw[0] ?? null;
619
+ return raw ?? null;
620
+ }
621
+ function isRedirectStatus(status) {
622
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
623
+ }
624
+ async function requestHostedHttpPinned(context) {
625
+ const lookup = (_hostname, _options, callback) => callback(null, context.address.address, context.address.family);
626
+ return context.url.protocol === "https:" ? requestWithClient(context, https, new https.Agent({ lookup })) : requestWithClient(context, http, new http.Agent({ lookup }));
627
+ }
628
+ function requestWithClient(context, client, agent) {
629
+ return new Promise((resolve, reject) => {
630
+ const req = client.request(context.url, {
631
+ method: context.method,
632
+ agent,
633
+ signal: context.signal,
634
+ timeout: context.timeoutMs
635
+ }, (response) => {
636
+ response.resume();
637
+ response.once("end", () => {
638
+ agent.destroy();
639
+ resolve({ status: response.statusCode ?? 0, headers: response.headers });
640
+ });
641
+ });
642
+ req.once("timeout", () => {
643
+ req.destroy(new Error("http timeout"));
644
+ });
645
+ req.once("error", (error) => {
646
+ agent.destroy();
647
+ reject(error);
648
+ });
649
+ req.end();
650
+ });
651
+ }
652
+ function throwIfAborted(signal) {
653
+ if (signal.aborted)
654
+ throw new Error("http timeout");
655
+ }
656
+ function elapsed(started) {
657
+ return Math.round((performance.now() - started) * 100) / 100;
658
+ }
196
659
 
197
660
  // src/imports.ts
198
661
  import { randomUUID } from "crypto";
@@ -206,140 +669,6 @@ var MIN_RETRY_COUNT = 0;
206
669
  var MAX_RETRY_COUNT = 10;
207
670
  var MAX_RESULT_LIMIT = 1000;
208
671
 
209
- // src/target-policy.ts
210
- import net2 from "net";
211
- var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
212
- function assertHostedTargetAllowed(target) {
213
- if (target.kind === "http" || target.kind === "browser_page") {
214
- if (!target.url)
215
- throw new Error("HTTP monitors require url");
216
- assertHostedHttpUrlAllowed(target.url);
217
- return;
218
- }
219
- if (target.kind === "tcp") {
220
- if (!target.host)
221
- throw new Error("TCP monitors require host");
222
- assertHostedHostAllowed(target.host, "TCP host");
223
- if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
224
- throw new Error("TCP monitors require a port from 1 to 65535");
225
- }
226
- return;
227
- }
228
- throw new Error("Monitor kind must be http, tcp, or browser_page");
229
- }
230
- function assertHostedHttpUrlAllowed(value) {
231
- const parsed = new URL(value);
232
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
233
- throw new Error("HTTP monitor url must use http or https");
234
- }
235
- if (parsed.username || parsed.password) {
236
- throw new Error("hosted target URLs must not contain userinfo");
237
- }
238
- for (const key of parsed.searchParams.keys()) {
239
- if (SECRET_PARAM_PATTERN.test(key)) {
240
- throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
241
- }
242
- }
243
- if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
244
- throw new Error("hosted target URL fragment contains secret-like data");
245
- }
246
- assertHostedHostAllowed(parsed.hostname, "HTTP host");
247
- }
248
- function assertHostedHostAllowed(hostname, label = "host") {
249
- const host = normalizeHost(hostname);
250
- if (!host)
251
- throw new Error(`${label} is required`);
252
- if (host === "localhost" || host.endsWith(".localhost")) {
253
- throw new Error(`${label} is not allowed in hosted mode: localhost`);
254
- }
255
- if (host.endsWith(".local") || host.endsWith(".internal")) {
256
- throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
257
- }
258
- const ipVersion = net2.isIP(host);
259
- if (ipVersion === 4 && isDeniedIpv4(host)) {
260
- throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
261
- }
262
- if (ipVersion === 6 && isDeniedIpv6(host)) {
263
- throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
264
- }
265
- }
266
- function normalizeHost(hostname) {
267
- return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
268
- }
269
- function isDeniedIpv4(ip) {
270
- const parts = ip.split(".").map((part) => Number(part));
271
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
272
- return true;
273
- }
274
- const [a, b] = parts;
275
- return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
276
- }
277
- function isDeniedIpv6(ip) {
278
- const normalized = ip.toLowerCase();
279
- const mappedIpv4 = ipv4FromMappedIpv6(normalized);
280
- if (mappedIpv4)
281
- return isDeniedIpv4(mappedIpv4);
282
- const words = parseIpv6Words(normalized);
283
- return normalized === "::" || normalized === "::1" || words !== null && (words[0] & 65472) === 65152 || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("ff");
284
- }
285
- function ipv4FromMappedIpv6(ip) {
286
- const words = parseIpv6Words(ip);
287
- if (!words)
288
- return null;
289
- if (words[0] !== 0 || words[1] !== 0 || words[2] !== 0 || words[3] !== 0 || words[4] !== 0 || words[5] !== 65535) {
290
- return null;
291
- }
292
- return [
293
- words[6] >> 8,
294
- words[6] & 255,
295
- words[7] >> 8,
296
- words[7] & 255
297
- ].join(".");
298
- }
299
- function parseIpv6Words(value) {
300
- let ip = value.toLowerCase();
301
- const zoneIndex = ip.indexOf("%");
302
- if (zoneIndex >= 0)
303
- ip = ip.slice(0, zoneIndex);
304
- if (ip.includes(".")) {
305
- const lastColon = ip.lastIndexOf(":");
306
- if (lastColon < 0)
307
- return null;
308
- const ipv4 = parseIpv4Words(ip.slice(lastColon + 1));
309
- if (!ipv4)
310
- return null;
311
- ip = `${ip.slice(0, lastColon)}:${(ipv4[0] << 8 | ipv4[1]).toString(16)}:${(ipv4[2] << 8 | ipv4[3]).toString(16)}`;
312
- }
313
- const compressed = ip.split("::");
314
- if (compressed.length > 2)
315
- return null;
316
- const left = parseIpv6Side(compressed[0]);
317
- const right = compressed.length === 2 ? parseIpv6Side(compressed[1]) : [];
318
- if (!left || !right)
319
- return null;
320
- if (compressed.length === 1)
321
- return left.length === 8 ? left : null;
322
- const missing = 8 - left.length - right.length;
323
- if (missing < 1)
324
- return null;
325
- return [...left, ...Array(missing).fill(0), ...right];
326
- }
327
- function parseIpv6Side(value) {
328
- if (!value)
329
- return [];
330
- const words = value.split(":");
331
- if (words.some((word) => !/^[0-9a-f]{1,4}$/.test(word)))
332
- return null;
333
- return words.map((word) => Number.parseInt(word, 16));
334
- }
335
- function parseIpv4Words(value) {
336
- const words = value.split(".").map((part) => Number(part));
337
- if (words.length !== 4 || words.some((word) => !Number.isInteger(word) || word < 0 || word > 255)) {
338
- return null;
339
- }
340
- return words;
341
- }
342
-
343
672
  // src/imports.ts
344
673
  function previewImport(store, request, options = {}) {
345
674
  const source = normalizeSource(request.source);
@@ -3134,7 +3463,7 @@ class UptimeService {
3134
3463
  throw new Error("Probe job fencing token is invalid");
3135
3464
  if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
3136
3465
  throw new Error("Probe job lease expired");
3137
- const evidence = input.evidence ? normalizeBrowserEvidence(monitor.url ?? monitor.host ?? "https://example.invalid", input.evidence) : null;
3466
+ const evidence = input.evidence ? normalizeSubmittedEvidence(monitor.url ?? monitor.host ?? "https://example.invalid", input.evidence) : null;
3138
3467
  const result = this.store.recordCheckResult({
3139
3468
  monitorId: monitor.id,
3140
3469
  checkedAt: input.checkedAt,
@@ -3178,6 +3507,13 @@ class MonitorCheckBusyError extends Error {
3178
3507
  function enabledReportChannels(schedule) {
3179
3508
  return ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel]));
3180
3509
  }
3510
+ function normalizeSubmittedEvidence(sourceUrl, evidence) {
3511
+ if (evidence.kind === "browser_page")
3512
+ return normalizeBrowserEvidence(sourceUrl, evidence);
3513
+ if (evidence.kind === "http_target_policy")
3514
+ return normalizeHttpTargetPolicyEvidence(evidence);
3515
+ throw new Error("Unsupported probe evidence kind");
3516
+ }
3181
3517
  function validateProbeSubmission(input) {
3182
3518
  if (!input.jobId.trim())
3183
3519
  throw new Error("Probe submission jobId is required");
@@ -4002,7 +4338,7 @@ function buildAwsDeploymentPlan(options = {}) {
4002
4338
  const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
4003
4339
  const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
4004
4340
  const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
4005
- const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.11");
4341
+ const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.13");
4006
4342
  const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
4007
4343
  const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
4008
4344
  const cluster = `${prefix}-${stage}`;
@@ -4144,7 +4480,7 @@ function buildAwsDeploymentPlan(options = {}) {
4144
4480
  "The infrastructure owner repository was not found in this workspace.",
4145
4481
  "The EFS SQLite bridge is single-writer only: web target desired count is 1 and scheduler/public-probe/reporter targets remain 0 until Postgres and cloud leases exist.",
4146
4482
  "Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
4147
- "Public probe execution still needs DNS, redirect, and rebinding SSRF enforcement plus cloud check-job leases.",
4483
+ "Public probe execution still needs cloud check-job leases wired to runHostedHttpCheck and live policy-decision log evidence.",
4148
4484
  "Private probe enrollment, claim, submit, heartbeat, revocation, and rotation are not cloud-backed yet."
4149
4485
  ],
4150
4486
  requiredEvidence: [
@@ -4281,12 +4617,16 @@ export {
4281
4617
  runTcpCheck,
4282
4618
  runMonitorCheck,
4283
4619
  runHttpCheck,
4620
+ runHostedHttpCheck,
4284
4621
  runBrowserPageCheck,
4285
4622
  rollbackImport,
4286
4623
  renderPrivateProbeEnv,
4287
4624
  probeResultSigningPayload,
4288
4625
  probePublicKeyFingerprint,
4289
4626
  previewImport,
4627
+ normalizeHttpTargetPolicyEvidence,
4628
+ isHttpTargetPolicyEvidence,
4629
+ isBrowserPageEvidence,
4290
4630
  generateProbeKeyPair,
4291
4631
  ensureUptimeHome,
4292
4632
  createUptimeClient,