| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475 |
- """Default-deny egress and retention policy for enterprise edge events."""
- from __future__ import annotations
- import ipaddress
- import re
- import unicodedata
- from collections.abc import Mapping
- from urllib.parse import urlsplit
- from .contracts import EdgeContractError, preflight_json, strict_json_bytes
- class EdgePolicyError(ValueError):
- """Raised when content or transport violates the edge boundary."""
- EDGE_ONLY = {"raw", "recent_detail", "restricted"}
- CONTROL_PLANE_ALLOWED = {
- "desensitized_metadata",
- "statistics",
- "lineage",
- "evidence",
- "health_summary",
- "diagnostic_summary",
- }
- RETENTION_DAYS = {
- "raw": 0,
- "recent_detail": 365,
- "metadata": 1095,
- "evidence": 2190,
- }
- DEFAULT_BYTE_LIMITS = {
- "desensitized_metadata": 65_536,
- "statistics": 32_768,
- "lineage": 65_536,
- "evidence": 131_072,
- "health_summary": 8_192,
- "diagnostic_summary": 16_384,
- }
- DEFAULT_COUNT_LIMITS = {
- "desensitized_metadata": 2_000,
- "statistics": 1_000,
- "lineage": 2_000,
- "evidence": 4_000,
- "health_summary": 256,
- "diagnostic_summary": 512,
- }
- _SENSITIVE_KEYS = {
- "api_key",
- "apikey",
- "authorization",
- "access_token",
- "cookie",
- "credential",
- "credentials",
- "password",
- "passwd",
- "private_key",
- "query",
- "query_text",
- "raw_record",
- "raw_records",
- "raw_row",
- "raw_rows",
- "secret",
- "sql",
- "sql_text",
- "statement",
- "token",
- }
- _SENSITIVE_KEY_PARTS = ("password", "passwd", "secret", "private_key", "api_key")
- _RAW_ROW_CONTAINER_KEYS = {
- "data",
- "items",
- "item",
- "record",
- "records",
- "result",
- "row",
- "rows",
- "sample",
- "samples",
- }
- _PRIVATE_KEY = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.IGNORECASE)
- _BEARER = re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{8,}", re.IGNORECASE)
- _CREDENTIAL_ASSIGNMENT = re.compile(
- r"\b(?:[a-z0-9]+[_-])*(?:password|passwd|pwd|secret|token|api[_-]?key|"
- r"authorization|credential)"
- r"\b\s*(?:=|:)\s*[^\s,;]+",
- re.IGNORECASE,
- )
- _SQL = re.compile(
- r"(?:"
- r"\bSELECT\b.{0,4096}?\bFROM\b"
- r"|\bINSERT\s+INTO\b"
- r"|\bUPDATE\b.{0,4096}?\bSET\b"
- r"|\bDELETE\s+FROM\b"
- r"|\bMERGE(?:\s+INTO)?\b"
- r"|\b(?:CREATE|ALTER|DROP|TRUNCATE)\s+"
- r"(?:TABLE|VIEW|SCHEMA|DATABASE|INDEX|FUNCTION|PROCEDURE)\b"
- r"|\b(?:GRANT|REVOKE)\b.{0,4096}?\b(?:TO|FROM)\b"
- r"|\bCOPY\b"
- r")",
- re.IGNORECASE | re.DOTALL,
- )
- def _normalized_key(value: str) -> str:
- compatible = unicodedata.normalize("NFKC", value)
- words = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", compatible)
- return re.sub(r"[^a-z0-9]+", "_", words.lower()).strip("_")
- def _is_sensitive_key(value: str) -> bool:
- folded = value.replace("_", "")
- return (
- value in _SENSITIVE_KEYS
- or folded
- in {
- "accesstoken",
- "apikey",
- "authorization",
- "clientsecret",
- "credential",
- "credentials",
- "password",
- "passwd",
- "privatekey",
- "rawrecords",
- "rawrows",
- "secret",
- "token",
- }
- or any(part in value for part in _SENSITIVE_KEY_PARTS)
- or value.startswith(("raw_", "recent_detail_", "token_"))
- or value.endswith("_token")
- )
- def _is_raw_row_container(key: str, value: object) -> bool:
- return (
- key in _RAW_ROW_CONTAINER_KEYS
- or key.endswith(("_rows", "_records"))
- ) and (
- isinstance(value, Mapping)
- or (
- isinstance(value, (list, tuple))
- and any(isinstance(child, Mapping) for child in value)
- )
- )
- def _contains_obfuscated_sql(value: str) -> bool:
- compatible = unicodedata.normalize("NFKC", value)
- without_comments = re.sub(
- r"/\*.*?\*/|--[^\r\n]*", "", compatible, flags=re.DOTALL
- )
- normalized = re.sub(r"\s+", " ", without_comments).upper()
- return bool(
- re.search(
- r"(?:\bSELECT\b.{0,4096}\bFROM\b|\bINSERT\s+INTO\b|"
- r"\bUPDATE\b.{0,4096}\bSET\b|\bDELETE\s+FROM\b|"
- r"\bMERGE(?:\s+INTO)?\b|\b(?:CREATE|ALTER|DROP|TRUNCATE)\s+"
- r"(?:TABLE|VIEW|SCHEMA|DATABASE|INDEX|FUNCTION|PROCEDURE)\b|"
- r"\b(?:GRANT|REVOKE)\b.{0,4096}\b(?:TO|FROM)\b|\bCOPY\b|"
- r"\bEXEC(?:UTE)?\b\s+[A-Z_\[][A-Z0-9_.$\[\]]*|"
- r"\bCALL\b\s+[A-Z_][A-Z0-9_.$]*\s*\()",
- normalized,
- )
- )
- def _approved_hosts(values: set[str] | frozenset[str], label: str) -> frozenset[str]:
- if not isinstance(values, (set, frozenset)):
- raise ValueError(f"{label} must be an explicit set")
- approved: set[str] = set()
- for value in values:
- if (
- not isinstance(value, str)
- or not value
- or value != value.lower()
- or value.endswith(".")
- or ":" in value
- or "/" in value
- or "@" in value
- ):
- raise ValueError(f"{label} contains an invalid host")
- try:
- value.encode("ascii")
- ipaddress.ip_address(value)
- except UnicodeEncodeError as exc:
- raise ValueError(f"{label} contains an invalid host") from exc
- except ValueError:
- pass
- else:
- raise ValueError(f"{label} cannot contain an IP literal")
- if not re.fullmatch(
- r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
- r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
- value,
- ):
- raise ValueError(f"{label} contains an invalid host")
- approved.add(value)
- return frozenset(approved)
- def _approved_origins(
- values: set[str] | frozenset[str] | None,
- hosts: frozenset[str],
- label: str,
- ) -> frozenset[str]:
- if values is None:
- return frozenset(f"https://{host}" for host in hosts)
- if not isinstance(values, (set, frozenset)):
- raise ValueError(f"{label} must be an explicit set")
- origins: set[str] = set()
- for value in values:
- if not isinstance(value, str):
- raise ValueError(f"{label} contains an invalid origin")
- try:
- parsed = urlsplit(value)
- port = parsed.port
- except ValueError as exc:
- raise ValueError(f"{label} contains an invalid origin") from exc
- if (
- parsed.scheme != "https"
- or parsed.hostname not in hosts
- or parsed.username is not None
- or parsed.password is not None
- or parsed.path not in {"", "/"}
- or parsed.query
- or parsed.fragment
- or port is None and parsed.netloc.endswith(":")
- ):
- raise ValueError(f"{label} contains an invalid origin")
- effective_port = port or 443
- canonical = (
- f"https://{parsed.hostname}"
- if effective_port == 443
- else f"https://{parsed.hostname}:{effective_port}"
- )
- if value.rstrip("/") != canonical:
- raise ValueError(f"{label} contains a non-canonical origin")
- origins.add(canonical)
- return frozenset(origins)
- def _limit_map(
- defaults: Mapping[str, int], overrides: Mapping[str, int] | None, label: str
- ) -> dict[str, int]:
- result = dict(defaults)
- if overrides is None:
- return result
- if not isinstance(overrides, Mapping):
- raise ValueError(f"{label} must be a mapping")
- unknown = set(overrides) - CONTROL_PLANE_ALLOWED
- if unknown:
- raise ValueError(f"{label} contains an unknown classification")
- for classification, limit in overrides.items():
- if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
- raise ValueError(f"{label} values must be positive integers")
- result[classification] = limit
- return result
- class EdgeEgressPolicy:
- def __init__(
- self,
- *,
- allowed_control_hosts: set[str] | frozenset[str],
- allowed_proxy_hosts: set[str] | frozenset[str] | None = None,
- allowed_control_origins: set[str] | frozenset[str] | None = None,
- allowed_proxy_origins: set[str] | frozenset[str] | None = None,
- byte_limits: Mapping[str, int] | None = None,
- count_limits: Mapping[str, int] | None = None,
- max_depth: int = 12,
- max_string_bytes: int = 4_096,
- ) -> None:
- self.allowed_control_hosts = _approved_hosts(
- allowed_control_hosts, "allowed_control_hosts"
- )
- self.allowed_proxy_hosts = _approved_hosts(
- allowed_proxy_hosts or set(), "allowed_proxy_hosts"
- )
- self.allowed_control_origins = _approved_origins(
- allowed_control_origins,
- self.allowed_control_hosts,
- "allowed_control_origins",
- )
- self.allowed_proxy_origins = _approved_origins(
- allowed_proxy_origins,
- self.allowed_proxy_hosts,
- "allowed_proxy_origins",
- )
- self.byte_limits = _limit_map(
- DEFAULT_BYTE_LIMITS, byte_limits, "byte_limits"
- )
- self.count_limits = _limit_map(
- DEFAULT_COUNT_LIMITS, count_limits, "count_limits"
- )
- if isinstance(max_depth, bool) or not isinstance(max_depth, int) or max_depth < 1:
- raise ValueError("max_depth must be a positive integer")
- if (
- isinstance(max_string_bytes, bool)
- or not isinstance(max_string_bytes, int)
- or max_string_bytes < 1
- ):
- raise ValueError("max_string_bytes must be a positive integer")
- self.max_depth = max_depth
- self.max_string_bytes = max_string_bytes
- def approve_event(self, event: Mapping[str, object]) -> dict[str, object]:
- if not isinstance(event, Mapping):
- raise EdgePolicyError("event must be a mapping")
- unknown = set(event) - {
- "event_id",
- "task_id",
- "gateway_id",
- "environment",
- "network_zone",
- "purpose",
- "classification",
- "contract_version",
- "occurred_at",
- "attempt",
- "idempotency_key",
- "policy_digest",
- "payload",
- }
- if unknown:
- raise EdgePolicyError("event contains unapproved properties")
- classification = event.get("classification")
- if classification in EDGE_ONLY:
- raise EdgePolicyError("classification is edge-only")
- if classification not in CONTROL_PLANE_ALLOWED:
- raise EdgePolicyError("classification is not control-plane approved")
- payload = event.get("payload")
- if not isinstance(payload, Mapping):
- raise EdgePolicyError("approved event payload must be a mapping")
- self.validate_approved_payload(str(classification), payload)
- try:
- return dict(event)
- except (TypeError, ValueError) as exc: # pragma: no cover - Mapping guard
- raise EdgePolicyError("event is invalid") from exc
- def validate_approved_payload(
- self, classification: str, payload: Mapping[str, object]
- ) -> None:
- if classification not in CONTROL_PLANE_ALLOWED:
- raise EdgePolicyError("classification is not control-plane approved")
- try:
- preflight_json(
- payload,
- max_depth=self.max_depth,
- max_nodes=self.count_limits[classification] + 1,
- max_string_bytes=self.max_string_bytes,
- max_total_bytes=self.byte_limits[classification],
- )
- encoded = strict_json_bytes(payload)
- except EdgeContractError as exc:
- detail = str(exc)
- if "node count" in detail:
- detail = "payload exceeds its independent item count limit"
- elif "string exceeds" in detail:
- detail = "payload string exceeds the string limit"
- elif "depth" in detail:
- detail = "payload exceeds the maximum depth"
- elif "byte limit" in detail:
- detail = "payload exceeds its independent byte limit"
- raise EdgePolicyError(detail) from exc
- if len(encoded) > self.byte_limits[classification]:
- raise EdgePolicyError("payload exceeds its independent byte limit")
- item_count = self._count_items(payload)
- if item_count > self.count_limits[classification]:
- raise EdgePolicyError("payload exceeds its independent item count limit")
- self._inspect(payload, depth=1)
- def _count_items(self, value: object) -> int:
- if isinstance(value, Mapping):
- return len(value) + sum(self._count_items(item) for item in value.values())
- if isinstance(value, (list, tuple)):
- return len(value) + sum(self._count_items(item) for item in value)
- return 0
- def _inspect(self, value: object, *, depth: int) -> None:
- if depth > self.max_depth:
- raise EdgePolicyError("payload exceeds the maximum depth")
- if isinstance(value, Mapping):
- for key, item in value.items():
- if not isinstance(key, str):
- raise EdgePolicyError("payload contains a non-string key")
- normalized = _normalized_key(key)
- if _is_sensitive_key(normalized):
- raise EdgePolicyError("payload contains sensitive content")
- if _is_raw_row_container(normalized, item):
- raise EdgePolicyError("payload contains sensitive raw-row content")
- self._inspect(item, depth=depth + 1)
- return
- if isinstance(value, (list, tuple)):
- for item in value:
- self._inspect(item, depth=depth + 1)
- return
- if isinstance(value, str):
- compatible = unicodedata.normalize("NFKC", value)
- if len(value.encode("utf-8")) > self.max_string_bytes:
- raise EdgePolicyError("payload string exceeds the string limit")
- if (
- _PRIVATE_KEY.search(compatible)
- or _BEARER.search(compatible)
- or _CREDENTIAL_ASSIGNMENT.search(compatible)
- or _SQL.search(compatible)
- or _contains_obfuscated_sql(compatible)
- ):
- raise EdgePolicyError("payload contains sensitive content")
- def validate_destination(
- self, control_url: str, *, proxy_url: str | None = None
- ) -> None:
- self._validate_url(
- control_url,
- approved_hosts=self.allowed_control_hosts,
- approved_origins=self.allowed_control_origins,
- label="destination",
- )
- if proxy_url is not None:
- self._validate_url(
- proxy_url,
- approved_hosts=self.allowed_proxy_hosts,
- approved_origins=self.allowed_proxy_origins,
- label="proxy",
- )
- @staticmethod
- def _validate_url(
- url: str,
- *,
- approved_hosts: frozenset[str],
- approved_origins: frozenset[str],
- label: str,
- ) -> None:
- if not isinstance(url, str):
- raise EdgePolicyError(f"{label} is not approved")
- try:
- parsed = urlsplit(url)
- port = parsed.port
- except ValueError as exc:
- raise EdgePolicyError(f"{label} is not approved") from exc
- if (
- parsed.scheme != "https"
- or not parsed.hostname
- or parsed.hostname not in approved_hosts
- or parsed.username is not None
- or parsed.password is not None
- or parsed.fragment
- or port is None and parsed.netloc.endswith(":")
- ):
- raise EdgePolicyError(f"{label} is not approved")
- effective_port = port or 443
- origin = (
- f"https://{parsed.hostname}"
- if effective_port == 443
- else f"https://{parsed.hostname}:{effective_port}"
- )
- if origin not in approved_origins:
- raise EdgePolicyError(f"{label} origin is not approved")
- @staticmethod
- def retention_days(classification: str) -> int:
- try:
- return RETENTION_DAYS[classification]
- except (KeyError, TypeError) as exc:
- raise EdgePolicyError("retention classification is invalid") from exc
|