@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 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", false);
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", false);
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 === "private" && !opts.ssrfStrict) continue;
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", false);
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", false);
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 === "private" && !opts.ssrfStrict) continue;
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();
@@ -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", false);
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", false);
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", false);
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", false);
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 === "private" && !opts.ssrfStrict) continue;
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", false);
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", false);
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 === "private" && !opts.ssrfStrict) continue;
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.9.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",