@node9/proxy 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +117 -12
- package/dist/cli.mjs +117 -12
- package/dist/dashboard.mjs +8 -3
- package/dist/index.js +115 -11
- package/dist/index.mjs +115 -11
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2046,6 +2046,12 @@ function normalizeIpLiteral(host) {
|
|
|
2046
2046
|
return null;
|
|
2047
2047
|
}
|
|
2048
2048
|
}
|
|
2049
|
+
function isStrictGatedTier(tier) {
|
|
2050
|
+
return STRICT_TIERS.has(tier);
|
|
2051
|
+
}
|
|
2052
|
+
function ssrfReason(m, asWritten) {
|
|
2053
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
2054
|
+
}
|
|
2049
2055
|
function classifySsrf(host) {
|
|
2050
2056
|
try {
|
|
2051
2057
|
if (typeof host !== "string" || !host) return null;
|
|
@@ -2063,7 +2069,7 @@ function classifySsrf(host) {
|
|
|
2063
2069
|
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
2064
2070
|
const o = v4Octets(ip);
|
|
2065
2071
|
if (o) {
|
|
2066
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified",
|
|
2072
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
2067
2073
|
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
2068
2074
|
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
2069
2075
|
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
@@ -2075,7 +2081,7 @@ function classifySsrf(host) {
|
|
|
2075
2081
|
}
|
|
2076
2082
|
const g = expandIpv6(ip);
|
|
2077
2083
|
if (!g) return null;
|
|
2078
|
-
if (g.every((x) => x === 0)) return hit("unspecified",
|
|
2084
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
2079
2085
|
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
2080
2086
|
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
2081
2087
|
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
@@ -2091,17 +2097,66 @@ function ssrfFloor(tokens, opts = {}) {
|
|
|
2091
2097
|
for (const { token, binary } of tokens) {
|
|
2092
2098
|
const m = classifySsrf(token);
|
|
2093
2099
|
if (!m) continue;
|
|
2094
|
-
if (m.tier
|
|
2100
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2095
2101
|
if (m.overridable && m.normalized && exempt2.has(m.normalized)) continue;
|
|
2096
|
-
return {
|
|
2097
|
-
...m,
|
|
2098
|
-
host: token,
|
|
2099
|
-
binary,
|
|
2100
|
-
reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
|
|
2101
|
-
};
|
|
2102
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
2102
2103
|
}
|
|
2103
2104
|
return null;
|
|
2104
2105
|
}
|
|
2106
|
+
function bareToolName(toolName) {
|
|
2107
|
+
const parts = toolName.split("__");
|
|
2108
|
+
return (parts.length >= 3 ? parts.slice(2).join("__") : toolName).toLowerCase();
|
|
2109
|
+
}
|
|
2110
|
+
function valuesAt(args, path77) {
|
|
2111
|
+
let cursors = [args];
|
|
2112
|
+
for (const rawSegment of path77.split(".")) {
|
|
2113
|
+
const isArray = rawSegment.endsWith("[]");
|
|
2114
|
+
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
|
|
2115
|
+
const next = [];
|
|
2116
|
+
for (const cursor of cursors) {
|
|
2117
|
+
if (cursor === null || typeof cursor !== "object") continue;
|
|
2118
|
+
const child = cursor[key];
|
|
2119
|
+
if (isArray) {
|
|
2120
|
+
if (Array.isArray(child)) next.push(...child);
|
|
2121
|
+
} else if (child !== void 0) {
|
|
2122
|
+
next.push(child);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
cursors = next;
|
|
2126
|
+
if (cursors.length === 0) return [];
|
|
2127
|
+
}
|
|
2128
|
+
return cursors.filter((v) => typeof v === "string");
|
|
2129
|
+
}
|
|
2130
|
+
function hostOf(value) {
|
|
2131
|
+
try {
|
|
2132
|
+
const u = new URL(value);
|
|
2133
|
+
const h = u.hostname;
|
|
2134
|
+
return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
|
2135
|
+
} catch {
|
|
2136
|
+
return null;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
2140
|
+
try {
|
|
2141
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2142
|
+
if (!paths) return null;
|
|
2143
|
+
const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
2144
|
+
for (const path77 of paths) {
|
|
2145
|
+
for (const value of valuesAt(args, path77)) {
|
|
2146
|
+
const host = hostOf(value);
|
|
2147
|
+
if (!host) continue;
|
|
2148
|
+
const m = classifySsrf(host);
|
|
2149
|
+
if (!m) continue;
|
|
2150
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2151
|
+
if (m.overridable && m.normalized && exempt2.has(m.normalized)) continue;
|
|
2152
|
+
return { ...m, argPath: path77, host, reason: ssrfReason(m, host) };
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
return null;
|
|
2156
|
+
} catch {
|
|
2157
|
+
return null;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2105
2160
|
function resolveCheck(v) {
|
|
2106
2161
|
return v === "off" || v === "block" ? v : "review";
|
|
2107
2162
|
}
|
|
@@ -2199,6 +2254,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2199
2254
|
};
|
|
2200
2255
|
}
|
|
2201
2256
|
}
|
|
2257
|
+
{
|
|
2258
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
2259
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
2260
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
2261
|
+
});
|
|
2262
|
+
if (dest) {
|
|
2263
|
+
return {
|
|
2264
|
+
decision: "block",
|
|
2265
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
2266
|
+
reason: dest.reason,
|
|
2267
|
+
ruleName: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
2268
|
+
ruleDescription: dest.reason,
|
|
2269
|
+
tier: 3
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2202
2273
|
if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
|
|
2203
2274
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
2204
2275
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
@@ -3205,7 +3276,7 @@ function* stringValues(obj, depth = 0) {
|
|
|
3205
3276
|
}
|
|
3206
3277
|
for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
|
|
3207
3278
|
}
|
|
3208
|
-
var import_safe_regex2, import_crypto3, import_mvdan_sh, import_picomatch, import_safe_regex22, import_safe_regex23, import_crypto4, IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, MAX, UNTRUSTED_TOOLS, SIGNALS, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, DLP_SCAN_LIMITS, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, FS_READ_TOOLS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, BASH_TOOL_NAMES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_HEREDOC_OPS, DEFAULT_EGRESS_ALLOWLIST, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, SSRF_MAX_HOST, METADATA_ADDRESSES, METADATA_HOSTNAMES, v4Octets, TIER_REASON, VERDICT_RANK, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, DESTRUCTIVE_OP_RE, SENSITIVE_PATH_RE, FILE_TOOLS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH2, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES, CANONICAL_EXTRACTOR_VERSION, DEDUPE_PREVIEW_LEN, TERMINAL_ESCAPE_RE, ENGINE_VERSION;
|
|
3279
|
+
var import_safe_regex2, import_crypto3, import_mvdan_sh, import_picomatch, import_safe_regex22, import_safe_regex23, import_crypto4, IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, MAX, UNTRUSTED_TOOLS, SIGNALS, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, DLP_SCAN_LIMITS, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, FS_READ_TOOLS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, BASH_TOOL_NAMES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_HEREDOC_OPS, DEFAULT_EGRESS_ALLOWLIST, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, SSRF_MAX_HOST, METADATA_ADDRESSES, STRICT_TIERS, METADATA_HOSTNAMES, v4Octets, TIER_REASON, DESTINATION_ARGS, VERDICT_RANK, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, DESTRUCTIVE_OP_RE, SENSITIVE_PATH_RE, FILE_TOOLS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH2, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES, CANONICAL_EXTRACTOR_VERSION, DEDUPE_PREVIEW_LEN, TERMINAL_ESCAPE_RE, ENGINE_VERSION;
|
|
3209
3280
|
var init_dist = __esm({
|
|
3210
3281
|
"packages/policy-engine/dist/index.mjs"() {
|
|
3211
3282
|
"use strict";
|
|
@@ -4360,9 +4431,15 @@ var init_dist = __esm({
|
|
|
4360
4431
|
// AWS ECS task role
|
|
4361
4432
|
"168.63.129.16",
|
|
4362
4433
|
// Azure WireServer
|
|
4363
|
-
"fd00:ec2::254"
|
|
4434
|
+
"fd00:ec2::254",
|
|
4364
4435
|
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
4436
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
4437
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
4438
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
4439
|
+
// and it is the reason relaxing cgnat is safe.
|
|
4440
|
+
"100.100.100.200"
|
|
4365
4441
|
]);
|
|
4442
|
+
STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
4366
4443
|
METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
4367
4444
|
v4Octets = (a) => {
|
|
4368
4445
|
const p = a.split(".");
|
|
@@ -4372,10 +4449,17 @@ var init_dist = __esm({
|
|
|
4372
4449
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
4373
4450
|
"link-local": "a link-local address",
|
|
4374
4451
|
multicast: "a multicast address",
|
|
4375
|
-
unspecified: "the unspecified address",
|
|
4452
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
4376
4453
|
cgnat: "a carrier-grade NAT address",
|
|
4377
4454
|
private: "a loopback or private address"
|
|
4378
4455
|
};
|
|
4456
|
+
DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
4457
|
+
["webfetch", ["url"]],
|
|
4458
|
+
["fetch", ["url", "uri"]],
|
|
4459
|
+
["navigate", ["url"]],
|
|
4460
|
+
["preview_start", ["url"]],
|
|
4461
|
+
["browser_batch", ["actions[].input.url"]]
|
|
4462
|
+
]);
|
|
4379
4463
|
VERDICT_RANK = {
|
|
4380
4464
|
allow: 0,
|
|
4381
4465
|
review: 1,
|
|
@@ -8746,6 +8830,26 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8746
8830
|
};
|
|
8747
8831
|
}
|
|
8748
8832
|
}
|
|
8833
|
+
{
|
|
8834
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
8835
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
8836
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
8837
|
+
});
|
|
8838
|
+
if (dest && !isObserveMode) {
|
|
8839
|
+
if (!isManual)
|
|
8840
|
+
appendLocalAudit(toolName, args, "deny", "ssrf-destination", meta, hashAuditArgs);
|
|
8841
|
+
return {
|
|
8842
|
+
approved: false,
|
|
8843
|
+
checkedBy: "local-policy",
|
|
8844
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
8845
|
+
reason: dest.reason,
|
|
8846
|
+
// AuthResult carries no ruleName; the rule identity the audit row needs
|
|
8847
|
+
// travels in ruleHit, as it does for a smart-rule block.
|
|
8848
|
+
ruleHit: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
8849
|
+
ruleDescription: dest.reason
|
|
8850
|
+
};
|
|
8851
|
+
}
|
|
8852
|
+
}
|
|
8749
8853
|
if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
|
|
8750
8854
|
const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
8751
8855
|
const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
|
|
@@ -9421,6 +9525,7 @@ var init_orchestrator = __esm({
|
|
|
9421
9525
|
init_dlp();
|
|
9422
9526
|
init_registry();
|
|
9423
9527
|
init_dist();
|
|
9528
|
+
init_dist();
|
|
9424
9529
|
init_audit();
|
|
9425
9530
|
init_config();
|
|
9426
9531
|
init_policy();
|
package/dist/cli.mjs
CHANGED
|
@@ -2057,6 +2057,12 @@ function normalizeIpLiteral(host) {
|
|
|
2057
2057
|
return null;
|
|
2058
2058
|
}
|
|
2059
2059
|
}
|
|
2060
|
+
function isStrictGatedTier(tier) {
|
|
2061
|
+
return STRICT_TIERS.has(tier);
|
|
2062
|
+
}
|
|
2063
|
+
function ssrfReason(m, asWritten) {
|
|
2064
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
2065
|
+
}
|
|
2060
2066
|
function classifySsrf(host) {
|
|
2061
2067
|
try {
|
|
2062
2068
|
if (typeof host !== "string" || !host) return null;
|
|
@@ -2074,7 +2080,7 @@ function classifySsrf(host) {
|
|
|
2074
2080
|
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
2075
2081
|
const o = v4Octets(ip);
|
|
2076
2082
|
if (o) {
|
|
2077
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified",
|
|
2083
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
2078
2084
|
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
2079
2085
|
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
2080
2086
|
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
@@ -2086,7 +2092,7 @@ function classifySsrf(host) {
|
|
|
2086
2092
|
}
|
|
2087
2093
|
const g = expandIpv6(ip);
|
|
2088
2094
|
if (!g) return null;
|
|
2089
|
-
if (g.every((x) => x === 0)) return hit("unspecified",
|
|
2095
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
2090
2096
|
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
2091
2097
|
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
2092
2098
|
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
@@ -2102,17 +2108,66 @@ function ssrfFloor(tokens, opts = {}) {
|
|
|
2102
2108
|
for (const { token, binary } of tokens) {
|
|
2103
2109
|
const m = classifySsrf(token);
|
|
2104
2110
|
if (!m) continue;
|
|
2105
|
-
if (m.tier
|
|
2111
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2106
2112
|
if (m.overridable && m.normalized && exempt2.has(m.normalized)) continue;
|
|
2107
|
-
return {
|
|
2108
|
-
...m,
|
|
2109
|
-
host: token,
|
|
2110
|
-
binary,
|
|
2111
|
-
reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
|
|
2112
|
-
};
|
|
2113
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
2113
2114
|
}
|
|
2114
2115
|
return null;
|
|
2115
2116
|
}
|
|
2117
|
+
function bareToolName(toolName) {
|
|
2118
|
+
const parts = toolName.split("__");
|
|
2119
|
+
return (parts.length >= 3 ? parts.slice(2).join("__") : toolName).toLowerCase();
|
|
2120
|
+
}
|
|
2121
|
+
function valuesAt(args, path77) {
|
|
2122
|
+
let cursors = [args];
|
|
2123
|
+
for (const rawSegment of path77.split(".")) {
|
|
2124
|
+
const isArray = rawSegment.endsWith("[]");
|
|
2125
|
+
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
|
|
2126
|
+
const next = [];
|
|
2127
|
+
for (const cursor of cursors) {
|
|
2128
|
+
if (cursor === null || typeof cursor !== "object") continue;
|
|
2129
|
+
const child = cursor[key];
|
|
2130
|
+
if (isArray) {
|
|
2131
|
+
if (Array.isArray(child)) next.push(...child);
|
|
2132
|
+
} else if (child !== void 0) {
|
|
2133
|
+
next.push(child);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
cursors = next;
|
|
2137
|
+
if (cursors.length === 0) return [];
|
|
2138
|
+
}
|
|
2139
|
+
return cursors.filter((v) => typeof v === "string");
|
|
2140
|
+
}
|
|
2141
|
+
function hostOf(value) {
|
|
2142
|
+
try {
|
|
2143
|
+
const u = new URL(value);
|
|
2144
|
+
const h = u.hostname;
|
|
2145
|
+
return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
|
2146
|
+
} catch {
|
|
2147
|
+
return null;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
2151
|
+
try {
|
|
2152
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2153
|
+
if (!paths) return null;
|
|
2154
|
+
const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
2155
|
+
for (const path77 of paths) {
|
|
2156
|
+
for (const value of valuesAt(args, path77)) {
|
|
2157
|
+
const host = hostOf(value);
|
|
2158
|
+
if (!host) continue;
|
|
2159
|
+
const m = classifySsrf(host);
|
|
2160
|
+
if (!m) continue;
|
|
2161
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2162
|
+
if (m.overridable && m.normalized && exempt2.has(m.normalized)) continue;
|
|
2163
|
+
return { ...m, argPath: path77, host, reason: ssrfReason(m, host) };
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return null;
|
|
2167
|
+
} catch {
|
|
2168
|
+
return null;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2116
2171
|
function resolveCheck(v) {
|
|
2117
2172
|
return v === "off" || v === "block" ? v : "review";
|
|
2118
2173
|
}
|
|
@@ -2210,6 +2265,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2210
2265
|
};
|
|
2211
2266
|
}
|
|
2212
2267
|
}
|
|
2268
|
+
{
|
|
2269
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
2270
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
2271
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
2272
|
+
});
|
|
2273
|
+
if (dest) {
|
|
2274
|
+
return {
|
|
2275
|
+
decision: "block",
|
|
2276
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
2277
|
+
reason: dest.reason,
|
|
2278
|
+
ruleName: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
2279
|
+
ruleDescription: dest.reason,
|
|
2280
|
+
tier: 3
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2213
2284
|
if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
|
|
2214
2285
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
2215
2286
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
@@ -3216,7 +3287,7 @@ function* stringValues(obj, depth = 0) {
|
|
|
3216
3287
|
}
|
|
3217
3288
|
for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
|
|
3218
3289
|
}
|
|
3219
|
-
var IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, MAX, UNTRUSTED_TOOLS, SIGNALS, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, DLP_SCAN_LIMITS, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, FS_READ_TOOLS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, BASH_TOOL_NAMES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_HEREDOC_OPS, DEFAULT_EGRESS_ALLOWLIST, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, SSRF_MAX_HOST, METADATA_ADDRESSES, METADATA_HOSTNAMES, v4Octets, TIER_REASON, VERDICT_RANK, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, DESTRUCTIVE_OP_RE, SENSITIVE_PATH_RE, FILE_TOOLS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH2, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES, CANONICAL_EXTRACTOR_VERSION, DEDUPE_PREVIEW_LEN, TERMINAL_ESCAPE_RE, ENGINE_VERSION;
|
|
3290
|
+
var IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, MAX, UNTRUSTED_TOOLS, SIGNALS, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, DLP_SCAN_LIMITS, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, FS_READ_TOOLS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, BASH_TOOL_NAMES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_HEREDOC_OPS, DEFAULT_EGRESS_ALLOWLIST, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, SSRF_MAX_HOST, METADATA_ADDRESSES, STRICT_TIERS, METADATA_HOSTNAMES, v4Octets, TIER_REASON, DESTINATION_ARGS, VERDICT_RANK, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, DESTRUCTIVE_OP_RE, SENSITIVE_PATH_RE, FILE_TOOLS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH2, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES, CANONICAL_EXTRACTOR_VERSION, DEDUPE_PREVIEW_LEN, TERMINAL_ESCAPE_RE, ENGINE_VERSION;
|
|
3220
3291
|
var init_dist = __esm({
|
|
3221
3292
|
"packages/policy-engine/dist/index.mjs"() {
|
|
3222
3293
|
"use strict";
|
|
@@ -4364,9 +4435,15 @@ var init_dist = __esm({
|
|
|
4364
4435
|
// AWS ECS task role
|
|
4365
4436
|
"168.63.129.16",
|
|
4366
4437
|
// Azure WireServer
|
|
4367
|
-
"fd00:ec2::254"
|
|
4438
|
+
"fd00:ec2::254",
|
|
4368
4439
|
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
4440
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
4441
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
4442
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
4443
|
+
// and it is the reason relaxing cgnat is safe.
|
|
4444
|
+
"100.100.100.200"
|
|
4369
4445
|
]);
|
|
4446
|
+
STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
4370
4447
|
METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
4371
4448
|
v4Octets = (a) => {
|
|
4372
4449
|
const p = a.split(".");
|
|
@@ -4376,10 +4453,17 @@ var init_dist = __esm({
|
|
|
4376
4453
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
4377
4454
|
"link-local": "a link-local address",
|
|
4378
4455
|
multicast: "a multicast address",
|
|
4379
|
-
unspecified: "the unspecified address",
|
|
4456
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
4380
4457
|
cgnat: "a carrier-grade NAT address",
|
|
4381
4458
|
private: "a loopback or private address"
|
|
4382
4459
|
};
|
|
4460
|
+
DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
4461
|
+
["webfetch", ["url"]],
|
|
4462
|
+
["fetch", ["url", "uri"]],
|
|
4463
|
+
["navigate", ["url"]],
|
|
4464
|
+
["preview_start", ["url"]],
|
|
4465
|
+
["browser_batch", ["actions[].input.url"]]
|
|
4466
|
+
]);
|
|
4383
4467
|
VERDICT_RANK = {
|
|
4384
4468
|
allow: 0,
|
|
4385
4469
|
review: 1,
|
|
@@ -8749,6 +8833,26 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
8749
8833
|
};
|
|
8750
8834
|
}
|
|
8751
8835
|
}
|
|
8836
|
+
{
|
|
8837
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
8838
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
8839
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
8840
|
+
});
|
|
8841
|
+
if (dest && !isObserveMode) {
|
|
8842
|
+
if (!isManual)
|
|
8843
|
+
appendLocalAudit(toolName, args, "deny", "ssrf-destination", meta, hashAuditArgs);
|
|
8844
|
+
return {
|
|
8845
|
+
approved: false,
|
|
8846
|
+
checkedBy: "local-policy",
|
|
8847
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
8848
|
+
reason: dest.reason,
|
|
8849
|
+
// AuthResult carries no ruleName; the rule identity the audit row needs
|
|
8850
|
+
// travels in ruleHit, as it does for a smart-rule block.
|
|
8851
|
+
ruleHit: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
8852
|
+
ruleDescription: dest.reason
|
|
8853
|
+
};
|
|
8854
|
+
}
|
|
8855
|
+
}
|
|
8752
8856
|
if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
|
|
8753
8857
|
const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
8754
8858
|
const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
|
|
@@ -9423,6 +9527,7 @@ var init_orchestrator = __esm({
|
|
|
9423
9527
|
init_dlp();
|
|
9424
9528
|
init_registry();
|
|
9425
9529
|
init_dist();
|
|
9530
|
+
init_dist();
|
|
9426
9531
|
init_audit();
|
|
9427
9532
|
init_config();
|
|
9428
9533
|
init_policy();
|
package/dist/dashboard.mjs
CHANGED
|
@@ -856,7 +856,7 @@ function classifySsrf(host) {
|
|
|
856
856
|
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
857
857
|
const o = v4Octets(ip);
|
|
858
858
|
if (o) {
|
|
859
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified",
|
|
859
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
860
860
|
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
861
861
|
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
862
862
|
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
@@ -868,7 +868,7 @@ function classifySsrf(host) {
|
|
|
868
868
|
}
|
|
869
869
|
const g = expandIpv6(ip);
|
|
870
870
|
if (!g) return null;
|
|
871
|
-
if (g.every((x) => x === 0)) return hit("unspecified",
|
|
871
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
872
872
|
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
873
873
|
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
874
874
|
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
@@ -1759,8 +1759,13 @@ var init_dist = __esm({
|
|
|
1759
1759
|
// AWS ECS task role
|
|
1760
1760
|
"168.63.129.16",
|
|
1761
1761
|
// Azure WireServer
|
|
1762
|
-
"fd00:ec2::254"
|
|
1762
|
+
"fd00:ec2::254",
|
|
1763
1763
|
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
1764
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
1765
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
1766
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
1767
|
+
// and it is the reason relaxing cgnat is safe.
|
|
1768
|
+
"100.100.100.200"
|
|
1764
1769
|
]);
|
|
1765
1770
|
METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
1766
1771
|
v4Octets = (a) => {
|
package/dist/index.js
CHANGED
|
@@ -3117,9 +3117,21 @@ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
|
3117
3117
|
// AWS ECS task role
|
|
3118
3118
|
"168.63.129.16",
|
|
3119
3119
|
// Azure WireServer
|
|
3120
|
-
"fd00:ec2::254"
|
|
3120
|
+
"fd00:ec2::254",
|
|
3121
3121
|
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3122
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3123
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3124
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
3125
|
+
// and it is the reason relaxing cgnat is safe.
|
|
3126
|
+
"100.100.100.200"
|
|
3122
3127
|
]);
|
|
3128
|
+
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3129
|
+
function isStrictGatedTier(tier) {
|
|
3130
|
+
return STRICT_TIERS.has(tier);
|
|
3131
|
+
}
|
|
3132
|
+
function ssrfReason(m, asWritten) {
|
|
3133
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3134
|
+
}
|
|
3123
3135
|
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3124
3136
|
var v4Octets = (a) => {
|
|
3125
3137
|
const p = a.split(".");
|
|
@@ -3142,7 +3154,7 @@ function classifySsrf(host) {
|
|
|
3142
3154
|
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3143
3155
|
const o = v4Octets(ip);
|
|
3144
3156
|
if (o) {
|
|
3145
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified",
|
|
3157
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3146
3158
|
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3147
3159
|
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3148
3160
|
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
@@ -3154,7 +3166,7 @@ function classifySsrf(host) {
|
|
|
3154
3166
|
}
|
|
3155
3167
|
const g = expandIpv6(ip);
|
|
3156
3168
|
if (!g) return null;
|
|
3157
|
-
if (g.every((x) => x === 0)) return hit("unspecified",
|
|
3169
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3158
3170
|
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3159
3171
|
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3160
3172
|
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
@@ -3167,7 +3179,7 @@ var TIER_REASON = {
|
|
|
3167
3179
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3168
3180
|
"link-local": "a link-local address",
|
|
3169
3181
|
multicast: "a multicast address",
|
|
3170
|
-
unspecified: "the unspecified address",
|
|
3182
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
3171
3183
|
cgnat: "a carrier-grade NAT address",
|
|
3172
3184
|
private: "a loopback or private address"
|
|
3173
3185
|
};
|
|
@@ -3178,17 +3190,73 @@ function ssrfFloor(tokens, opts = {}) {
|
|
|
3178
3190
|
for (const { token, binary } of tokens) {
|
|
3179
3191
|
const m = classifySsrf(token);
|
|
3180
3192
|
if (!m) continue;
|
|
3181
|
-
if (m.tier
|
|
3193
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3182
3194
|
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3183
|
-
return {
|
|
3184
|
-
...m,
|
|
3185
|
-
host: token,
|
|
3186
|
-
binary,
|
|
3187
|
-
reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
|
|
3188
|
-
};
|
|
3195
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3189
3196
|
}
|
|
3190
3197
|
return null;
|
|
3191
3198
|
}
|
|
3199
|
+
var DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
3200
|
+
["webfetch", ["url"]],
|
|
3201
|
+
["fetch", ["url", "uri"]],
|
|
3202
|
+
["navigate", ["url"]],
|
|
3203
|
+
["preview_start", ["url"]],
|
|
3204
|
+
["browser_batch", ["actions[].input.url"]]
|
|
3205
|
+
]);
|
|
3206
|
+
function bareToolName(toolName) {
|
|
3207
|
+
const parts = toolName.split("__");
|
|
3208
|
+
return (parts.length >= 3 ? parts.slice(2).join("__") : toolName).toLowerCase();
|
|
3209
|
+
}
|
|
3210
|
+
function valuesAt(args, path15) {
|
|
3211
|
+
let cursors = [args];
|
|
3212
|
+
for (const rawSegment of path15.split(".")) {
|
|
3213
|
+
const isArray = rawSegment.endsWith("[]");
|
|
3214
|
+
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
|
|
3215
|
+
const next = [];
|
|
3216
|
+
for (const cursor of cursors) {
|
|
3217
|
+
if (cursor === null || typeof cursor !== "object") continue;
|
|
3218
|
+
const child = cursor[key];
|
|
3219
|
+
if (isArray) {
|
|
3220
|
+
if (Array.isArray(child)) next.push(...child);
|
|
3221
|
+
} else if (child !== void 0) {
|
|
3222
|
+
next.push(child);
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
cursors = next;
|
|
3226
|
+
if (cursors.length === 0) return [];
|
|
3227
|
+
}
|
|
3228
|
+
return cursors.filter((v) => typeof v === "string");
|
|
3229
|
+
}
|
|
3230
|
+
function hostOf(value) {
|
|
3231
|
+
try {
|
|
3232
|
+
const u = new URL(value);
|
|
3233
|
+
const h = u.hostname;
|
|
3234
|
+
return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
|
3235
|
+
} catch {
|
|
3236
|
+
return null;
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
3240
|
+
try {
|
|
3241
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
3242
|
+
if (!paths) return null;
|
|
3243
|
+
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
3244
|
+
for (const path15 of paths) {
|
|
3245
|
+
for (const value of valuesAt(args, path15)) {
|
|
3246
|
+
const host = hostOf(value);
|
|
3247
|
+
if (!host) continue;
|
|
3248
|
+
const m = classifySsrf(host);
|
|
3249
|
+
if (!m) continue;
|
|
3250
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3251
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3252
|
+
return { ...m, argPath: path15, host, reason: ssrfReason(m, host) };
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
return null;
|
|
3256
|
+
} catch {
|
|
3257
|
+
return null;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3192
3260
|
function resolveCheck(v) {
|
|
3193
3261
|
return v === "off" || v === "block" ? v : "review";
|
|
3194
3262
|
}
|
|
@@ -3292,6 +3360,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3292
3360
|
};
|
|
3293
3361
|
}
|
|
3294
3362
|
}
|
|
3363
|
+
{
|
|
3364
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
3365
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
3366
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
3367
|
+
});
|
|
3368
|
+
if (dest) {
|
|
3369
|
+
return {
|
|
3370
|
+
decision: "block",
|
|
3371
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
3372
|
+
reason: dest.reason,
|
|
3373
|
+
ruleName: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
3374
|
+
ruleDescription: dest.reason,
|
|
3375
|
+
tier: 3
|
|
3376
|
+
};
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3295
3379
|
if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
|
|
3296
3380
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
3297
3381
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
@@ -7164,6 +7248,26 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
7164
7248
|
};
|
|
7165
7249
|
}
|
|
7166
7250
|
}
|
|
7251
|
+
{
|
|
7252
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
7253
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
7254
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
7255
|
+
});
|
|
7256
|
+
if (dest && !isObserveMode) {
|
|
7257
|
+
if (!isManual)
|
|
7258
|
+
appendLocalAudit(toolName, args, "deny", "ssrf-destination", meta, hashAuditArgs);
|
|
7259
|
+
return {
|
|
7260
|
+
approved: false,
|
|
7261
|
+
checkedBy: "local-policy",
|
|
7262
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
7263
|
+
reason: dest.reason,
|
|
7264
|
+
// AuthResult carries no ruleName; the rule identity the audit row needs
|
|
7265
|
+
// travels in ruleHit, as it does for a smart-rule block.
|
|
7266
|
+
ruleHit: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
7267
|
+
ruleDescription: dest.reason
|
|
7268
|
+
};
|
|
7269
|
+
}
|
|
7270
|
+
}
|
|
7167
7271
|
if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
|
|
7168
7272
|
const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
7169
7273
|
const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
|
package/dist/index.mjs
CHANGED
|
@@ -3087,9 +3087,21 @@ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
|
3087
3087
|
// AWS ECS task role
|
|
3088
3088
|
"168.63.129.16",
|
|
3089
3089
|
// Azure WireServer
|
|
3090
|
-
"fd00:ec2::254"
|
|
3090
|
+
"fd00:ec2::254",
|
|
3091
3091
|
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3092
|
+
// Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
|
|
3093
|
+
// classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
|
|
3094
|
+
// live credential endpoint. It has to be named here, above the range check,
|
|
3095
|
+
// and it is the reason relaxing cgnat is safe.
|
|
3096
|
+
"100.100.100.200"
|
|
3092
3097
|
]);
|
|
3098
|
+
var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
|
|
3099
|
+
function isStrictGatedTier(tier) {
|
|
3100
|
+
return STRICT_TIERS.has(tier);
|
|
3101
|
+
}
|
|
3102
|
+
function ssrfReason(m, asWritten) {
|
|
3103
|
+
return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
|
|
3104
|
+
}
|
|
3093
3105
|
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3094
3106
|
var v4Octets = (a) => {
|
|
3095
3107
|
const p = a.split(".");
|
|
@@ -3112,7 +3124,7 @@ function classifySsrf(host) {
|
|
|
3112
3124
|
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3113
3125
|
const o = v4Octets(ip);
|
|
3114
3126
|
if (o) {
|
|
3115
|
-
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified",
|
|
3127
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
|
|
3116
3128
|
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3117
3129
|
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3118
3130
|
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
@@ -3124,7 +3136,7 @@ function classifySsrf(host) {
|
|
|
3124
3136
|
}
|
|
3125
3137
|
const g = expandIpv6(ip);
|
|
3126
3138
|
if (!g) return null;
|
|
3127
|
-
if (g.every((x) => x === 0)) return hit("unspecified",
|
|
3139
|
+
if (g.every((x) => x === 0)) return hit("unspecified", true);
|
|
3128
3140
|
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3129
3141
|
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3130
3142
|
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
@@ -3137,7 +3149,7 @@ var TIER_REASON = {
|
|
|
3137
3149
|
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3138
3150
|
"link-local": "a link-local address",
|
|
3139
3151
|
multicast: "a multicast address",
|
|
3140
|
-
unspecified: "the unspecified address",
|
|
3152
|
+
unspecified: "the unspecified address, which reaches this host",
|
|
3141
3153
|
cgnat: "a carrier-grade NAT address",
|
|
3142
3154
|
private: "a loopback or private address"
|
|
3143
3155
|
};
|
|
@@ -3148,17 +3160,73 @@ function ssrfFloor(tokens, opts = {}) {
|
|
|
3148
3160
|
for (const { token, binary } of tokens) {
|
|
3149
3161
|
const m = classifySsrf(token);
|
|
3150
3162
|
if (!m) continue;
|
|
3151
|
-
if (m.tier
|
|
3163
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3152
3164
|
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3153
|
-
return {
|
|
3154
|
-
...m,
|
|
3155
|
-
host: token,
|
|
3156
|
-
binary,
|
|
3157
|
-
reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
|
|
3158
|
-
};
|
|
3165
|
+
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
3159
3166
|
}
|
|
3160
3167
|
return null;
|
|
3161
3168
|
}
|
|
3169
|
+
var DESTINATION_ARGS = /* @__PURE__ */ new Map([
|
|
3170
|
+
["webfetch", ["url"]],
|
|
3171
|
+
["fetch", ["url", "uri"]],
|
|
3172
|
+
["navigate", ["url"]],
|
|
3173
|
+
["preview_start", ["url"]],
|
|
3174
|
+
["browser_batch", ["actions[].input.url"]]
|
|
3175
|
+
]);
|
|
3176
|
+
function bareToolName(toolName) {
|
|
3177
|
+
const parts = toolName.split("__");
|
|
3178
|
+
return (parts.length >= 3 ? parts.slice(2).join("__") : toolName).toLowerCase();
|
|
3179
|
+
}
|
|
3180
|
+
function valuesAt(args, path15) {
|
|
3181
|
+
let cursors = [args];
|
|
3182
|
+
for (const rawSegment of path15.split(".")) {
|
|
3183
|
+
const isArray = rawSegment.endsWith("[]");
|
|
3184
|
+
const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
|
|
3185
|
+
const next = [];
|
|
3186
|
+
for (const cursor of cursors) {
|
|
3187
|
+
if (cursor === null || typeof cursor !== "object") continue;
|
|
3188
|
+
const child = cursor[key];
|
|
3189
|
+
if (isArray) {
|
|
3190
|
+
if (Array.isArray(child)) next.push(...child);
|
|
3191
|
+
} else if (child !== void 0) {
|
|
3192
|
+
next.push(child);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
cursors = next;
|
|
3196
|
+
if (cursors.length === 0) return [];
|
|
3197
|
+
}
|
|
3198
|
+
return cursors.filter((v) => typeof v === "string");
|
|
3199
|
+
}
|
|
3200
|
+
function hostOf(value) {
|
|
3201
|
+
try {
|
|
3202
|
+
const u = new URL(value);
|
|
3203
|
+
const h = u.hostname;
|
|
3204
|
+
return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
|
|
3205
|
+
} catch {
|
|
3206
|
+
return null;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
3210
|
+
try {
|
|
3211
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
3212
|
+
if (!paths) return null;
|
|
3213
|
+
const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
3214
|
+
for (const path15 of paths) {
|
|
3215
|
+
for (const value of valuesAt(args, path15)) {
|
|
3216
|
+
const host = hostOf(value);
|
|
3217
|
+
if (!host) continue;
|
|
3218
|
+
const m = classifySsrf(host);
|
|
3219
|
+
if (!m) continue;
|
|
3220
|
+
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
3221
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3222
|
+
return { ...m, argPath: path15, host, reason: ssrfReason(m, host) };
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
return null;
|
|
3226
|
+
} catch {
|
|
3227
|
+
return null;
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3162
3230
|
function resolveCheck(v) {
|
|
3163
3231
|
return v === "off" || v === "block" ? v : "review";
|
|
3164
3232
|
}
|
|
@@ -3262,6 +3330,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3262
3330
|
};
|
|
3263
3331
|
}
|
|
3264
3332
|
}
|
|
3333
|
+
{
|
|
3334
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
3335
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
3336
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
3337
|
+
});
|
|
3338
|
+
if (dest) {
|
|
3339
|
+
return {
|
|
3340
|
+
decision: "block",
|
|
3341
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
3342
|
+
reason: dest.reason,
|
|
3343
|
+
ruleName: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
3344
|
+
ruleDescription: dest.reason,
|
|
3345
|
+
tier: 3
|
|
3346
|
+
};
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3265
3349
|
if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
|
|
3266
3350
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
3267
3351
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
@@ -7134,6 +7218,26 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
7134
7218
|
};
|
|
7135
7219
|
}
|
|
7136
7220
|
}
|
|
7221
|
+
{
|
|
7222
|
+
const dest = ssrfDestinationFloor(toolName, args, {
|
|
7223
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
7224
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
7225
|
+
});
|
|
7226
|
+
if (dest && !isObserveMode) {
|
|
7227
|
+
if (!isManual)
|
|
7228
|
+
appendLocalAudit(toolName, args, "deny", "ssrf-destination", meta, hashAuditArgs);
|
|
7229
|
+
return {
|
|
7230
|
+
approved: false,
|
|
7231
|
+
checkedBy: "local-policy",
|
|
7232
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
7233
|
+
reason: dest.reason,
|
|
7234
|
+
// AuthResult carries no ruleName; the rule identity the audit row needs
|
|
7235
|
+
// travels in ruleHit, as it does for a smart-rule block.
|
|
7236
|
+
ruleHit: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
|
|
7237
|
+
ruleDescription: dest.reason
|
|
7238
|
+
};
|
|
7239
|
+
}
|
|
7240
|
+
}
|
|
7137
7241
|
if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
|
|
7138
7242
|
const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
7139
7243
|
const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|