pps_client.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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. ) -> list[dict]:
  77. """Search a quarantine folder and return up to `limit` message records.
  78. `query` is a raw filter fragment the API requires, e.g. "from=*" (match all)
  79. or "rcpt=@example.com". The API needs at least one of from/rcpt/subject.
  80. Results are sorted newest-first by the server; we truncate client-side.
  81. """
  82. params = self._query_to_params(query)
  83. params["folder"] = folder
  84. startdate = datetime.now(timezone.utc) - timedelta(days=days_back)
  85. params["startdate"] = startdate.strftime(_DATE_FMT)
  86. resp = self._request("GET", params=params)
  87. data = resp.json()
  88. records = data.get("records", []) or []
  89. log.info(
  90. "search folder=%r query=%r -> %d record(s)", folder, query, len(records)
  91. )
  92. if limit and len(records) > limit:
  93. records = records[:limit]
  94. return records
  95. def get_raw(self, guid: str) -> bytes:
  96. """Fetch the raw RFC822 bytes for a single message by its stable guid."""
  97. resp = self._request("GET", params={"guid": guid})
  98. return resp.content
  99. # ------------------------------------------------------------------ actions
  100. def act(
  101. self,
  102. action: str,
  103. folder: str,
  104. localguids: list[str],
  105. *,
  106. targetfolder: str | None = None,
  107. deletedfolder: str | None = None,
  108. scan: bool = False,
  109. ) -> dict:
  110. """POST an action for one or more messages in `folder`.
  111. `localguids` are the folder-local handles (not the stable guid). One POST can
  112. carry many, comma-separated. Returns the parsed JSON status body.
  113. """
  114. if not localguids:
  115. return {"status": "no messages"}
  116. payload: dict[str, str] = {
  117. "action": action,
  118. "folder": folder,
  119. "localguid": ",".join(localguids),
  120. }
  121. if targetfolder:
  122. payload["targetfolder"] = targetfolder
  123. if deletedfolder:
  124. payload["deletedfolder"] = deletedfolder
  125. if scan:
  126. payload["scan"] = "t"
  127. log.info(
  128. "action=%s folder=%r count=%d targetfolder=%r deletedfolder=%r",
  129. action, folder, len(localguids), targetfolder, deletedfolder,
  130. )
  131. resp = self._request("POST", json=payload)
  132. # Success is 200 or 204 with a small JSON body; 204 has no body.
  133. if resp.status_code == 204 or not resp.content:
  134. result = {"status": "ok"}
  135. else:
  136. try:
  137. result = resp.json()
  138. except ValueError:
  139. result = {"status": resp.text.strip()}
  140. log.info("action=%s folder=%r -> %s", action, folder, result)
  141. return result
  142. # ------------------------------------------------------------------ transport
  143. def _request(self, method: str, *, params: dict | None = None,
  144. json: dict | None = None) -> requests.Response:
  145. """Issue one request, logging it and converting any failure into PPSError."""
  146. try:
  147. resp = self.session.request(
  148. method, self.endpoint, params=params, json=json, timeout=self.timeout
  149. )
  150. except requests.RequestException as exc:
  151. log.error("%s %s failed at transport: %s", method, self.endpoint, exc)
  152. raise PPSError(f"PPS request failed ({method}): {exc}") from exc
  153. reqid = resp.headers.get("x-pps-reqid")
  154. log.debug(
  155. "%s %s -> HTTP %d (x-pps-reqid=%s)",
  156. method, resp.url, resp.status_code, reqid,
  157. )
  158. self._raise_for_status(resp, method, reqid)
  159. return resp
  160. @staticmethod
  161. def _raise_for_status(resp: requests.Response, method: str, reqid: str | None) -> None:
  162. if resp.status_code < 400:
  163. return
  164. body = (resp.text or "").strip()
  165. snippet = body[:_BODY_SNIPPET] + ("…" if len(body) > _BODY_SNIPPET else "")
  166. # The full picture in one place: status, PPS request-id (to grep PPS logs),
  167. # the resolved request URL, and whatever body PPS returned (often the reason).
  168. detail = (
  169. f"PPS returned HTTP {resp.status_code} for {method} {resp.url} "
  170. f"(x-pps-reqid={reqid or 'n/a'}): {snippet or '<empty body>'}"
  171. )
  172. log.error(detail)
  173. raise PPSError(detail, status=resp.status_code, body=body, reqid=reqid)
  174. # ------------------------------------------------------------------ helpers
  175. @staticmethod
  176. def _query_to_params(query: str) -> dict[str, str]:
  177. """Parse a raw filter fragment like 'from=*' or 'rcpt=@x.com&subject=*hi*'."""
  178. params: dict[str, str] = {}
  179. for part in (query or "").split("&"):
  180. part = part.strip()
  181. if not part or "=" not in part:
  182. continue
  183. key, value = part.split("=", 1)
  184. params[key.strip()] = value.strip()
  185. if not params:
  186. # API demands at least one of from/rcpt/subject; default to match-all.
  187. params["from"] = "*"
  188. return params