@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.mjs CHANGED
@@ -3073,6 +3073,213 @@ function analyzeShellCommand(command) {
3073
3073
  return { actions, paths, allTokens };
3074
3074
  }
3075
3075
 
3076
+ // src/egress/ssrf.ts
3077
+ var SSRF_MAX_HOST = 253;
3078
+ function parseComponent(s) {
3079
+ if (!s) return null;
3080
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
3081
+ if (s === "0") return 0;
3082
+ if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
3083
+ if (/^[1-9][0-9]*$/.test(s)) return Number(s);
3084
+ return null;
3085
+ }
3086
+ function parseIpv4(input) {
3087
+ let s = input;
3088
+ if (s.endsWith(".")) s = s.slice(0, -1);
3089
+ if (!s) return null;
3090
+ const parts = s.split(".");
3091
+ if (parts.length > 4) return null;
3092
+ const vals = [];
3093
+ for (const p of parts) {
3094
+ const v = parseComponent(p);
3095
+ if (v === null || !Number.isFinite(v) || v < 0) return null;
3096
+ vals.push(v);
3097
+ }
3098
+ const n = vals.length;
3099
+ for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
3100
+ const last = vals[n - 1];
3101
+ const remainingBytes = 4 - (n - 1);
3102
+ const limit = Math.pow(256, remainingBytes);
3103
+ if (last >= limit) return null;
3104
+ let value = last;
3105
+ for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
3106
+ if (value > 4294967295) return null;
3107
+ return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
3108
+ }
3109
+ function expandIpv6(input) {
3110
+ const s = input.toLowerCase();
3111
+ if (!/^[0-9a-f:.]+$/.test(s)) return null;
3112
+ if ((s.match(/::/g) ?? []).length > 1) return null;
3113
+ let head = s;
3114
+ let tailV4 = null;
3115
+ const lastColon = s.lastIndexOf(":");
3116
+ const afterLast = s.slice(lastColon + 1);
3117
+ if (afterLast.includes(".")) {
3118
+ const dotted = parseIpv4(afterLast);
3119
+ if (!dotted) return null;
3120
+ const o = dotted.split(".").map(Number);
3121
+ tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
3122
+ head = s.slice(0, lastColon + 1) + "0";
3123
+ }
3124
+ const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
3125
+ const toGroups = (part) => {
3126
+ if (!part) return [];
3127
+ const out = [];
3128
+ for (const g of part.split(":")) {
3129
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
3130
+ out.push(parseInt(g, 16));
3131
+ }
3132
+ return out;
3133
+ };
3134
+ const left = toGroups(lhs);
3135
+ if (left === null) return null;
3136
+ let right = [];
3137
+ if (rhs !== null) {
3138
+ const r = toGroups(rhs);
3139
+ if (r === null) return null;
3140
+ right = r;
3141
+ }
3142
+ if (tailV4) {
3143
+ if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
3144
+ else left.splice(left.length - 1, 1, ...tailV4);
3145
+ }
3146
+ const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
3147
+ if (rhs === null && groups.length !== 8) return null;
3148
+ if (rhs !== null && left.length + right.length > 8) return null;
3149
+ if (groups.length !== 8) return null;
3150
+ return groups;
3151
+ }
3152
+ function compressIpv6(g) {
3153
+ let bestStart = -1;
3154
+ let bestLen = 0;
3155
+ let i = 0;
3156
+ while (i < 8) {
3157
+ if (g[i] !== 0) {
3158
+ i++;
3159
+ continue;
3160
+ }
3161
+ let j = i;
3162
+ while (j < 8 && g[j] === 0) j++;
3163
+ if (j - i > bestLen) {
3164
+ bestLen = j - i;
3165
+ bestStart = i;
3166
+ }
3167
+ i = j;
3168
+ }
3169
+ const hex = g.map((x) => x.toString(16));
3170
+ if (bestLen < 2) return hex.join(":");
3171
+ return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
3172
+ }
3173
+ function normalizeIpLiteral(host) {
3174
+ try {
3175
+ if (typeof host !== "string") return null;
3176
+ let s = host.trim();
3177
+ if (!s || s.length > SSRF_MAX_HOST) return null;
3178
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
3179
+ const zone = s.indexOf("%");
3180
+ if (zone >= 0) s = s.slice(0, zone);
3181
+ if (!s) return null;
3182
+ if (s.includes(":")) {
3183
+ const g = expandIpv6(s);
3184
+ if (!g) return null;
3185
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
3186
+ if (mapped) {
3187
+ return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
3188
+ }
3189
+ return compressIpv6(g);
3190
+ }
3191
+ return parseIpv4(s);
3192
+ } catch {
3193
+ return null;
3194
+ }
3195
+ }
3196
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
3197
+ "169.254.169.254",
3198
+ // AWS / Azure / DigitalOcean / OpenStack IMDS
3199
+ "169.254.170.2",
3200
+ // AWS ECS task role
3201
+ "168.63.129.16",
3202
+ // Azure WireServer
3203
+ "fd00:ec2::254",
3204
+ // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
3205
+ // Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
3206
+ // classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
3207
+ // live credential endpoint. It has to be named here, above the range check,
3208
+ // and it is the reason relaxing cgnat is safe.
3209
+ "100.100.100.200"
3210
+ ]);
3211
+ var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
3212
+ function isStrictGatedTier(tier) {
3213
+ return STRICT_TIERS.has(tier);
3214
+ }
3215
+ function ssrfReason(m, asWritten) {
3216
+ return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
3217
+ }
3218
+ var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
3219
+ var v4Octets = (a) => {
3220
+ const p = a.split(".");
3221
+ return p.length === 4 ? p.map(Number) : null;
3222
+ };
3223
+ function classifySsrf(host) {
3224
+ try {
3225
+ if (typeof host !== "string" || !host) return null;
3226
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
3227
+ const ip = normalizeIpLiteral(host);
3228
+ if (ip === null) {
3229
+ return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
3230
+ }
3231
+ const hit = (tier, overridable) => ({
3232
+ tier,
3233
+ overridable,
3234
+ kind: "address",
3235
+ normalized: ip
3236
+ });
3237
+ if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
3238
+ const o = v4Octets(ip);
3239
+ if (o) {
3240
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
3241
+ if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
3242
+ if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
3243
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
3244
+ if (o[0] === 127) return hit("private", true);
3245
+ if (o[0] === 10) return hit("private", true);
3246
+ if (o[0] === 192 && o[1] === 168) return hit("private", true);
3247
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
3248
+ return null;
3249
+ }
3250
+ const g = expandIpv6(ip);
3251
+ if (!g) return null;
3252
+ if (g.every((x) => x === 0)) return hit("unspecified", true);
3253
+ if ((g[0] & 65472) === 65152) return hit("link-local", false);
3254
+ if ((g[0] & 65280) === 65280) return hit("multicast", false);
3255
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
3256
+ return null;
3257
+ } catch {
3258
+ return null;
3259
+ }
3260
+ }
3261
+ var TIER_REASON = {
3262
+ metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3263
+ "link-local": "a link-local address",
3264
+ multicast: "a multicast address",
3265
+ unspecified: "the unspecified address, which reaches this host",
3266
+ cgnat: "a carrier-grade NAT address",
3267
+ private: "a loopback or private address"
3268
+ };
3269
+ function ssrfFloor(tokens, opts = {}) {
3270
+ const exempt = new Set(
3271
+ (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3272
+ );
3273
+ for (const { token, binary } of tokens) {
3274
+ const m = classifySsrf(token);
3275
+ if (!m) continue;
3276
+ if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3277
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3278
+ return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3279
+ }
3280
+ return null;
3281
+ }
3282
+
3076
3283
  // src/egress/index.ts
3077
3284
  var DEFAULT_EGRESS_ALLOWLIST = [
3078
3285
  // node9's own control plane (api, app, dev-api, staging and the apex).
@@ -3098,29 +3305,38 @@ var DEFAULT_EGRESS_ALLOWLIST = [
3098
3305
  "*.ubuntu.com"
3099
3306
  ];
3100
3307
  function hostMatches(host, pattern) {
3101
- const h = host.toLowerCase();
3102
- const p = pattern.toLowerCase().trim();
3308
+ const p = pattern.trim().toLowerCase();
3103
3309
  if (!p) return false;
3104
3310
  if (p === "*") return true;
3311
+ const h = canonicalHost(host);
3105
3312
  if (p.startsWith("*.")) {
3106
- const suffix = p.slice(2);
3313
+ const suffix = canonicalHost(p.slice(2));
3107
3314
  return h === suffix || h.endsWith("." + suffix);
3108
3315
  }
3109
- return h === p;
3316
+ return h === canonicalHost(p);
3110
3317
  }
3111
3318
  function matchesAny(host, patterns) {
3112
- for (const p of patterns) if (hostMatches(host, p)) return true;
3319
+ for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
3113
3320
  return false;
3114
3321
  }
3322
+ var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
3323
+ function isUniqueLocalV6(host) {
3324
+ const ip = normalizeIpLiteral(host);
3325
+ if (!ip || !ip.includes(":")) return false;
3326
+ const g = expandIpv6(ip);
3327
+ return g !== null && (g[0] & 65024) === 64512;
3328
+ }
3115
3329
  function isPrivateHost(host) {
3116
- const h = host.toLowerCase();
3117
- if (h === "localhost" || h === "0.0.0.0") return true;
3118
- if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localhost")) return true;
3119
- if (/^127\./.test(h)) return true;
3120
- if (/^10\./.test(h)) return true;
3121
- if (/^192\.168\./.test(h)) return true;
3122
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
3123
- return false;
3330
+ const h = host.trim().toLowerCase().replace(/\.$/, "");
3331
+ const m = classifySsrf(h);
3332
+ if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3333
+ if (h === "localhost") return true;
3334
+ if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3335
+ return isUniqueLocalV6(h);
3336
+ }
3337
+ function canonicalHost(host) {
3338
+ const ip = normalizeIpLiteral(host);
3339
+ return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
3124
3340
  }
3125
3341
  function evaluateEgress(dests, policy) {
3126
3342
  if (!policy.enabled) return null;
@@ -3474,213 +3690,6 @@ function parseAllSshHostsFromCommand(command) {
3474
3690
  return extractAllSshHosts(tokens.slice(1));
3475
3691
  }
3476
3692
 
3477
- // src/egress/ssrf.ts
3478
- var SSRF_MAX_HOST = 253;
3479
- function parseComponent(s) {
3480
- if (!s) return null;
3481
- if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
3482
- if (s === "0") return 0;
3483
- if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
3484
- if (/^[1-9][0-9]*$/.test(s)) return Number(s);
3485
- return null;
3486
- }
3487
- function parseIpv4(input) {
3488
- let s = input;
3489
- if (s.endsWith(".")) s = s.slice(0, -1);
3490
- if (!s) return null;
3491
- const parts = s.split(".");
3492
- if (parts.length > 4) return null;
3493
- const vals = [];
3494
- for (const p of parts) {
3495
- const v = parseComponent(p);
3496
- if (v === null || !Number.isFinite(v) || v < 0) return null;
3497
- vals.push(v);
3498
- }
3499
- const n = vals.length;
3500
- for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
3501
- const last = vals[n - 1];
3502
- const remainingBytes = 4 - (n - 1);
3503
- const limit = Math.pow(256, remainingBytes);
3504
- if (last >= limit) return null;
3505
- let value = last;
3506
- for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
3507
- if (value > 4294967295) return null;
3508
- return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
3509
- }
3510
- function expandIpv6(input) {
3511
- const s = input.toLowerCase();
3512
- if (!/^[0-9a-f:.]+$/.test(s)) return null;
3513
- if ((s.match(/::/g) ?? []).length > 1) return null;
3514
- let head = s;
3515
- let tailV4 = null;
3516
- const lastColon = s.lastIndexOf(":");
3517
- const afterLast = s.slice(lastColon + 1);
3518
- if (afterLast.includes(".")) {
3519
- const dotted = parseIpv4(afterLast);
3520
- if (!dotted) return null;
3521
- const o = dotted.split(".").map(Number);
3522
- tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
3523
- head = s.slice(0, lastColon + 1) + "0";
3524
- }
3525
- const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
3526
- const toGroups = (part) => {
3527
- if (!part) return [];
3528
- const out = [];
3529
- for (const g of part.split(":")) {
3530
- if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
3531
- out.push(parseInt(g, 16));
3532
- }
3533
- return out;
3534
- };
3535
- const left = toGroups(lhs);
3536
- if (left === null) return null;
3537
- let right = [];
3538
- if (rhs !== null) {
3539
- const r = toGroups(rhs);
3540
- if (r === null) return null;
3541
- right = r;
3542
- }
3543
- if (tailV4) {
3544
- if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
3545
- else left.splice(left.length - 1, 1, ...tailV4);
3546
- }
3547
- const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
3548
- if (rhs === null && groups.length !== 8) return null;
3549
- if (rhs !== null && left.length + right.length > 8) return null;
3550
- if (groups.length !== 8) return null;
3551
- return groups;
3552
- }
3553
- function compressIpv6(g) {
3554
- let bestStart = -1;
3555
- let bestLen = 0;
3556
- let i = 0;
3557
- while (i < 8) {
3558
- if (g[i] !== 0) {
3559
- i++;
3560
- continue;
3561
- }
3562
- let j = i;
3563
- while (j < 8 && g[j] === 0) j++;
3564
- if (j - i > bestLen) {
3565
- bestLen = j - i;
3566
- bestStart = i;
3567
- }
3568
- i = j;
3569
- }
3570
- const hex = g.map((x) => x.toString(16));
3571
- if (bestLen < 2) return hex.join(":");
3572
- return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
3573
- }
3574
- function normalizeIpLiteral(host) {
3575
- try {
3576
- if (typeof host !== "string") return null;
3577
- let s = host.trim();
3578
- if (!s || s.length > SSRF_MAX_HOST) return null;
3579
- if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
3580
- const zone = s.indexOf("%");
3581
- if (zone >= 0) s = s.slice(0, zone);
3582
- if (!s) return null;
3583
- if (s.includes(":")) {
3584
- const g = expandIpv6(s);
3585
- if (!g) return null;
3586
- const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
3587
- if (mapped) {
3588
- return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
3589
- }
3590
- return compressIpv6(g);
3591
- }
3592
- return parseIpv4(s);
3593
- } catch {
3594
- return null;
3595
- }
3596
- }
3597
- var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
3598
- "169.254.169.254",
3599
- // AWS / Azure / DigitalOcean / OpenStack IMDS
3600
- "169.254.170.2",
3601
- // AWS ECS task role
3602
- "168.63.129.16",
3603
- // Azure WireServer
3604
- "fd00:ec2::254",
3605
- // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
3606
- // Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
3607
- // classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
3608
- // live credential endpoint. It has to be named here, above the range check,
3609
- // and it is the reason relaxing cgnat is safe.
3610
- "100.100.100.200"
3611
- ]);
3612
- var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
3613
- function isStrictGatedTier(tier) {
3614
- return STRICT_TIERS.has(tier);
3615
- }
3616
- function ssrfReason(m, asWritten) {
3617
- return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
3618
- }
3619
- var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
3620
- var v4Octets = (a) => {
3621
- const p = a.split(".");
3622
- return p.length === 4 ? p.map(Number) : null;
3623
- };
3624
- function classifySsrf(host) {
3625
- try {
3626
- if (typeof host !== "string" || !host) return null;
3627
- const lower = host.trim().toLowerCase().replace(/\.$/, "");
3628
- const ip = normalizeIpLiteral(host);
3629
- if (ip === null) {
3630
- return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
3631
- }
3632
- const hit = (tier, overridable) => ({
3633
- tier,
3634
- overridable,
3635
- kind: "address",
3636
- normalized: ip
3637
- });
3638
- if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
3639
- const o = v4Octets(ip);
3640
- if (o) {
3641
- if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
3642
- if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
3643
- if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
3644
- if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
3645
- if (o[0] === 127) return hit("private", true);
3646
- if (o[0] === 10) return hit("private", true);
3647
- if (o[0] === 192 && o[1] === 168) return hit("private", true);
3648
- if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
3649
- return null;
3650
- }
3651
- const g = expandIpv6(ip);
3652
- if (!g) return null;
3653
- if (g.every((x) => x === 0)) return hit("unspecified", true);
3654
- if ((g[0] & 65472) === 65152) return hit("link-local", false);
3655
- if ((g[0] & 65280) === 65280) return hit("multicast", false);
3656
- if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
3657
- return null;
3658
- } catch {
3659
- return null;
3660
- }
3661
- }
3662
- var TIER_REASON = {
3663
- metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3664
- "link-local": "a link-local address",
3665
- multicast: "a multicast address",
3666
- unspecified: "the unspecified address, which reaches this host",
3667
- cgnat: "a carrier-grade NAT address",
3668
- private: "a loopback or private address"
3669
- };
3670
- function ssrfFloor(tokens, opts = {}) {
3671
- const exempt = new Set(
3672
- (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3673
- );
3674
- for (const { token, binary } of tokens) {
3675
- const m = classifySsrf(token);
3676
- if (!m) continue;
3677
- if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3678
- if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3679
- return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3680
- }
3681
- return null;
3682
- }
3683
-
3684
3693
  // src/egress/destinations.ts
3685
3694
  var DESTINATION_ARGS = /* @__PURE__ */ new Map([
3686
3695
  ["webfetch", ["url"]],
@@ -3722,6 +3731,22 @@ function hostOf(value) {
3722
3731
  return null;
3723
3732
  }
3724
3733
  }
3734
+ function extractToolDestinations(toolName, args) {
3735
+ try {
3736
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
3737
+ if (!paths) return [];
3738
+ const out = [];
3739
+ for (const path of paths) {
3740
+ for (const value of valuesAt(args, path)) {
3741
+ const host = hostOf(value);
3742
+ if (host) out.push({ host, binary: toolName, raw: value });
3743
+ }
3744
+ }
3745
+ return out;
3746
+ } catch {
3747
+ return [];
3748
+ }
3749
+ }
3725
3750
  function ssrfDestinationFloor(toolName, args, opts = {}) {
3726
3751
  try {
3727
3752
  const paths = DESTINATION_ARGS.get(bareToolName(toolName));
@@ -3840,6 +3865,16 @@ function pipeChainVerdict(command, isTrustedHost, highAction = "review") {
3840
3865
  tier: 3
3841
3866
  };
3842
3867
  }
3868
+ function egressPolicyVerdict(eg) {
3869
+ return {
3870
+ decision: eg.verdict,
3871
+ blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
3872
+ reason: eg.reason,
3873
+ ruleName: `egress:${eg.binary}:${eg.host}`,
3874
+ ruleDescription: eg.reason,
3875
+ tier: eg.verdict === "block" ? 3 : 4
3876
+ };
3877
+ }
3843
3878
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
3844
3879
  const { agent, cwd, activeEnvironment } = context;
3845
3880
  const { checkProvenance, isTrustedHost } = hooks;
@@ -3874,7 +3909,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3874
3909
  };
3875
3910
  }
3876
3911
  }
3877
- if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
3912
+ const pendingToolEgress = config.policy.egress?.enabled ? (() => {
3913
+ const dests = extractToolDestinations(toolName, args);
3914
+ const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
3915
+ return eg ? egressPolicyVerdict(eg) : void 0;
3916
+ })() : void 0;
3917
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) {
3918
+ return pendingToolEgress ?? { decision: "allow" };
3919
+ }
3878
3920
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
3879
3921
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
3880
3922
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
@@ -3963,6 +4005,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3963
4005
  }
3964
4006
  let allTokens = [];
3965
4007
  let pathTokens = [];
4008
+ if (pendingToolEgress) return pendingToolEgress;
3966
4009
  const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
3967
4010
  if (shellCommand) {
3968
4011
  const analyzed = analyzeShellCommand(shellCommand);
@@ -4028,16 +4071,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
4028
4071
  const dests = extractShellDestinations(shellCommand);
4029
4072
  if (dests.length > 0) {
4030
4073
  const eg = evaluateEgress(dests, config.policy.egress);
4031
- if (eg) {
4032
- return {
4033
- decision: eg.verdict,
4034
- blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
4035
- reason: eg.reason,
4036
- ruleName: `egress:${eg.binary}:${eg.host}`,
4037
- ruleDescription: eg.reason,
4038
- tier: eg.verdict === "block" ? 3 : 4
4039
- };
4040
- }
4074
+ if (eg) return egressPolicyVerdict(eg);
4041
4075
  }
4042
4076
  }
4043
4077
  const firstToken = analyzed.actions[0] ?? "";
@@ -5960,6 +5994,7 @@ export {
5960
5994
  extractSessionLevelFindings,
5961
5995
  extractShellDestTokens,
5962
5996
  extractShellDestinations,
5997
+ extractToolDestinations,
5963
5998
  fileOperandFlagsOf,
5964
5999
  getCompiledRegex,
5965
6000
  getNestedValue,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "2.16.1",
3
+ "version": "2.17.0",
4
4
  "description": "Shared policy evaluation engine for node9 — DLP, smart rules, AST shell parsing, shields, loop detection. Pure functions, no I/O. Used by both node9-proxy and the node9 SaaS firewall.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://node9.ai",