@node9/policy-engine 2.16.1 → 2.16.2

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 CHANGED
@@ -431,7 +431,25 @@ interface EgressVerdict {
431
431
  declare const DEFAULT_EGRESS_ALLOWLIST: readonly string[];
432
432
  /** Glob host match: "*" = any, "*.x" = apex x + any subdomain, else exact. */
433
433
  declare function hostMatches(host: string, pattern: string): boolean;
434
- /** localhost / loopback / RFC1918 / link-local-ish — never a real exfil target. */
434
+ /**
435
+ * "Private" for the allowPrivate opt-in: loopback, RFC1918 and its IPv6
436
+ * analogue, the unspecified address, and the conventional local suffixes.
437
+ *
438
+ * The address half is classifySsrf's answer, so every spelling that file
439
+ * normalizes (brackets, zone id, IPv4-mapped IPv6) is one spelling here too.
440
+ * The old body had its own IPv4-only regexes and returned false for `[::1]`,
441
+ * which BLOCKED a local IPv6 dev server under allowPrivate (measured
442
+ * 2026-09-20; the shell extractor keeps the brackets, the declared-URL
443
+ * extractor strips them, and this function knew neither). Two parsers for
444
+ * one question is how that happens.
445
+ *
446
+ * NOT private here: link-local, multicast, the metadata endpoints. Those are
447
+ * the SSRF floor's tiers and allowPrivate must not be able to reach them;
448
+ * evaluateEgress documents that the floor runs first, and this function
449
+ * agrees with it rather than relying on it. CGNAT (100.64/10) is also out:
450
+ * a mesh-VPN peer is not everyone's private network, which is why the floor
451
+ * gave it its own tier; a Tailscale user lists the range in `allow`.
452
+ */
435
453
  declare function isPrivateHost(host: string): boolean;
436
454
  /**
437
455
  * Evaluate extracted destinations against the egress policy. Precedence per
package/dist/index.d.ts CHANGED
@@ -431,7 +431,25 @@ interface EgressVerdict {
431
431
  declare const DEFAULT_EGRESS_ALLOWLIST: readonly string[];
432
432
  /** Glob host match: "*" = any, "*.x" = apex x + any subdomain, else exact. */
433
433
  declare function hostMatches(host: string, pattern: string): boolean;
434
- /** localhost / loopback / RFC1918 / link-local-ish — never a real exfil target. */
434
+ /**
435
+ * "Private" for the allowPrivate opt-in: loopback, RFC1918 and its IPv6
436
+ * analogue, the unspecified address, and the conventional local suffixes.
437
+ *
438
+ * The address half is classifySsrf's answer, so every spelling that file
439
+ * normalizes (brackets, zone id, IPv4-mapped IPv6) is one spelling here too.
440
+ * The old body had its own IPv4-only regexes and returned false for `[::1]`,
441
+ * which BLOCKED a local IPv6 dev server under allowPrivate (measured
442
+ * 2026-09-20; the shell extractor keeps the brackets, the declared-URL
443
+ * extractor strips them, and this function knew neither). Two parsers for
444
+ * one question is how that happens.
445
+ *
446
+ * NOT private here: link-local, multicast, the metadata endpoints. Those are
447
+ * the SSRF floor's tiers and allowPrivate must not be able to reach them;
448
+ * evaluateEgress documents that the floor runs first, and this function
449
+ * agrees with it rather than relying on it. CGNAT (100.64/10) is also out:
450
+ * a mesh-VPN peer is not everyone's private network, which is why the floor
451
+ * gave it its own tier; a Tailscale user lists the range in `allow`.
452
+ */
435
453
  declare function isPrivateHost(host: string): boolean;
436
454
  /**
437
455
  * Evaluate extracted destinations against the egress policy. Precedence per
package/dist/index.js CHANGED
@@ -3213,6 +3213,213 @@ function analyzeShellCommand(command) {
3213
3213
  return { actions, paths, allTokens };
3214
3214
  }
3215
3215
 
3216
+ // src/egress/ssrf.ts
3217
+ var SSRF_MAX_HOST = 253;
3218
+ function parseComponent(s) {
3219
+ if (!s) return null;
3220
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
3221
+ if (s === "0") return 0;
3222
+ if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
3223
+ if (/^[1-9][0-9]*$/.test(s)) return Number(s);
3224
+ return null;
3225
+ }
3226
+ function parseIpv4(input) {
3227
+ let s = input;
3228
+ if (s.endsWith(".")) s = s.slice(0, -1);
3229
+ if (!s) return null;
3230
+ const parts = s.split(".");
3231
+ if (parts.length > 4) return null;
3232
+ const vals = [];
3233
+ for (const p of parts) {
3234
+ const v = parseComponent(p);
3235
+ if (v === null || !Number.isFinite(v) || v < 0) return null;
3236
+ vals.push(v);
3237
+ }
3238
+ const n = vals.length;
3239
+ for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
3240
+ const last = vals[n - 1];
3241
+ const remainingBytes = 4 - (n - 1);
3242
+ const limit = Math.pow(256, remainingBytes);
3243
+ if (last >= limit) return null;
3244
+ let value = last;
3245
+ for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
3246
+ if (value > 4294967295) return null;
3247
+ return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
3248
+ }
3249
+ function expandIpv6(input) {
3250
+ const s = input.toLowerCase();
3251
+ if (!/^[0-9a-f:.]+$/.test(s)) return null;
3252
+ if ((s.match(/::/g) ?? []).length > 1) return null;
3253
+ let head = s;
3254
+ let tailV4 = null;
3255
+ const lastColon = s.lastIndexOf(":");
3256
+ const afterLast = s.slice(lastColon + 1);
3257
+ if (afterLast.includes(".")) {
3258
+ const dotted = parseIpv4(afterLast);
3259
+ if (!dotted) return null;
3260
+ const o = dotted.split(".").map(Number);
3261
+ tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
3262
+ head = s.slice(0, lastColon + 1) + "0";
3263
+ }
3264
+ const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
3265
+ const toGroups = (part) => {
3266
+ if (!part) return [];
3267
+ const out = [];
3268
+ for (const g of part.split(":")) {
3269
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
3270
+ out.push(parseInt(g, 16));
3271
+ }
3272
+ return out;
3273
+ };
3274
+ const left = toGroups(lhs);
3275
+ if (left === null) return null;
3276
+ let right = [];
3277
+ if (rhs !== null) {
3278
+ const r = toGroups(rhs);
3279
+ if (r === null) return null;
3280
+ right = r;
3281
+ }
3282
+ if (tailV4) {
3283
+ if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
3284
+ else left.splice(left.length - 1, 1, ...tailV4);
3285
+ }
3286
+ const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
3287
+ if (rhs === null && groups.length !== 8) return null;
3288
+ if (rhs !== null && left.length + right.length > 8) return null;
3289
+ if (groups.length !== 8) return null;
3290
+ return groups;
3291
+ }
3292
+ function compressIpv6(g) {
3293
+ let bestStart = -1;
3294
+ let bestLen = 0;
3295
+ let i = 0;
3296
+ while (i < 8) {
3297
+ if (g[i] !== 0) {
3298
+ i++;
3299
+ continue;
3300
+ }
3301
+ let j = i;
3302
+ while (j < 8 && g[j] === 0) j++;
3303
+ if (j - i > bestLen) {
3304
+ bestLen = j - i;
3305
+ bestStart = i;
3306
+ }
3307
+ i = j;
3308
+ }
3309
+ const hex = g.map((x) => x.toString(16));
3310
+ if (bestLen < 2) return hex.join(":");
3311
+ return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
3312
+ }
3313
+ function normalizeIpLiteral(host) {
3314
+ try {
3315
+ if (typeof host !== "string") return null;
3316
+ let s = host.trim();
3317
+ if (!s || s.length > SSRF_MAX_HOST) return null;
3318
+ if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
3319
+ const zone = s.indexOf("%");
3320
+ if (zone >= 0) s = s.slice(0, zone);
3321
+ if (!s) return null;
3322
+ if (s.includes(":")) {
3323
+ const g = expandIpv6(s);
3324
+ if (!g) return null;
3325
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
3326
+ if (mapped) {
3327
+ return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
3328
+ }
3329
+ return compressIpv6(g);
3330
+ }
3331
+ return parseIpv4(s);
3332
+ } catch {
3333
+ return null;
3334
+ }
3335
+ }
3336
+ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
3337
+ "169.254.169.254",
3338
+ // AWS / Azure / DigitalOcean / OpenStack IMDS
3339
+ "169.254.170.2",
3340
+ // AWS ECS task role
3341
+ "168.63.129.16",
3342
+ // Azure WireServer
3343
+ "fd00:ec2::254",
3344
+ // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
3345
+ // Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
3346
+ // classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
3347
+ // live credential endpoint. It has to be named here, above the range check,
3348
+ // and it is the reason relaxing cgnat is safe.
3349
+ "100.100.100.200"
3350
+ ]);
3351
+ var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
3352
+ function isStrictGatedTier(tier) {
3353
+ return STRICT_TIERS.has(tier);
3354
+ }
3355
+ function ssrfReason(m, asWritten) {
3356
+ return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
3357
+ }
3358
+ var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
3359
+ var v4Octets = (a) => {
3360
+ const p = a.split(".");
3361
+ return p.length === 4 ? p.map(Number) : null;
3362
+ };
3363
+ function classifySsrf(host) {
3364
+ try {
3365
+ if (typeof host !== "string" || !host) return null;
3366
+ const lower = host.trim().toLowerCase().replace(/\.$/, "");
3367
+ const ip = normalizeIpLiteral(host);
3368
+ if (ip === null) {
3369
+ return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
3370
+ }
3371
+ const hit = (tier, overridable) => ({
3372
+ tier,
3373
+ overridable,
3374
+ kind: "address",
3375
+ normalized: ip
3376
+ });
3377
+ if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
3378
+ const o = v4Octets(ip);
3379
+ if (o) {
3380
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
3381
+ if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
3382
+ if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
3383
+ if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
3384
+ if (o[0] === 127) return hit("private", true);
3385
+ if (o[0] === 10) return hit("private", true);
3386
+ if (o[0] === 192 && o[1] === 168) return hit("private", true);
3387
+ if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
3388
+ return null;
3389
+ }
3390
+ const g = expandIpv6(ip);
3391
+ if (!g) return null;
3392
+ if (g.every((x) => x === 0)) return hit("unspecified", true);
3393
+ if ((g[0] & 65472) === 65152) return hit("link-local", false);
3394
+ if ((g[0] & 65280) === 65280) return hit("multicast", false);
3395
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
3396
+ return null;
3397
+ } catch {
3398
+ return null;
3399
+ }
3400
+ }
3401
+ var TIER_REASON = {
3402
+ metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
3403
+ "link-local": "a link-local address",
3404
+ multicast: "a multicast address",
3405
+ unspecified: "the unspecified address, which reaches this host",
3406
+ cgnat: "a carrier-grade NAT address",
3407
+ private: "a loopback or private address"
3408
+ };
3409
+ function ssrfFloor(tokens, opts = {}) {
3410
+ const exempt = new Set(
3411
+ (opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
3412
+ );
3413
+ for (const { token, binary } of tokens) {
3414
+ const m = classifySsrf(token);
3415
+ if (!m) continue;
3416
+ if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3417
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3418
+ return { ...m, host: token, binary, reason: ssrfReason(m, token) };
3419
+ }
3420
+ return null;
3421
+ }
3422
+
3216
3423
  // src/egress/index.ts
3217
3424
  var DEFAULT_EGRESS_ALLOWLIST = [
3218
3425
  // node9's own control plane (api, app, dev-api, staging and the apex).
@@ -3252,15 +3459,20 @@ function matchesAny(host, patterns) {
3252
3459
  for (const p of patterns) if (hostMatches(host, p)) return true;
3253
3460
  return false;
3254
3461
  }
3462
+ var PRIVATE_HOST_SUFFIXES = [".local", ".internal", ".localhost"];
3463
+ function isUniqueLocalV6(host) {
3464
+ const ip = normalizeIpLiteral(host);
3465
+ if (!ip || !ip.includes(":")) return false;
3466
+ const g = expandIpv6(ip);
3467
+ return g !== null && (g[0] & 65024) === 64512;
3468
+ }
3255
3469
  function isPrivateHost(host) {
3256
- const h = host.toLowerCase();
3257
- if (h === "localhost" || h === "0.0.0.0") return true;
3258
- if (h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".localhost")) return true;
3259
- if (/^127\./.test(h)) return true;
3260
- if (/^10\./.test(h)) return true;
3261
- if (/^192\.168\./.test(h)) return true;
3262
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
3263
- return false;
3470
+ const h = host.trim().toLowerCase();
3471
+ const m = classifySsrf(h);
3472
+ if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
3473
+ if (h === "localhost") return true;
3474
+ if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
3475
+ return isUniqueLocalV6(h);
3264
3476
  }
3265
3477
  function evaluateEgress(dests, policy) {
3266
3478
  if (!policy.enabled) return null;
@@ -3614,213 +3826,6 @@ function parseAllSshHostsFromCommand(command) {
3614
3826
  return extractAllSshHosts(tokens.slice(1));
3615
3827
  }
3616
3828
 
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
3829
  // src/egress/destinations.ts
3825
3830
  var DESTINATION_ARGS = /* @__PURE__ */ new Map([
3826
3831
  ["webfetch", ["url"]],
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).
@@ -3112,15 +3319,20 @@ function matchesAny(host, patterns) {
3112
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();
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);
3124
3336
  }
3125
3337
  function evaluateEgress(dests, policy) {
3126
3338
  if (!policy.enabled) return null;
@@ -3474,213 +3686,6 @@ function parseAllSshHostsFromCommand(command) {
3474
3686
  return extractAllSshHosts(tokens.slice(1));
3475
3687
  }
3476
3688
 
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
3689
  // src/egress/destinations.ts
3685
3690
  var DESTINATION_ARGS = /* @__PURE__ */ new Map([
3686
3691
  ["webfetch", ["url"]],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "2.16.1",
3
+ "version": "2.16.2",
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",