posture 0.2.2__tar.gz → 0.2.4__tar.gz

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.
Files changed (83) hide show
  1. {posture-0.2.2 → posture-0.2.4}/PKG-INFO +1 -1
  2. {posture-0.2.2 → posture-0.2.4}/pyproject.toml +1 -1
  3. {posture-0.2.2 → posture-0.2.4}/src/posture/__init__.py +1 -1
  4. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/mde.py +64 -2
  5. {posture-0.2.2 → posture-0.2.4}/tests/test_mde.py +84 -0
  6. {posture-0.2.2 → posture-0.2.4}/tests/test_posture.py +1 -1
  7. {posture-0.2.2 → posture-0.2.4}/.github/workflows/publish.yml +0 -0
  8. {posture-0.2.2 → posture-0.2.4}/.gitignore +0 -0
  9. {posture-0.2.2 → posture-0.2.4}/LICENSE +0 -0
  10. {posture-0.2.2 → posture-0.2.4}/README.md +0 -0
  11. {posture-0.2.2 → posture-0.2.4}/scripts/extract_test.py +0 -0
  12. {posture-0.2.2 → posture-0.2.4}/src/posture/base.py +0 -0
  13. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/__init__.py +0 -0
  14. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/_azure_oauth.py +0 -0
  15. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/azure_entra.py +0 -0
  16. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/crowdstrike.py +0 -0
  17. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/intune.py +0 -0
  18. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/jamf.py +0 -0
  19. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/knowbe4.py +0 -0
  20. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/okta.py +0 -0
  21. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/salesforce.json +0 -0
  22. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/salesforce.py +0 -0
  23. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/tenableio.py +0 -0
  24. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/upguard.py +0 -0
  25. {posture-0.2.2 → posture-0.2.4}/src/posture/collectors/workspaceone.py +0 -0
  26. {posture-0.2.2 → posture-0.2.4}/src/posture/exceptions.py +0 -0
  27. {posture-0.2.2 → posture-0.2.4}/src/posture/parse.py +0 -0
  28. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/azure_entra/audit_logs_page.json +0 -0
  29. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/azure_entra/signins_page.json +0 -0
  30. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/azure_entra/users_page.json +0 -0
  31. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/crowdstrike/hosts_bad_timestamps.json +0 -0
  32. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/crowdstrike/hosts_epoch_timestamps.json +0 -0
  33. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/crowdstrike/hosts_missing_fields.json +0 -0
  34. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/crowdstrike/hosts_page.json +0 -0
  35. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/crowdstrike/vulnerabilities_page.json +0 -0
  36. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/crowdstrike/zta_page.json +0 -0
  37. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/intune/attack_simulation_users_page.json +0 -0
  38. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/intune/attack_simulations_page.json +0 -0
  39. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/intune/device_configurations_page.json +0 -0
  40. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/intune/managed_devices_page.json +0 -0
  41. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/jamf/computers_inventory_detail.json +0 -0
  42. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/jamf/computers_inventory_page.json +0 -0
  43. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/jamf/policies_page.json +0 -0
  44. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/knowbe4/enrollments_page.json +0 -0
  45. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/knowbe4/pst_recipients_page.json +0 -0
  46. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/knowbe4/psts_page.json +0 -0
  47. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/mde/machine_vulnerabilities_page.json +0 -0
  48. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/mde/machines_page.json +0 -0
  49. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/mde/vulnerabilities_page.json +0 -0
  50. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/okta/device_users_page.json +0 -0
  51. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/okta/devices_page.json +0 -0
  52. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/okta/users_page.json +0 -0
  53. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/salesforce/custom_schema.json +0 -0
  54. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/salesforce/domain_page.json +0 -0
  55. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/tenableio/assets_page.json +0 -0
  56. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/tenableio/vulns_page.json +0 -0
  57. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/upguard/organisation.json +0 -0
  58. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/upguard/vendor_risks_page.json +0 -0
  59. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/upguard/vendors_page.json +0 -0
  60. {posture-0.2.2 → posture-0.2.4}/tests/fixtures/workspaceone/computers_page.json +0 -0
  61. {posture-0.2.2 → posture-0.2.4}/tests/test_azure_entra.py +0 -0
  62. {posture-0.2.2 → posture-0.2.4}/tests/test_catalog.py +0 -0
  63. {posture-0.2.2 → posture-0.2.4}/tests/test_crowdstrike.py +0 -0
  64. {posture-0.2.2 → posture-0.2.4}/tests/test_intune.py +0 -0
  65. {posture-0.2.2 → posture-0.2.4}/tests/test_jamf.py +0 -0
  66. {posture-0.2.2 → posture-0.2.4}/tests/test_knowbe4.py +0 -0
  67. {posture-0.2.2 → posture-0.2.4}/tests/test_okta.py +0 -0
  68. {posture-0.2.2 → posture-0.2.4}/tests/test_parse.py +0 -0
  69. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_azure_entra.py +0 -0
  70. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_intune.py +0 -0
  71. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_jamf.py +0 -0
  72. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_knowbe4.py +0 -0
  73. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_mde.py +0 -0
  74. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_okta.py +0 -0
  75. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_salesforce.py +0 -0
  76. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_tenableio.py +0 -0
  77. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_upguard.py +0 -0
  78. {posture-0.2.2 → posture-0.2.4}/tests/test_parse_workspaceone.py +0 -0
  79. {posture-0.2.2 → posture-0.2.4}/tests/test_redaction.py +0 -0
  80. {posture-0.2.2 → posture-0.2.4}/tests/test_salesforce.py +0 -0
  81. {posture-0.2.2 → posture-0.2.4}/tests/test_tenableio.py +0 -0
  82. {posture-0.2.2 → posture-0.2.4}/tests/test_upguard.py +0 -0
  83. {posture-0.2.2 → posture-0.2.4}/tests/test_workspaceone.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posture
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Runtime-agnostic Python library for CCM (Continuous Control Monitoring) data collection.
5
5
  Project-URL: Homepage, https://github.com/massyn/posture
6
6
  Author-email: Phil Massyn <phil.massyn@icloud.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "posture"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Runtime-agnostic Python library for CCM (Continuous Control Monitoring) data collection."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,7 +30,7 @@ logger = logging.getLogger("posture")
30
30
  load_dotenv()
31
31
  logger.debug("loaded .env via python-dotenv")
32
32
 
33
- __version__ = "0.2.2"
33
+ __version__ = "0.2.4"
34
34
 
35
35
  __all__ = [
36
36
  "CCM",
@@ -33,6 +33,11 @@ each retry just re-bursts at lower concurrency instead of avoiding the 429s
33
33
  in the first place. This is the same root cause and fix as the KnowBe4
34
34
  collector's ``_pace_request``.
35
35
 
36
+ A 5xx for a single machine (e.g. a stale/decommissioned device record) is
37
+ handled locally per-machine — see ``_fetch_all_vulns_for_machine`` — rather
38
+ than propagating up to base.py's batch-level retry, which would otherwise
39
+ fail (or re-burst) the whole fan-out over one bad machine.
40
+
36
41
  Resources: ``machines``, ``vulnerabilities``, ``device_av_info``,
37
42
  ``machine_vulnerabilities`` (requires machines ids).
38
43
  """
@@ -40,17 +45,32 @@ Resources: ``machines``, ``vulnerabilities``, ``device_av_info``,
40
45
  from __future__ import annotations
41
46
 
42
47
  import concurrent.futures
48
+ import logging
43
49
  import threading
44
50
  import time
45
51
  from typing import Any
46
52
 
53
+ import requests
54
+
47
55
  from posture.base import Collector, RateLimitedSignal, UnauthorizedSignal
48
56
  from posture.collectors._azure_oauth import fetch_azure_ad_token
49
57
 
58
+ logger = logging.getLogger("posture.collectors.mde")
59
+
50
60
  _API_BASE_URL = "https://api.security.microsoft.com"
51
61
  _PAGE_SIZE = 1000 # comfortably under the documented $top max of 8,000
52
62
  _DEFAULT_MACHINE_VULN_MAX_WORKERS = 10
53
63
 
64
+ # A lone machine 500ing is a per-device MDE fault, not a whole-tenant problem
65
+ # (unlike 429/401, which are meaningful at the batch level and handled by
66
+ # base.py's _request_with_retry). Retrying the entire fan-out for one bad
67
+ # machine would burn the shared retry budget and re-halve concurrency for
68
+ # every other machine, so a 5xx for a single machine is retried locally here
69
+ # and, if it still fails, that machine is skipped rather than failing the
70
+ # whole 'machine_vulnerabilities' resource.
71
+ _MACHINE_VULN_SERVER_ERROR_RETRIES = 2
72
+ _MACHINE_VULN_SERVER_ERROR_WAIT_SECONDS = 60.0
73
+
54
74
  # MDE's documented general API limit is ~100 calls/minute per app registration.
55
75
  # Staying comfortably under that (0.65s between requests ~= 92/min) leaves
56
76
  # headroom for other traffic against the same app registration.
@@ -294,8 +314,50 @@ class MdeCollector(Collector):
294
314
  # No $top/$skip/nextLink documented for this endpoint — it's a
295
315
  # single, complete response (unlike the org-wide list endpoints).
296
316
  endpoint = _ENDPOINTS["machine_vulnerabilities"].format(id=machine_id)
297
- response = self._get(_API_BASE_URL + endpoint)
298
- return response.json().get("value", [])
317
+ attempt = 0
318
+ while True:
319
+ try:
320
+ response = self._get(_API_BASE_URL + endpoint)
321
+ return response.json().get("value", [])
322
+ except requests.exceptions.HTTPError as exc:
323
+ status = exc.response.status_code if exc.response is not None else None
324
+ if status == 404:
325
+ # Machine-specific endpoint: a 404 means this machine has
326
+ # no vulnerability data (e.g. offboarded since the machine
327
+ # list was pulled), not a collection-wide failure.
328
+ logger.info(
329
+ "machine_vulnerabilities: no data for machine (404), skipping",
330
+ extra={
331
+ "source": self.env_prefix.lower(),
332
+ "machine_id": machine_id,
333
+ },
334
+ )
335
+ return []
336
+ if status is None or status < 500:
337
+ raise
338
+ attempt += 1
339
+ if attempt > _MACHINE_VULN_SERVER_ERROR_RETRIES:
340
+ logger.warning(
341
+ "machine_vulnerabilities: skipping machine after repeated "
342
+ "server errors",
343
+ extra={
344
+ "source": self.env_prefix.lower(),
345
+ "machine_id": machine_id,
346
+ "status": status,
347
+ "attempts": attempt,
348
+ },
349
+ )
350
+ return []
351
+ logger.warning(
352
+ "machine_vulnerabilities: server error for machine, retrying",
353
+ extra={
354
+ "source": self.env_prefix.lower(),
355
+ "machine_id": machine_id,
356
+ "status": status,
357
+ "attempt": attempt,
358
+ },
359
+ )
360
+ time.sleep(_MACHINE_VULN_SERVER_ERROR_WAIT_SECONDS)
299
361
 
300
362
  def _pace_request(self) -> None:
301
363
  # Shared across threads so concurrent machine_vulnerabilities workers
@@ -1,4 +1,5 @@
1
1
  import json
2
+ from unittest.mock import patch
2
3
 
3
4
  import responses
4
5
 
@@ -139,3 +140,86 @@ def test_machine_vulnerabilities_fans_out_and_injects_machine_id() -> None:
139
140
 
140
141
  assert len(df) == 2
141
142
  assert set(df["machine_id"]) == {"machine-1", "machine-2"}
143
+
144
+
145
+ @responses.activate
146
+ def test_machine_vulnerabilities_retries_then_recovers_from_server_error() -> None:
147
+ responses.add(
148
+ responses.POST,
149
+ "https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token",
150
+ json={"access_token": "tok"},
151
+ status=200,
152
+ )
153
+ responses.add(
154
+ responses.GET,
155
+ "https://api.security.microsoft.com/api/machines",
156
+ json={"value": [{"id": "machine-1"}]},
157
+ status=200,
158
+ )
159
+ responses.add(
160
+ responses.GET,
161
+ "https://api.security.microsoft.com/api/machines/machine-1/vulnerabilities",
162
+ status=500,
163
+ )
164
+ responses.add(
165
+ responses.GET,
166
+ "https://api.security.microsoft.com/api/machines/machine-1/vulnerabilities",
167
+ json={"value": [{"id": "mv-1"}]},
168
+ status=200,
169
+ )
170
+
171
+ ccm = CCM(
172
+ "mde", {"tenant_id": "tenant-1", "client_id": "id", "client_secret": "secret"}
173
+ )
174
+ with patch("posture.collectors.mde.time.sleep") as mock_sleep:
175
+ df = ccm.collect("machine_vulnerabilities")
176
+
177
+ assert len(df) == 1
178
+ assert 60.0 in [call.args[0] for call in mock_sleep.call_args_list]
179
+
180
+
181
+ @responses.activate
182
+ def test_machine_vulnerabilities_skips_machine_after_repeated_server_errors() -> None:
183
+ responses.add(
184
+ responses.POST,
185
+ "https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token",
186
+ json={"access_token": "tok"},
187
+ status=200,
188
+ )
189
+ responses.add(
190
+ responses.GET,
191
+ "https://api.security.microsoft.com/api/machines",
192
+ json={"value": [{"id": "machine-1"}, {"id": "machine-2"}]},
193
+ status=200,
194
+ )
195
+ responses.add(
196
+ responses.GET,
197
+ "https://api.security.microsoft.com/api/machines/machine-1/vulnerabilities",
198
+ status=500,
199
+ )
200
+ responses.add(
201
+ responses.GET,
202
+ "https://api.security.microsoft.com/api/machines/machine-1/vulnerabilities",
203
+ status=500,
204
+ )
205
+ responses.add(
206
+ responses.GET,
207
+ "https://api.security.microsoft.com/api/machines/machine-1/vulnerabilities",
208
+ status=500,
209
+ )
210
+ responses.add(
211
+ responses.GET,
212
+ "https://api.security.microsoft.com/api/machines/machine-2/vulnerabilities",
213
+ json={"value": [{"id": "mv-2"}]},
214
+ status=200,
215
+ )
216
+
217
+ ccm = CCM(
218
+ "mde", {"tenant_id": "tenant-1", "client_id": "id", "client_secret": "secret"}
219
+ )
220
+ with patch("posture.collectors.mde.time.sleep"):
221
+ df = ccm.collect("machine_vulnerabilities")
222
+
223
+ # machine-1 is skipped after exhausting retries; machine-2 still succeeds.
224
+ assert len(df) == 1
225
+ assert df.iloc[0]["machine_id"] == "machine-2"
@@ -2,4 +2,4 @@ import posture
2
2
 
3
3
 
4
4
  def test_version() -> None:
5
- assert posture.__version__ == "0.2.0"
5
+ assert posture.__version__ == "0.2.4"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes