@node9/policy-engine 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/index.d.mts CHANGED
@@ -1254,6 +1254,13 @@ declare const SSRF_MAX_HOST = 253;
1254
1254
  * id are stripped here: the caller does NOT strip them (measured).
1255
1255
  */
1256
1256
  declare function normalizeIpLiteral(host: string): string | null;
1257
+ /** Is this tier reachable by default, waiting on the `ssrfStrict` knob?
1258
+ * Exported so the non-shell path asks the SAME question rather than
1259
+ * approximating it with `overridable`, which is a different property. */
1260
+ declare function isStrictGatedTier(tier: SsrfTier): boolean;
1261
+ /** The sentence a user reads when the floor stops them. One builder, so the
1262
+ * shell path and the non-shell path cannot word the same block differently. */
1263
+ declare function ssrfReason(m: SsrfMatch, asWritten: string): string;
1257
1264
  /**
1258
1265
  * Classify a destination host. Returns null when it is not a protected address,
1259
1266
  * which means the ordinary egress policy decides. Never throws.
@@ -1288,7 +1295,36 @@ declare function ssrfFloor(tokens: ReadonlyArray<{
1288
1295
  binary: string;
1289
1296
  }>, opts?: SsrfFloorOptions): SsrfVerdict | null;
1290
1297
 
1298
+ /**
1299
+ * Tools whose named argument really is a destination the agent is about to
1300
+ * reach. Keyed on the BARE tool name (an `mcp__server__` prefix is stripped
1301
+ * before matching), because the browser tools arrive over MCP.
1302
+ *
1303
+ * Derived from real history, not imagination: walking 240,058 audit entries
1304
+ * produced exactly these argument paths. A tool is here because its network
1305
+ * semantics are declared by its name, not because it happens to carry a key
1306
+ * called `url` (founder call, 2026-09-08: ignore those).
1307
+ *
1308
+ * `[]` in a path means "every element of this array".
1309
+ */
1310
+ declare const DESTINATION_ARGS: ReadonlyMap<string, readonly string[]>;
1311
+ interface DestinationHit extends SsrfMatch {
1312
+ /** The path that carried it, for the message and the audit row. */
1313
+ argPath: string;
1314
+ host: string;
1315
+ reason: string;
1316
+ }
1317
+ /**
1318
+ * Judge a non-shell tool call. Returns the first protected destination, or
1319
+ * null. Never throws: this runs on the hook path, where an exception would
1320
+ * break every tool call on the machine.
1321
+ */
1322
+ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
1323
+ ssrfStrict?: boolean;
1324
+ ssrfAllow?: readonly string[];
1325
+ }): DestinationHit | null;
1326
+
1291
1327
  /** Engine version stamped on audit entries for future drift detection. */
1292
1328
  declare const ENGINE_VERSION = "1.4.0";
1293
1329
 
1294
- export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfFloor, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, validateOverrides, validateRegex, validateShieldDefinition };
1330
+ export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, validateOverrides, validateRegex, validateShieldDefinition };
package/dist/index.d.ts CHANGED
@@ -1254,6 +1254,13 @@ declare const SSRF_MAX_HOST = 253;
1254
1254
  * id are stripped here: the caller does NOT strip them (measured).
1255
1255
  */
1256
1256
  declare function normalizeIpLiteral(host: string): string | null;
1257
+ /** Is this tier reachable by default, waiting on the `ssrfStrict` knob?
1258
+ * Exported so the non-shell path asks the SAME question rather than
1259
+ * approximating it with `overridable`, which is a different property. */
1260
+ declare function isStrictGatedTier(tier: SsrfTier): boolean;
1261
+ /** The sentence a user reads when the floor stops them. One builder, so the
1262
+ * shell path and the non-shell path cannot word the same block differently. */
1263
+ declare function ssrfReason(m: SsrfMatch, asWritten: string): string;
1257
1264
  /**
1258
1265
  * Classify a destination host. Returns null when it is not a protected address,
1259
1266
  * which means the ordinary egress policy decides. Never throws.
@@ -1288,7 +1295,36 @@ declare function ssrfFloor(tokens: ReadonlyArray<{
1288
1295
  binary: string;
1289
1296
  }>, opts?: SsrfFloorOptions): SsrfVerdict | null;
1290
1297
 
1298
+ /**
1299
+ * Tools whose named argument really is a destination the agent is about to
1300
+ * reach. Keyed on the BARE tool name (an `mcp__server__` prefix is stripped
1301
+ * before matching), because the browser tools arrive over MCP.
1302
+ *
1303
+ * Derived from real history, not imagination: walking 240,058 audit entries
1304
+ * produced exactly these argument paths. A tool is here because its network
1305
+ * semantics are declared by its name, not because it happens to carry a key
1306
+ * called `url` (founder call, 2026-09-08: ignore those).
1307
+ *
1308
+ * `[]` in a path means "every element of this array".
1309
+ */
1310
+ declare const DESTINATION_ARGS: ReadonlyMap<string, readonly string[]>;
1311
+ interface DestinationHit extends SsrfMatch {
1312
+ /** The path that carried it, for the message and the audit row. */
1313
+ argPath: string;
1314
+ host: string;
1315
+ reason: string;
1316
+ }
1317
+ /**
1318
+ * Judge a non-shell tool call. Returns the first protected destination, or
1319
+ * null. Never throws: this runs on the hook path, where an exception would
1320
+ * break every tool call on the machine.
1321
+ */
1322
+ declare function ssrfDestinationFloor(toolName: string, args: unknown, opts?: {
1323
+ ssrfStrict?: boolean;
1324
+ ssrfAllow?: readonly string[];
1325
+ }): DestinationHit | null;
1326
+
1291
1327
  /** Engine version stamped on audit entries for future drift detection. */
1292
1328
  declare const ENGINE_VERSION = "1.4.0";
1293
1329
 
1294
- export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfFloor, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, validateOverrides, validateRegex, validateShieldDefinition };
1330
+ export { AST_FS_REGEX_RULES, type AuditEntryForClassify, BASH_TOOL_NAMES, BUILTIN_SHIELDS, type BlastEnvFinding, type BlastFinding, type BlastResult, type BlastSummary, CANARY_MIN_LENGTH, CANONICAL_EXTRACTOR_HASH, CANONICAL_EXTRACTOR_VERSION, COST_PER_LOOP_ITER_USD, type CanaryHit, type CanaryValue, type CanaryView, type CanonicalAgent, type CanonicalFinding, type CanonicalFindingType, type CanonicalSourceType, DEFAULT_EGRESS_ALLOWLIST, DESTINATION_ARGS, DESTRUCTIVE_OP_RE, DLP_PATTERNS, DLP_SCAN_LIMITS, type DlpMatch, ENGINE_VERSION, type EgressPolicy, type EgressVerdict, type ExtractContext, FILE_TOOLS, FLAGS_WITH_VALUES, FS_READ_TOOLS, type FsOpVerdict, type InjectionConfidence, type InjectionContext, type InjectionMatch, LONG_OUTPUT_THRESHOLD_BYTES, LOOP_MAX_RECORDS, LOOP_THRESHOLD_FOR_WASTE, type LoopWindowEvaluation, PRIVILEGE_ESCALATION_RE, type PiiPattern, type PipeChainAnalysis, type PolicyConfig, type PolicyContext, type PolicyHostHooks, type PolicyVerdict, type ProvenanceLookup, type ProvenanceTrust, REALTIME_PII_PATTERNS, type RiskMetadata, SCAN_SIGNAL_WEIGHTS, SENSITIVE_PATH_RE, SENSITIVE_PATH_REGEXES, SSRF_MAX_HOST, type ScanFinding, type ScanSignals, type ScanSummary, type ScoreTier, type SessionExtractContext, type SessionToolCall, type Severity, type ShellCommandAnalysis, type ShellDestToken, type ShellDestination, type ShieldDefinition, type ShieldOverrides, type ShieldVerdict, type SmartCondition, type SmartRule, type SsrfFloorOptions, type SsrfMatch, type SsrfTier, type SsrfVerdict, type ToolCallEntry, type ToolCallRecord, analyzeFsOperation, analyzePipeChain, analyzeShellCommand, checkDangerousSql, classifyAuditEntry, classifyRuleSeverity, classifyScanSignal, classifySsrf, computeAgentDeviceScore, computeArgsHash, computeBlendedSecurityScore, computeScanScore, computeSecurityScore, dedupeCanonicalFindings, detectArgsPii, detectDangerousEval, detectDangerousShellExec, detectInlineExec, detectPii, evaluateEgress, evaluateLoopWindow, evaluatePolicy, evaluateSmartConditions, extractAllSshHosts, extractCanonicalFindings, extractNetworkTargets, extractPositionalArgs, extractSessionLevelFindings, extractShellDestTokens, extractShellDestinations, getCompiledRegex, getNestedValue, hostMatches, isBashTool, isIgnoredTool, isPrivateHost, isProtectedHomePath, isShellShapedTool, isShieldVerdict, isStrictGatedTier, matchCanary, matchCanaryArgs, matchSensitivePath, matchesPattern, narrativeRuleLabel, normalizeCommandForPolicy, normalizeIpLiteral, parseAllSshHostsFromCommand, parseDestHost, previewArgs, redactText, resolvePinned, scanArgs, scanInjection, scanText, sensitivePathMatch, ssrfDestinationFloor, ssrfFloor, ssrfReason, summarizeBlast, summarizeScan, toScanFinding, toolMatchesRule, truncateBlastPath, validateOverrides, validateRegex, validateShieldDefinition };
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ __export(src_exports, {
38
38
  CANONICAL_EXTRACTOR_VERSION: () => CANONICAL_EXTRACTOR_VERSION,
39
39
  COST_PER_LOOP_ITER_USD: () => COST_PER_LOOP_ITER_USD,
40
40
  DEFAULT_EGRESS_ALLOWLIST: () => DEFAULT_EGRESS_ALLOWLIST,
41
+ DESTINATION_ARGS: () => DESTINATION_ARGS,
41
42
  DESTRUCTIVE_OP_RE: () => DESTRUCTIVE_OP_RE,
42
43
  DLP_PATTERNS: () => DLP_PATTERNS,
43
44
  DLP_SCAN_LIMITS: () => DLP_SCAN_LIMITS,
@@ -93,6 +94,7 @@ __export(src_exports, {
93
94
  isProtectedHomePath: () => isProtectedHomePath,
94
95
  isShellShapedTool: () => isShellShapedTool,
95
96
  isShieldVerdict: () => isShieldVerdict,
97
+ isStrictGatedTier: () => isStrictGatedTier,
96
98
  matchCanary: () => matchCanary,
97
99
  matchCanaryArgs: () => matchCanaryArgs,
98
100
  matchSensitivePath: () => matchSensitivePath,
@@ -109,7 +111,9 @@ __export(src_exports, {
109
111
  scanInjection: () => scanInjection,
110
112
  scanText: () => scanText,
111
113
  sensitivePathMatch: () => sensitivePathMatch,
114
+ ssrfDestinationFloor: () => ssrfDestinationFloor,
112
115
  ssrfFloor: () => ssrfFloor,
116
+ ssrfReason: () => ssrfReason,
113
117
  summarizeBlast: () => summarizeBlast,
114
118
  summarizeScan: () => summarizeScan,
115
119
  toScanFinding: () => toScanFinding,
@@ -2866,9 +2870,21 @@ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
2866
2870
  // AWS ECS task role
2867
2871
  "168.63.129.16",
2868
2872
  // Azure WireServer
2869
- "fd00:ec2::254"
2873
+ "fd00:ec2::254",
2870
2874
  // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
2875
+ // Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
2876
+ // classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
2877
+ // live credential endpoint. It has to be named here, above the range check,
2878
+ // and it is the reason relaxing cgnat is safe.
2879
+ "100.100.100.200"
2871
2880
  ]);
2881
+ var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
2882
+ function isStrictGatedTier(tier) {
2883
+ return STRICT_TIERS.has(tier);
2884
+ }
2885
+ function ssrfReason(m, asWritten) {
2886
+ return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
2887
+ }
2872
2888
  var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
2873
2889
  var v4Octets = (a) => {
2874
2890
  const p = a.split(".");
@@ -2891,7 +2907,7 @@ function classifySsrf(host) {
2891
2907
  if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
2892
2908
  const o = v4Octets(ip);
2893
2909
  if (o) {
2894
- if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
2910
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
2895
2911
  if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
2896
2912
  if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
2897
2913
  if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
@@ -2903,7 +2919,7 @@ function classifySsrf(host) {
2903
2919
  }
2904
2920
  const g = expandIpv6(ip);
2905
2921
  if (!g) return null;
2906
- if (g.every((x) => x === 0)) return hit("unspecified", false);
2922
+ if (g.every((x) => x === 0)) return hit("unspecified", true);
2907
2923
  if ((g[0] & 65472) === 65152) return hit("link-local", false);
2908
2924
  if ((g[0] & 65280) === 65280) return hit("multicast", false);
2909
2925
  if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
@@ -2916,7 +2932,7 @@ var TIER_REASON = {
2916
2932
  metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
2917
2933
  "link-local": "a link-local address",
2918
2934
  multicast: "a multicast address",
2919
- unspecified: "the unspecified address",
2935
+ unspecified: "the unspecified address, which reaches this host",
2920
2936
  cgnat: "a carrier-grade NAT address",
2921
2937
  private: "a loopback or private address"
2922
2938
  };
@@ -2927,18 +2943,76 @@ function ssrfFloor(tokens, opts = {}) {
2927
2943
  for (const { token, binary } of tokens) {
2928
2944
  const m = classifySsrf(token);
2929
2945
  if (!m) continue;
2930
- if (m.tier === "private" && !opts.ssrfStrict) continue;
2946
+ if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
2931
2947
  if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
2932
- return {
2933
- ...m,
2934
- host: token,
2935
- binary,
2936
- reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
2937
- };
2948
+ return { ...m, host: token, binary, reason: ssrfReason(m, token) };
2938
2949
  }
2939
2950
  return null;
2940
2951
  }
2941
2952
 
2953
+ // src/egress/destinations.ts
2954
+ var DESTINATION_ARGS = /* @__PURE__ */ new Map([
2955
+ ["webfetch", ["url"]],
2956
+ ["fetch", ["url", "uri"]],
2957
+ ["navigate", ["url"]],
2958
+ ["preview_start", ["url"]],
2959
+ ["browser_batch", ["actions[].input.url"]]
2960
+ ]);
2961
+ function bareToolName(toolName) {
2962
+ const parts = toolName.split("__");
2963
+ return (parts.length >= 3 ? parts.slice(2).join("__") : toolName).toLowerCase();
2964
+ }
2965
+ function valuesAt(args, path) {
2966
+ let cursors = [args];
2967
+ for (const rawSegment of path.split(".")) {
2968
+ const isArray = rawSegment.endsWith("[]");
2969
+ const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
2970
+ const next = [];
2971
+ for (const cursor of cursors) {
2972
+ if (cursor === null || typeof cursor !== "object") continue;
2973
+ const child = cursor[key];
2974
+ if (isArray) {
2975
+ if (Array.isArray(child)) next.push(...child);
2976
+ } else if (child !== void 0) {
2977
+ next.push(child);
2978
+ }
2979
+ }
2980
+ cursors = next;
2981
+ if (cursors.length === 0) return [];
2982
+ }
2983
+ return cursors.filter((v) => typeof v === "string");
2984
+ }
2985
+ function hostOf(value) {
2986
+ try {
2987
+ const u = new URL(value);
2988
+ const h = u.hostname;
2989
+ return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
2990
+ } catch {
2991
+ return null;
2992
+ }
2993
+ }
2994
+ function ssrfDestinationFloor(toolName, args, opts = {}) {
2995
+ try {
2996
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
2997
+ if (!paths) return null;
2998
+ const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
2999
+ for (const path of paths) {
3000
+ for (const value of valuesAt(args, path)) {
3001
+ const host = hostOf(value);
3002
+ if (!host) continue;
3003
+ const m = classifySsrf(host);
3004
+ if (!m) continue;
3005
+ if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
3006
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
3007
+ return { ...m, argPath: path, host, reason: ssrfReason(m, host) };
3008
+ }
3009
+ }
3010
+ return null;
3011
+ } catch {
3012
+ return null;
3013
+ }
3014
+ }
3015
+
2942
3016
  // src/policy/index.ts
2943
3017
  function resolveCheck(v) {
2944
3018
  return v === "off" || v === "block" ? v : "review";
@@ -3052,6 +3126,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3052
3126
  };
3053
3127
  }
3054
3128
  }
3129
+ {
3130
+ const dest = ssrfDestinationFloor(toolName, args, {
3131
+ ssrfAllow: config.policy.egress?.ssrfAllow,
3132
+ ssrfStrict: config.policy.egress?.ssrfStrict
3133
+ });
3134
+ if (dest) {
3135
+ return {
3136
+ decision: "block",
3137
+ blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
3138
+ reason: dest.reason,
3139
+ ruleName: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
3140
+ ruleDescription: dest.reason,
3141
+ tier: 3
3142
+ };
3143
+ }
3144
+ }
3055
3145
  if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
3056
3146
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
3057
3147
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
@@ -5055,6 +5145,7 @@ var ENGINE_VERSION = "1.4.0";
5055
5145
  CANONICAL_EXTRACTOR_VERSION,
5056
5146
  COST_PER_LOOP_ITER_USD,
5057
5147
  DEFAULT_EGRESS_ALLOWLIST,
5148
+ DESTINATION_ARGS,
5058
5149
  DESTRUCTIVE_OP_RE,
5059
5150
  DLP_PATTERNS,
5060
5151
  DLP_SCAN_LIMITS,
@@ -5110,6 +5201,7 @@ var ENGINE_VERSION = "1.4.0";
5110
5201
  isProtectedHomePath,
5111
5202
  isShellShapedTool,
5112
5203
  isShieldVerdict,
5204
+ isStrictGatedTier,
5113
5205
  matchCanary,
5114
5206
  matchCanaryArgs,
5115
5207
  matchSensitivePath,
@@ -5126,7 +5218,9 @@ var ENGINE_VERSION = "1.4.0";
5126
5218
  scanInjection,
5127
5219
  scanText,
5128
5220
  sensitivePathMatch,
5221
+ ssrfDestinationFloor,
5129
5222
  ssrfFloor,
5223
+ ssrfReason,
5130
5224
  summarizeBlast,
5131
5225
  summarizeScan,
5132
5226
  toScanFinding,
package/dist/index.mjs CHANGED
@@ -2743,9 +2743,21 @@ var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
2743
2743
  // AWS ECS task role
2744
2744
  "168.63.129.16",
2745
2745
  // Azure WireServer
2746
- "fd00:ec2::254"
2746
+ "fd00:ec2::254",
2747
2747
  // AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
2748
+ // Alibaba Cloud IMDS. It sits INSIDE 100.64/10, so before this line it was
2749
+ // classified as cgnat and therefore exemptable: one ssrfAllow entry opened a
2750
+ // live credential endpoint. It has to be named here, above the range check,
2751
+ // and it is the reason relaxing cgnat is safe.
2752
+ "100.100.100.200"
2748
2753
  ]);
2754
+ var STRICT_TIERS = /* @__PURE__ */ new Set(["private", "unspecified", "cgnat"]);
2755
+ function isStrictGatedTier(tier) {
2756
+ return STRICT_TIERS.has(tier);
2757
+ }
2758
+ function ssrfReason(m, asWritten) {
2759
+ return `Blocked: ${asWritten} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== asWritten ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.");
2760
+ }
2749
2761
  var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
2750
2762
  var v4Octets = (a) => {
2751
2763
  const p = a.split(".");
@@ -2768,7 +2780,7 @@ function classifySsrf(host) {
2768
2780
  if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
2769
2781
  const o = v4Octets(ip);
2770
2782
  if (o) {
2771
- if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
2783
+ if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", true);
2772
2784
  if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
2773
2785
  if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
2774
2786
  if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
@@ -2780,7 +2792,7 @@ function classifySsrf(host) {
2780
2792
  }
2781
2793
  const g = expandIpv6(ip);
2782
2794
  if (!g) return null;
2783
- if (g.every((x) => x === 0)) return hit("unspecified", false);
2795
+ if (g.every((x) => x === 0)) return hit("unspecified", true);
2784
2796
  if ((g[0] & 65472) === 65152) return hit("link-local", false);
2785
2797
  if ((g[0] & 65280) === 65280) return hit("multicast", false);
2786
2798
  if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
@@ -2793,7 +2805,7 @@ var TIER_REASON = {
2793
2805
  metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
2794
2806
  "link-local": "a link-local address",
2795
2807
  multicast: "a multicast address",
2796
- unspecified: "the unspecified address",
2808
+ unspecified: "the unspecified address, which reaches this host",
2797
2809
  cgnat: "a carrier-grade NAT address",
2798
2810
  private: "a loopback or private address"
2799
2811
  };
@@ -2804,18 +2816,76 @@ function ssrfFloor(tokens, opts = {}) {
2804
2816
  for (const { token, binary } of tokens) {
2805
2817
  const m = classifySsrf(token);
2806
2818
  if (!m) continue;
2807
- if (m.tier === "private" && !opts.ssrfStrict) continue;
2819
+ if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
2808
2820
  if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
2809
- return {
2810
- ...m,
2811
- host: token,
2812
- binary,
2813
- reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
2814
- };
2821
+ return { ...m, host: token, binary, reason: ssrfReason(m, token) };
2815
2822
  }
2816
2823
  return null;
2817
2824
  }
2818
2825
 
2826
+ // src/egress/destinations.ts
2827
+ var DESTINATION_ARGS = /* @__PURE__ */ new Map([
2828
+ ["webfetch", ["url"]],
2829
+ ["fetch", ["url", "uri"]],
2830
+ ["navigate", ["url"]],
2831
+ ["preview_start", ["url"]],
2832
+ ["browser_batch", ["actions[].input.url"]]
2833
+ ]);
2834
+ function bareToolName(toolName) {
2835
+ const parts = toolName.split("__");
2836
+ return (parts.length >= 3 ? parts.slice(2).join("__") : toolName).toLowerCase();
2837
+ }
2838
+ function valuesAt(args, path) {
2839
+ let cursors = [args];
2840
+ for (const rawSegment of path.split(".")) {
2841
+ const isArray = rawSegment.endsWith("[]");
2842
+ const key = isArray ? rawSegment.slice(0, -2) : rawSegment;
2843
+ const next = [];
2844
+ for (const cursor of cursors) {
2845
+ if (cursor === null || typeof cursor !== "object") continue;
2846
+ const child = cursor[key];
2847
+ if (isArray) {
2848
+ if (Array.isArray(child)) next.push(...child);
2849
+ } else if (child !== void 0) {
2850
+ next.push(child);
2851
+ }
2852
+ }
2853
+ cursors = next;
2854
+ if (cursors.length === 0) return [];
2855
+ }
2856
+ return cursors.filter((v) => typeof v === "string");
2857
+ }
2858
+ function hostOf(value) {
2859
+ try {
2860
+ const u = new URL(value);
2861
+ const h = u.hostname;
2862
+ return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
2863
+ } catch {
2864
+ return null;
2865
+ }
2866
+ }
2867
+ function ssrfDestinationFloor(toolName, args, opts = {}) {
2868
+ try {
2869
+ const paths = DESTINATION_ARGS.get(bareToolName(toolName));
2870
+ if (!paths) return null;
2871
+ const exempt = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
2872
+ for (const path of paths) {
2873
+ for (const value of valuesAt(args, path)) {
2874
+ const host = hostOf(value);
2875
+ if (!host) continue;
2876
+ const m = classifySsrf(host);
2877
+ if (!m) continue;
2878
+ if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
2879
+ if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
2880
+ return { ...m, argPath: path, host, reason: ssrfReason(m, host) };
2881
+ }
2882
+ }
2883
+ return null;
2884
+ } catch {
2885
+ return null;
2886
+ }
2887
+ }
2888
+
2819
2889
  // src/policy/index.ts
2820
2890
  function resolveCheck(v) {
2821
2891
  return v === "off" || v === "block" ? v : "review";
@@ -2929,6 +2999,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2929
2999
  };
2930
3000
  }
2931
3001
  }
3002
+ {
3003
+ const dest = ssrfDestinationFloor(toolName, args, {
3004
+ ssrfAllow: config.policy.egress?.ssrfAllow,
3005
+ ssrfStrict: config.policy.egress?.ssrfStrict
3006
+ });
3007
+ if (dest) {
3008
+ return {
3009
+ decision: "block",
3010
+ blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
3011
+ reason: dest.reason,
3012
+ ruleName: `ssrf:${dest.tier}:${toolName}:${dest.host}`,
3013
+ ruleDescription: dest.reason,
3014
+ tier: 3
3015
+ };
3016
+ }
3017
+ }
2932
3018
  if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
2933
3019
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
2934
3020
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
@@ -4931,6 +5017,7 @@ export {
4931
5017
  CANONICAL_EXTRACTOR_VERSION,
4932
5018
  COST_PER_LOOP_ITER_USD,
4933
5019
  DEFAULT_EGRESS_ALLOWLIST,
5020
+ DESTINATION_ARGS,
4934
5021
  DESTRUCTIVE_OP_RE,
4935
5022
  DLP_PATTERNS,
4936
5023
  DLP_SCAN_LIMITS,
@@ -4986,6 +5073,7 @@ export {
4986
5073
  isProtectedHomePath,
4987
5074
  isShellShapedTool,
4988
5075
  isShieldVerdict,
5076
+ isStrictGatedTier,
4989
5077
  matchCanary,
4990
5078
  matchCanaryArgs,
4991
5079
  matchSensitivePath,
@@ -5002,7 +5090,9 @@ export {
5002
5090
  scanInjection,
5003
5091
  scanText,
5004
5092
  sensitivePathMatch,
5093
+ ssrfDestinationFloor,
5005
5094
  ssrfFloor,
5095
+ ssrfReason,
5006
5096
  summarizeBlast,
5007
5097
  summarizeScan,
5008
5098
  toScanFinding,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "2.9.2",
3
+ "version": "2.10.0",
4
4
  "description": "Shared policy evaluation engine for node9 — DLP, smart rules, AST shell parsing, shields, loop detection. Pure functions, no I/O. Used by both node9-proxy and the node9 SaaS firewall.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://node9.ai",