pps_client.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """Thin client for the Proofpoint Protection Server Quarantine Search REST API.
  2. Covers the three things the PoC needs:
  3. - search(): GET /rest/v1/quarantine -> list of message records (JSON)
  4. - get_raw(): GET .../quarantine?guid= -> raw RFC822 bytes of one message
  5. - act(): POST /rest/v1/quarantine -> release / move / delete actions
  6. All requests use HTTP Basic auth against the admin port (default 10000).
  7. See "Proofpoint Public APIs rev W", "Proofpoint Quarantine Search REST API".
  8. """
  9. from __future__ import annotations
  10. import logging
  11. from datetime import datetime, timedelta, timezone
  12. from urllib.parse import urljoin
  13. import requests
  14. import urllib3
  15. log = logging.getLogger("pps.client")
  16. # PPS accepts "YYYY-MM-DD HH:MM:SS" UTC time strings for startdate/enddate.
  17. _DATE_FMT = "%Y-%m-%d %H:%M:%S"
  18. _HEADERS = {
  19. "Accept": "application/json",
  20. "User-Agent": "pps-quarantine-manager/0.1",
  21. }
  22. # How much of a response body to keep in an error message / log line.
  23. _BODY_SNIPPET = 2000
  24. class PPSError(RuntimeError):
  25. """Raised when a PPS request fails. Carries status, response body and request-id."""
  26. def __init__(
  27. self,
  28. message: str,
  29. status: int | None = None,
  30. body: str = "",
  31. reqid: str | None = None,
  32. ):
  33. super().__init__(message)
  34. self.status = status
  35. self.body = body
  36. self.reqid = reqid
  37. class PPSClient:
  38. def __init__(
  39. self,
  40. base_url: str,
  41. username: str,
  42. password: str,
  43. verify_tls: bool | str = False,
  44. timeout: int = 120,
  45. client_cert: str | None = None,
  46. client_key: str | None = None,
  47. ):
  48. # Endpoint is a fixed path on the admin service.
  49. self.endpoint = urljoin(base_url.rstrip("/") + "/", "rest/v1/quarantine")
  50. self.timeout = timeout
  51. self.session = requests.Session()
  52. self.session.auth = (username, password)
  53. self.session.headers.update(_HEADERS)
  54. self.session.verify = verify_tls
  55. # Client-certificate (mutual TLS) auth. Some PPS deployments sit behind nginx
  56. # configured to require a client cert ("400 No required SSL certificate was sent").
  57. # `client_cert` may be a combined cert+key PEM, or a cert paired with `client_key`.
  58. if client_cert:
  59. self.session.cert = (client_cert, client_key) if client_key else client_cert
  60. log.info("Using client certificate for mutual TLS: %s", client_cert)
  61. if verify_tls is False:
  62. # Intentionally skipping verification (PoC / self-signed PPS certs). Silence
  63. # the per-request InsecureRequestWarning but say so once, loudly, at startup.
  64. urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
  65. log.warning(
  66. "TLS verification is DISABLED for %s — do not use in production.",
  67. self.endpoint,
  68. )
  69. # ------------------------------------------------------------------ search
  70. def search(
  71. self,
  72. folder: str,
  73. query: str,
  74. limit: int = 200,
  75. days_back: int = 7,
  76. enddate: str | None = None,
  77. ) -> list[dict]:
  78. """Search a quarantine folder and return up to `limit` message records.
  79. `query` is a raw filter fragment the API requires, e.g. "from=*" (match all)
  80. or "rcpt=@example.com". The API needs at least one of from/rcpt/subject.
  81. Results are sorted newest-first by the server; we truncate client-side.
  82. `enddate` (a "YYYY-MM-DD HH:MM:SS" string) upper-bounds the date range — used
  83. to page backwards through a folder that holds more than the API's 1000-row cap.
  84. """
  85. params = self._query_to_params(query)
  86. params["folder"] = folder
  87. startdate = datetime.now(timezone.utc) - timedelta(days=days_back)
  88. params["startdate"] = startdate.strftime(_DATE_FMT)
  89. if enddate:
  90. params["enddate"] = enddate
  91. resp = self._request("GET", params=params)
  92. data = resp.json()
  93. records = data.get("records", []) or []
  94. log.info(
  95. "search folder=%r query=%r enddate=%r -> %d record(s)",
  96. folder, query, enddate, len(records),
  97. )
  98. if limit and len(records) > limit:
  99. records = records[:limit]
  100. return records
  101. def get_raw(self, guid: str) -> bytes:
  102. """Fetch the raw RFC822 bytes for a single message by its stable guid."""
  103. resp = self._request("GET", params={"guid": guid})
  104. return resp.content
  105. # ------------------------------------------------------------------ actions
  106. def act(
  107. self,
  108. action: str,
  109. folder: str,
  110. localguids: list[str],
  111. *,
  112. targetfolder: str | None = None,
  113. deletedfolder: str | None = None,
  114. scan: bool = False,
  115. ) -> dict:
  116. """POST an action for one or more messages in `folder`.
  117. `localguids` are the folder-local handles (not the stable guid). One POST can
  118. carry many, comma-separated. Returns the parsed JSON status body.
  119. """
  120. if not localguids:
  121. return {"status": "no messages"}
  122. payload: dict[str, str] = {
  123. "action": action,
  124. "folder": folder,
  125. "localguid": ",".join(localguids),
  126. }
  127. if targetfolder:
  128. payload["targetfolder"] = targetfolder
  129. if deletedfolder:
  130. payload["deletedfolder"] = deletedfolder
  131. if scan:
  132. payload["scan"] = "t"
  133. log.info(
  134. "action=%s folder=%r count=%d targetfolder=%r deletedfolder=%r",
  135. action, folder, len(localguids), targetfolder, deletedfolder,
  136. )
  137. resp = self._request("POST", json=payload)
  138. # Success is 200 or 204 with a small JSON body; 204 has no body.
  139. if resp.status_code == 204 or not resp.content:
  140. result = {"status": "ok"}
  141. else:
  142. try:
  143. result = resp.json()
  144. except ValueError:
  145. result = {"status": resp.text.strip()}
  146. log.info("action=%s folder=%r -> %s", action, folder, result)
  147. return result
  148. # ------------------------------------------------------------------ transport
  149. def _request(self, method: str, *, params: dict | None = None,
  150. json: dict | None = None) -> requests.Response:
  151. """Issue one request, logging it and converting any failure into PPSError."""
  152. try:
  153. resp = self.session.request(
  154. method, self.endpoint, params=params, json=json, timeout=self.timeout
  155. )
  156. except requests.RequestException as exc:
  157. log.error("%s %s failed at transport: %s", method, self.endpoint, exc)
  158. raise PPSError(f"PPS request failed ({method}): {exc}") from exc
  159. reqid = resp.headers.get("x-pps-reqid")
  160. log.debug(
  161. "%s %s -> HTTP %d (x-pps-reqid=%s)",
  162. method, resp.url, resp.status_code, reqid,
  163. )
  164. self._raise_for_status(resp, method, reqid)
  165. return resp
  166. @staticmethod
  167. def _raise_for_status(resp: requests.Response, method: str, reqid: str | None) -> None:
  168. if resp.status_code < 400:
  169. return
  170. body = (resp.text or "").strip()
  171. snippet = body[:_BODY_SNIPPET] + ("…" if len(body) > _BODY_SNIPPET else "")
  172. # The full picture in one place: status, PPS request-id (to grep PPS logs),
  173. # the resolved request URL, and whatever body PPS returned (often the reason).
  174. detail = (
  175. f"PPS returned HTTP {resp.status_code} for {method} {resp.url} "
  176. f"(x-pps-reqid={reqid or 'n/a'}): {snippet or '<empty body>'}"
  177. )
  178. log.error(detail)
  179. raise PPSError(detail, status=resp.status_code, body=body, reqid=reqid)
  180. # ------------------------------------------------------------------ helpers
  181. @staticmethod
  182. def _query_to_params(query: str) -> dict[str, str]:
  183. """Parse a raw filter fragment like 'from=*' or 'rcpt=@x.com&subject=*hi*'."""
  184. params: dict[str, str] = {}
  185. for part in (query or "").split("&"):
  186. part = part.strip()
  187. if not part or "=" not in part:
  188. continue
  189. key, value = part.split("=", 1)
  190. params[key.strip()] = value.strip()
  191. if not params:
  192. # API demands at least one of from/rcpt/subject; default to match-all.
  193. params["from"] = "*"
  194. return params