policy.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. """Default-deny egress and retention policy for enterprise edge events."""
  2. from __future__ import annotations
  3. import ipaddress
  4. import re
  5. import unicodedata
  6. from collections.abc import Mapping
  7. from urllib.parse import urlsplit
  8. from .contracts import EdgeContractError, preflight_json, strict_json_bytes
  9. class EdgePolicyError(ValueError):
  10. """Raised when content or transport violates the edge boundary."""
  11. EDGE_ONLY = {"raw", "recent_detail", "restricted"}
  12. CONTROL_PLANE_ALLOWED = {
  13. "desensitized_metadata",
  14. "statistics",
  15. "lineage",
  16. "evidence",
  17. "health_summary",
  18. "diagnostic_summary",
  19. }
  20. RETENTION_DAYS = {
  21. "raw": 0,
  22. "recent_detail": 365,
  23. "metadata": 1095,
  24. "evidence": 2190,
  25. }
  26. DEFAULT_BYTE_LIMITS = {
  27. "desensitized_metadata": 65_536,
  28. "statistics": 32_768,
  29. "lineage": 65_536,
  30. "evidence": 131_072,
  31. "health_summary": 8_192,
  32. "diagnostic_summary": 16_384,
  33. }
  34. DEFAULT_COUNT_LIMITS = {
  35. "desensitized_metadata": 2_000,
  36. "statistics": 1_000,
  37. "lineage": 2_000,
  38. "evidence": 4_000,
  39. "health_summary": 256,
  40. "diagnostic_summary": 512,
  41. }
  42. _SENSITIVE_KEYS = {
  43. "api_key",
  44. "apikey",
  45. "authorization",
  46. "access_token",
  47. "cookie",
  48. "credential",
  49. "credentials",
  50. "password",
  51. "passwd",
  52. "private_key",
  53. "query",
  54. "query_text",
  55. "raw_record",
  56. "raw_records",
  57. "raw_row",
  58. "raw_rows",
  59. "secret",
  60. "sql",
  61. "sql_text",
  62. "statement",
  63. "token",
  64. }
  65. _SENSITIVE_KEY_PARTS = ("password", "passwd", "secret", "private_key", "api_key")
  66. _RAW_ROW_CONTAINER_KEYS = {
  67. "data",
  68. "items",
  69. "item",
  70. "record",
  71. "records",
  72. "result",
  73. "row",
  74. "rows",
  75. "sample",
  76. "samples",
  77. }
  78. _PRIVATE_KEY = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", re.IGNORECASE)
  79. _BEARER = re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{8,}", re.IGNORECASE)
  80. _CREDENTIAL_ASSIGNMENT = re.compile(
  81. r"\b(?:[a-z0-9]+[_-])*(?:password|passwd|pwd|secret|token|api[_-]?key|"
  82. r"authorization|credential)"
  83. r"\b\s*(?:=|:)\s*[^\s,;]+",
  84. re.IGNORECASE,
  85. )
  86. _SQL = re.compile(
  87. r"(?:"
  88. r"\bSELECT\b.{0,4096}?\bFROM\b"
  89. r"|\bINSERT\s+INTO\b"
  90. r"|\bUPDATE\b.{0,4096}?\bSET\b"
  91. r"|\bDELETE\s+FROM\b"
  92. r"|\bMERGE(?:\s+INTO)?\b"
  93. r"|\b(?:CREATE|ALTER|DROP|TRUNCATE)\s+"
  94. r"(?:TABLE|VIEW|SCHEMA|DATABASE|INDEX|FUNCTION|PROCEDURE)\b"
  95. r"|\b(?:GRANT|REVOKE)\b.{0,4096}?\b(?:TO|FROM)\b"
  96. r"|\bCOPY\b"
  97. r")",
  98. re.IGNORECASE | re.DOTALL,
  99. )
  100. def _normalized_key(value: str) -> str:
  101. compatible = unicodedata.normalize("NFKC", value)
  102. words = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", compatible)
  103. return re.sub(r"[^a-z0-9]+", "_", words.lower()).strip("_")
  104. def _is_sensitive_key(value: str) -> bool:
  105. folded = value.replace("_", "")
  106. return (
  107. value in _SENSITIVE_KEYS
  108. or folded
  109. in {
  110. "accesstoken",
  111. "apikey",
  112. "authorization",
  113. "clientsecret",
  114. "credential",
  115. "credentials",
  116. "password",
  117. "passwd",
  118. "privatekey",
  119. "rawrecords",
  120. "rawrows",
  121. "secret",
  122. "token",
  123. }
  124. or any(part in value for part in _SENSITIVE_KEY_PARTS)
  125. or value.startswith(("raw_", "recent_detail_", "token_"))
  126. or value.endswith("_token")
  127. )
  128. def _is_raw_row_container(key: str, value: object) -> bool:
  129. return (
  130. key in _RAW_ROW_CONTAINER_KEYS
  131. or key.endswith(("_rows", "_records"))
  132. ) and (
  133. isinstance(value, Mapping)
  134. or (
  135. isinstance(value, (list, tuple))
  136. and any(isinstance(child, Mapping) for child in value)
  137. )
  138. )
  139. def _contains_obfuscated_sql(value: str) -> bool:
  140. compatible = unicodedata.normalize("NFKC", value)
  141. without_comments = re.sub(
  142. r"/\*.*?\*/|--[^\r\n]*", "", compatible, flags=re.DOTALL
  143. )
  144. normalized = re.sub(r"\s+", " ", without_comments).upper()
  145. return bool(
  146. re.search(
  147. r"(?:\bSELECT\b.{0,4096}\bFROM\b|\bINSERT\s+INTO\b|"
  148. r"\bUPDATE\b.{0,4096}\bSET\b|\bDELETE\s+FROM\b|"
  149. r"\bMERGE(?:\s+INTO)?\b|\b(?:CREATE|ALTER|DROP|TRUNCATE)\s+"
  150. r"(?:TABLE|VIEW|SCHEMA|DATABASE|INDEX|FUNCTION|PROCEDURE)\b|"
  151. r"\b(?:GRANT|REVOKE)\b.{0,4096}\b(?:TO|FROM)\b|\bCOPY\b|"
  152. r"\bEXEC(?:UTE)?\b\s+[A-Z_\[][A-Z0-9_.$\[\]]*|"
  153. r"\bCALL\b\s+[A-Z_][A-Z0-9_.$]*\s*\()",
  154. normalized,
  155. )
  156. )
  157. def _approved_hosts(values: set[str] | frozenset[str], label: str) -> frozenset[str]:
  158. if not isinstance(values, (set, frozenset)):
  159. raise ValueError(f"{label} must be an explicit set")
  160. approved: set[str] = set()
  161. for value in values:
  162. if (
  163. not isinstance(value, str)
  164. or not value
  165. or value != value.lower()
  166. or value.endswith(".")
  167. or ":" in value
  168. or "/" in value
  169. or "@" in value
  170. ):
  171. raise ValueError(f"{label} contains an invalid host")
  172. try:
  173. value.encode("ascii")
  174. ipaddress.ip_address(value)
  175. except UnicodeEncodeError as exc:
  176. raise ValueError(f"{label} contains an invalid host") from exc
  177. except ValueError:
  178. pass
  179. else:
  180. raise ValueError(f"{label} cannot contain an IP literal")
  181. if not re.fullmatch(
  182. r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
  183. r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
  184. value,
  185. ):
  186. raise ValueError(f"{label} contains an invalid host")
  187. approved.add(value)
  188. return frozenset(approved)
  189. def _approved_origins(
  190. values: set[str] | frozenset[str] | None,
  191. hosts: frozenset[str],
  192. label: str,
  193. ) -> frozenset[str]:
  194. if values is None:
  195. return frozenset(f"https://{host}" for host in hosts)
  196. if not isinstance(values, (set, frozenset)):
  197. raise ValueError(f"{label} must be an explicit set")
  198. origins: set[str] = set()
  199. for value in values:
  200. if not isinstance(value, str):
  201. raise ValueError(f"{label} contains an invalid origin")
  202. try:
  203. parsed = urlsplit(value)
  204. port = parsed.port
  205. except ValueError as exc:
  206. raise ValueError(f"{label} contains an invalid origin") from exc
  207. if (
  208. parsed.scheme != "https"
  209. or parsed.hostname not in hosts
  210. or parsed.username is not None
  211. or parsed.password is not None
  212. or parsed.path not in {"", "/"}
  213. or parsed.query
  214. or parsed.fragment
  215. or port is None and parsed.netloc.endswith(":")
  216. ):
  217. raise ValueError(f"{label} contains an invalid origin")
  218. effective_port = port or 443
  219. canonical = (
  220. f"https://{parsed.hostname}"
  221. if effective_port == 443
  222. else f"https://{parsed.hostname}:{effective_port}"
  223. )
  224. if value.rstrip("/") != canonical:
  225. raise ValueError(f"{label} contains a non-canonical origin")
  226. origins.add(canonical)
  227. return frozenset(origins)
  228. def _limit_map(
  229. defaults: Mapping[str, int], overrides: Mapping[str, int] | None, label: str
  230. ) -> dict[str, int]:
  231. result = dict(defaults)
  232. if overrides is None:
  233. return result
  234. if not isinstance(overrides, Mapping):
  235. raise ValueError(f"{label} must be a mapping")
  236. unknown = set(overrides) - CONTROL_PLANE_ALLOWED
  237. if unknown:
  238. raise ValueError(f"{label} contains an unknown classification")
  239. for classification, limit in overrides.items():
  240. if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
  241. raise ValueError(f"{label} values must be positive integers")
  242. result[classification] = limit
  243. return result
  244. class EdgeEgressPolicy:
  245. def __init__(
  246. self,
  247. *,
  248. allowed_control_hosts: set[str] | frozenset[str],
  249. allowed_proxy_hosts: set[str] | frozenset[str] | None = None,
  250. allowed_control_origins: set[str] | frozenset[str] | None = None,
  251. allowed_proxy_origins: set[str] | frozenset[str] | None = None,
  252. byte_limits: Mapping[str, int] | None = None,
  253. count_limits: Mapping[str, int] | None = None,
  254. max_depth: int = 12,
  255. max_string_bytes: int = 4_096,
  256. ) -> None:
  257. self.allowed_control_hosts = _approved_hosts(
  258. allowed_control_hosts, "allowed_control_hosts"
  259. )
  260. self.allowed_proxy_hosts = _approved_hosts(
  261. allowed_proxy_hosts or set(), "allowed_proxy_hosts"
  262. )
  263. self.allowed_control_origins = _approved_origins(
  264. allowed_control_origins,
  265. self.allowed_control_hosts,
  266. "allowed_control_origins",
  267. )
  268. self.allowed_proxy_origins = _approved_origins(
  269. allowed_proxy_origins,
  270. self.allowed_proxy_hosts,
  271. "allowed_proxy_origins",
  272. )
  273. self.byte_limits = _limit_map(
  274. DEFAULT_BYTE_LIMITS, byte_limits, "byte_limits"
  275. )
  276. self.count_limits = _limit_map(
  277. DEFAULT_COUNT_LIMITS, count_limits, "count_limits"
  278. )
  279. if isinstance(max_depth, bool) or not isinstance(max_depth, int) or max_depth < 1:
  280. raise ValueError("max_depth must be a positive integer")
  281. if (
  282. isinstance(max_string_bytes, bool)
  283. or not isinstance(max_string_bytes, int)
  284. or max_string_bytes < 1
  285. ):
  286. raise ValueError("max_string_bytes must be a positive integer")
  287. self.max_depth = max_depth
  288. self.max_string_bytes = max_string_bytes
  289. def approve_event(self, event: Mapping[str, object]) -> dict[str, object]:
  290. if not isinstance(event, Mapping):
  291. raise EdgePolicyError("event must be a mapping")
  292. unknown = set(event) - {
  293. "event_id",
  294. "task_id",
  295. "gateway_id",
  296. "environment",
  297. "network_zone",
  298. "purpose",
  299. "classification",
  300. "contract_version",
  301. "occurred_at",
  302. "attempt",
  303. "idempotency_key",
  304. "policy_digest",
  305. "payload",
  306. }
  307. if unknown:
  308. raise EdgePolicyError("event contains unapproved properties")
  309. classification = event.get("classification")
  310. if classification in EDGE_ONLY:
  311. raise EdgePolicyError("classification is edge-only")
  312. if classification not in CONTROL_PLANE_ALLOWED:
  313. raise EdgePolicyError("classification is not control-plane approved")
  314. payload = event.get("payload")
  315. if not isinstance(payload, Mapping):
  316. raise EdgePolicyError("approved event payload must be a mapping")
  317. self.validate_approved_payload(str(classification), payload)
  318. try:
  319. return dict(event)
  320. except (TypeError, ValueError) as exc: # pragma: no cover - Mapping guard
  321. raise EdgePolicyError("event is invalid") from exc
  322. def validate_approved_payload(
  323. self, classification: str, payload: Mapping[str, object]
  324. ) -> None:
  325. if classification not in CONTROL_PLANE_ALLOWED:
  326. raise EdgePolicyError("classification is not control-plane approved")
  327. try:
  328. preflight_json(
  329. payload,
  330. max_depth=self.max_depth,
  331. max_nodes=self.count_limits[classification] + 1,
  332. max_string_bytes=self.max_string_bytes,
  333. max_total_bytes=self.byte_limits[classification],
  334. )
  335. encoded = strict_json_bytes(payload)
  336. except EdgeContractError as exc:
  337. detail = str(exc)
  338. if "node count" in detail:
  339. detail = "payload exceeds its independent item count limit"
  340. elif "string exceeds" in detail:
  341. detail = "payload string exceeds the string limit"
  342. elif "depth" in detail:
  343. detail = "payload exceeds the maximum depth"
  344. elif "byte limit" in detail:
  345. detail = "payload exceeds its independent byte limit"
  346. raise EdgePolicyError(detail) from exc
  347. if len(encoded) > self.byte_limits[classification]:
  348. raise EdgePolicyError("payload exceeds its independent byte limit")
  349. item_count = self._count_items(payload)
  350. if item_count > self.count_limits[classification]:
  351. raise EdgePolicyError("payload exceeds its independent item count limit")
  352. self._inspect(payload, depth=1)
  353. def _count_items(self, value: object) -> int:
  354. if isinstance(value, Mapping):
  355. return len(value) + sum(self._count_items(item) for item in value.values())
  356. if isinstance(value, (list, tuple)):
  357. return len(value) + sum(self._count_items(item) for item in value)
  358. return 0
  359. def _inspect(self, value: object, *, depth: int) -> None:
  360. if depth > self.max_depth:
  361. raise EdgePolicyError("payload exceeds the maximum depth")
  362. if isinstance(value, Mapping):
  363. for key, item in value.items():
  364. if not isinstance(key, str):
  365. raise EdgePolicyError("payload contains a non-string key")
  366. normalized = _normalized_key(key)
  367. if _is_sensitive_key(normalized):
  368. raise EdgePolicyError("payload contains sensitive content")
  369. if _is_raw_row_container(normalized, item):
  370. raise EdgePolicyError("payload contains sensitive raw-row content")
  371. self._inspect(item, depth=depth + 1)
  372. return
  373. if isinstance(value, (list, tuple)):
  374. for item in value:
  375. self._inspect(item, depth=depth + 1)
  376. return
  377. if isinstance(value, str):
  378. compatible = unicodedata.normalize("NFKC", value)
  379. if len(value.encode("utf-8")) > self.max_string_bytes:
  380. raise EdgePolicyError("payload string exceeds the string limit")
  381. if (
  382. _PRIVATE_KEY.search(compatible)
  383. or _BEARER.search(compatible)
  384. or _CREDENTIAL_ASSIGNMENT.search(compatible)
  385. or _SQL.search(compatible)
  386. or _contains_obfuscated_sql(compatible)
  387. ):
  388. raise EdgePolicyError("payload contains sensitive content")
  389. def validate_destination(
  390. self, control_url: str, *, proxy_url: str | None = None
  391. ) -> None:
  392. self._validate_url(
  393. control_url,
  394. approved_hosts=self.allowed_control_hosts,
  395. approved_origins=self.allowed_control_origins,
  396. label="destination",
  397. )
  398. if proxy_url is not None:
  399. self._validate_url(
  400. proxy_url,
  401. approved_hosts=self.allowed_proxy_hosts,
  402. approved_origins=self.allowed_proxy_origins,
  403. label="proxy",
  404. )
  405. @staticmethod
  406. def _validate_url(
  407. url: str,
  408. *,
  409. approved_hosts: frozenset[str],
  410. approved_origins: frozenset[str],
  411. label: str,
  412. ) -> None:
  413. if not isinstance(url, str):
  414. raise EdgePolicyError(f"{label} is not approved")
  415. try:
  416. parsed = urlsplit(url)
  417. port = parsed.port
  418. except ValueError as exc:
  419. raise EdgePolicyError(f"{label} is not approved") from exc
  420. if (
  421. parsed.scheme != "https"
  422. or not parsed.hostname
  423. or parsed.hostname not in approved_hosts
  424. or parsed.username is not None
  425. or parsed.password is not None
  426. or parsed.fragment
  427. or port is None and parsed.netloc.endswith(":")
  428. ):
  429. raise EdgePolicyError(f"{label} is not approved")
  430. effective_port = port or 443
  431. origin = (
  432. f"https://{parsed.hostname}"
  433. if effective_port == 443
  434. else f"https://{parsed.hostname}:{effective_port}"
  435. )
  436. if origin not in approved_origins:
  437. raise EdgePolicyError(f"{label} origin is not approved")
  438. @staticmethod
  439. def retention_days(classification: str) -> int:
  440. try:
  441. return RETENTION_DAYS[classification]
  442. except (KeyError, TypeError) as exc:
  443. raise EdgePolicyError("retention classification is invalid") from exc