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.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- 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
|