@hasna/uptime 0.1.10 → 0.1.12
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/CHANGELOG.md +41 -0
- package/dist/api.js +487 -87
- package/dist/checks.d.ts +37 -5
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +471 -4
- package/dist/cli/index.js +486 -89
- package/dist/cloud-plan.js +2 -2
- package/dist/imports.d.ts +6 -2
- package/dist/imports.d.ts.map +1 -1
- package/dist/imports.js +162 -15
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +493 -89
- package/dist/mcp/index.js +483 -86
- package/dist/service.d.ts +3 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +486 -86
- package/dist/store.js +152 -8
- package/dist/target-policy.d.ts +7 -0
- package/dist/target-policy.d.ts.map +1 -1
- package/dist/types.d.ts +26 -1
- package/dist/types.d.ts.map +1 -1
- package/docs/aws-deployment-runbook.md +155 -51
- package/infra/aws/README.md +3 -2
- package/infra/aws/outputs.tf +35 -0
- package/infra/aws/terraform.tfvars.example +1 -1
- package/infra/aws/variables.tf +1 -1
- package/package.json +1 -1
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
|
|
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 =
|
|
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,83 +669,10 @@ 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
|
-
return normalized === "::" || normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("ff") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:169.254.") || /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) || normalized.startsWith("::ffff:192.168.");
|
|
280
|
-
}
|
|
281
|
-
|
|
282
672
|
// src/imports.ts
|
|
283
|
-
function previewImport(store, request) {
|
|
673
|
+
function previewImport(store, request, options = {}) {
|
|
284
674
|
const source = normalizeSource(request.source);
|
|
285
|
-
const items = dedupePreviewItems(request.records.map((record) => previewRecord(store, source, record, request.defaults ?? {})));
|
|
675
|
+
const items = dedupePreviewItems(request.records.map((record) => previewRecord(store, source, record, request.defaults ?? {}, options)));
|
|
286
676
|
return {
|
|
287
677
|
source,
|
|
288
678
|
generatedAt: new Date().toISOString(),
|
|
@@ -356,7 +746,7 @@ function rollbackImport(store, batchId) {
|
|
|
356
746
|
items
|
|
357
747
|
};
|
|
358
748
|
}
|
|
359
|
-
function previewRecord(store, source, record, defaults) {
|
|
749
|
+
function previewRecord(store, source, record, defaults, options) {
|
|
360
750
|
const warnings = [];
|
|
361
751
|
let candidate;
|
|
362
752
|
try {
|
|
@@ -376,13 +766,16 @@ function previewRecord(store, source, record, defaults) {
|
|
|
376
766
|
reason: error instanceof Error ? error.message : String(error)
|
|
377
767
|
};
|
|
378
768
|
}
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
|
|
769
|
+
const monitorOptions = options.workspaceId ? { workspaceId: options.workspaceId } : undefined;
|
|
770
|
+
const rawProvenance = store.getProvenance(candidate.source, candidate.sourceId);
|
|
771
|
+
const provenanceMonitor = rawProvenance ? store.getMonitor(rawProvenance.monitorId, monitorOptions) : null;
|
|
772
|
+
const provenance = provenanceMonitor ? rawProvenance : null;
|
|
773
|
+
const monitor = provenanceMonitor ?? store.getMonitor(candidate.name, monitorOptions);
|
|
774
|
+
if (rawProvenance && !provenanceMonitor && !options.workspaceId) {
|
|
382
775
|
return { candidate, action: "create", monitor: null, provenance, warnings: ["source provenance points to a missing monitor"], reason: null };
|
|
383
776
|
}
|
|
384
777
|
if (provenance && monitor) {
|
|
385
|
-
const nameOwner = store.getMonitor(candidate.name);
|
|
778
|
+
const nameOwner = store.getMonitor(candidate.name, monitorOptions);
|
|
386
779
|
if (nameOwner && nameOwner.id !== monitor.id) {
|
|
387
780
|
return {
|
|
388
781
|
candidate,
|
|
@@ -2729,8 +3122,8 @@ class UptimeService {
|
|
|
2729
3122
|
const execute = () => this.submitProbeResultInTransaction(input);
|
|
2730
3123
|
return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
|
|
2731
3124
|
}
|
|
2732
|
-
previewImport(request) {
|
|
2733
|
-
return previewImport(this.store, request);
|
|
3125
|
+
previewImport(request, options = {}) {
|
|
3126
|
+
return previewImport(this.store, request, options);
|
|
2734
3127
|
}
|
|
2735
3128
|
applyImport(request) {
|
|
2736
3129
|
return applyImport(this.store, request);
|
|
@@ -3070,7 +3463,7 @@ class UptimeService {
|
|
|
3070
3463
|
throw new Error("Probe job fencing token is invalid");
|
|
3071
3464
|
if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
|
|
3072
3465
|
throw new Error("Probe job lease expired");
|
|
3073
|
-
const evidence = input.evidence ?
|
|
3466
|
+
const evidence = input.evidence ? normalizeSubmittedEvidence(monitor.url ?? monitor.host ?? "https://example.invalid", input.evidence) : null;
|
|
3074
3467
|
const result = this.store.recordCheckResult({
|
|
3075
3468
|
monitorId: monitor.id,
|
|
3076
3469
|
checkedAt: input.checkedAt,
|
|
@@ -3114,6 +3507,13 @@ class MonitorCheckBusyError extends Error {
|
|
|
3114
3507
|
function enabledReportChannels(schedule) {
|
|
3115
3508
|
return ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel]));
|
|
3116
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
|
+
}
|
|
3117
3517
|
function validateProbeSubmission(input) {
|
|
3118
3518
|
if (!input.jobId.trim())
|
|
3119
3519
|
throw new Error("Probe submission jobId is required");
|
|
@@ -3758,7 +4158,7 @@ async function handleApiRoute(service, request, url, apiPath, options, hosted, a
|
|
|
3758
4158
|
return json(service.submitProbeResult(await jsonBody(request)), 201);
|
|
3759
4159
|
}
|
|
3760
4160
|
if (request.method === "POST" && apiPath === "/api/imports/preview") {
|
|
3761
|
-
return json(service.previewImport(await jsonBody(request)));
|
|
4161
|
+
return json(service.previewImport(await jsonBody(request), { workspaceId: actor?.workspaceId }));
|
|
3762
4162
|
}
|
|
3763
4163
|
if (request.method === "POST" && apiPath === "/api/imports/apply") {
|
|
3764
4164
|
if (hosted)
|
|
@@ -3938,7 +4338,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
3938
4338
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
3939
4339
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
3940
4340
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
3941
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
4341
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.12");
|
|
3942
4342
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
3943
4343
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
3944
4344
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -4080,7 +4480,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
4080
4480
|
"The infrastructure owner repository was not found in this workspace.",
|
|
4081
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.",
|
|
4082
4482
|
"Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
|
|
4083
|
-
"Public probe execution still needs
|
|
4483
|
+
"Public probe execution still needs cloud check-job leases wired to runHostedHttpCheck and live policy-decision log evidence.",
|
|
4084
4484
|
"Private probe enrollment, claim, submit, heartbeat, revocation, and rotation are not cloud-backed yet."
|
|
4085
4485
|
],
|
|
4086
4486
|
requiredEvidence: [
|
|
@@ -4217,12 +4617,16 @@ export {
|
|
|
4217
4617
|
runTcpCheck,
|
|
4218
4618
|
runMonitorCheck,
|
|
4219
4619
|
runHttpCheck,
|
|
4620
|
+
runHostedHttpCheck,
|
|
4220
4621
|
runBrowserPageCheck,
|
|
4221
4622
|
rollbackImport,
|
|
4222
4623
|
renderPrivateProbeEnv,
|
|
4223
4624
|
probeResultSigningPayload,
|
|
4224
4625
|
probePublicKeyFingerprint,
|
|
4225
4626
|
previewImport,
|
|
4627
|
+
normalizeHttpTargetPolicyEvidence,
|
|
4628
|
+
isHttpTargetPolicyEvidence,
|
|
4629
|
+
isBrowserPageEvidence,
|
|
4226
4630
|
generateProbeKeyPair,
|
|
4227
4631
|
ensureUptimeHome,
|
|
4228
4632
|
createUptimeClient,
|