@node9/policy-engine 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/index.d.mts +49 -13
- package/dist/index.d.ts +49 -13
- package/dist/index.js +267 -231
- package/dist/index.mjs +266 -231
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -90,6 +90,7 @@ __export(src_exports, {
|
|
|
90
90
|
extractSessionLevelFindings: () => extractSessionLevelFindings,
|
|
91
91
|
extractShellDestTokens: () => extractShellDestTokens,
|
|
92
92
|
extractShellDestinations: () => extractShellDestinations,
|
|
93
|
+
extractToolDestinations: () => extractToolDestinations,
|
|
93
94
|
fileOperandFlagsOf: () => fileOperandFlagsOf,
|
|
94
95
|
getCompiledRegex: () => getCompiledRegex,
|
|
95
96
|
getNestedValue: () => getNestedValue,
|
|
@@ -3213,6 +3214,213 @@ function analyzeShellCommand(command) {
|
|
|
3213
3214
|
return { actions, paths, allTokens };
|
|
3214
3215
|
}
|
|
3215
3216
|
|
|
3217
|
+
// src/egress/ssrf.ts
|
|
3218
|
+
var SSRF_MAX_HOST = 253;
|
|
3219
|
+
function parseComponent(s) {
|
|
3220
|
+
if (!s) return null;
|
|
3221
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3222
|
+
if (s === "0") return 0;
|
|
3223
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3224
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3225
|
+
return null;
|
|
3226
|
+
}
|
|
3227
|
+
function parseIpv4(input) {
|
|
3228
|
+
let s = input;
|
|
3229
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3230
|
+
if (!s) return null;
|
|
3231
|
+
const parts = s.split(".");
|
|
3232
|
+
if (parts.length > 4) return null;
|
|
3233
|
+
const vals = [];
|
|
3234
|
+
for (const p of parts) {
|
|
3235
|
+
const v = parseComponent(p);
|
|
3236
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3237
|
+
vals.push(v);
|
|
3238
|
+
}
|
|
3239
|
+
const n = vals.length;
|
|
3240
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3241
|
+
const last = vals[n - 1];
|
|
3242
|
+
const remainingBytes = 4 - (n - 1);
|
|
3243
|
+
const limit = Math.pow(256, remainingBytes);
|
|
3244
|
+
if (last >= limit) return null;
|
|
3245
|
+
let value = last;
|
|
3246
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3247
|
+
if (value > 4294967295) return null;
|
|
3248
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3249
|
+
}
|
|
3250
|
+
function expandIpv6(input) {
|
|
3251
|
+
const s = input.toLowerCase();
|
|
3252
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3253
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3254
|
+
let head = s;
|
|
3255
|
+
let tailV4 = null;
|
|
3256
|
+
const lastColon = s.lastIndexOf(":");
|
|
3257
|
+
const afterLast = s.slice(lastColon + 1);
|
|
3258
|
+
if (afterLast.includes(".")) {
|
|
3259
|
+
const dotted = parseIpv4(afterLast);
|
|
3260
|
+
if (!dotted) return null;
|
|
3261
|
+
const o = dotted.split(".").map(Number);
|
|
3262
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3263
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
3264
|
+
}
|
|
3265
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3266
|
+
const toGroups = (part) => {
|
|
3267
|
+
if (!part) return [];
|
|
3268
|
+
const out = [];
|
|
3269
|
+
for (const g of part.split(":")) {
|
|
3270
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3271
|
+
out.push(parseInt(g, 16));
|
|
3272
|
+
}
|
|
3273
|
+
return out;
|
|
3274
|
+
};
|
|
3275
|
+
const left = toGroups(lhs);
|
|
3276
|
+
if (left === null) return null;
|
|
3277
|
+
let right = [];
|
|
3278
|
+
if (rhs !== null) {
|
|
3279
|
+
const r = toGroups(rhs);
|
|
3280
|
+
if (r === null) return null;
|
|
3281
|
+
right = r;
|
|
3282
|
+
}
|
|
3283
|
+
if (tailV4) {
|
|
3284
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3285
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3286
|
+
}
|
|
3287
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3288
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
3289
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3290
|
+
if (groups.length !== 8) return null;
|
|
3291
|
+
return groups;
|
|
3292
|
+
}
|
|
3293
|
+
function compressIpv6(g) {
|
|
3294
|
+
let bestStart = -1;
|
|
3295
|
+
let bestLen = 0;
|
|
3296
|
+
let i = 0;
|
|
3297
|
+
while (i < 8) {
|
|
3298
|
+
if (g[i] !== 0) {
|
|
3299
|
+
i++;
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
let j = i;
|
|
3303
|
+
while (j < 8 && g[j] === 0) j++;
|
|
3304
|
+
if (j - i > bestLen) {
|
|
3305
|
+
bestLen = j - i;
|
|
3306
|
+
bestStart = i;
|
|
3307
|
+
}
|
|
3308
|
+
i = j;
|
|
3309
|
+
}
|
|
3310
|
+
const hex = g.map((x) => x.toString(16));
|
|
3311
|
+
if (bestLen < 2) return hex.join(":");
|
|
3312
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3313
|
+
}
|
|
3314
|
+
function normalizeIpLiteral(host) {
|
|
3315
|
+
try {
|
|
3316
|
+
if (typeof host !== "string") return null;
|
|
3317
|
+
let s = host.trim();
|
|
3318
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3319
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3320
|
+
const zone = s.indexOf("%");
|
|
3321
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
3322
|
+
if (!s) return null;
|
|
3323
|
+
if (s.includes(":")) {
|
|
3324
|
+
const g = expandIpv6(s);
|
|
3325
|
+
if (!g) return null;
|
|
3326
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3327
|
+
if (mapped) {
|
|
3328
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3329
|
+
}
|
|
3330
|
+
return compressIpv6(g);
|
|
3331
|
+
}
|
|
3332
|
+
return parseIpv4(s);
|
|
3333
|
+
} catch {
|
|
3334
|
+
return null;
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3338
|
+
"169.254.169.254",
|
|
3339
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3340
|
+
"169.254.170.2",
|
|
3341
|
+
// AWS ECS task role
|
|
3342
|
+
"168.63.129.16",
|
|
3343
|
+
// Azure WireServer
|
|
3344
|
+
"fd00:ec2::254",
|
|
3345
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3346
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3347
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3348
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
3349
|
+
// and it is the reason relaxing cgnat is safe.
|
|
3350
|
+
"100.100.100.200"
|
|
3351
|
+
]);
|
|
3352
|
+
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3353
|
+
function isStrictGatedTier(tier) {
|
|
3354
|
+
return STRICT_TIERS.has(tier);
|
|
3355
|
+
}
|
|
3356
|
+
function ssrfReason(m, asWritten) {
|
|
3357
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3358
|
+
}
|
|
3359
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3360
|
+
var v4Octets = (a) => {
|
|
3361
|
+
const p = a.split(".");
|
|
3362
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
3363
|
+
};
|
|
3364
|
+
function classifySsrf(host) {
|
|
3365
|
+
try {
|
|
3366
|
+
if (typeof host !== "string" || !host) return null;
|
|
3367
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3368
|
+
const ip = normalizeIpLiteral(host);
|
|
3369
|
+
if (ip === null) {
|
|
3370
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3371
|
+
}
|
|
3372
|
+
const hit = (tier, overridable) => ({
|
|
3373
|
+
tier,
|
|
3374
|
+
overridable,
|
|
3375
|
+
kind: "address",
|
|
3376
|
+
normalized: ip
|
|
3377
|
+
});
|
|
3378
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3379
|
+
const o = v4Octets(ip);
|
|
3380
|
+
if (o) {
|
|
3381
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3382
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3383
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3384
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3385
|
+
if (o[0] === 127) return hit("private", true);
|
|
3386
|
+
if (o[0] === 10) return hit("private", true);
|
|
3387
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3388
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3389
|
+
return null;
|
|
3390
|
+
}
|
|
3391
|
+
const g = expandIpv6(ip);
|
|
3392
|
+
if (!g) return null;
|
|
3393
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3394
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3395
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3396
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3397
|
+
return null;
|
|
3398
|
+
} catch {
|
|
3399
|
+
return null;
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
var TIER_REASON = {
|
|
3403
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3404
|
+
"link-local": "a link-local address",
|
|
3405
|
+
multicast: "a multicast address",
|
|
3406
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
3407
|
+
cgnat: "a carrier-grade NAT address",
|
|
3408
|
+
private: "a loopback or private address"
|
|
3409
|
+
};
|
|
3410
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
3411
|
+
const exempt = new Set(
|
|
3412
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3413
|
+
);
|
|
3414
|
+
for (const { token, binary } of tokens) {
|
|
3415
|
+
const m = classifySsrf(token);
|
|
3416
|
+
if (!m) continue;
|
|
3417
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3418
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3419
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3420
|
+
}
|
|
3421
|
+
return null;
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3216
3424
|
// src/egress/index.ts
|
|
3217
3425
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
3218
3426
|
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
@@ -3238,29 +3446,38 @@ var DEFAULT_EGRESS_ALLOWLIST = [
|
|
|
3238
3446
|
"*.ubuntu.com"
|
|
3239
3447
|
];
|
|
3240
3448
|
function hostMatches(host, pattern) {
|
|
3241
|
-
const
|
|
3242
|
-
const p = pattern.toLowerCase().trim();
|
|
3449
|
+
const p = pattern.trim().toLowerCase();
|
|
3243
3450
|
if (!p) return false;
|
|
3244
3451
|
if (p === "*") return true;
|
|
3452
|
+
const h = canonicalHost(host);
|
|
3245
3453
|
if (p.startsWith("*.")) {
|
|
3246
|
-
const suffix = p.slice(2);
|
|
3454
|
+
const suffix = canonicalHost(p.slice(2));
|
|
3247
3455
|
return h === suffix || h.endsWith("." + suffix);
|
|
3248
3456
|
}
|
|
3249
|
-
return h === p;
|
|
3457
|
+
return h === canonicalHost(p);
|
|
3250
3458
|
}
|
|
3251
3459
|
function matchesAny(host, patterns) {
|
|
3252
|
-
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
3460
|
+
for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
|
|
3253
3461
|
return false;
|
|
3254
3462
|
}
|
|
3463
|
+
var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
|
|
3464
|
+
function isUniqueLocalV6(host) {
|
|
3465
|
+
const ip = normalizeIpLiteral(host);
|
|
3466
|
+
if (!ip || !ip.includes(":")) return false;
|
|
3467
|
+
const g = expandIpv6(ip);
|
|
3468
|
+
return g !== null && (g[0] & 65024) === 64512;
|
|
3469
|
+
}
|
|
3255
3470
|
function isPrivateHost(host) {
|
|
3256
|
-
const h = host.toLowerCase();
|
|
3257
|
-
|
|
3258
|
-
if (
|
|
3259
|
-
if (
|
|
3260
|
-
if (
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3471
|
+
const h = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3472
|
+
const m = classifySsrf(h);
|
|
3473
|
+
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
3474
|
+
if (h === "localhost") return true;
|
|
3475
|
+
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
3476
|
+
return isUniqueLocalV6(h);
|
|
3477
|
+
}
|
|
3478
|
+
function canonicalHost(host) {
|
|
3479
|
+
const ip = normalizeIpLiteral(host);
|
|
3480
|
+
return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
|
|
3264
3481
|
}
|
|
3265
3482
|
function evaluateEgress(dests, policy) {
|
|
3266
3483
|
if (!policy.enabled) return null;
|
|
@@ -3614,213 +3831,6 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
3614
3831
|
return extractAllSshHosts(tokens.slice(1));
|
|
3615
3832
|
}
|
|
3616
3833
|
|
|
3617
|
-
// src/egress/ssrf.ts
|
|
3618
|
-
var SSRF_MAX_HOST = 253;
|
|
3619
|
-
function parseComponent(s) {
|
|
3620
|
-
if (!s) return null;
|
|
3621
|
-
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
3622
|
-
if (s === "0") return 0;
|
|
3623
|
-
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
3624
|
-
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
3625
|
-
return null;
|
|
3626
|
-
}
|
|
3627
|
-
function parseIpv4(input) {
|
|
3628
|
-
let s = input;
|
|
3629
|
-
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
3630
|
-
if (!s) return null;
|
|
3631
|
-
const parts = s.split(".");
|
|
3632
|
-
if (parts.length > 4) return null;
|
|
3633
|
-
const vals = [];
|
|
3634
|
-
for (const p of parts) {
|
|
3635
|
-
const v = parseComponent(p);
|
|
3636
|
-
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
3637
|
-
vals.push(v);
|
|
3638
|
-
}
|
|
3639
|
-
const n = vals.length;
|
|
3640
|
-
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
3641
|
-
const last = vals[n - 1];
|
|
3642
|
-
const remainingBytes = 4 - (n - 1);
|
|
3643
|
-
const limit = Math.pow(256, remainingBytes);
|
|
3644
|
-
if (last >= limit) return null;
|
|
3645
|
-
let value = last;
|
|
3646
|
-
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
3647
|
-
if (value > 4294967295) return null;
|
|
3648
|
-
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
3649
|
-
}
|
|
3650
|
-
function expandIpv6(input) {
|
|
3651
|
-
const s = input.toLowerCase();
|
|
3652
|
-
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
3653
|
-
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
3654
|
-
let head = s;
|
|
3655
|
-
let tailV4 = null;
|
|
3656
|
-
const lastColon = s.lastIndexOf(":");
|
|
3657
|
-
const afterLast = s.slice(lastColon + 1);
|
|
3658
|
-
if (afterLast.includes(".")) {
|
|
3659
|
-
const dotted = parseIpv4(afterLast);
|
|
3660
|
-
if (!dotted) return null;
|
|
3661
|
-
const o = dotted.split(".").map(Number);
|
|
3662
|
-
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
3663
|
-
head = s.slice(0, lastColon + 1) + "0";
|
|
3664
|
-
}
|
|
3665
|
-
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
3666
|
-
const toGroups = (part) => {
|
|
3667
|
-
if (!part) return [];
|
|
3668
|
-
const out = [];
|
|
3669
|
-
for (const g of part.split(":")) {
|
|
3670
|
-
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
3671
|
-
out.push(parseInt(g, 16));
|
|
3672
|
-
}
|
|
3673
|
-
return out;
|
|
3674
|
-
};
|
|
3675
|
-
const left = toGroups(lhs);
|
|
3676
|
-
if (left === null) return null;
|
|
3677
|
-
let right = [];
|
|
3678
|
-
if (rhs !== null) {
|
|
3679
|
-
const r = toGroups(rhs);
|
|
3680
|
-
if (r === null) return null;
|
|
3681
|
-
right = r;
|
|
3682
|
-
}
|
|
3683
|
-
if (tailV4) {
|
|
3684
|
-
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
3685
|
-
else left.splice(left.length - 1, 1, ...tailV4);
|
|
3686
|
-
}
|
|
3687
|
-
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
3688
|
-
if (rhs === null && groups.length !== 8) return null;
|
|
3689
|
-
if (rhs !== null && left.length + right.length > 8) return null;
|
|
3690
|
-
if (groups.length !== 8) return null;
|
|
3691
|
-
return groups;
|
|
3692
|
-
}
|
|
3693
|
-
function compressIpv6(g) {
|
|
3694
|
-
let bestStart = -1;
|
|
3695
|
-
let bestLen = 0;
|
|
3696
|
-
let i = 0;
|
|
3697
|
-
while (i < 8) {
|
|
3698
|
-
if (g[i] !== 0) {
|
|
3699
|
-
i++;
|
|
3700
|
-
continue;
|
|
3701
|
-
}
|
|
3702
|
-
let j = i;
|
|
3703
|
-
while (j < 8 && g[j] === 0) j++;
|
|
3704
|
-
if (j - i > bestLen) {
|
|
3705
|
-
bestLen = j - i;
|
|
3706
|
-
bestStart = i;
|
|
3707
|
-
}
|
|
3708
|
-
i = j;
|
|
3709
|
-
}
|
|
3710
|
-
const hex = g.map((x) => x.toString(16));
|
|
3711
|
-
if (bestLen < 2) return hex.join(":");
|
|
3712
|
-
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3713
|
-
}
|
|
3714
|
-
function normalizeIpLiteral(host) {
|
|
3715
|
-
try {
|
|
3716
|
-
if (typeof host !== "string") return null;
|
|
3717
|
-
let s = host.trim();
|
|
3718
|
-
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3719
|
-
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3720
|
-
const zone = s.indexOf("%");
|
|
3721
|
-
if (zone >= 0) s = s.slice(0, zone);
|
|
3722
|
-
if (!s) return null;
|
|
3723
|
-
if (s.includes(":")) {
|
|
3724
|
-
const g = expandIpv6(s);
|
|
3725
|
-
if (!g) return null;
|
|
3726
|
-
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3727
|
-
if (mapped) {
|
|
3728
|
-
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3729
|
-
}
|
|
3730
|
-
return compressIpv6(g);
|
|
3731
|
-
}
|
|
3732
|
-
return parseIpv4(s);
|
|
3733
|
-
} catch {
|
|
3734
|
-
return null;
|
|
3735
|
-
}
|
|
3736
|
-
}
|
|
3737
|
-
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3738
|
-
"169.254.169.254",
|
|
3739
|
-
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3740
|
-
"169.254.170.2",
|
|
3741
|
-
// AWS ECS task role
|
|
3742
|
-
"168.63.129.16",
|
|
3743
|
-
// Azure WireServer
|
|
3744
|
-
"fd00:ec2::254",
|
|
3745
|
-
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3746
|
-
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3747
|
-
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3748
|
-
// live credential endpoint. It has to be named here, above the range check,
|
|
3749
|
-
// and it is the reason relaxing cgnat is safe.
|
|
3750
|
-
"100.100.100.200"
|
|
3751
|
-
]);
|
|
3752
|
-
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3753
|
-
function isStrictGatedTier(tier) {
|
|
3754
|
-
return STRICT_TIERS.has(tier);
|
|
3755
|
-
}
|
|
3756
|
-
function ssrfReason(m, asWritten) {
|
|
3757
|
-
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3758
|
-
}
|
|
3759
|
-
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3760
|
-
var v4Octets = (a) => {
|
|
3761
|
-
const p = a.split(".");
|
|
3762
|
-
return p.length === 4 ? p.map(Number) : null;
|
|
3763
|
-
};
|
|
3764
|
-
function classifySsrf(host) {
|
|
3765
|
-
try {
|
|
3766
|
-
if (typeof host !== "string" || !host) return null;
|
|
3767
|
-
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3768
|
-
const ip = normalizeIpLiteral(host);
|
|
3769
|
-
if (ip === null) {
|
|
3770
|
-
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3771
|
-
}
|
|
3772
|
-
const hit = (tier, overridable) => ({
|
|
3773
|
-
tier,
|
|
3774
|
-
overridable,
|
|
3775
|
-
kind: "address",
|
|
3776
|
-
normalized: ip
|
|
3777
|
-
});
|
|
3778
|
-
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3779
|
-
const o = v4Octets(ip);
|
|
3780
|
-
if (o) {
|
|
3781
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3782
|
-
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3783
|
-
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3784
|
-
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3785
|
-
if (o[0] === 127) return hit("private", true);
|
|
3786
|
-
if (o[0] === 10) return hit("private", true);
|
|
3787
|
-
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3788
|
-
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3789
|
-
return null;
|
|
3790
|
-
}
|
|
3791
|
-
const g = expandIpv6(ip);
|
|
3792
|
-
if (!g) return null;
|
|
3793
|
-
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3794
|
-
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3795
|
-
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3796
|
-
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3797
|
-
return null;
|
|
3798
|
-
} catch {
|
|
3799
|
-
return null;
|
|
3800
|
-
}
|
|
3801
|
-
}
|
|
3802
|
-
var TIER_REASON = {
|
|
3803
|
-
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3804
|
-
"link-local": "a link-local address",
|
|
3805
|
-
multicast: "a multicast address",
|
|
3806
|
-
unspecified: "the unspecified address, which reaches this host",
|
|
3807
|
-
cgnat: "a carrier-grade NAT address",
|
|
3808
|
-
private: "a loopback or private address"
|
|
3809
|
-
};
|
|
3810
|
-
function ssrfFloor(tokens, opts = {}) {
|
|
3811
|
-
const exempt = new Set(
|
|
3812
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3813
|
-
);
|
|
3814
|
-
for (const { token, binary } of tokens) {
|
|
3815
|
-
const m = classifySsrf(token);
|
|
3816
|
-
if (!m) continue;
|
|
3817
|
-
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3818
|
-
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3819
|
-
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3820
|
-
}
|
|
3821
|
-
return null;
|
|
3822
|
-
}
|
|
3823
|
-
|
|
3824
3834
|
// src/egress/destinations.ts
|
|
3825
3835
|
var DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
3826
3836
|
["webfetch", ["url"]],
|
|
@@ -3862,6 +3872,22 @@ function hostOf(value) {
|
|
|
3862
3872
|
return null;
|
|
3863
3873
|
}
|
|
3864
3874
|
}
|
|
3875
|
+
function extractToolDestinations(toolName, args) {
|
|
3876
|
+
try {
|
|
3877
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
3878
|
+
if (!paths) return [];
|
|
3879
|
+
const out = [];
|
|
3880
|
+
for (const path of paths) {
|
|
3881
|
+
for (const value of valuesAt(args, path)) {
|
|
3882
|
+
const host = hostOf(value);
|
|
3883
|
+
if (host) out.push({ host, binary: toolName, raw: value });
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
return out;
|
|
3887
|
+
} catch {
|
|
3888
|
+
return [];
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3865
3891
|
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
3866
3892
|
try {
|
|
3867
3893
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
@@ -3980,6 +4006,16 @@ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
|
|
|
3980
4006
|
tier: 3
|
|
3981
4007
|
};
|
|
3982
4008
|
}
|
|
4009
|
+
function egressPolicyVerdict(eg) {
|
|
4010
|
+
return {
|
|
4011
|
+
decision: eg.verdict,
|
|
4012
|
+
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4013
|
+
reason: eg.reason,
|
|
4014
|
+
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4015
|
+
ruleDescription: eg.reason,
|
|
4016
|
+
tier: eg.verdict === "block" ? 3 : 4
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
3983
4019
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
3984
4020
|
const { agent, cwd, activeEnvironment } = context;
|
|
3985
4021
|
const { checkProvenance, isTrustedHost } = hooks;
|
|
@@ -4014,7 +4050,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4014
4050
|
};
|
|
4015
4051
|
}
|
|
4016
4052
|
}
|
|
4017
|
-
|
|
4053
|
+
const pendingToolEgress = config.policy.egress?.enabled ? (() => {
|
|
4054
|
+
const dests = extractToolDestinations(toolName, args);
|
|
4055
|
+
const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
|
|
4056
|
+
return eg ? egressPolicyVerdict(eg) : void 0;
|
|
4057
|
+
})() : void 0;
|
|
4058
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) {
|
|
4059
|
+
return pendingToolEgress ?? { decision: "allow" };
|
|
4060
|
+
}
|
|
4018
4061
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
4019
4062
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
4020
4063
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
@@ -4103,6 +4146,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4103
4146
|
}
|
|
4104
4147
|
let allTokens = [];
|
|
4105
4148
|
let pathTokens = [];
|
|
4149
|
+
if (pendingToolEgress) return pendingToolEgress;
|
|
4106
4150
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
4107
4151
|
if (shellCommand) {
|
|
4108
4152
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
@@ -4168,16 +4212,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
4168
4212
|
const dests = extractShellDestinations(shellCommand);
|
|
4169
4213
|
if (dests.length > 0) {
|
|
4170
4214
|
const eg = evaluateEgress(dests, config.policy.egress);
|
|
4171
|
-
if (eg)
|
|
4172
|
-
return {
|
|
4173
|
-
decision: eg.verdict,
|
|
4174
|
-
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
4175
|
-
reason: eg.reason,
|
|
4176
|
-
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
4177
|
-
ruleDescription: eg.reason,
|
|
4178
|
-
tier: eg.verdict === "block" ? 3 : 4
|
|
4179
|
-
};
|
|
4180
|
-
}
|
|
4215
|
+
if (eg) return egressPolicyVerdict(eg);
|
|
4181
4216
|
}
|
|
4182
4217
|
}
|
|
4183
4218
|
const firstToken = analyzed.actions[0] ?? "";
|
|
@@ -6101,6 +6136,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
6101
6136
|
extractSessionLevelFindings,
|
|
6102
6137
|
extractShellDestTokens,
|
|
6103
6138
|
extractShellDestinations,
|
|
6139
|
+
extractToolDestinations,
|
|
6104
6140
|
fileOperandFlagsOf,
|
|
6105
6141
|
getCompiledRegex,
|
|
6106
6142
|
getNestedValue,
|