@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/cli/index.js
CHANGED
|
@@ -2577,13 +2577,240 @@ import { randomUUID as randomUUID4 } from "crypto";
|
|
|
2577
2577
|
import { existsSync as existsSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
|
|
2578
2578
|
|
|
2579
2579
|
// src/checks.ts
|
|
2580
|
+
import dns from "dns/promises";
|
|
2581
|
+
import http from "http";
|
|
2582
|
+
import https from "https";
|
|
2583
|
+
import net2 from "net";
|
|
2584
|
+
|
|
2585
|
+
// src/target-policy.ts
|
|
2580
2586
|
import net from "net";
|
|
2587
|
+
var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
2588
|
+
var DENIED_IPV4_CIDRS = [
|
|
2589
|
+
["0.0.0.0", 8],
|
|
2590
|
+
["10.0.0.0", 8],
|
|
2591
|
+
["100.64.0.0", 10],
|
|
2592
|
+
["127.0.0.0", 8],
|
|
2593
|
+
["169.254.0.0", 16],
|
|
2594
|
+
["172.16.0.0", 12],
|
|
2595
|
+
["192.0.0.0", 24],
|
|
2596
|
+
["192.0.2.0", 24],
|
|
2597
|
+
["192.88.99.0", 24],
|
|
2598
|
+
["192.168.0.0", 16],
|
|
2599
|
+
["198.18.0.0", 15],
|
|
2600
|
+
["198.51.100.0", 24],
|
|
2601
|
+
["203.0.113.0", 24],
|
|
2602
|
+
["224.0.0.0", 4],
|
|
2603
|
+
["240.0.0.0", 4]
|
|
2604
|
+
];
|
|
2605
|
+
var DENIED_IPV6_CIDRS = [
|
|
2606
|
+
["::", 128],
|
|
2607
|
+
["::1", 128],
|
|
2608
|
+
["64:ff9b::", 96],
|
|
2609
|
+
["64:ff9b:1::", 48],
|
|
2610
|
+
["100::", 64],
|
|
2611
|
+
["100:0:0:1::", 64],
|
|
2612
|
+
["2001::", 23],
|
|
2613
|
+
["2001:db8::", 32],
|
|
2614
|
+
["2002::", 16],
|
|
2615
|
+
["2620:4f:8000::", 48],
|
|
2616
|
+
["3fff::", 20],
|
|
2617
|
+
["5f00::", 16],
|
|
2618
|
+
["fc00::", 7],
|
|
2619
|
+
["fe80::", 10],
|
|
2620
|
+
["ff00::", 8]
|
|
2621
|
+
];
|
|
2622
|
+
function assertHostedTargetAllowed(target) {
|
|
2623
|
+
if (target.kind === "http" || target.kind === "browser_page") {
|
|
2624
|
+
if (!target.url)
|
|
2625
|
+
throw new Error("HTTP monitors require url");
|
|
2626
|
+
assertHostedHttpUrlAllowed(target.url);
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
if (target.kind === "tcp") {
|
|
2630
|
+
if (!target.host)
|
|
2631
|
+
throw new Error("TCP monitors require host");
|
|
2632
|
+
assertHostedHostAllowed(target.host, "TCP host");
|
|
2633
|
+
if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
|
|
2634
|
+
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
2635
|
+
}
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
2639
|
+
}
|
|
2640
|
+
function assertHostedHttpUrlAllowed(value) {
|
|
2641
|
+
const parsed = new URL(value);
|
|
2642
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2643
|
+
throw new Error("HTTP monitor url must use http or https");
|
|
2644
|
+
}
|
|
2645
|
+
if (parsed.username || parsed.password) {
|
|
2646
|
+
throw new Error("hosted target URLs must not contain userinfo");
|
|
2647
|
+
}
|
|
2648
|
+
for (const key of parsed.searchParams.keys()) {
|
|
2649
|
+
if (SECRET_PARAM_PATTERN.test(key)) {
|
|
2650
|
+
throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
|
|
2654
|
+
throw new Error("hosted target URL fragment contains secret-like data");
|
|
2655
|
+
}
|
|
2656
|
+
assertHostedHostAllowed(parsed.hostname, "HTTP host");
|
|
2657
|
+
}
|
|
2658
|
+
function assertHostedHostAllowed(hostname, label = "host") {
|
|
2659
|
+
const host = normalizeHostedHost(hostname);
|
|
2660
|
+
if (!host)
|
|
2661
|
+
throw new Error(`${label} is required`);
|
|
2662
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
2663
|
+
throw new Error(`${label} is not allowed in hosted mode: localhost`);
|
|
2664
|
+
}
|
|
2665
|
+
if (host.endsWith(".local") || host.endsWith(".internal")) {
|
|
2666
|
+
throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
|
|
2667
|
+
}
|
|
2668
|
+
const ipVersion = net.isIP(host);
|
|
2669
|
+
if (ipVersion === 4 && isDeniedIpv4(host)) {
|
|
2670
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
|
|
2671
|
+
}
|
|
2672
|
+
if (ipVersion === 6 && isDeniedIpv6(host)) {
|
|
2673
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
function assertHostedResolvedAddressesAllowed(hostname, addresses, label = "resolved address") {
|
|
2677
|
+
if (addresses.length === 0) {
|
|
2678
|
+
throw new Error(`${label} is not allowed in hosted mode: DNS returned no addresses for ${normalizeHostedHost(hostname) || "host"}`);
|
|
2679
|
+
}
|
|
2680
|
+
for (const entry of addresses) {
|
|
2681
|
+
assertHostedAddressAllowed(entry.address, label);
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
function assertHostedAddressAllowed(address, label = "resolved address") {
|
|
2685
|
+
const host = normalizeHostedHost(address);
|
|
2686
|
+
const ipVersion = net.isIP(host);
|
|
2687
|
+
if (ipVersion === 4 && isDeniedIpv4(host)) {
|
|
2688
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
|
|
2689
|
+
}
|
|
2690
|
+
if (ipVersion === 6 && isDeniedIpv6(host)) {
|
|
2691
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
|
|
2692
|
+
}
|
|
2693
|
+
if (ipVersion === 0) {
|
|
2694
|
+
throw new Error(`${label} is not allowed in hosted mode: DNS returned a non-IP address`);
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
function normalizeHostedHost(hostname) {
|
|
2698
|
+
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
2699
|
+
}
|
|
2700
|
+
function isDeniedIpv4(ip) {
|
|
2701
|
+
const parts = parseIpv4Words(ip);
|
|
2702
|
+
if (!parts)
|
|
2703
|
+
return true;
|
|
2704
|
+
return DENIED_IPV4_CIDRS.some(([base, prefix]) => ipv4MatchesCidr(parts, parseIpv4Words(base), prefix));
|
|
2705
|
+
}
|
|
2706
|
+
function isDeniedIpv6(ip) {
|
|
2707
|
+
const normalized = ip.toLowerCase();
|
|
2708
|
+
const words = parseIpv6Words(normalized);
|
|
2709
|
+
if (!words)
|
|
2710
|
+
return true;
|
|
2711
|
+
const mappedIpv4 = ipv4FromMappedIpv6Words(words);
|
|
2712
|
+
if (mappedIpv4)
|
|
2713
|
+
return isDeniedIpv4(mappedIpv4);
|
|
2714
|
+
return isIpv4CompatibleIpv6(words) || DENIED_IPV6_CIDRS.some(([base, prefix]) => ipv6MatchesCidr(words, parseIpv6Words(base), prefix));
|
|
2715
|
+
}
|
|
2716
|
+
function isIpv4CompatibleIpv6(words) {
|
|
2717
|
+
if (!words)
|
|
2718
|
+
return false;
|
|
2719
|
+
if (!words.slice(0, 6).every((word) => word === 0))
|
|
2720
|
+
return false;
|
|
2721
|
+
if (words[6] === 0 && (words[7] === 0 || words[7] === 1))
|
|
2722
|
+
return false;
|
|
2723
|
+
return true;
|
|
2724
|
+
}
|
|
2725
|
+
function ipv4FromMappedIpv6Words(words) {
|
|
2726
|
+
if (words[0] !== 0 || words[1] !== 0 || words[2] !== 0 || words[3] !== 0 || words[4] !== 0 || words[5] !== 65535) {
|
|
2727
|
+
return null;
|
|
2728
|
+
}
|
|
2729
|
+
return ipv4FromWords(words[6], words[7]);
|
|
2730
|
+
}
|
|
2731
|
+
function ipv4FromWords(high, low) {
|
|
2732
|
+
return [
|
|
2733
|
+
high >> 8,
|
|
2734
|
+
high & 255,
|
|
2735
|
+
low >> 8,
|
|
2736
|
+
low & 255
|
|
2737
|
+
].join(".");
|
|
2738
|
+
}
|
|
2739
|
+
function ipv4MatchesCidr(parts, base, prefix) {
|
|
2740
|
+
const mask = prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0;
|
|
2741
|
+
return (ipv4ToNumber(parts) & mask) >>> 0 === (ipv4ToNumber(base) & mask) >>> 0;
|
|
2742
|
+
}
|
|
2743
|
+
function ipv4ToNumber(parts) {
|
|
2744
|
+
return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
|
|
2745
|
+
}
|
|
2746
|
+
function ipv6MatchesCidr(words, base, prefix) {
|
|
2747
|
+
const fullWords = Math.floor(prefix / 16);
|
|
2748
|
+
for (let index = 0;index < fullWords; index += 1) {
|
|
2749
|
+
if (words[index] !== base[index])
|
|
2750
|
+
return false;
|
|
2751
|
+
}
|
|
2752
|
+
const remainingBits = prefix % 16;
|
|
2753
|
+
if (remainingBits === 0)
|
|
2754
|
+
return true;
|
|
2755
|
+
const mask = 65535 << 16 - remainingBits & 65535;
|
|
2756
|
+
return (words[fullWords] & mask) === (base[fullWords] & mask);
|
|
2757
|
+
}
|
|
2758
|
+
function parseIpv6Words(value) {
|
|
2759
|
+
let ip = value.toLowerCase();
|
|
2760
|
+
const zoneIndex = ip.indexOf("%");
|
|
2761
|
+
if (zoneIndex >= 0)
|
|
2762
|
+
ip = ip.slice(0, zoneIndex);
|
|
2763
|
+
if (ip.includes(".")) {
|
|
2764
|
+
const lastColon = ip.lastIndexOf(":");
|
|
2765
|
+
if (lastColon < 0)
|
|
2766
|
+
return null;
|
|
2767
|
+
const ipv4 = parseIpv4Words(ip.slice(lastColon + 1));
|
|
2768
|
+
if (!ipv4)
|
|
2769
|
+
return null;
|
|
2770
|
+
ip = `${ip.slice(0, lastColon)}:${(ipv4[0] << 8 | ipv4[1]).toString(16)}:${(ipv4[2] << 8 | ipv4[3]).toString(16)}`;
|
|
2771
|
+
}
|
|
2772
|
+
const compressed = ip.split("::");
|
|
2773
|
+
if (compressed.length > 2)
|
|
2774
|
+
return null;
|
|
2775
|
+
const left = parseIpv6Side(compressed[0]);
|
|
2776
|
+
const right = compressed.length === 2 ? parseIpv6Side(compressed[1]) : [];
|
|
2777
|
+
if (!left || !right)
|
|
2778
|
+
return null;
|
|
2779
|
+
if (compressed.length === 1)
|
|
2780
|
+
return left.length === 8 ? left : null;
|
|
2781
|
+
const missing = 8 - left.length - right.length;
|
|
2782
|
+
if (missing < 1)
|
|
2783
|
+
return null;
|
|
2784
|
+
return [...left, ...Array(missing).fill(0), ...right];
|
|
2785
|
+
}
|
|
2786
|
+
function parseIpv6Side(value) {
|
|
2787
|
+
if (!value)
|
|
2788
|
+
return [];
|
|
2789
|
+
const words = value.split(":");
|
|
2790
|
+
if (words.some((word) => !/^[0-9a-f]{1,4}$/.test(word)))
|
|
2791
|
+
return null;
|
|
2792
|
+
return words.map((word) => Number.parseInt(word, 16));
|
|
2793
|
+
}
|
|
2794
|
+
function parseIpv4Words(value) {
|
|
2795
|
+
const words = value.split(".").map((part) => Number(part));
|
|
2796
|
+
if (words.length !== 4 || words.some((word) => !Number.isInteger(word) || word < 0 || word > 255)) {
|
|
2797
|
+
return null;
|
|
2798
|
+
}
|
|
2799
|
+
return words;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// src/checks.ts
|
|
2581
2803
|
async function runMonitorCheck(monitor, options = {}) {
|
|
2582
2804
|
if (!monitor.enabled) {
|
|
2583
2805
|
return { status: "down", latencyMs: null, error: "monitor is disabled" };
|
|
2584
2806
|
}
|
|
2585
|
-
if (monitor.kind === "http")
|
|
2586
|
-
return
|
|
2807
|
+
if (monitor.kind === "http") {
|
|
2808
|
+
return options.hostedTargetPolicy ? runHostedHttpCheck(monitor, {
|
|
2809
|
+
resolveHost: options.resolveHost,
|
|
2810
|
+
request: options.hostedHttpRequest,
|
|
2811
|
+
maxRedirects: options.maxRedirects
|
|
2812
|
+
}) : runHttpCheck(monitor, options.fetch ?? fetch);
|
|
2813
|
+
}
|
|
2587
2814
|
if (monitor.kind === "browser_page")
|
|
2588
2815
|
return runBrowserPageCheck(monitor, { fetch: options.fetch, runner: options.browserPage });
|
|
2589
2816
|
if (monitor.kind === "tcp")
|
|
@@ -2621,12 +2848,87 @@ async function runHttpCheck(monitor, fetchImpl = fetch) {
|
|
|
2621
2848
|
clearTimeout(timeout);
|
|
2622
2849
|
}
|
|
2623
2850
|
}
|
|
2851
|
+
async function runHostedHttpCheck(monitor, options = {}) {
|
|
2852
|
+
if (!monitor.url)
|
|
2853
|
+
return { status: "down", latencyMs: null, error: "missing url" };
|
|
2854
|
+
const resolver = options.resolveHost ?? resolveHostedHost;
|
|
2855
|
+
const request = options.request ?? requestHostedHttpPinned;
|
|
2856
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
2857
|
+
const controller = new AbortController;
|
|
2858
|
+
const timeout = setTimeout(() => controller.abort(), monitor.timeoutMs);
|
|
2859
|
+
const started = performance.now();
|
|
2860
|
+
const decisions = [];
|
|
2861
|
+
let currentUrl;
|
|
2862
|
+
let redirectCount = 0;
|
|
2863
|
+
try {
|
|
2864
|
+
currentUrl = new URL(monitor.url);
|
|
2865
|
+
} catch (error) {
|
|
2866
|
+
clearTimeout(timeout);
|
|
2867
|
+
return {
|
|
2868
|
+
status: "down",
|
|
2869
|
+
latencyMs: 0,
|
|
2870
|
+
statusCode: null,
|
|
2871
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2872
|
+
evidence: hostedHttpEvidence(null, redirectCount, decisions)
|
|
2873
|
+
};
|
|
2874
|
+
}
|
|
2875
|
+
try {
|
|
2876
|
+
while (true) {
|
|
2877
|
+
throwIfAborted(controller.signal);
|
|
2878
|
+
const stage = redirectCount === 0 ? "request" : "redirect";
|
|
2879
|
+
const address = await resolveAndRecordHostedHttpDecision(currentUrl, stage, resolver, decisions);
|
|
2880
|
+
const response = await request({
|
|
2881
|
+
url: currentUrl,
|
|
2882
|
+
method: monitor.method || "GET",
|
|
2883
|
+
timeoutMs: monitor.timeoutMs,
|
|
2884
|
+
address,
|
|
2885
|
+
signal: controller.signal
|
|
2886
|
+
});
|
|
2887
|
+
const location = redirectLocation(response.headers);
|
|
2888
|
+
if (isRedirectStatus(response.status) && location) {
|
|
2889
|
+
if (redirectCount >= maxRedirects) {
|
|
2890
|
+
const latencyMs2 = elapsed(started);
|
|
2891
|
+
return {
|
|
2892
|
+
status: "down",
|
|
2893
|
+
latencyMs: latencyMs2,
|
|
2894
|
+
statusCode: response.status,
|
|
2895
|
+
error: `too many redirects after ${maxRedirects}`,
|
|
2896
|
+
evidence: hostedHttpEvidence(currentUrl, redirectCount, decisions)
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
currentUrl = new URL(location, currentUrl);
|
|
2900
|
+
redirectCount += 1;
|
|
2901
|
+
continue;
|
|
2902
|
+
}
|
|
2903
|
+
const latencyMs = elapsed(started);
|
|
2904
|
+
const ok = monitor.expectedStatus == null ? response.status >= 200 && response.status < 400 : response.status === monitor.expectedStatus;
|
|
2905
|
+
return {
|
|
2906
|
+
status: ok ? "up" : "down",
|
|
2907
|
+
latencyMs,
|
|
2908
|
+
statusCode: response.status,
|
|
2909
|
+
error: ok ? null : `unexpected status ${response.status}`,
|
|
2910
|
+
evidence: hostedHttpEvidence(currentUrl, redirectCount, decisions)
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
} catch (error) {
|
|
2914
|
+
const latencyMs = elapsed(started);
|
|
2915
|
+
return {
|
|
2916
|
+
status: "down",
|
|
2917
|
+
latencyMs,
|
|
2918
|
+
statusCode: null,
|
|
2919
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2920
|
+
evidence: hostedHttpEvidence(currentUrl, redirectCount, decisions)
|
|
2921
|
+
};
|
|
2922
|
+
} finally {
|
|
2923
|
+
clearTimeout(timeout);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2624
2926
|
async function runTcpCheck(monitor) {
|
|
2625
2927
|
if (!monitor.host || !monitor.port)
|
|
2626
2928
|
return { status: "down", latencyMs: null, error: "missing host or port" };
|
|
2627
2929
|
const started = performance.now();
|
|
2628
2930
|
return new Promise((resolve) => {
|
|
2629
|
-
const socket =
|
|
2931
|
+
const socket = net2.createConnection({ host: monitor.host, port: monitor.port, timeout: monitor.timeoutMs });
|
|
2630
2932
|
let settled = false;
|
|
2631
2933
|
const finish = (result) => {
|
|
2632
2934
|
if (settled)
|
|
@@ -2714,6 +3016,40 @@ function normalizeBrowserEvidence(sourceUrl, raw) {
|
|
|
2714
3016
|
retentionClass: "short"
|
|
2715
3017
|
};
|
|
2716
3018
|
}
|
|
3019
|
+
function normalizeHttpTargetPolicyEvidence(raw) {
|
|
3020
|
+
if (!isHttpTargetPolicyEvidence(raw))
|
|
3021
|
+
throw new Error("HTTP target-policy evidence is invalid");
|
|
3022
|
+
return {
|
|
3023
|
+
kind: "http_target_policy",
|
|
3024
|
+
mode: "hosted",
|
|
3025
|
+
finalUrl: raw.finalUrl ? redactUrl(raw.finalUrl) : null,
|
|
3026
|
+
redirectCount: Math.max(0, Math.min(20, Math.trunc(raw.redirectCount))),
|
|
3027
|
+
decisions: raw.decisions.slice(0, 20).map((decision) => ({
|
|
3028
|
+
stage: decision.stage,
|
|
3029
|
+
decision: decision.decision,
|
|
3030
|
+
url: redactUrl(decision.url),
|
|
3031
|
+
host: redactText(normalizeHostedHost(decision.host)),
|
|
3032
|
+
targetClass: "public_http",
|
|
3033
|
+
probeClass: "public",
|
|
3034
|
+
protocol: decision.protocol,
|
|
3035
|
+
resolvedAddresses: decision.resolvedAddresses.slice(0, 20).map((address) => ({
|
|
3036
|
+
address: normalizeHostedHost(address.address),
|
|
3037
|
+
family: address.family
|
|
3038
|
+
})),
|
|
3039
|
+
ruleId: redactText(decision.ruleId),
|
|
3040
|
+
reason: decision.reason ? redactText(decision.reason) : null
|
|
3041
|
+
})),
|
|
3042
|
+
redacted: true,
|
|
3043
|
+
redactionStatus: "redacted",
|
|
3044
|
+
retentionClass: "short"
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
function isHttpTargetPolicyEvidence(value) {
|
|
3048
|
+
if (!value || typeof value !== "object" || value.kind !== "http_target_policy")
|
|
3049
|
+
return false;
|
|
3050
|
+
const evidence = value;
|
|
3051
|
+
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)));
|
|
3052
|
+
}
|
|
2717
3053
|
function validateBrowserPageUrl(value) {
|
|
2718
3054
|
const parsed = new URL(value);
|
|
2719
3055
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
@@ -2770,6 +3106,130 @@ function redactText(value) {
|
|
|
2770
3106
|
function isSecretKey(value) {
|
|
2771
3107
|
return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
|
|
2772
3108
|
}
|
|
3109
|
+
async function resolveAndRecordHostedHttpDecision(url, stage, resolver, decisions) {
|
|
3110
|
+
let addresses = [];
|
|
3111
|
+
try {
|
|
3112
|
+
assertHostedHttpUrlAllowed(url.toString());
|
|
3113
|
+
addresses = normalizeResolvedAddresses(await resolver(normalizeHostedHost(url.hostname)));
|
|
3114
|
+
assertHostedResolvedAddressesAllowed(url.hostname, addresses, "HTTP resolved address");
|
|
3115
|
+
decisions.push({
|
|
3116
|
+
stage,
|
|
3117
|
+
decision: "allowed",
|
|
3118
|
+
url: sanitizePolicyUrl(url),
|
|
3119
|
+
host: normalizeHostedHost(url.hostname),
|
|
3120
|
+
targetClass: "public_http",
|
|
3121
|
+
probeClass: "public",
|
|
3122
|
+
protocol: url.protocol,
|
|
3123
|
+
resolvedAddresses: addresses,
|
|
3124
|
+
ruleId: "hosted-http-runtime-target-policy",
|
|
3125
|
+
reason: null
|
|
3126
|
+
});
|
|
3127
|
+
return addresses[0];
|
|
3128
|
+
} catch (error) {
|
|
3129
|
+
decisions.push({
|
|
3130
|
+
stage,
|
|
3131
|
+
decision: "blocked",
|
|
3132
|
+
url: sanitizePolicyUrl(url),
|
|
3133
|
+
host: normalizeHostedHost(url.hostname),
|
|
3134
|
+
targetClass: "public_http",
|
|
3135
|
+
probeClass: "public",
|
|
3136
|
+
protocol: url.protocol === "http:" || url.protocol === "https:" ? url.protocol : "http:",
|
|
3137
|
+
resolvedAddresses: addresses,
|
|
3138
|
+
ruleId: "hosted-http-runtime-target-policy",
|
|
3139
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
3140
|
+
});
|
|
3141
|
+
throw error;
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
async function resolveHostedHost(hostname) {
|
|
3145
|
+
const host = normalizeHostedHost(hostname);
|
|
3146
|
+
const ipVersion = net2.isIP(host);
|
|
3147
|
+
if (ipVersion === 4 || ipVersion === 6)
|
|
3148
|
+
return [{ address: host, family: ipVersion }];
|
|
3149
|
+
return dns.lookup(host, { all: true, verbatim: true });
|
|
3150
|
+
}
|
|
3151
|
+
function normalizeResolvedAddresses(addresses) {
|
|
3152
|
+
return addresses.map((entry) => {
|
|
3153
|
+
const address = normalizeHostedHost(entry.address);
|
|
3154
|
+
const detected = net2.isIP(address);
|
|
3155
|
+
const family = entry.family === 4 || entry.family === 6 ? entry.family : detected;
|
|
3156
|
+
if (family !== 4 && family !== 6) {
|
|
3157
|
+
throw new Error("HTTP resolved address is not allowed in hosted mode: DNS returned a non-IP address");
|
|
3158
|
+
}
|
|
3159
|
+
return { address, family };
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
function hostedHttpEvidence(finalUrl, redirectCount, decisions) {
|
|
3163
|
+
return {
|
|
3164
|
+
kind: "http_target_policy",
|
|
3165
|
+
mode: "hosted",
|
|
3166
|
+
finalUrl: finalUrl ? sanitizePolicyUrl(finalUrl) : null,
|
|
3167
|
+
redirectCount,
|
|
3168
|
+
decisions,
|
|
3169
|
+
redacted: true,
|
|
3170
|
+
redactionStatus: "redacted",
|
|
3171
|
+
retentionClass: "short"
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
function sanitizePolicyUrl(url) {
|
|
3175
|
+
const copy = new URL(url.toString());
|
|
3176
|
+
copy.username = "";
|
|
3177
|
+
copy.password = "";
|
|
3178
|
+
copy.hash = "";
|
|
3179
|
+
for (const key of copy.searchParams.keys()) {
|
|
3180
|
+
if (isSecretKey(key))
|
|
3181
|
+
copy.searchParams.set(key, "[redacted]");
|
|
3182
|
+
}
|
|
3183
|
+
return copy.toString();
|
|
3184
|
+
}
|
|
3185
|
+
function redirectLocation(headers) {
|
|
3186
|
+
if (!headers)
|
|
3187
|
+
return null;
|
|
3188
|
+
if (headers instanceof Headers)
|
|
3189
|
+
return headers.get("location");
|
|
3190
|
+
const raw = headers.location ?? headers.Location;
|
|
3191
|
+
if (Array.isArray(raw))
|
|
3192
|
+
return raw[0] ?? null;
|
|
3193
|
+
return raw ?? null;
|
|
3194
|
+
}
|
|
3195
|
+
function isRedirectStatus(status) {
|
|
3196
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
3197
|
+
}
|
|
3198
|
+
async function requestHostedHttpPinned(context) {
|
|
3199
|
+
const lookup = (_hostname, _options, callback) => callback(null, context.address.address, context.address.family);
|
|
3200
|
+
return context.url.protocol === "https:" ? requestWithClient(context, https, new https.Agent({ lookup })) : requestWithClient(context, http, new http.Agent({ lookup }));
|
|
3201
|
+
}
|
|
3202
|
+
function requestWithClient(context, client, agent) {
|
|
3203
|
+
return new Promise((resolve, reject) => {
|
|
3204
|
+
const req = client.request(context.url, {
|
|
3205
|
+
method: context.method,
|
|
3206
|
+
agent,
|
|
3207
|
+
signal: context.signal,
|
|
3208
|
+
timeout: context.timeoutMs
|
|
3209
|
+
}, (response) => {
|
|
3210
|
+
response.resume();
|
|
3211
|
+
response.once("end", () => {
|
|
3212
|
+
agent.destroy();
|
|
3213
|
+
resolve({ status: response.statusCode ?? 0, headers: response.headers });
|
|
3214
|
+
});
|
|
3215
|
+
});
|
|
3216
|
+
req.once("timeout", () => {
|
|
3217
|
+
req.destroy(new Error("http timeout"));
|
|
3218
|
+
});
|
|
3219
|
+
req.once("error", (error) => {
|
|
3220
|
+
agent.destroy();
|
|
3221
|
+
reject(error);
|
|
3222
|
+
});
|
|
3223
|
+
req.end();
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
function throwIfAborted(signal) {
|
|
3227
|
+
if (signal.aborted)
|
|
3228
|
+
throw new Error("http timeout");
|
|
3229
|
+
}
|
|
3230
|
+
function elapsed(started) {
|
|
3231
|
+
return Math.round((performance.now() - started) * 100) / 100;
|
|
3232
|
+
}
|
|
2773
3233
|
|
|
2774
3234
|
// src/service.ts
|
|
2775
3235
|
import { createPublicKey, randomUUID as randomUUID3 } from "crypto";
|
|
@@ -2786,83 +3246,10 @@ var MIN_RETRY_COUNT = 0;
|
|
|
2786
3246
|
var MAX_RETRY_COUNT = 10;
|
|
2787
3247
|
var MAX_RESULT_LIMIT = 1000;
|
|
2788
3248
|
|
|
2789
|
-
// src/target-policy.ts
|
|
2790
|
-
import net2 from "net";
|
|
2791
|
-
var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
2792
|
-
function assertHostedTargetAllowed(target) {
|
|
2793
|
-
if (target.kind === "http" || target.kind === "browser_page") {
|
|
2794
|
-
if (!target.url)
|
|
2795
|
-
throw new Error("HTTP monitors require url");
|
|
2796
|
-
assertHostedHttpUrlAllowed(target.url);
|
|
2797
|
-
return;
|
|
2798
|
-
}
|
|
2799
|
-
if (target.kind === "tcp") {
|
|
2800
|
-
if (!target.host)
|
|
2801
|
-
throw new Error("TCP monitors require host");
|
|
2802
|
-
assertHostedHostAllowed(target.host, "TCP host");
|
|
2803
|
-
if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
|
|
2804
|
-
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
2805
|
-
}
|
|
2806
|
-
return;
|
|
2807
|
-
}
|
|
2808
|
-
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
2809
|
-
}
|
|
2810
|
-
function assertHostedHttpUrlAllowed(value) {
|
|
2811
|
-
const parsed = new URL(value);
|
|
2812
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2813
|
-
throw new Error("HTTP monitor url must use http or https");
|
|
2814
|
-
}
|
|
2815
|
-
if (parsed.username || parsed.password) {
|
|
2816
|
-
throw new Error("hosted target URLs must not contain userinfo");
|
|
2817
|
-
}
|
|
2818
|
-
for (const key of parsed.searchParams.keys()) {
|
|
2819
|
-
if (SECRET_PARAM_PATTERN.test(key)) {
|
|
2820
|
-
throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
|
|
2821
|
-
}
|
|
2822
|
-
}
|
|
2823
|
-
if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
|
|
2824
|
-
throw new Error("hosted target URL fragment contains secret-like data");
|
|
2825
|
-
}
|
|
2826
|
-
assertHostedHostAllowed(parsed.hostname, "HTTP host");
|
|
2827
|
-
}
|
|
2828
|
-
function assertHostedHostAllowed(hostname, label = "host") {
|
|
2829
|
-
const host = normalizeHost(hostname);
|
|
2830
|
-
if (!host)
|
|
2831
|
-
throw new Error(`${label} is required`);
|
|
2832
|
-
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
2833
|
-
throw new Error(`${label} is not allowed in hosted mode: localhost`);
|
|
2834
|
-
}
|
|
2835
|
-
if (host.endsWith(".local") || host.endsWith(".internal")) {
|
|
2836
|
-
throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
|
|
2837
|
-
}
|
|
2838
|
-
const ipVersion = net2.isIP(host);
|
|
2839
|
-
if (ipVersion === 4 && isDeniedIpv4(host)) {
|
|
2840
|
-
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
|
|
2841
|
-
}
|
|
2842
|
-
if (ipVersion === 6 && isDeniedIpv6(host)) {
|
|
2843
|
-
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
|
|
2844
|
-
}
|
|
2845
|
-
}
|
|
2846
|
-
function normalizeHost(hostname) {
|
|
2847
|
-
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
2848
|
-
}
|
|
2849
|
-
function isDeniedIpv4(ip) {
|
|
2850
|
-
const parts = ip.split(".").map((part) => Number(part));
|
|
2851
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
2852
|
-
return true;
|
|
2853
|
-
}
|
|
2854
|
-
const [a, b] = parts;
|
|
2855
|
-
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;
|
|
2856
|
-
}
|
|
2857
|
-
function isDeniedIpv6(ip) {
|
|
2858
|
-
const normalized = ip.toLowerCase();
|
|
2859
|
-
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.");
|
|
2860
|
-
}
|
|
2861
|
-
|
|
2862
3249
|
// src/imports.ts
|
|
2863
|
-
function previewImport(store, request) {
|
|
3250
|
+
function previewImport(store, request, options = {}) {
|
|
2864
3251
|
const source = normalizeSource(request.source);
|
|
2865
|
-
const items = dedupePreviewItems(request.records.map((record) => previewRecord(store, source, record, request.defaults ?? {})));
|
|
3252
|
+
const items = dedupePreviewItems(request.records.map((record) => previewRecord(store, source, record, request.defaults ?? {}, options)));
|
|
2866
3253
|
return {
|
|
2867
3254
|
source,
|
|
2868
3255
|
generatedAt: new Date().toISOString(),
|
|
@@ -2936,7 +3323,7 @@ function rollbackImport(store, batchId) {
|
|
|
2936
3323
|
items
|
|
2937
3324
|
};
|
|
2938
3325
|
}
|
|
2939
|
-
function previewRecord(store, source, record, defaults) {
|
|
3326
|
+
function previewRecord(store, source, record, defaults, options) {
|
|
2940
3327
|
const warnings = [];
|
|
2941
3328
|
let candidate;
|
|
2942
3329
|
try {
|
|
@@ -2956,13 +3343,16 @@ function previewRecord(store, source, record, defaults) {
|
|
|
2956
3343
|
reason: error instanceof Error ? error.message : String(error)
|
|
2957
3344
|
};
|
|
2958
3345
|
}
|
|
2959
|
-
const
|
|
2960
|
-
const
|
|
2961
|
-
|
|
3346
|
+
const monitorOptions = options.workspaceId ? { workspaceId: options.workspaceId } : undefined;
|
|
3347
|
+
const rawProvenance = store.getProvenance(candidate.source, candidate.sourceId);
|
|
3348
|
+
const provenanceMonitor = rawProvenance ? store.getMonitor(rawProvenance.monitorId, monitorOptions) : null;
|
|
3349
|
+
const provenance = provenanceMonitor ? rawProvenance : null;
|
|
3350
|
+
const monitor = provenanceMonitor ?? store.getMonitor(candidate.name, monitorOptions);
|
|
3351
|
+
if (rawProvenance && !provenanceMonitor && !options.workspaceId) {
|
|
2962
3352
|
return { candidate, action: "create", monitor: null, provenance, warnings: ["source provenance points to a missing monitor"], reason: null };
|
|
2963
3353
|
}
|
|
2964
3354
|
if (provenance && monitor) {
|
|
2965
|
-
const nameOwner = store.getMonitor(candidate.name);
|
|
3355
|
+
const nameOwner = store.getMonitor(candidate.name, monitorOptions);
|
|
2966
3356
|
if (nameOwner && nameOwner.id !== monitor.id) {
|
|
2967
3357
|
return {
|
|
2968
3358
|
candidate,
|
|
@@ -5310,8 +5700,8 @@ class UptimeService {
|
|
|
5310
5700
|
const execute = () => this.submitProbeResultInTransaction(input);
|
|
5311
5701
|
return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
|
|
5312
5702
|
}
|
|
5313
|
-
previewImport(request) {
|
|
5314
|
-
return previewImport(this.store, request);
|
|
5703
|
+
previewImport(request, options = {}) {
|
|
5704
|
+
return previewImport(this.store, request, options);
|
|
5315
5705
|
}
|
|
5316
5706
|
applyImport(request) {
|
|
5317
5707
|
return applyImport(this.store, request);
|
|
@@ -5651,7 +6041,7 @@ class UptimeService {
|
|
|
5651
6041
|
throw new Error("Probe job fencing token is invalid");
|
|
5652
6042
|
if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
|
|
5653
6043
|
throw new Error("Probe job lease expired");
|
|
5654
|
-
const evidence = input.evidence ?
|
|
6044
|
+
const evidence = input.evidence ? normalizeSubmittedEvidence(monitor.url ?? monitor.host ?? "https://example.invalid", input.evidence) : null;
|
|
5655
6045
|
const result = this.store.recordCheckResult({
|
|
5656
6046
|
monitorId: monitor.id,
|
|
5657
6047
|
checkedAt: input.checkedAt,
|
|
@@ -5691,6 +6081,13 @@ class MonitorCheckBusyError extends Error {
|
|
|
5691
6081
|
function enabledReportChannels(schedule) {
|
|
5692
6082
|
return ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel]));
|
|
5693
6083
|
}
|
|
6084
|
+
function normalizeSubmittedEvidence(sourceUrl, evidence) {
|
|
6085
|
+
if (evidence.kind === "browser_page")
|
|
6086
|
+
return normalizeBrowserEvidence(sourceUrl, evidence);
|
|
6087
|
+
if (evidence.kind === "http_target_policy")
|
|
6088
|
+
return normalizeHttpTargetPolicyEvidence(evidence);
|
|
6089
|
+
throw new Error("Unsupported probe evidence kind");
|
|
6090
|
+
}
|
|
5694
6091
|
function validateProbeSubmission(input) {
|
|
5695
6092
|
if (!input.jobId.trim())
|
|
5696
6093
|
throw new Error("Probe submission jobId is required");
|
|
@@ -6355,7 +6752,7 @@ async function handleApiRoute(service, request, url, apiPath, options, hosted, a
|
|
|
6355
6752
|
return json(service.submitProbeResult(await jsonBody(request)), 201);
|
|
6356
6753
|
}
|
|
6357
6754
|
if (request.method === "POST" && apiPath === "/api/imports/preview") {
|
|
6358
|
-
return json(service.previewImport(await jsonBody(request)));
|
|
6755
|
+
return json(service.previewImport(await jsonBody(request), { workspaceId: actor?.workspaceId }));
|
|
6359
6756
|
}
|
|
6360
6757
|
if (request.method === "POST" && apiPath === "/api/imports/apply") {
|
|
6361
6758
|
if (hosted)
|
|
@@ -6535,7 +6932,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
6535
6932
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
6536
6933
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
6537
6934
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
6538
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
6935
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.12");
|
|
6539
6936
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
6540
6937
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
6541
6938
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -6677,7 +7074,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
6677
7074
|
"The infrastructure owner repository was not found in this workspace.",
|
|
6678
7075
|
"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.",
|
|
6679
7076
|
"Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
|
|
6680
|
-
"Public probe execution still needs
|
|
7077
|
+
"Public probe execution still needs cloud check-job leases wired to runHostedHttpCheck and live policy-decision log evidence.",
|
|
6681
7078
|
"Private probe enrollment, claim, submit, heartbeat, revocation, and rotation are not cloud-backed yet."
|
|
6682
7079
|
],
|
|
6683
7080
|
requiredEvidence: [
|