@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.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
|
|
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
|
-
|
|
3118
|
-
if (
|
|
3119
|
-
if (
|
|
3120
|
-
if (
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|