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,286 @@
|
|
|
1
|
+
"""Check a list of devices against expectations, once (fast) or until done (watch).
|
|
2
|
+
|
|
3
|
+
Speed comes from three things. Lookups for different devices run in parallel. Each
|
|
4
|
+
expected group's members are fetched once per pass rather than once per device. And a
|
|
5
|
+
watch rechecks only the devices not yet complete, unless asked to recheck them all.
|
|
6
|
+
|
|
7
|
+
A rejected token or a missing permission (HTTP 401 or 403) ends the check, since every
|
|
8
|
+
device would fail the same way. Anything else that fails for one device is recorded
|
|
9
|
+
as an error for that device, and a watch simply tries again next pass.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from typing import TypeVar
|
|
19
|
+
|
|
20
|
+
from libre_devops_helpers.core.auth import utc_now
|
|
21
|
+
from libre_devops_helpers.core.errors import ApiError, LdoError, ReauthRequired
|
|
22
|
+
from libre_devops_helpers.core.poll import PollLimits, PollOutcome, poll
|
|
23
|
+
from libre_devops_helpers.microsoft.devices.models import (
|
|
24
|
+
CheckRun,
|
|
25
|
+
DeviceReport,
|
|
26
|
+
Expectations,
|
|
27
|
+
Outcome,
|
|
28
|
+
)
|
|
29
|
+
from libre_devops_helpers.microsoft.entra.client import EntraClient
|
|
30
|
+
from libre_devops_helpers.microsoft.entra.models import EntraDevice
|
|
31
|
+
from libre_devops_helpers.microsoft.intune.client import IntuneClient
|
|
32
|
+
from libre_devops_helpers.microsoft.intune.models import ManagedDevice
|
|
33
|
+
from libre_devops_helpers.microsoft.xdr.client import XdrClient
|
|
34
|
+
from libre_devops_helpers.microsoft.xdr.models import MachineLookup
|
|
35
|
+
|
|
36
|
+
_FATAL = frozenset({401, 403})
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DeviceChecker:
|
|
42
|
+
"""Checks devices against ``Expectations`` using whichever clients they need."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
entra: EntraClient | None = None,
|
|
48
|
+
xdr: XdrClient | None = None,
|
|
49
|
+
intune: IntuneClient | None = None,
|
|
50
|
+
workers: int = 8,
|
|
51
|
+
clock: Callable[[], datetime] = utc_now,
|
|
52
|
+
) -> None:
|
|
53
|
+
if workers < 1:
|
|
54
|
+
raise LdoError("workers must be at least 1")
|
|
55
|
+
self._entra = entra
|
|
56
|
+
self._xdr = xdr
|
|
57
|
+
self._intune = intune
|
|
58
|
+
self._workers = workers
|
|
59
|
+
self._clock = clock
|
|
60
|
+
|
|
61
|
+
def check(self, names: Sequence[str], expectations: Expectations) -> CheckRun:
|
|
62
|
+
"""One pass over ``names``, in order."""
|
|
63
|
+
self._require_clients(expectations)
|
|
64
|
+
self._prepare(expectations)
|
|
65
|
+
members = self._group_members(expectations.groups)
|
|
66
|
+
if not names:
|
|
67
|
+
return CheckRun((), self._clock(), 0)
|
|
68
|
+
try:
|
|
69
|
+
reports = self._check_all(names, expectations, members)
|
|
70
|
+
except ReauthRequired:
|
|
71
|
+
# A sign-in lapsed mid-pass, in a worker, which must not stop to ask anyone.
|
|
72
|
+
# Ask here on the calling thread (this raises again if nobody can sign in),
|
|
73
|
+
# then run the pass again.
|
|
74
|
+
self._prepare(expectations)
|
|
75
|
+
reports = self._check_all(names, expectations, members)
|
|
76
|
+
return CheckRun(tuple(reports), self._clock(), len(names))
|
|
77
|
+
|
|
78
|
+
def _prepare(self, expectations: Expectations) -> None:
|
|
79
|
+
"""Get each service's token on this thread before the workers need it.
|
|
80
|
+
|
|
81
|
+
A token is normally cached already, so this is cheap; when a sign-in has lapsed,
|
|
82
|
+
it is where a credential that can ask someone to sign in again gets to ask.
|
|
83
|
+
"""
|
|
84
|
+
for needed, client in (
|
|
85
|
+
(expectations.needs_entra or expectations.groups, self._entra),
|
|
86
|
+
(expectations.needs_defender, self._xdr),
|
|
87
|
+
(expectations.needs_intune, self._intune),
|
|
88
|
+
):
|
|
89
|
+
if needed and client is not None:
|
|
90
|
+
client.api.ensure_token()
|
|
91
|
+
|
|
92
|
+
def _check_all(
|
|
93
|
+
self,
|
|
94
|
+
names: Sequence[str],
|
|
95
|
+
expectations: Expectations,
|
|
96
|
+
members: Mapping[str, frozenset[str]],
|
|
97
|
+
) -> list[DeviceReport]:
|
|
98
|
+
with ThreadPoolExecutor(max_workers=min(self._workers, len(names))) as pool:
|
|
99
|
+
return list(pool.map(lambda name: self._check_one(name, expectations, members), names))
|
|
100
|
+
|
|
101
|
+
def _require_clients(self, expectations: Expectations) -> None:
|
|
102
|
+
missing = [
|
|
103
|
+
service
|
|
104
|
+
for service, needed, client in (
|
|
105
|
+
("Entra", expectations.needs_entra, self._entra),
|
|
106
|
+
("Defender", expectations.needs_defender, self._xdr),
|
|
107
|
+
("Intune", expectations.needs_intune, self._intune),
|
|
108
|
+
)
|
|
109
|
+
if needed and client is None
|
|
110
|
+
]
|
|
111
|
+
if missing:
|
|
112
|
+
raise LdoError(f"these checks need a client for {', '.join(missing)}")
|
|
113
|
+
|
|
114
|
+
def _group_members(self, groups: Sequence[str]) -> dict[str, frozenset[str]]:
|
|
115
|
+
if not groups or self._entra is None:
|
|
116
|
+
return {}
|
|
117
|
+
entra = self._entra
|
|
118
|
+
members: dict[str, frozenset[str]] = {}
|
|
119
|
+
for ref in groups:
|
|
120
|
+
group = entra.get_group(ref)
|
|
121
|
+
members[ref] = frozenset(device.id for device in entra.group_devices(group))
|
|
122
|
+
return members
|
|
123
|
+
|
|
124
|
+
def _check_one(
|
|
125
|
+
self, name: str, expectations: Expectations, members: Mapping[str, frozenset[str]]
|
|
126
|
+
) -> DeviceReport:
|
|
127
|
+
errors: dict[str, str] = {}
|
|
128
|
+
entra_devices: tuple[EntraDevice, ...] = ()
|
|
129
|
+
lookup: MachineLookup | None = None
|
|
130
|
+
managed: tuple[ManagedDevice, ...] = ()
|
|
131
|
+
if expectations.needs_entra and self._entra is not None:
|
|
132
|
+
entra = self._entra
|
|
133
|
+
entra_devices = _guarded(errors, "entra", lambda: tuple(entra.find_devices(name)), ())
|
|
134
|
+
if expectations.needs_defender and self._xdr is not None:
|
|
135
|
+
xdr = self._xdr
|
|
136
|
+
lookup = _guarded(errors, "defender", lambda: xdr.find_machine(name), None)
|
|
137
|
+
if expectations.needs_intune and self._intune is not None:
|
|
138
|
+
intune = self._intune
|
|
139
|
+
managed = _guarded(errors, "intune", lambda: tuple(intune.find_devices(name)), ())
|
|
140
|
+
|
|
141
|
+
outcomes: list[Outcome] = []
|
|
142
|
+
if expectations.in_entra:
|
|
143
|
+
outcomes.append(_entra_outcome(entra_devices, errors.get("entra")))
|
|
144
|
+
machine = lookup.machine if lookup is not None else None
|
|
145
|
+
defender_error = errors.get("defender")
|
|
146
|
+
if expectations.onboarded:
|
|
147
|
+
if defender_error:
|
|
148
|
+
outcomes.append(Outcome("defender", "error", defender_error))
|
|
149
|
+
elif machine is None:
|
|
150
|
+
outcomes.append(Outcome("defender", "unmet", "no Defender record"))
|
|
151
|
+
elif machine.onboarding_status == "Onboarded":
|
|
152
|
+
outcomes.append(Outcome("defender", "met", f"onboarded, {machine.health_status}"))
|
|
153
|
+
else:
|
|
154
|
+
outcomes.append(
|
|
155
|
+
Outcome("defender", "unmet", machine.onboarding_status or "not onboarded")
|
|
156
|
+
)
|
|
157
|
+
if expectations.active:
|
|
158
|
+
if defender_error:
|
|
159
|
+
outcomes.append(Outcome("active", "error", defender_error))
|
|
160
|
+
elif machine is None:
|
|
161
|
+
outcomes.append(Outcome("active", "unmet", "no Defender record"))
|
|
162
|
+
elif machine.health_status == "Active":
|
|
163
|
+
outcomes.append(Outcome("active", "met", "Active"))
|
|
164
|
+
else:
|
|
165
|
+
outcomes.append(Outcome("active", "unmet", machine.health_status or "unknown"))
|
|
166
|
+
for tag in expectations.tags:
|
|
167
|
+
check = f"tag {tag}"
|
|
168
|
+
if defender_error:
|
|
169
|
+
outcomes.append(Outcome(check, "error", defender_error))
|
|
170
|
+
elif machine is None:
|
|
171
|
+
outcomes.append(Outcome(check, "unmet", "no Defender record"))
|
|
172
|
+
elif tag.casefold() in {item.casefold() for item in machine.machine_tags}:
|
|
173
|
+
outcomes.append(Outcome(check, "met", "tagged"))
|
|
174
|
+
else:
|
|
175
|
+
outcomes.append(Outcome(check, "unmet", "tag missing"))
|
|
176
|
+
for device_group in expectations.device_groups:
|
|
177
|
+
check = f"device group {device_group}"
|
|
178
|
+
if defender_error:
|
|
179
|
+
outcomes.append(Outcome(check, "error", defender_error))
|
|
180
|
+
elif machine is None:
|
|
181
|
+
outcomes.append(Outcome(check, "unmet", "no Defender record"))
|
|
182
|
+
elif machine.device_group.casefold() == device_group.casefold():
|
|
183
|
+
outcomes.append(Outcome(check, "met", "in the device group"))
|
|
184
|
+
else:
|
|
185
|
+
outcomes.append(Outcome(check, "unmet", f"in {machine.device_group or 'none'}"))
|
|
186
|
+
for group in expectations.groups:
|
|
187
|
+
check = f"group {group}"
|
|
188
|
+
if errors.get("entra"):
|
|
189
|
+
outcomes.append(Outcome(check, "error", errors["entra"]))
|
|
190
|
+
elif not entra_devices:
|
|
191
|
+
outcomes.append(Outcome(check, "unmet", "not in Entra"))
|
|
192
|
+
elif any(device.id in members.get(group, frozenset()) for device in entra_devices):
|
|
193
|
+
outcomes.append(Outcome(check, "met", "member"))
|
|
194
|
+
else:
|
|
195
|
+
outcomes.append(Outcome(check, "unmet", "not a member"))
|
|
196
|
+
newest = managed[0] if managed else None
|
|
197
|
+
if expectations.in_intune:
|
|
198
|
+
if errors.get("intune"):
|
|
199
|
+
outcomes.append(Outcome("intune", "error", errors["intune"]))
|
|
200
|
+
elif newest is None:
|
|
201
|
+
outcomes.append(Outcome("intune", "unmet", "not enrolled in Intune"))
|
|
202
|
+
else:
|
|
203
|
+
outcomes.append(Outcome("intune", "met", newest.management_agent or "enrolled"))
|
|
204
|
+
if expectations.compliant:
|
|
205
|
+
if errors.get("intune"):
|
|
206
|
+
outcomes.append(Outcome("compliant", "error", errors["intune"]))
|
|
207
|
+
elif newest is None:
|
|
208
|
+
outcomes.append(Outcome("compliant", "unmet", "not enrolled in Intune"))
|
|
209
|
+
elif newest.compliant:
|
|
210
|
+
outcomes.append(Outcome("compliant", "met", "compliant"))
|
|
211
|
+
else:
|
|
212
|
+
outcomes.append(Outcome("compliant", "unmet", newest.compliance_state or "unknown"))
|
|
213
|
+
return DeviceReport(name, tuple(outcomes), entra_devices, lookup, managed)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def watch(
|
|
217
|
+
checker: DeviceChecker,
|
|
218
|
+
names: Sequence[str],
|
|
219
|
+
expectations: Expectations,
|
|
220
|
+
limits: PollLimits,
|
|
221
|
+
*,
|
|
222
|
+
recheck: bool = False,
|
|
223
|
+
clock: Callable[[], float] = time.monotonic,
|
|
224
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
225
|
+
on_pass: Callable[[int, CheckRun], None] | None = None,
|
|
226
|
+
on_wait: Callable[[float], None] | None = None,
|
|
227
|
+
) -> PollOutcome[CheckRun]:
|
|
228
|
+
"""Check ``names`` every ``limits.interval`` until all are complete or a limit hits.
|
|
229
|
+
|
|
230
|
+
A device that met everything is not checked again unless ``recheck`` is set, so
|
|
231
|
+
later passes only cost calls for the devices still outstanding. A pass that fails
|
|
232
|
+
outright (other than on a rejected token) is reported and the next pass tries again.
|
|
233
|
+
"""
|
|
234
|
+
latest: dict[str, DeviceReport] = {}
|
|
235
|
+
order = list(names)
|
|
236
|
+
|
|
237
|
+
def current(error: str | None = None) -> CheckRun:
|
|
238
|
+
reports = tuple(latest[name] for name in order if name in latest)
|
|
239
|
+
return CheckRun(reports, utc_now(), len(order), error)
|
|
240
|
+
|
|
241
|
+
def run_pass(_number: int) -> CheckRun:
|
|
242
|
+
pending = (
|
|
243
|
+
order
|
|
244
|
+
if recheck
|
|
245
|
+
else [name for name in order if name not in latest or not latest[name].complete]
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
run = checker.check(pending, expectations)
|
|
249
|
+
except ApiError as exc:
|
|
250
|
+
if exc.status in _FATAL:
|
|
251
|
+
raise
|
|
252
|
+
return current(str(exc))
|
|
253
|
+
for report in run.reports:
|
|
254
|
+
latest[report.name] = report
|
|
255
|
+
return current()
|
|
256
|
+
|
|
257
|
+
return poll(
|
|
258
|
+
run_pass,
|
|
259
|
+
lambda run: run.complete,
|
|
260
|
+
limits,
|
|
261
|
+
clock=clock,
|
|
262
|
+
sleep=sleep,
|
|
263
|
+
on_pass=on_pass,
|
|
264
|
+
on_wait=on_wait,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _guarded(errors: dict[str, str], key: str, call: Callable[[], T], default: T) -> T:
|
|
269
|
+
try:
|
|
270
|
+
return call()
|
|
271
|
+
except ApiError as exc:
|
|
272
|
+
if exc.status in _FATAL:
|
|
273
|
+
raise
|
|
274
|
+
errors[key] = str(exc)
|
|
275
|
+
return default
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _entra_outcome(devices: tuple[EntraDevice, ...], error: str | None) -> Outcome:
|
|
279
|
+
if error:
|
|
280
|
+
return Outcome("entra", "error", error)
|
|
281
|
+
if not devices:
|
|
282
|
+
return Outcome("entra", "unmet", "not in Entra")
|
|
283
|
+
if all(device.enabled is False for device in devices):
|
|
284
|
+
return Outcome("entra", "unmet", "disabled in Entra")
|
|
285
|
+
detail = "present" if len(devices) == 1 else f"{len(devices)} objects share the name"
|
|
286
|
+
return Outcome("entra", "met", detail)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""One device across Entra, Defender and Intune, and what looks wrong about it.
|
|
2
|
+
|
|
3
|
+
Entra is required: it is where a device's identity lives. Defender and Intune are
|
|
4
|
+
optional, and a failure reading either one (a suspended service, a token without the
|
|
5
|
+
scope) becomes a finding rather than an error, so the rest of the picture still shows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
from libre_devops_helpers.core.auth import utc_now
|
|
13
|
+
from libre_devops_helpers.core.errors import ApiError
|
|
14
|
+
from libre_devops_helpers.core.util import format_duration
|
|
15
|
+
from libre_devops_helpers.microsoft.devices.models import DeviceView, Finding
|
|
16
|
+
from libre_devops_helpers.microsoft.entra.client import EntraClient
|
|
17
|
+
from libre_devops_helpers.microsoft.entra.models import EntraDevice, EntraGroup
|
|
18
|
+
from libre_devops_helpers.microsoft.intune.client import IntuneClient
|
|
19
|
+
from libre_devops_helpers.microsoft.intune.models import ManagedDevice
|
|
20
|
+
from libre_devops_helpers.microsoft.xdr.client import XdrClient
|
|
21
|
+
from libre_devops_helpers.microsoft.xdr.models import MachineLookup
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def inspect_device(
|
|
25
|
+
name: str,
|
|
26
|
+
*,
|
|
27
|
+
entra: EntraClient,
|
|
28
|
+
xdr: XdrClient | None = None,
|
|
29
|
+
intune: IntuneClient | None = None,
|
|
30
|
+
stale_after: timedelta = timedelta(days=7),
|
|
31
|
+
now: datetime | None = None,
|
|
32
|
+
) -> DeviceView:
|
|
33
|
+
"""Everything the services know about ``name``, with findings worth acting on."""
|
|
34
|
+
now = now or utc_now()
|
|
35
|
+
findings: list[Finding] = []
|
|
36
|
+
|
|
37
|
+
devices = tuple(entra.find_devices(name))
|
|
38
|
+
groups: dict[str, tuple[EntraGroup, ...]] = {
|
|
39
|
+
device.id: tuple(entra.device_groups(device)) for device in devices
|
|
40
|
+
}
|
|
41
|
+
findings.extend(_entra_findings(devices))
|
|
42
|
+
|
|
43
|
+
lookup: MachineLookup | None = None
|
|
44
|
+
if xdr is not None:
|
|
45
|
+
try:
|
|
46
|
+
lookup = xdr.find_machine(name)
|
|
47
|
+
except ApiError as exc:
|
|
48
|
+
findings.append(Finding("warn", f"Defender could not be read: {exc}"))
|
|
49
|
+
else:
|
|
50
|
+
findings.extend(_defender_findings(lookup, devices, stale_after, now))
|
|
51
|
+
|
|
52
|
+
managed: tuple[ManagedDevice, ...] | None = None
|
|
53
|
+
if intune is not None:
|
|
54
|
+
try:
|
|
55
|
+
managed = tuple(intune.find_devices(name))
|
|
56
|
+
except ApiError as exc:
|
|
57
|
+
findings.append(Finding("warn", f"Intune could not be read: {exc}"))
|
|
58
|
+
else:
|
|
59
|
+
findings.extend(_intune_findings(managed, devices))
|
|
60
|
+
|
|
61
|
+
if not any(finding.level == "warn" for finding in findings):
|
|
62
|
+
findings.append(Finding("ok", "nothing looks wrong"))
|
|
63
|
+
return DeviceView(name, devices, groups, lookup, managed, tuple(findings))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _entra_findings(devices: tuple[EntraDevice, ...]) -> list[Finding]:
|
|
67
|
+
if not devices:
|
|
68
|
+
return [Finding("warn", "not in Entra")]
|
|
69
|
+
found: list[Finding] = []
|
|
70
|
+
if len(devices) > 1:
|
|
71
|
+
found.append(
|
|
72
|
+
Finding("warn", f"{len(devices)} Entra objects share this name (stale registrations)")
|
|
73
|
+
)
|
|
74
|
+
found.extend(
|
|
75
|
+
Finding("warn", f"Entra object {device.id} is disabled")
|
|
76
|
+
for device in devices
|
|
77
|
+
if device.enabled is False
|
|
78
|
+
)
|
|
79
|
+
return found
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _defender_findings(
|
|
83
|
+
lookup: MachineLookup,
|
|
84
|
+
devices: tuple[EntraDevice, ...],
|
|
85
|
+
stale_after: timedelta,
|
|
86
|
+
now: datetime,
|
|
87
|
+
) -> list[Finding]:
|
|
88
|
+
machine = lookup.machine
|
|
89
|
+
if machine is None:
|
|
90
|
+
return [Finding("warn", "no Defender record")]
|
|
91
|
+
found: list[Finding] = []
|
|
92
|
+
if len(lookup.records) > 1:
|
|
93
|
+
found.append(Finding("warn", f"{len(lookup.records)} Defender records; the newest is used"))
|
|
94
|
+
if lookup.matched_name != lookup.query.strip().rstrip("."):
|
|
95
|
+
found.append(Finding("info", f"Defender knows it by its short name {lookup.matched_name}"))
|
|
96
|
+
if machine.onboarding_status != "Onboarded":
|
|
97
|
+
found.append(
|
|
98
|
+
Finding("warn", f"Defender onboarding status is {machine.onboarding_status or '-'}")
|
|
99
|
+
)
|
|
100
|
+
if machine.health_status != "Active":
|
|
101
|
+
found.append(Finding("warn", f"Defender health is {machine.health_status or '-'}"))
|
|
102
|
+
if machine.last_seen is not None and now - machine.last_seen > stale_after:
|
|
103
|
+
found.append(
|
|
104
|
+
Finding("warn", f"Defender last saw it {format_duration(now - machine.last_seen)} ago")
|
|
105
|
+
)
|
|
106
|
+
if not machine.aad_device_id:
|
|
107
|
+
found.append(
|
|
108
|
+
Finding(
|
|
109
|
+
"info", "Defender has no Entra device id for it (not Entra joined or registered)"
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
elif devices and machine.aad_device_id.lower() not in {d.device_id.lower() for d in devices}:
|
|
113
|
+
found.append(
|
|
114
|
+
Finding(
|
|
115
|
+
"warn",
|
|
116
|
+
f"Defender links it to Entra device {machine.aad_device_id}, "
|
|
117
|
+
"which is not one of the Entra objects with this name",
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return found
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _intune_findings(
|
|
124
|
+
managed: tuple[ManagedDevice, ...], devices: tuple[EntraDevice, ...]
|
|
125
|
+
) -> list[Finding]:
|
|
126
|
+
if not managed:
|
|
127
|
+
return [Finding("info", "not enrolled in Intune")]
|
|
128
|
+
newest = managed[0]
|
|
129
|
+
found: list[Finding] = []
|
|
130
|
+
if len(managed) > 1:
|
|
131
|
+
found.append(Finding("warn", f"{len(managed)} Intune records; the newest sync is used"))
|
|
132
|
+
if not newest.compliant:
|
|
133
|
+
found.append(Finding("warn", f"Intune compliance is {newest.compliance_state or '-'}"))
|
|
134
|
+
if (
|
|
135
|
+
newest.azure_ad_device_id
|
|
136
|
+
and devices
|
|
137
|
+
and newest.azure_ad_device_id.lower() not in {d.device_id.lower() for d in devices}
|
|
138
|
+
):
|
|
139
|
+
found.append(
|
|
140
|
+
Finding(
|
|
141
|
+
"warn",
|
|
142
|
+
f"Intune links it to Entra device {newest.azure_ad_device_id}, "
|
|
143
|
+
"which is not one of the Entra objects with this name",
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return found
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""What a device check expects, and what it found."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.core.errors import InputError
|
|
11
|
+
from libre_devops_helpers.microsoft.entra.models import EntraDevice, EntraGroup
|
|
12
|
+
from libre_devops_helpers.microsoft.intune.models import ManagedDevice
|
|
13
|
+
from libre_devops_helpers.microsoft.xdr.models import MachineLookup
|
|
14
|
+
|
|
15
|
+
Status = Literal["met", "unmet", "error"]
|
|
16
|
+
Level = Literal["ok", "info", "warn"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Expectations:
|
|
21
|
+
"""The state every device should be in. Each enabled item is one check per device."""
|
|
22
|
+
|
|
23
|
+
in_entra: bool = True
|
|
24
|
+
onboarded: bool = True
|
|
25
|
+
active: bool = False
|
|
26
|
+
tags: tuple[str, ...] = ()
|
|
27
|
+
device_groups: tuple[str, ...] = ()
|
|
28
|
+
groups: tuple[str, ...] = ()
|
|
29
|
+
in_intune: bool = False
|
|
30
|
+
compliant: bool = False
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not self.checks:
|
|
34
|
+
raise InputError(
|
|
35
|
+
"nothing to check",
|
|
36
|
+
hint="enable at least one of Entra, Defender, tags, device groups, groups",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def needs_entra(self) -> bool:
|
|
41
|
+
return self.in_entra or bool(self.groups)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def needs_defender(self) -> bool:
|
|
45
|
+
return self.onboarded or self.active or bool(self.tags) or bool(self.device_groups)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def needs_intune(self) -> bool:
|
|
49
|
+
return self.in_intune or self.compliant
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def checks(self) -> tuple[str, ...]:
|
|
53
|
+
"""The check names, in the order they are reported."""
|
|
54
|
+
names: list[str] = []
|
|
55
|
+
if self.in_entra:
|
|
56
|
+
names.append("entra")
|
|
57
|
+
if self.onboarded:
|
|
58
|
+
names.append("defender")
|
|
59
|
+
if self.active:
|
|
60
|
+
names.append("active")
|
|
61
|
+
names.extend(f"tag {tag}" for tag in self.tags)
|
|
62
|
+
names.extend(f"device group {group}" for group in self.device_groups)
|
|
63
|
+
names.extend(f"group {group}" for group in self.groups)
|
|
64
|
+
if self.in_intune:
|
|
65
|
+
names.append("intune")
|
|
66
|
+
if self.compliant:
|
|
67
|
+
names.append("compliant")
|
|
68
|
+
return tuple(names)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Outcome:
|
|
73
|
+
"""One check on one device."""
|
|
74
|
+
|
|
75
|
+
check: str
|
|
76
|
+
status: Status
|
|
77
|
+
detail: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class DeviceReport:
|
|
82
|
+
"""Everything one check pass found for one device name."""
|
|
83
|
+
|
|
84
|
+
name: str
|
|
85
|
+
outcomes: tuple[Outcome, ...]
|
|
86
|
+
entra: tuple[EntraDevice, ...] = ()
|
|
87
|
+
defender: MachineLookup | None = None
|
|
88
|
+
intune: tuple[ManagedDevice, ...] = ()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def complete(self) -> bool:
|
|
92
|
+
return all(outcome.status == "met" for outcome in self.outcomes)
|
|
93
|
+
|
|
94
|
+
def outcome(self, check: str) -> Outcome | None:
|
|
95
|
+
return next((item for item in self.outcomes if item.check == check), None)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class CheckRun:
|
|
100
|
+
"""One pass over every device. ``error`` is set when the pass itself failed."""
|
|
101
|
+
|
|
102
|
+
reports: tuple[DeviceReport, ...]
|
|
103
|
+
checked_at: datetime
|
|
104
|
+
expected: int
|
|
105
|
+
error: str | None = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def complete(self) -> bool:
|
|
109
|
+
return (
|
|
110
|
+
self.error is None
|
|
111
|
+
and len(self.reports) == self.expected
|
|
112
|
+
and all(report.complete for report in self.reports)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def counts(self, checks: tuple[str, ...]) -> dict[str, int]:
|
|
116
|
+
"""How many devices meet each check."""
|
|
117
|
+
return {
|
|
118
|
+
check: sum(
|
|
119
|
+
1
|
|
120
|
+
for report in self.reports
|
|
121
|
+
if (outcome := report.outcome(check)) is not None and outcome.status == "met"
|
|
122
|
+
)
|
|
123
|
+
for check in checks
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class Finding:
|
|
129
|
+
"""One observation about a device from ``devices show``."""
|
|
130
|
+
|
|
131
|
+
level: Level
|
|
132
|
+
message: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class DeviceView:
|
|
137
|
+
"""A device across Entra, Defender and Intune, with what looks wrong about it.
|
|
138
|
+
|
|
139
|
+
``defender`` and ``intune`` are None when that service was not asked, or could not
|
|
140
|
+
be read (a finding says which).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
name: str
|
|
144
|
+
entra: tuple[EntraDevice, ...]
|
|
145
|
+
groups: Mapping[str, tuple[EntraGroup, ...]] = field(default_factory=dict)
|
|
146
|
+
defender: MachineLookup | None = None
|
|
147
|
+
intune: tuple[ManagedDevice, ...] | None = None
|
|
148
|
+
findings: tuple[Finding, ...] = ()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Entra ID through Microsoft Graph: devices, users, groups, roles, sign-ins, apps and policies.
|
|
2
|
+
|
|
3
|
+
Depends only on ``core`` and the shared Microsoft layer. Public API::
|
|
4
|
+
|
|
5
|
+
from libre_devops_helpers.microsoft.auth import AzureCliCredential
|
|
6
|
+
from libre_devops_helpers.microsoft.entra import EntraClient
|
|
7
|
+
|
|
8
|
+
with EntraClient.create(AzureCliCredential(), tenant_id) as entra:
|
|
9
|
+
for device in entra.find_devices("web01.corp.example.com"):
|
|
10
|
+
groups = entra.device_groups(device)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from libre_devops_helpers.microsoft.entra.client import EntraClient, MemberKind, expiring
|
|
14
|
+
from libre_devops_helpers.microsoft.entra.models import (
|
|
15
|
+
AppCredential,
|
|
16
|
+
ConditionalAccessPolicy,
|
|
17
|
+
DirectoryObject,
|
|
18
|
+
EntraDevice,
|
|
19
|
+
EntraGroup,
|
|
20
|
+
EntraUser,
|
|
21
|
+
RoleAssignment,
|
|
22
|
+
RoleReport,
|
|
23
|
+
SignIn,
|
|
24
|
+
)
|
|
25
|
+
from libre_devops_helpers.microsoft.entra.permissions import REQUIREMENTS
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"REQUIREMENTS",
|
|
29
|
+
"AppCredential",
|
|
30
|
+
"ConditionalAccessPolicy",
|
|
31
|
+
"DirectoryObject",
|
|
32
|
+
"EntraClient",
|
|
33
|
+
"EntraDevice",
|
|
34
|
+
"EntraGroup",
|
|
35
|
+
"EntraUser",
|
|
36
|
+
"MemberKind",
|
|
37
|
+
"RoleAssignment",
|
|
38
|
+
"RoleReport",
|
|
39
|
+
"SignIn",
|
|
40
|
+
"expiring",
|
|
41
|
+
]
|