libre-devops-helpers 0.4.1__py3-none-any.whl

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 (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,292 @@
1
+ """Microsoft Graph, directly: any GET, paged; objects by name or id; Advanced Hunting.
2
+
3
+ The feature modules (entra, intune, incidents, pim) each read the part of Graph they
4
+ need. This one is the general tool: read any path, as ``az rest`` would, but knowing
5
+ Graph's paging, its query options and the ``ConsistencyLevel`` its advanced queries
6
+ want. It only reads: the one POST, ``runHuntingQuery``, runs a query and changes nothing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from datetime import timedelta
15
+ from typing import Any, Self
16
+ from urllib.parse import quote, urlsplit
17
+
18
+ import requests
19
+
20
+ from libre_devops_helpers.core import brand
21
+ from libre_devops_helpers.core.auth import TokenProvider, token_source
22
+ from libre_devops_helpers.core.errors import ApiError, InputError, NotFoundError
23
+ from libre_devops_helpers.core.http import ApiClient
24
+ from libre_devops_helpers.core.tables import QueryResult
25
+ from libre_devops_helpers.core.util import candidate_names, is_guid, odata_string
26
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
27
+ from libre_devops_helpers.microsoft.config import Profile
28
+
29
+ VERSIONS = ("v1.0", "beta")
30
+ HUNT_HINT = (
31
+ "Advanced Hunting through Graph needs ThreatHunting.Read.All on the token, which the "
32
+ "Azure CLI's token never carries: use an interactive or device-code profile whose app "
33
+ f"has it (or {brand.command('xdr hunt --endpoint')}, for the device tables with the "
34
+ "Azure CLI's sign-in)"
35
+ )
36
+ _UNSAFE = re.compile(r"[\s\\]|\.\.")
37
+
38
+ # What each kind of object is looked up by, besides its id.
39
+ KINDS = {
40
+ "user": ("users", ("userPrincipalName", "displayName", "mail")),
41
+ "device": ("devices", ("displayName",)),
42
+ "group": ("groups", ("displayName", "mailNickname")),
43
+ "app": ("applications", ("displayName",)),
44
+ "sp": ("servicePrincipals", ("displayName",)),
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class GraphPage:
50
+ """What a collection GET returned: its items, and more if there is more."""
51
+
52
+ items: tuple[dict[str, Any], ...]
53
+ count: int | None = None # @odata.count, when asked for with --count
54
+ more: bool = False # a next page exists that was not fetched
55
+
56
+
57
+ class GraphClient:
58
+ """Reads Microsoft Graph for one tenant. Close it (or use ``with``) when done."""
59
+
60
+ def __init__(self, api: ApiClient) -> None:
61
+ self.api = api
62
+
63
+ @classmethod
64
+ def create(
65
+ cls,
66
+ tokens: TokenProvider,
67
+ tenant_id: str,
68
+ *,
69
+ graph_url: str = PUBLIC.graph_url,
70
+ verify: bool | str = True,
71
+ session: requests.Session | None = None,
72
+ ) -> Self:
73
+ api = ApiClient(
74
+ graph_url,
75
+ token_source(tokens, graph_url, tenant_id),
76
+ name="Microsoft Graph",
77
+ verify=verify,
78
+ session=session,
79
+ )
80
+ return cls(api)
81
+
82
+ @classmethod
83
+ def for_profile(
84
+ cls,
85
+ profile: Profile,
86
+ tokens: TokenProvider,
87
+ *,
88
+ verify: bool | str = True,
89
+ session: requests.Session | None = None,
90
+ ) -> Self:
91
+ return cls.create(
92
+ tokens,
93
+ profile.tenant_id,
94
+ graph_url=profile.cloud.graph_url,
95
+ verify=verify,
96
+ session=session,
97
+ )
98
+
99
+ def close(self) -> None:
100
+ self.api.close()
101
+
102
+ def __enter__(self) -> Self:
103
+ return self
104
+
105
+ def __exit__(self, *exc_info: object) -> None:
106
+ self.close()
107
+
108
+ # Any path ------------------------------------------------------------------------
109
+
110
+ def get(
111
+ self,
112
+ path: str,
113
+ *,
114
+ params: Mapping[str, str] | None = None,
115
+ beta: bool = False,
116
+ eventual: bool = False,
117
+ ) -> dict[str, Any]:
118
+ """GET ``path`` (``users``, ``/beta/me``, or a full Graph URL) once."""
119
+ headers = {"ConsistencyLevel": "eventual"} if eventual else None
120
+ return self.api.get(graph_path(path, beta=beta), params=params, headers=headers)
121
+
122
+ def page(
123
+ self,
124
+ path: str,
125
+ *,
126
+ params: Mapping[str, str] | None = None,
127
+ beta: bool = False,
128
+ eventual: bool = False,
129
+ limit: int | None = None,
130
+ all_pages: bool = False,
131
+ ) -> GraphPage:
132
+ """A collection's items: the first page, up to ``limit``, or every page."""
133
+ data = self.get(path, params=params, beta=beta, eventual=eventual)
134
+ if not isinstance(data.get("value"), list):
135
+ raise InputError(f"{path} is a single object, not a collection")
136
+ items: list[dict[str, Any]] = []
137
+ count = data.get("@odata.count")
138
+ while True:
139
+ items.extend(item for item in data["value"] if isinstance(item, dict))
140
+ link = data.get("@odata.nextLink")
141
+ if limit is not None and len(items) >= limit:
142
+ return GraphPage(tuple(items[:limit]), count, bool(link) or len(items) > limit)
143
+ if not isinstance(link, str) or not link:
144
+ return GraphPage(tuple(items), count)
145
+ if not all_pages and limit is None:
146
+ return GraphPage(tuple(items), count, more=True)
147
+ headers = {"ConsistencyLevel": "eventual"} if eventual else None
148
+ data = self.api.get(link, headers=headers)
149
+
150
+ # Objects by name or id -----------------------------------------------------------
151
+
152
+ def lookup(self, kind: str, ref: str, *, select: str | None = None) -> list[dict[str, Any]]:
153
+ """Every object of ``kind`` that ``ref`` names: an id, or a name it goes by.
154
+
155
+ Devices are tried by FQDN, then short host name, as elsewhere in this tool; a
156
+ name can match several objects (stale device registrations keep the name).
157
+ """
158
+ if kind not in KINDS:
159
+ raise InputError(f"unknown kind {kind!r}: use one of {', '.join(KINDS)}")
160
+ collection, fields = KINDS[kind]
161
+ ref = ref.strip()
162
+ if not ref:
163
+ raise InputError(f"no {kind} named")
164
+ params = {"$select": select} if select else {}
165
+ if is_guid(ref):
166
+ return self._by_id(kind, collection, ref, params)
167
+ if kind == "user" and "@" in ref:
168
+ try:
169
+ return [self.get(f"users/{quote(ref, safe='@')}", params=params)]
170
+ except ApiError as exc:
171
+ if exc.status != 404:
172
+ raise
173
+ names = candidate_names(ref) if kind == "device" else [ref]
174
+ for name in names:
175
+ either = " or ".join(f"{field} eq {odata_string(name)}" for field in fields)
176
+ found = self.page(
177
+ collection, params={**params, "$filter": either}, all_pages=True
178
+ ).items
179
+ if found:
180
+ return list(found)
181
+ return []
182
+
183
+ def _by_id(
184
+ self, kind: str, collection: str, ref: str, params: Mapping[str, str]
185
+ ) -> list[dict[str, Any]]:
186
+ try:
187
+ return [self.get(f"{collection}/{ref}", params=params)]
188
+ except ApiError as exc:
189
+ if exc.status != 404:
190
+ raise
191
+ # Not an object id: for these kinds, the other id people use.
192
+ other = {"device": "deviceId", "app": "appId", "sp": "appId"}.get(kind)
193
+ if other is None:
194
+ return []
195
+ expression = f"{other} eq {odata_string(ref)}"
196
+ return list(self.page(collection, params={**params, "$filter": expression}).items)
197
+
198
+ def me(self) -> dict[str, Any]:
199
+ """The signed-in user (delegated tokens only)."""
200
+ return self.get(
201
+ "me",
202
+ params={"$select": "id,displayName,userPrincipalName,mail,jobTitle,department"},
203
+ )
204
+
205
+ def service_principal(self, app_id: str) -> dict[str, Any] | None:
206
+ """The service principal an app-only token belongs to, or None."""
207
+ if not is_guid(app_id):
208
+ return None
209
+ found = self.page(
210
+ "servicePrincipals",
211
+ params={
212
+ "$filter": f"appId eq {odata_string(app_id)}",
213
+ "$select": "id,displayName,appId",
214
+ },
215
+ ).items
216
+ return found[0] if found else None
217
+
218
+ # Advanced Hunting ----------------------------------------------------------------
219
+
220
+ def hunt(self, query: str, *, timespan: timedelta | None = None) -> QueryResult:
221
+ """Run an Advanced Hunting (KQL) query over the whole Defender XDR schema."""
222
+ if not query.strip():
223
+ raise InputError("the hunting query is empty")
224
+ body: dict[str, Any] = {"Query": query}
225
+ if timespan is not None:
226
+ body["Timespan"] = iso_duration(timespan)
227
+ try:
228
+ data = self.api.post("/v1.0/security/runHuntingQuery", body)
229
+ except ApiError as exc:
230
+ if exc.status in {401, 403} and not _suspended(exc):
231
+ raise ApiError(
232
+ str(exc),
233
+ status=exc.status,
234
+ code=exc.code,
235
+ request_id=exc.request_id,
236
+ hint=HUNT_HINT,
237
+ ) from None
238
+ raise
239
+ schema = data.get("schema") if isinstance(data.get("schema"), list) else []
240
+ rows = [row for row in data.get("results") or () if isinstance(row, dict)]
241
+ columns = [str(column.get("name")) for column in schema if isinstance(column, dict)]
242
+ if not columns:
243
+ return QueryResult.from_records(rows)
244
+ return QueryResult(tuple(columns), tuple(rows))
245
+
246
+
247
+ def _suspended(exc: ApiError) -> bool:
248
+ """A suspended service answers 403 too; that hint says why better than a scope one."""
249
+ return "suspended" in str(exc).lower()
250
+
251
+
252
+ def graph_path(path: str, *, beta: bool = False) -> str:
253
+ """``path`` as Graph wants it: ``users`` -> ``/v1.0/users``, ``beta/me`` kept as is.
254
+
255
+ A full Graph URL (a nextLink, or one pasted from Graph Explorer) passes through; the
256
+ API client refuses any other host, so the token cannot be sent elsewhere.
257
+ """
258
+ text = path.strip()
259
+ if not text:
260
+ raise InputError("no Graph path given", hint="for example: users, me, devices")
261
+ if "://" in text:
262
+ return text
263
+ base = urlsplit(text).path.lstrip("/")
264
+ if _UNSAFE.search(base):
265
+ raise InputError(f"{path!r} is not a Graph path")
266
+ first = base.split("/", 1)[0]
267
+ if first in VERSIONS:
268
+ if beta and first != "beta":
269
+ raise InputError("--beta and a v1.0 path disagree")
270
+ return "/" + text.lstrip("/")
271
+ return f"/{'beta' if beta else 'v1.0'}/{text.lstrip('/')}"
272
+
273
+
274
+ def iso_duration(span: timedelta) -> str:
275
+ """An ISO 8601 duration: ``P7D``, ``PT6H``, ``PT30M``, ``PT45S``."""
276
+ seconds = int(span.total_seconds())
277
+ if seconds <= 0:
278
+ raise InputError("a timespan must be positive")
279
+ if seconds % 86400 == 0:
280
+ return f"P{seconds // 86400}D"
281
+ if seconds % 3600 == 0:
282
+ return f"PT{seconds // 3600}H"
283
+ if seconds % 60 == 0:
284
+ return f"PT{seconds // 60}M"
285
+ return f"PT{seconds}S"
286
+
287
+
288
+ def not_found(kind: str, ref: str) -> NotFoundError:
289
+ tried = (
290
+ " or ".join(repr(name) for name in candidate_names(ref)) if kind == "device" else repr(ref)
291
+ )
292
+ return NotFoundError(f"no {kind} is named {tried}")
@@ -0,0 +1,51 @@
1
+ """Defender XDR incidents, through the Graph security API, Sentinel's included.
2
+
3
+ In the unified security operations platform, Microsoft Sentinel's incidents land in the
4
+ same queue as Defender's; each alert's ``serviceSource`` says where it came from.
5
+
6
+ Public API::
7
+
8
+ from libre_devops_helpers.microsoft.incidents import IncidentsClient
9
+
10
+ found = IncidentsClient.for_profile(profile, tokens).incidents(statuses=OPEN_STATUSES)
11
+ """
12
+
13
+ from libre_devops_helpers.microsoft.incidents.client import (
14
+ MAX_INCIDENTS,
15
+ IncidentList,
16
+ IncidentsClient,
17
+ Summary,
18
+ most_severe,
19
+ newest,
20
+ severities_from,
21
+ summarise,
22
+ )
23
+ from libre_devops_helpers.microsoft.incidents.models import (
24
+ OPEN_STATUSES,
25
+ SEVERITY_ORDER,
26
+ SOURCES,
27
+ STATUSES,
28
+ Incident,
29
+ IncidentAlert,
30
+ severity_rank,
31
+ )
32
+ from libre_devops_helpers.microsoft.incidents.permissions import REQUIREMENTS
33
+
34
+ __all__ = [
35
+ "MAX_INCIDENTS",
36
+ "OPEN_STATUSES",
37
+ "REQUIREMENTS",
38
+ "SEVERITY_ORDER",
39
+ "SOURCES",
40
+ "STATUSES",
41
+ "Incident",
42
+ "IncidentAlert",
43
+ "IncidentList",
44
+ "IncidentsClient",
45
+ "Summary",
46
+ "most_severe",
47
+ "newest",
48
+ "severities_from",
49
+ "severity_rank",
50
+ "summarise",
51
+ ]
@@ -0,0 +1,217 @@
1
+ """The Graph security API's incidents: one queue for Defender XDR and Sentinel.
2
+
3
+ Graph filters incidents on time, status and severity; which services raised an
4
+ incident's alerts (Sentinel, say) is only known from the alerts, so that filter, and
5
+ the sorting, happen here. Incidents come with their alerts (``$expand=alerts``).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import Counter
11
+ from collections.abc import Iterable, Sequence
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from typing import Self
15
+
16
+ import requests
17
+
18
+ from libre_devops_helpers.core.auth import TokenProvider, token_source
19
+ from libre_devops_helpers.core.errors import ApiError, InputError, NotFoundError
20
+ from libre_devops_helpers.core.http import ApiClient
21
+ from libre_devops_helpers.core.util import odata_datetime, odata_string
22
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
23
+ from libre_devops_helpers.microsoft.config import Profile
24
+ from libre_devops_helpers.microsoft.incidents.models import (
25
+ SEVERITY_ORDER,
26
+ STATUSES,
27
+ Incident,
28
+ severity_rank,
29
+ )
30
+
31
+ _PATH = "/v1.0/security/incidents"
32
+ PAGE_SIZE = 50 # Graph's most per page
33
+ MAX_INCIDENTS = 2000 # a runaway guard: narrow the window for more
34
+ _EARLIEST = datetime.min.replace(tzinfo=UTC)
35
+ SCOPE_HINT = (
36
+ "incidents need SecurityIncident.Read.All on the Graph token, which the Azure CLI's "
37
+ "token never carries: use an interactive or device-code profile whose app has it. "
38
+ "The account also needs a Defender XDR role, such as Security Reader"
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class IncidentList:
44
+ """Incidents found, and whether the guard stopped the listing short."""
45
+
46
+ incidents: tuple[Incident, ...]
47
+ truncated: bool = False
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Summary:
52
+ total: int
53
+ by_severity: dict[str, int]
54
+ by_status: dict[str, int]
55
+ by_source: dict[str, int]
56
+
57
+
58
+ class IncidentsClient:
59
+ """Reads incidents through Microsoft Graph. Close it (or use ``with``) when done."""
60
+
61
+ def __init__(self, api: ApiClient) -> None:
62
+ self.api = api
63
+
64
+ @classmethod
65
+ def create(
66
+ cls,
67
+ tokens: TokenProvider,
68
+ tenant_id: str,
69
+ *,
70
+ graph_url: str = PUBLIC.graph_url,
71
+ verify: bool | str = True,
72
+ session: requests.Session | None = None,
73
+ ) -> Self:
74
+ api = ApiClient(
75
+ graph_url,
76
+ token_source(tokens, graph_url, tenant_id),
77
+ name="Microsoft Graph",
78
+ verify=verify,
79
+ session=session,
80
+ )
81
+ return cls(api)
82
+
83
+ @classmethod
84
+ def for_profile(
85
+ cls,
86
+ profile: Profile,
87
+ tokens: TokenProvider,
88
+ *,
89
+ verify: bool | str = True,
90
+ session: requests.Session | None = None,
91
+ ) -> Self:
92
+ return cls.create(
93
+ tokens,
94
+ profile.tenant_id,
95
+ graph_url=profile.cloud.graph_url,
96
+ verify=verify,
97
+ session=session,
98
+ )
99
+
100
+ def close(self) -> None:
101
+ self.api.close()
102
+
103
+ def __enter__(self) -> Self:
104
+ return self
105
+
106
+ def __exit__(self, *exc_info: object) -> None:
107
+ self.close()
108
+
109
+ def incidents(
110
+ self,
111
+ *,
112
+ start: datetime | None = None,
113
+ end: datetime | None = None,
114
+ by: str = "createdDateTime",
115
+ statuses: Sequence[str] = (),
116
+ severities: Sequence[str] = (),
117
+ sources: Sequence[str] = (),
118
+ ) -> IncidentList:
119
+ """Incidents from ``start`` to ``end`` (on ``by``), with the given statuses and
120
+ severities (any when empty), whose alerts came from any of ``sources``."""
121
+ if by not in {"createdDateTime", "lastUpdateDateTime"}:
122
+ raise InputError(f"incidents can be windowed on created or updated, not {by!r}")
123
+ for status in statuses:
124
+ if status not in STATUSES:
125
+ raise InputError(f"unknown incident status {status!r}")
126
+ params = {"$expand": "alerts", "$top": str(PAGE_SIZE)}
127
+ expression = _filter(start, end, by, statuses, severities)
128
+ if expression:
129
+ params["$filter"] = expression
130
+ found: list[Incident] = []
131
+ truncated = False
132
+ try:
133
+ for record in self.api.get_all(_PATH, params=params):
134
+ if len(found) >= MAX_INCIDENTS:
135
+ truncated = True
136
+ break
137
+ found.append(Incident.from_graph(record))
138
+ except ApiError as exc:
139
+ raise _explained(exc) from None
140
+ if sources:
141
+ wanted = set(sources)
142
+ found = [incident for incident in found if wanted & set(incident.sources)]
143
+ return IncidentList(tuple(found), truncated)
144
+
145
+ def incident(self, incident_id: str) -> Incident:
146
+ """One incident, with its alerts and their evidence."""
147
+ if not incident_id.strip().isdigit():
148
+ raise InputError(f"{incident_id!r} is not an incident id (they are numbers)")
149
+ try:
150
+ record = self.api.get(f"{_PATH}/{incident_id.strip()}", params={"$expand": "alerts"})
151
+ except ApiError as exc:
152
+ if exc.status == 404:
153
+ raise NotFoundError(f"no incident {incident_id}") from None
154
+ raise _explained(exc) from None
155
+ return Incident.from_graph(record)
156
+
157
+
158
+ def most_severe(incidents: Iterable[Incident]) -> list[Incident]:
159
+ """Most severe first, and newest first within a severity."""
160
+ ordered = newest(incidents)
161
+ return sorted(ordered, key=lambda incident: severity_rank(incident.severity))
162
+
163
+
164
+ def newest(incidents: Iterable[Incident]) -> list[Incident]:
165
+ """Newest first, by when each was created."""
166
+ return sorted(incidents, key=lambda incident: incident.created or _EARLIEST, reverse=True)
167
+
168
+
169
+ def summarise(incidents: Sequence[Incident]) -> Summary:
170
+ """How many, by severity (most severe first), status and source."""
171
+ severities = Counter(incident.severity.lower() or "unknown" for incident in incidents)
172
+ order = [*SEVERITY_ORDER, *sorted(set(severities) - set(SEVERITY_ORDER))]
173
+ sources = Counter(name for incident in incidents for name in incident.source_names)
174
+ return Summary(
175
+ total=len(incidents),
176
+ by_severity={name: severities[name] for name in order if severities[name]},
177
+ by_status=dict(Counter(incident.status for incident in incidents).most_common()),
178
+ by_source=dict(sources.most_common()),
179
+ )
180
+
181
+
182
+ def severities_from(minimum: str) -> tuple[str, ...]:
183
+ """The severities at or above ``minimum``: ``medium`` -> high, medium."""
184
+ level = minimum.strip().lower()
185
+ if level not in SEVERITY_ORDER:
186
+ raise InputError(
187
+ f"unknown severity {minimum!r}", hint=f"use one of {', '.join(SEVERITY_ORDER)}"
188
+ )
189
+ return SEVERITY_ORDER[: SEVERITY_ORDER.index(level) + 1]
190
+
191
+
192
+ def _filter(
193
+ start: datetime | None,
194
+ end: datetime | None,
195
+ by: str,
196
+ statuses: Sequence[str],
197
+ severities: Sequence[str],
198
+ ) -> str:
199
+ terms: list[str] = []
200
+ if start is not None:
201
+ terms.append(f"{by} ge {odata_datetime(start)}")
202
+ if end is not None:
203
+ terms.append(f"{by} lt {odata_datetime(end)}")
204
+ for field, values in (("status", statuses), ("severity", severities)):
205
+ if values:
206
+ either = " or ".join(f"{field} eq {odata_string(value)}" for value in values)
207
+ terms.append(f"({either})" if len(values) > 1 else either)
208
+ return " and ".join(terms)
209
+
210
+
211
+ def _explained(exc: ApiError) -> ApiError:
212
+ # A suspended service answers 403 too, and its own hint says why better.
213
+ if exc.status in {401, 403} and "suspended" not in str(exc).lower():
214
+ return ApiError(
215
+ str(exc), status=exc.status, code=exc.code, request_id=exc.request_id, hint=SCOPE_HINT
216
+ )
217
+ return exc