@node9/proxy 2.16.1 → 2.17.0
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/cli.js +270 -234
- package/dist/cli.mjs +270 -234
- package/dist/dashboard.mjs +2 -2
- package/dist/index.js +269 -233
- package/dist/index.mjs +269 -233
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3446,6 +3446,211 @@ function analyzeShellCommand(command) {
|
|
|
3446
3446
|
}
|
|
3447
3447
|
return { actions, paths, allTokens };
|
|
3448
3448
|
}
|
|
3449
|
+
var SSRF_MAX_HOST = 253;
|
|
3450
|
+
function parseComponent(s) {
|
|
3451
|
+
if (!s) return null;
|
|
3452
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3453
|
+
if (s === "0") return 0;
|
|
3454
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3455
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3456
|
+
return null;
|
|
3457
|
+
}
|
|
3458
|
+
function parseIpv4(input) {
|
|
3459
|
+
let s = input;
|
|
3460
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3461
|
+
if (!s) return null;
|
|
3462
|
+
const parts = s.split(".");
|
|
3463
|
+
if (parts.length > 4) return null;
|
|
3464
|
+
const vals = [];
|
|
3465
|
+
for (const p of parts) {
|
|
3466
|
+
const v = parseComponent(p);
|
|
3467
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3468
|
+
vals.push(v);
|
|
3469
|
+
}
|
|
3470
|
+
const n = vals.length;
|
|
3471
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3472
|
+
const last = vals[n - 1];
|
|
3473
|
+
const remainingBytes = 4 - (n - 1);
|
|
3474
|
+
const limit = Math.pow(256, remainingBytes);
|
|
3475
|
+
if (last >= limit) return null;
|
|
3476
|
+
let value = last;
|
|
3477
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3478
|
+
if (value > 4294967295) return null;
|
|
3479
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3480
|
+
}
|
|
3481
|
+
function expandIpv6(input) {
|
|
3482
|
+
const s = input.toLowerCase();
|
|
3483
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3484
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3485
|
+
let head = s;
|
|
3486
|
+
let tailV4 = null;
|
|
3487
|
+
const lastColon = s.lastIndexOf(":");
|
|
3488
|
+
const afterLast = s.slice(lastColon + 1);
|
|
3489
|
+
if (afterLast.includes(".")) {
|
|
3490
|
+
const dotted = parseIpv4(afterLast);
|
|
3491
|
+
if (!dotted) return null;
|
|
3492
|
+
const o = dotted.split(".").map(Number);
|
|
3493
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3494
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
3495
|
+
}
|
|
3496
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3497
|
+
const toGroups = (part) => {
|
|
3498
|
+
if (!part) return [];
|
|
3499
|
+
const out = [];
|
|
3500
|
+
for (const g of part.split(":")) {
|
|
3501
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3502
|
+
out.push(parseInt(g, 16));
|
|
3503
|
+
}
|
|
3504
|
+
return out;
|
|
3505
|
+
};
|
|
3506
|
+
const left = toGroups(lhs);
|
|
3507
|
+
if (left === null) return null;
|
|
3508
|
+
let right = [];
|
|
3509
|
+
if (rhs !== null) {
|
|
3510
|
+
const r = toGroups(rhs);
|
|
3511
|
+
if (r === null) return null;
|
|
3512
|
+
right = r;
|
|
3513
|
+
}
|
|
3514
|
+
if (tailV4) {
|
|
3515
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3516
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3517
|
+
}
|
|
3518
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3519
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
3520
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3521
|
+
if (groups.length !== 8) return null;
|
|
3522
|
+
return groups;
|
|
3523
|
+
}
|
|
3524
|
+
function compressIpv6(g) {
|
|
3525
|
+
let bestStart = -1;
|
|
3526
|
+
let bestLen = 0;
|
|
3527
|
+
let i = 0;
|
|
3528
|
+
while (i < 8) {
|
|
3529
|
+
if (g[i] !== 0) {
|
|
3530
|
+
i++;
|
|
3531
|
+
continue;
|
|
3532
|
+
}
|
|
3533
|
+
let j = i;
|
|
3534
|
+
while (j < 8 && g[j] === 0) j++;
|
|
3535
|
+
if (j - i > bestLen) {
|
|
3536
|
+
bestLen = j - i;
|
|
3537
|
+
bestStart = i;
|
|
3538
|
+
}
|
|
3539
|
+
i = j;
|
|
3540
|
+
}
|
|
3541
|
+
const hex = g.map((x) => x.toString(16));
|
|
3542
|
+
if (bestLen < 2) return hex.join(":");
|
|
3543
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3544
|
+
}
|
|
3545
|
+
function normalizeIpLiteral(host) {
|
|
3546
|
+
try {
|
|
3547
|
+
if (typeof host !== "string") return null;
|
|
3548
|
+
let s = host.trim();
|
|
3549
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3550
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3551
|
+
const zone = s.indexOf("%");
|
|
3552
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
3553
|
+
if (!s) return null;
|
|
3554
|
+
if (s.includes(":")) {
|
|
3555
|
+
const g = expandIpv6(s);
|
|
3556
|
+
if (!g) return null;
|
|
3557
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3558
|
+
if (mapped) {
|
|
3559
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3560
|
+
}
|
|
3561
|
+
return compressIpv6(g);
|
|
3562
|
+
}
|
|
3563
|
+
return parseIpv4(s);
|
|
3564
|
+
} catch {
|
|
3565
|
+
return null;
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3569
|
+
"169.254.169.254",
|
|
3570
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3571
|
+
"169.254.170.2",
|
|
3572
|
+
// AWS ECS task role
|
|
3573
|
+
"168.63.129.16",
|
|
3574
|
+
// Azure WireServer
|
|
3575
|
+
"fd00:ec2::254",
|
|
3576
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3577
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3578
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3579
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
3580
|
+
// and it is the reason relaxing cgnat is safe.
|
|
3581
|
+
"100.100.100.200"
|
|
3582
|
+
]);
|
|
3583
|
+
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3584
|
+
function isStrictGatedTier(tier) {
|
|
3585
|
+
return STRICT_TIERS.has(tier);
|
|
3586
|
+
}
|
|
3587
|
+
function ssrfReason(m, asWritten) {
|
|
3588
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3589
|
+
}
|
|
3590
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3591
|
+
var v4Octets = (a) => {
|
|
3592
|
+
const p = a.split(".");
|
|
3593
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
3594
|
+
};
|
|
3595
|
+
function classifySsrf(host) {
|
|
3596
|
+
try {
|
|
3597
|
+
if (typeof host !== "string" || !host) return null;
|
|
3598
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3599
|
+
const ip = normalizeIpLiteral(host);
|
|
3600
|
+
if (ip === null) {
|
|
3601
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3602
|
+
}
|
|
3603
|
+
const hit = (tier, overridable) => ({
|
|
3604
|
+
tier,
|
|
3605
|
+
overridable,
|
|
3606
|
+
kind: "address",
|
|
3607
|
+
normalized: ip
|
|
3608
|
+
});
|
|
3609
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3610
|
+
const o = v4Octets(ip);
|
|
3611
|
+
if (o) {
|
|
3612
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3613
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3614
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3615
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3616
|
+
if (o[0] === 127) return hit("private", true);
|
|
3617
|
+
if (o[0] === 10) return hit("private", true);
|
|
3618
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3619
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3620
|
+
return null;
|
|
3621
|
+
}
|
|
3622
|
+
const g = expandIpv6(ip);
|
|
3623
|
+
if (!g) return null;
|
|
3624
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3625
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3626
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3627
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3628
|
+
return null;
|
|
3629
|
+
} catch {
|
|
3630
|
+
return null;
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
var TIER_REASON = {
|
|
3634
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3635
|
+
"link-local": "a link-local address",
|
|
3636
|
+
multicast: "a multicast address",
|
|
3637
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
3638
|
+
cgnat: "a carrier-grade NAT address",
|
|
3639
|
+
private: "a loopback or private address"
|
|
3640
|
+
};
|
|
3641
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
3642
|
+
const exempt = new Set(
|
|
3643
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3644
|
+
);
|
|
3645
|
+
for (const { token, binary } of tokens) {
|
|
3646
|
+
const m = classifySsrf(token);
|
|
3647
|
+
if (!m) continue;
|
|
3648
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3649
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3650
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3651
|
+
}
|
|
3652
|
+
return null;
|
|
3653
|
+
}
|
|
3449
3654
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
3450
3655
|
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
3451
3656
|
// Without it, turning egress on asks the user to approve node9 itself.
|
|
@@ -3470,29 +3675,38 @@ var DEFAULT_EGRESS_ALLOWLIST = [
|
|
|
3470
3675
|
"*.ubuntu.com"
|
|
3471
3676
|
];
|
|
3472
3677
|
function hostMatches(host, pattern) {
|
|
3473
|
-
const
|
|
3474
|
-
const p = pattern.toLowerCase().trim();
|
|
3678
|
+
const p = pattern.trim().toLowerCase();
|
|
3475
3679
|
if (!p) return false;
|
|
3476
3680
|
if (p === "*") return true;
|
|
3681
|
+
const h = canonicalHost(host);
|
|
3477
3682
|
if (p.startsWith("*.")) {
|
|
3478
|
-
const suffix = p.slice(2);
|
|
3683
|
+
const suffix = canonicalHost(p.slice(2));
|
|
3479
3684
|
return h === suffix || h.endsWith("." + suffix);
|
|
3480
3685
|
}
|
|
3481
|
-
return h === p;
|
|
3686
|
+
return h === canonicalHost(p);
|
|
3482
3687
|
}
|
|
3483
3688
|
function matchesAny(host, patterns) {
|
|
3484
|
-
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
3689
|
+
for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
|
|
3485
3690
|
return false;
|
|
3486
3691
|
}
|
|
3692
|
+
var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
|
|
3693
|
+
function isUniqueLocalV6(host) {
|
|
3694
|
+
const ip = normalizeIpLiteral(host);
|
|
3695
|
+
if (!ip || !ip.includes(":")) return false;
|
|
3696
|
+
const g = expandIpv6(ip);
|
|
3697
|
+
return g !== null && (g[0] & 65024) === 64512;
|
|
3698
|
+
}
|
|
3487
3699
|
function isPrivateHost(host) {
|
|
3488
|
-
const h = host.toLowerCase();
|
|
3489
|
-
|
|
3490
|
-
if (
|
|
3491
|
-
if (
|
|
3492
|
-
if (
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3700
|
+
const h = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3701
|
+
const m = classifySsrf(h);
|
|
3702
|
+
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
3703
|
+
if (h === "localhost") return true;
|
|
3704
|
+
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
3705
|
+
return isUniqueLocalV6(h);
|
|
3706
|
+
}
|
|
3707
|
+
function canonicalHost(host) {
|
|
3708
|
+
const ip = normalizeIpLiteral(host);
|
|
3709
|
+
return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
|
|
3496
3710
|
}
|
|
3497
3711
|
function evaluateEgress(dests, policy) {
|
|
3498
3712
|
if (!policy.enabled) return null;
|
|
@@ -3835,211 +4049,6 @@ function extractAllSshHosts(tokens) {
|
|
|
3835
4049
|
}
|
|
3836
4050
|
return [...hosts].filter(Boolean);
|
|
3837
4051
|
}
|
|
3838
|
-
var SSRF_MAX_HOST = 253;
|
|
3839
|
-
function parseComponent(s) {
|
|
3840
|
-
if (!s) return null;
|
|
3841
|
-
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3842
|
-
if (s === "0") return 0;
|
|
3843
|
-
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3844
|
-
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3845
|
-
return null;
|
|
3846
|
-
}
|
|
3847
|
-
function parseIpv4(input) {
|
|
3848
|
-
let s = input;
|
|
3849
|
-
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3850
|
-
if (!s) return null;
|
|
3851
|
-
const parts = s.split(".");
|
|
3852
|
-
if (parts.length > 4) return null;
|
|
3853
|
-
const vals = [];
|
|
3854
|
-
for (const p of parts) {
|
|
3855
|
-
const v = parseComponent(p);
|
|
3856
|
-
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3857
|
-
vals.push(v);
|
|
3858
|
-
}
|
|
3859
|
-
const n = vals.length;
|
|
3860
|
-
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3861
|
-
const last = vals[n - 1];
|
|
3862
|
-
const remainingBytes = 4 - (n - 1);
|
|
3863
|
-
const limit = Math.pow(256, remainingBytes);
|
|
3864
|
-
if (last >= limit) return null;
|
|
3865
|
-
let value = last;
|
|
3866
|
-
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3867
|
-
if (value > 4294967295) return null;
|
|
3868
|
-
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3869
|
-
}
|
|
3870
|
-
function expandIpv6(input) {
|
|
3871
|
-
const s = input.toLowerCase();
|
|
3872
|
-
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3873
|
-
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3874
|
-
let head = s;
|
|
3875
|
-
let tailV4 = null;
|
|
3876
|
-
const lastColon = s.lastIndexOf(":");
|
|
3877
|
-
const afterLast = s.slice(lastColon + 1);
|
|
3878
|
-
if (afterLast.includes(".")) {
|
|
3879
|
-
const dotted = parseIpv4(afterLast);
|
|
3880
|
-
if (!dotted) return null;
|
|
3881
|
-
const o = dotted.split(".").map(Number);
|
|
3882
|
-
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3883
|
-
head = s.slice(0, lastColon + 1) + "0";
|
|
3884
|
-
}
|
|
3885
|
-
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3886
|
-
const toGroups = (part) => {
|
|
3887
|
-
if (!part) return [];
|
|
3888
|
-
const out = [];
|
|
3889
|
-
for (const g of part.split(":")) {
|
|
3890
|
-
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3891
|
-
out.push(parseInt(g, 16));
|
|
3892
|
-
}
|
|
3893
|
-
return out;
|
|
3894
|
-
};
|
|
3895
|
-
const left = toGroups(lhs);
|
|
3896
|
-
if (left === null) return null;
|
|
3897
|
-
let right = [];
|
|
3898
|
-
if (rhs !== null) {
|
|
3899
|
-
const r = toGroups(rhs);
|
|
3900
|
-
if (r === null) return null;
|
|
3901
|
-
right = r;
|
|
3902
|
-
}
|
|
3903
|
-
if (tailV4) {
|
|
3904
|
-
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3905
|
-
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3906
|
-
}
|
|
3907
|
-
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3908
|
-
if (rhs === null && groups.length !== 8) return null;
|
|
3909
|
-
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3910
|
-
if (groups.length !== 8) return null;
|
|
3911
|
-
return groups;
|
|
3912
|
-
}
|
|
3913
|
-
function compressIpv6(g) {
|
|
3914
|
-
let bestStart = -1;
|
|
3915
|
-
let bestLen = 0;
|
|
3916
|
-
let i = 0;
|
|
3917
|
-
while (i < 8) {
|
|
3918
|
-
if (g[i] !== 0) {
|
|
3919
|
-
i++;
|
|
3920
|
-
continue;
|
|
3921
|
-
}
|
|
3922
|
-
let j = i;
|
|
3923
|
-
while (j < 8 && g[j] === 0) j++;
|
|
3924
|
-
if (j - i > bestLen) {
|
|
3925
|
-
bestLen = j - i;
|
|
3926
|
-
bestStart = i;
|
|
3927
|
-
}
|
|
3928
|
-
i = j;
|
|
3929
|
-
}
|
|
3930
|
-
const hex = g.map((x) => x.toString(16));
|
|
3931
|
-
if (bestLen < 2) return hex.join(":");
|
|
3932
|
-
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3933
|
-
}
|
|
3934
|
-
function normalizeIpLiteral(host) {
|
|
3935
|
-
try {
|
|
3936
|
-
if (typeof host !== "string") return null;
|
|
3937
|
-
let s = host.trim();
|
|
3938
|
-
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3939
|
-
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3940
|
-
const zone = s.indexOf("%");
|
|
3941
|
-
if (zone >= 0) s = s.slice(0, zone);
|
|
3942
|
-
if (!s) return null;
|
|
3943
|
-
if (s.includes(":")) {
|
|
3944
|
-
const g = expandIpv6(s);
|
|
3945
|
-
if (!g) return null;
|
|
3946
|
-
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3947
|
-
if (mapped) {
|
|
3948
|
-
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3949
|
-
}
|
|
3950
|
-
return compressIpv6(g);
|
|
3951
|
-
}
|
|
3952
|
-
return parseIpv4(s);
|
|
3953
|
-
} catch {
|
|
3954
|
-
return null;
|
|
3955
|
-
}
|
|
3956
|
-
}
|
|
3957
|
-
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3958
|
-
"169.254.169.254",
|
|
3959
|
-
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3960
|
-
"169.254.170.2",
|
|
3961
|
-
// AWS ECS task role
|
|
3962
|
-
"168.63.129.16",
|
|
3963
|
-
// Azure WireServer
|
|
3964
|
-
"fd00:ec2::254",
|
|
3965
|
-
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3966
|
-
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3967
|
-
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3968
|
-
// live credential endpoint. It has to be named here, above the range check,
|
|
3969
|
-
// and it is the reason relaxing cgnat is safe.
|
|
3970
|
-
"100.100.100.200"
|
|
3971
|
-
]);
|
|
3972
|
-
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3973
|
-
function isStrictGatedTier(tier) {
|
|
3974
|
-
return STRICT_TIERS.has(tier);
|
|
3975
|
-
}
|
|
3976
|
-
function ssrfReason(m, asWritten) {
|
|
3977
|
-
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3978
|
-
}
|
|
3979
|
-
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3980
|
-
var v4Octets = (a) => {
|
|
3981
|
-
const p = a.split(".");
|
|
3982
|
-
return p.length === 4 ? p.map(Number) : null;
|
|
3983
|
-
};
|
|
3984
|
-
function classifySsrf(host) {
|
|
3985
|
-
try {
|
|
3986
|
-
if (typeof host !== "string" || !host) return null;
|
|
3987
|
-
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3988
|
-
const ip = normalizeIpLiteral(host);
|
|
3989
|
-
if (ip === null) {
|
|
3990
|
-
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3991
|
-
}
|
|
3992
|
-
const hit = (tier, overridable) => ({
|
|
3993
|
-
tier,
|
|
3994
|
-
overridable,
|
|
3995
|
-
kind: "address",
|
|
3996
|
-
normalized: ip
|
|
3997
|
-
});
|
|
3998
|
-
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3999
|
-
const o = v4Octets(ip);
|
|
4000
|
-
if (o) {
|
|
4001
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
4002
|
-
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
4003
|
-
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
4004
|
-
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
4005
|
-
if (o[0] === 127) return hit("private", true);
|
|
4006
|
-
if (o[0] === 10) return hit("private", true);
|
|
4007
|
-
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
4008
|
-
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
4009
|
-
return null;
|
|
4010
|
-
}
|
|
4011
|
-
const g = expandIpv6(ip);
|
|
4012
|
-
if (!g) return null;
|
|
4013
|
-
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
4014
|
-
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
4015
|
-
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
4016
|
-
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
4017
|
-
return null;
|
|
4018
|
-
} catch {
|
|
4019
|
-
return null;
|
|
4020
|
-
}
|
|
4021
|
-
}
|
|
4022
|
-
var TIER_REASON = {
|
|
4023
|
-
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
4024
|
-
"link-local": "a link-local address",
|
|
4025
|
-
multicast: "a multicast address",
|
|
4026
|
-
unspecified: "the unspecified address, which reaches this host",
|
|
4027
|
-
cgnat: "a carrier-grade NAT address",
|
|
4028
|
-
private: "a loopback or private address"
|
|
4029
|
-
};
|
|
4030
|
-
function ssrfFloor(tokens, opts = {}) {
|
|
4031
|
-
const exempt = new Set(
|
|
4032
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
4033
|
-
);
|
|
4034
|
-
for (const { token, binary } of tokens) {
|
|
4035
|
-
const m = classifySsrf(token);
|
|
4036
|
-
if (!m) continue;
|
|
4037
|
-
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
4038
|
-
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
4039
|
-
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
4040
|
-
}
|
|
4041
|
-
return null;
|
|
4042
|
-
}
|
|
4043
4052
|
var DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
4044
4053
|
["webfetch", ["url"]],
|
|
4045
4054
|
["fetch", ["url", "uri"]],
|
|
@@ -4080,6 +4089,22 @@ function hostOf(value) {
|
|
|
4080
4089
|
return null;
|
|
4081
4090
|
}
|
|
4082
4091
|
}
|
|
4092
|
+
function extractToolDestinations(toolName, args) {
|
|
4093
|
+
try {
|
|
4094
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
4095
|
+
if (!paths) return [];
|
|
4096
|
+
const out = [];
|
|
4097
|
+
for (const path16 of paths) {
|
|
4098
|
+
for (const value of valuesAt(args, path16)) {
|
|
4099
|
+
const host = hostOf(value);
|
|
4100
|
+
if (host) out.push({ host, binary: toolName, raw: value });
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
return out;
|
|
4104
|
+
} catch {
|
|
4105
|
+
return [];
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4083
4108
|
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
4084
4109
|
try {
|
|
4085
4110
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
@@ -4187,6 +4212,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
|
|
|
4187
4212
|
tier: 3
|
|
4188
4213
|
};
|
|
4189
4214
|
}
|
|
4215
|
+
function egressPolicyVerdict(eg) {
|
|
4216
|
+
return {
|
|
4217
|
+
decision: eg.verdict,
|
|
4218
|
+
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4219
|
+
reason: eg.reason,
|
|
4220
|
+
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4221
|
+
ruleDescription: eg.reason,
|
|
4222
|
+
tier: eg.verdict === "block" ? 3 : 4
|
|
4223
|
+
};
|
|
4224
|
+
}
|
|
4190
4225
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
4191
4226
|
const { agent, cwd, activeEnvironment } = context;
|
|
4192
4227
|
const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
|
|
@@ -4221,7 +4256,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4221
4256
|
};
|
|
4222
4257
|
}
|
|
4223
4258
|
}
|
|
4224
|
-
|
|
4259
|
+
const pendingToolEgress = config.policy.egress?.enabled ? (() => {
|
|
4260
|
+
const dests = extractToolDestinations(toolName, args);
|
|
4261
|
+
const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
|
|
4262
|
+
return eg ? egressPolicyVerdict(eg) : void 0;
|
|
4263
|
+
})() : void 0;
|
|
4264
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) {
|
|
4265
|
+
return pendingToolEgress ?? { decision: "allow" };
|
|
4266
|
+
}
|
|
4225
4267
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
4226
4268
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
4227
4269
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
@@ -4310,6 +4352,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4310
4352
|
}
|
|
4311
4353
|
let allTokens = [];
|
|
4312
4354
|
let pathTokens = [];
|
|
4355
|
+
if (pendingToolEgress) return pendingToolEgress;
|
|
4313
4356
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
4314
4357
|
if (shellCommand) {
|
|
4315
4358
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
@@ -4375,16 +4418,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4375
4418
|
const dests = extractShellDestinations(shellCommand);
|
|
4376
4419
|
if (dests.length > 0) {
|
|
4377
4420
|
const eg = evaluateEgress(dests, config.policy.egress);
|
|
4378
|
-
if (eg)
|
|
4379
|
-
return {
|
|
4380
|
-
decision: eg.verdict,
|
|
4381
|
-
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4382
|
-
reason: eg.reason,
|
|
4383
|
-
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4384
|
-
ruleDescription: eg.reason,
|
|
4385
|
-
tier: eg.verdict === "block" ? 3 : 4
|
|
4386
|
-
};
|
|
4387
|
-
}
|
|
4421
|
+
if (eg) return egressPolicyVerdict(eg);
|
|
4388
4422
|
}
|
|
4389
4423
|
}
|
|
4390
4424
|
const firstToken = analyzed.actions[0] ?? "";
|
|
@@ -8324,8 +8358,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8324
8358
|
};
|
|
8325
8359
|
}
|
|
8326
8360
|
}
|
|
8361
|
+
const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
|
|
8362
|
+
const judge = !isIgnoredTool2(toolName) || declaredEgress;
|
|
8327
8363
|
if (isObserveMode) {
|
|
8328
|
-
if (
|
|
8364
|
+
if (judge) {
|
|
8329
8365
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
8330
8366
|
const wouldBlock = policyResult.decision === "block";
|
|
8331
8367
|
if (!isManual)
|
|
@@ -8350,7 +8386,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8350
8386
|
return { approved: true, checkedBy: "audit" };
|
|
8351
8387
|
}
|
|
8352
8388
|
if (config.settings.mode === "audit") {
|
|
8353
|
-
if (
|
|
8389
|
+
if (judge) {
|
|
8354
8390
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
8355
8391
|
if (policyResult.decision === "review") {
|
|
8356
8392
|
appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
|
|
@@ -8389,9 +8425,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8389
8425
|
appPermReviewTool = bareTool;
|
|
8390
8426
|
}
|
|
8391
8427
|
}
|
|
8392
|
-
if (!taintWarning &&
|
|
8428
|
+
if (!taintWarning && judge) {
|
|
8393
8429
|
const ld = config.policy.loopDetection;
|
|
8394
|
-
if (ld.enabled && !appPermReview) {
|
|
8430
|
+
if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
|
|
8395
8431
|
const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
|
|
8396
8432
|
if (loopResult.looping) {
|
|
8397
8433
|
const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
|