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,548 @@
|
|
|
1
|
+
"""Device commands across Entra, Defender and Intune: check, watch, show, and AV versions."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from libre_devops_helpers.cli import render
|
|
9
|
+
from libre_devops_helpers.cli.exits import ATTENTION, INTERRUPTED
|
|
10
|
+
from libre_devops_helpers.cli.options import (
|
|
11
|
+
ColumnOption,
|
|
12
|
+
FromFileOption,
|
|
13
|
+
NamesArgument,
|
|
14
|
+
OutputOption,
|
|
15
|
+
ProfileOption,
|
|
16
|
+
SheetOption,
|
|
17
|
+
duration,
|
|
18
|
+
get_runtime,
|
|
19
|
+
names,
|
|
20
|
+
)
|
|
21
|
+
from libre_devops_helpers.cli.render import Output
|
|
22
|
+
from libre_devops_helpers.cli.runtime import MicrosoftRuntime
|
|
23
|
+
from libre_devops_helpers.core.poll import PollLimits
|
|
24
|
+
from libre_devops_helpers.core.util import format_duration
|
|
25
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
26
|
+
from libre_devops_helpers.microsoft.devices import (
|
|
27
|
+
AvStatus,
|
|
28
|
+
CheckRun,
|
|
29
|
+
DeviceChecker,
|
|
30
|
+
DeviceReport,
|
|
31
|
+
Expectations,
|
|
32
|
+
av_query,
|
|
33
|
+
av_statuses,
|
|
34
|
+
inspect_device,
|
|
35
|
+
version_key,
|
|
36
|
+
watch,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
devices_app = typer.Typer(
|
|
40
|
+
rich_markup_mode="markdown",
|
|
41
|
+
help="Devices across Entra, Defender and Intune: check, watch, show, and AV versions.",
|
|
42
|
+
no_args_is_help=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_STATUS_COLOURS = {"met": "green", "unmet": "yellow", "error": "red"}
|
|
46
|
+
_FINDING_COLOURS = {"ok": "green", "info": None, "warn": "yellow"}
|
|
47
|
+
|
|
48
|
+
EntraOption = Annotated[
|
|
49
|
+
bool, typer.Option("--entra/--no-entra", help="Expect the device in Entra ID.")
|
|
50
|
+
]
|
|
51
|
+
DefenderOption = Annotated[
|
|
52
|
+
bool, typer.Option("--defender/--no-defender", help="Expect it onboarded to Defender.")
|
|
53
|
+
]
|
|
54
|
+
ActiveOption = Annotated[
|
|
55
|
+
bool, typer.Option("--active", help="Expect Defender health to be Active.")
|
|
56
|
+
]
|
|
57
|
+
TagOption = Annotated[
|
|
58
|
+
list[str] | None,
|
|
59
|
+
typer.Option("--tag", help="Expect this Defender machine tag. Repeatable."),
|
|
60
|
+
]
|
|
61
|
+
DeviceGroupOption = Annotated[
|
|
62
|
+
list[str] | None,
|
|
63
|
+
typer.Option(
|
|
64
|
+
"--device-group",
|
|
65
|
+
help="Expect the device in this Defender device group (its name). Repeatable.",
|
|
66
|
+
),
|
|
67
|
+
]
|
|
68
|
+
GroupOption = Annotated[
|
|
69
|
+
list[str] | None,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--group", help="Expect membership of this Entra group: its object id or name. Repeatable."
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
IntuneOption = Annotated[bool, typer.Option("--intune", help="Expect it enrolled in Intune.")]
|
|
75
|
+
CompliantOption = Annotated[
|
|
76
|
+
bool, typer.Option("--compliant", help="Expect Intune to report it compliant.")
|
|
77
|
+
]
|
|
78
|
+
WorkersOption = Annotated[
|
|
79
|
+
int,
|
|
80
|
+
typer.Option("--workers", min=1, max=32, help="Devices looked up at once."),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def register(app: typer.Typer) -> None:
|
|
85
|
+
app.add_typer(devices_app, name="devices")
|
|
86
|
+
app.add_typer(devices_app, name="device", hidden=True) # the singular works as well
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _expectations(
|
|
90
|
+
entra: bool,
|
|
91
|
+
defender: bool,
|
|
92
|
+
active: bool,
|
|
93
|
+
tags: list[str] | None,
|
|
94
|
+
device_groups: list[str] | None,
|
|
95
|
+
groups: list[str] | None,
|
|
96
|
+
intune: bool,
|
|
97
|
+
compliant: bool,
|
|
98
|
+
) -> Expectations:
|
|
99
|
+
return Expectations(
|
|
100
|
+
in_entra=entra,
|
|
101
|
+
onboarded=defender,
|
|
102
|
+
active=active,
|
|
103
|
+
tags=tuple(tags or ()),
|
|
104
|
+
device_groups=tuple(device_groups or ()),
|
|
105
|
+
groups=tuple(groups or ()),
|
|
106
|
+
in_intune=intune,
|
|
107
|
+
compliant=compliant,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _checker(
|
|
112
|
+
runtime: MicrosoftRuntime, profile: Profile, expectations: Expectations, workers: int
|
|
113
|
+
) -> DeviceChecker:
|
|
114
|
+
# Only build the clients the expectations need, so an Entra-only check never asks
|
|
115
|
+
# Defender or Intune for a token.
|
|
116
|
+
return DeviceChecker(
|
|
117
|
+
entra=runtime.entra(profile) if expectations.needs_entra else None,
|
|
118
|
+
xdr=runtime.xdr(profile) if expectations.needs_defender else None,
|
|
119
|
+
intune=runtime.intune(profile) if expectations.needs_intune else None,
|
|
120
|
+
workers=workers,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_run(run: CheckRun, expectations: Expectations, output: Output) -> None:
|
|
125
|
+
checks = expectations.checks
|
|
126
|
+
rows: list[list[render.Cell]] = []
|
|
127
|
+
for report in run.reports:
|
|
128
|
+
cells: list[render.Cell] = [report.name]
|
|
129
|
+
for check in checks:
|
|
130
|
+
outcome = report.outcome(check)
|
|
131
|
+
if outcome is None:
|
|
132
|
+
cells.append("")
|
|
133
|
+
elif outcome.status == "met":
|
|
134
|
+
cells.append(("ok", "green"))
|
|
135
|
+
else:
|
|
136
|
+
cells.append((outcome.detail, _STATUS_COLOURS[outcome.status]))
|
|
137
|
+
rows.append(cells)
|
|
138
|
+
render.emit(
|
|
139
|
+
output,
|
|
140
|
+
["DEVICE", *(check.upper() for check in checks)],
|
|
141
|
+
rows,
|
|
142
|
+
{
|
|
143
|
+
"complete": run.complete,
|
|
144
|
+
"checked_at": run.checked_at,
|
|
145
|
+
"error": run.error,
|
|
146
|
+
"devices": [_record(report) for report in run.reports],
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _record(report: DeviceReport) -> dict[str, Any]:
|
|
152
|
+
return {
|
|
153
|
+
"name": report.name,
|
|
154
|
+
"complete": report.complete,
|
|
155
|
+
"checks": {
|
|
156
|
+
outcome.check: {"status": outcome.status, "detail": outcome.detail}
|
|
157
|
+
for outcome in report.outcomes
|
|
158
|
+
},
|
|
159
|
+
"entra": [dict(device.raw) for device in report.entra],
|
|
160
|
+
"defender": [dict(record.raw) for record in report.defender.records]
|
|
161
|
+
if report.defender
|
|
162
|
+
else [],
|
|
163
|
+
"intune": [dict(device.raw) for device in report.intune],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _summary(run: CheckRun, expectations: Expectations) -> str:
|
|
168
|
+
counts = run.counts(expectations.checks)
|
|
169
|
+
done = sum(1 for report in run.reports if report.complete)
|
|
170
|
+
parts = ", ".join(f"{check} {count}/{run.expected}" for check, count in counts.items())
|
|
171
|
+
return f"{done}/{run.expected} complete ({parts})"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@devices_app.command("check")
|
|
175
|
+
def check(
|
|
176
|
+
ctx: typer.Context,
|
|
177
|
+
devices: NamesArgument = None,
|
|
178
|
+
from_file: FromFileOption = None,
|
|
179
|
+
column: ColumnOption = None,
|
|
180
|
+
sheet: SheetOption = None,
|
|
181
|
+
entra: EntraOption = True,
|
|
182
|
+
defender: DefenderOption = True,
|
|
183
|
+
active: ActiveOption = False,
|
|
184
|
+
tag: TagOption = None,
|
|
185
|
+
device_group: DeviceGroupOption = None,
|
|
186
|
+
group: GroupOption = None,
|
|
187
|
+
intune: IntuneOption = False,
|
|
188
|
+
compliant: CompliantOption = False,
|
|
189
|
+
workers: WorkersOption = 8,
|
|
190
|
+
profile: ProfileOption = None,
|
|
191
|
+
output: OutputOption = Output.TABLE,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Check a list of devices once, fast, against what you expect of them.
|
|
194
|
+
|
|
195
|
+
By default each device must be in Entra and onboarded to Defender. Exits 3 when any
|
|
196
|
+
device misses any expectation.
|
|
197
|
+
"""
|
|
198
|
+
wanted = names(devices, from_file, column, sheet)
|
|
199
|
+
expectations = _expectations(
|
|
200
|
+
entra, defender, active, tag, device_group, group, intune, compliant
|
|
201
|
+
)
|
|
202
|
+
runtime = get_runtime(ctx).microsoft
|
|
203
|
+
selected = runtime.profile(profile)
|
|
204
|
+
run = _checker(runtime, selected, expectations, workers).check(wanted, expectations)
|
|
205
|
+
_render_run(run, expectations, output)
|
|
206
|
+
render.note(_summary(run, expectations) + f" (profile {selected.name})")
|
|
207
|
+
if not run.complete:
|
|
208
|
+
raise typer.Exit(ATTENTION)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@devices_app.command("watch")
|
|
212
|
+
def watch_devices(
|
|
213
|
+
ctx: typer.Context,
|
|
214
|
+
devices: NamesArgument = None,
|
|
215
|
+
from_file: FromFileOption = None,
|
|
216
|
+
column: ColumnOption = None,
|
|
217
|
+
sheet: SheetOption = None,
|
|
218
|
+
interval: Annotated[
|
|
219
|
+
str, typer.Option("--interval", help="Time between passes, e.g. 90s, 5m, 1h.")
|
|
220
|
+
] = "5m",
|
|
221
|
+
timeout: Annotated[
|
|
222
|
+
str | None,
|
|
223
|
+
typer.Option("--timeout", help="Give up after this long, e.g. 2h. Default: never."),
|
|
224
|
+
] = None,
|
|
225
|
+
max_passes: Annotated[
|
|
226
|
+
int | None,
|
|
227
|
+
typer.Option("--max-passes", min=1, help="Give up after this many passes."),
|
|
228
|
+
] = None,
|
|
229
|
+
recheck: Annotated[
|
|
230
|
+
bool,
|
|
231
|
+
typer.Option("--recheck", help="Recheck devices that already met everything, each pass."),
|
|
232
|
+
] = False,
|
|
233
|
+
entra: EntraOption = True,
|
|
234
|
+
defender: DefenderOption = True,
|
|
235
|
+
active: ActiveOption = False,
|
|
236
|
+
tag: TagOption = None,
|
|
237
|
+
device_group: DeviceGroupOption = None,
|
|
238
|
+
group: GroupOption = None,
|
|
239
|
+
intune: IntuneOption = False,
|
|
240
|
+
compliant: CompliantOption = False,
|
|
241
|
+
workers: WorkersOption = 8,
|
|
242
|
+
profile: ProfileOption = None,
|
|
243
|
+
output: OutputOption = Output.TABLE,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Check devices repeatedly until every one meets every expectation, or a limit hits.
|
|
246
|
+
|
|
247
|
+
Progress goes to stderr after each pass; the final state goes to stdout. Exits 0
|
|
248
|
+
when complete, 3 when a limit stopped it first, and 130 on Ctrl-C.
|
|
249
|
+
"""
|
|
250
|
+
wanted = names(devices, from_file, column, sheet)
|
|
251
|
+
expectations = _expectations(
|
|
252
|
+
entra, defender, active, tag, device_group, group, intune, compliant
|
|
253
|
+
)
|
|
254
|
+
every = duration(interval) or timedelta(minutes=5)
|
|
255
|
+
limit = duration(timeout)
|
|
256
|
+
limits = PollLimits(
|
|
257
|
+
interval=every.total_seconds(),
|
|
258
|
+
timeout=limit.total_seconds() if limit else None,
|
|
259
|
+
max_passes=max_passes,
|
|
260
|
+
)
|
|
261
|
+
runtime = get_runtime(ctx).microsoft
|
|
262
|
+
selected = runtime.profile(profile)
|
|
263
|
+
checker = _checker(runtime, selected, expectations, workers)
|
|
264
|
+
stop = f", up to {format_duration(limit)}" if limit else ""
|
|
265
|
+
stop += f", at most {max_passes} pass(es)" if max_passes else ""
|
|
266
|
+
render.note(
|
|
267
|
+
f"watching {len(wanted)} device(s) every {format_duration(every)}{stop}; Ctrl-C to stop"
|
|
268
|
+
)
|
|
269
|
+
last: list[CheckRun] = []
|
|
270
|
+
|
|
271
|
+
def on_pass(number: int, run: CheckRun) -> None:
|
|
272
|
+
last[:] = [run]
|
|
273
|
+
prefix = f"pass {number}: "
|
|
274
|
+
if run.error:
|
|
275
|
+
render.warn(prefix + f"failed, will retry: {run.error}")
|
|
276
|
+
else:
|
|
277
|
+
render.note(prefix + _summary(run, expectations))
|
|
278
|
+
|
|
279
|
+
def on_wait(seconds: float) -> None:
|
|
280
|
+
render.note(f"next pass in {format_duration(timedelta(seconds=seconds))}")
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
outcome = watch(
|
|
284
|
+
checker,
|
|
285
|
+
wanted,
|
|
286
|
+
expectations,
|
|
287
|
+
limits,
|
|
288
|
+
recheck=recheck,
|
|
289
|
+
clock=runtime.runtime.clock,
|
|
290
|
+
sleep=runtime.runtime.sleep,
|
|
291
|
+
on_pass=on_pass,
|
|
292
|
+
on_wait=on_wait,
|
|
293
|
+
)
|
|
294
|
+
except KeyboardInterrupt:
|
|
295
|
+
if last:
|
|
296
|
+
_render_run(last[0], expectations, output)
|
|
297
|
+
render.note("stopped")
|
|
298
|
+
raise typer.Exit(INTERRUPTED) from None
|
|
299
|
+
|
|
300
|
+
_render_run(outcome.result, expectations, output)
|
|
301
|
+
reasons = {
|
|
302
|
+
"complete": "every device meets every expectation",
|
|
303
|
+
"timeout": "timed out before every device was complete",
|
|
304
|
+
"max-passes": "reached --max-passes before every device was complete",
|
|
305
|
+
}
|
|
306
|
+
render.note(
|
|
307
|
+
f"{reasons[outcome.reason]} after {outcome.passes} pass(es), "
|
|
308
|
+
f"{format_duration(timedelta(seconds=outcome.elapsed))}"
|
|
309
|
+
)
|
|
310
|
+
if not outcome.complete:
|
|
311
|
+
raise typer.Exit(ATTENTION)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@devices_app.command("show")
|
|
315
|
+
def show(
|
|
316
|
+
ctx: typer.Context,
|
|
317
|
+
device: Annotated[str, typer.Argument(help="Device name: FQDN or short hostname.")],
|
|
318
|
+
intune: Annotated[
|
|
319
|
+
bool, typer.Option("--intune", help="Also read Intune (needs a token that can).")
|
|
320
|
+
] = False,
|
|
321
|
+
defender: Annotated[
|
|
322
|
+
bool, typer.Option("--defender/--no-defender", help="Read Defender.")
|
|
323
|
+
] = True,
|
|
324
|
+
stale_after: Annotated[
|
|
325
|
+
str, typer.Option("--stale-after", help="Warn when Defender last saw it longer ago.")
|
|
326
|
+
] = "7d",
|
|
327
|
+
profile: ProfileOption = None,
|
|
328
|
+
output: OutputOption = Output.TABLE,
|
|
329
|
+
) -> None:
|
|
330
|
+
"""Show one device across Entra, Defender and Intune, and what looks wrong about it.
|
|
331
|
+
|
|
332
|
+
Exits 3 when there is any warning.
|
|
333
|
+
"""
|
|
334
|
+
runtime = get_runtime(ctx).microsoft
|
|
335
|
+
selected = runtime.profile(profile)
|
|
336
|
+
view = inspect_device(
|
|
337
|
+
device,
|
|
338
|
+
entra=runtime.entra(selected),
|
|
339
|
+
xdr=runtime.xdr(selected) if defender else None,
|
|
340
|
+
intune=runtime.intune(selected) if intune else None,
|
|
341
|
+
stale_after=duration(stale_after) or timedelta(days=7),
|
|
342
|
+
)
|
|
343
|
+
warned = any(finding.level == "warn" for finding in view.findings)
|
|
344
|
+
if output is not Output.TABLE:
|
|
345
|
+
render.emit(
|
|
346
|
+
output,
|
|
347
|
+
["LEVEL", "FINDING"],
|
|
348
|
+
[[finding.level, finding.message] for finding in view.findings],
|
|
349
|
+
{
|
|
350
|
+
"name": view.name,
|
|
351
|
+
"findings": [
|
|
352
|
+
{"level": finding.level, "message": finding.message}
|
|
353
|
+
for finding in view.findings
|
|
354
|
+
],
|
|
355
|
+
"entra": [
|
|
356
|
+
{
|
|
357
|
+
"device": dict(item.raw),
|
|
358
|
+
"groups": [dict(group.raw) for group in view.groups.get(item.id, ())],
|
|
359
|
+
}
|
|
360
|
+
for item in view.entra
|
|
361
|
+
],
|
|
362
|
+
"defender": [dict(record.raw) for record in view.defender.records]
|
|
363
|
+
if view.defender
|
|
364
|
+
else None,
|
|
365
|
+
"intune": None if view.intune is None else [dict(item.raw) for item in view.intune],
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
if warned:
|
|
369
|
+
raise typer.Exit(ATTENTION)
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
render.echo(render.title(f"Entra ({len(view.entra)} object(s))"))
|
|
373
|
+
for item in view.entra:
|
|
374
|
+
groups = ", ".join(group.display_name for group in view.groups.get(item.id, ())) or "-"
|
|
375
|
+
render.echo(
|
|
376
|
+
render.pairs(
|
|
377
|
+
[
|
|
378
|
+
("Name", item.display_name),
|
|
379
|
+
("Object id", item.id),
|
|
380
|
+
("Device id", item.device_id),
|
|
381
|
+
("OS", f"{item.operating_system} {item.os_version}".strip()),
|
|
382
|
+
("Enabled", render.yes_no(item.enabled)),
|
|
383
|
+
("Trust", item.trust_type),
|
|
384
|
+
("Last sign-in", render.when(item.last_sign_in)),
|
|
385
|
+
("Groups", groups),
|
|
386
|
+
]
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
render.echo()
|
|
390
|
+
if view.defender is not None:
|
|
391
|
+
machine = view.defender.machine
|
|
392
|
+
render.echo(render.title(f"Defender ({len(view.defender.records)} record(s))"))
|
|
393
|
+
if machine is not None:
|
|
394
|
+
render.echo(
|
|
395
|
+
render.pairs(
|
|
396
|
+
[
|
|
397
|
+
("Name", machine.computer_dns_name),
|
|
398
|
+
("Machine id", machine.id),
|
|
399
|
+
("Onboarding", machine.onboarding_status),
|
|
400
|
+
("Health", machine.health_status),
|
|
401
|
+
("Last seen", render.when(machine.last_seen)),
|
|
402
|
+
("OS", f"{machine.os_platform} {machine.os_version}".strip()),
|
|
403
|
+
("Agent", machine.agent_version),
|
|
404
|
+
("Risk", machine.risk_score),
|
|
405
|
+
("Exposure", machine.exposure_level),
|
|
406
|
+
("Tags", ", ".join(machine.machine_tags)),
|
|
407
|
+
("Entra device id", machine.aad_device_id),
|
|
408
|
+
]
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
render.echo()
|
|
412
|
+
if view.intune is not None:
|
|
413
|
+
render.echo(render.title(f"Intune ({len(view.intune)} record(s))"))
|
|
414
|
+
for managed in view.intune[:1]:
|
|
415
|
+
render.echo(
|
|
416
|
+
render.pairs(
|
|
417
|
+
[
|
|
418
|
+
("Name", managed.device_name),
|
|
419
|
+
("Compliance", managed.compliance_state),
|
|
420
|
+
("Last sync", render.when(managed.last_sync)),
|
|
421
|
+
("User", managed.user_principal_name),
|
|
422
|
+
("Entra device id", managed.azure_ad_device_id),
|
|
423
|
+
]
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
render.echo()
|
|
427
|
+
render.echo(render.title("Findings"))
|
|
428
|
+
render.echo(
|
|
429
|
+
render.table(
|
|
430
|
+
["LEVEL", "FINDING"],
|
|
431
|
+
[
|
|
432
|
+
[(finding.level, _FINDING_COLOURS[finding.level]), finding.message]
|
|
433
|
+
for finding in view.findings
|
|
434
|
+
],
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
if warned:
|
|
438
|
+
raise typer.Exit(ATTENTION)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@devices_app.command("av-signature")
|
|
442
|
+
def av_signature(
|
|
443
|
+
ctx: typer.Context,
|
|
444
|
+
devices: NamesArgument = None,
|
|
445
|
+
from_file: FromFileOption = None,
|
|
446
|
+
column: ColumnOption = None,
|
|
447
|
+
sheet: SheetOption = None,
|
|
448
|
+
at_least: Annotated[
|
|
449
|
+
str | None,
|
|
450
|
+
typer.Option(
|
|
451
|
+
"--at-least", metavar="VERSION", help="Flag signatures older than this, e.g. 1.419.0.0."
|
|
452
|
+
),
|
|
453
|
+
] = None,
|
|
454
|
+
endpoint: Annotated[
|
|
455
|
+
bool,
|
|
456
|
+
typer.Option(
|
|
457
|
+
"--endpoint",
|
|
458
|
+
help="Through the Defender for Endpoint API, as 'xdr hunt --endpoint' does.",
|
|
459
|
+
),
|
|
460
|
+
] = False,
|
|
461
|
+
show_query: Annotated[
|
|
462
|
+
bool, typer.Option("--show-query", help="Print the KQL instead of running it.")
|
|
463
|
+
] = False,
|
|
464
|
+
profile: ProfileOption = None,
|
|
465
|
+
output: OutputOption = Output.TABLE,
|
|
466
|
+
) -> None:
|
|
467
|
+
"""The Defender Antivirus signature, engine and platform versions of devices.
|
|
468
|
+
|
|
469
|
+
One built-in Advanced Hunting query for every device named, through Graph like
|
|
470
|
+
'xdr hunt' (or --endpoint). UP TO DATE is Defender's own definitions check. Exits 3
|
|
471
|
+
when a device is not found, is out of date, or is older than --at-least.
|
|
472
|
+
"""
|
|
473
|
+
wanted = names(devices, from_file, column, sheet)
|
|
474
|
+
if at_least:
|
|
475
|
+
version_key(at_least) # a bad version fails before the query runs
|
|
476
|
+
query = av_query(wanted)
|
|
477
|
+
if show_query:
|
|
478
|
+
render.echo(query)
|
|
479
|
+
return
|
|
480
|
+
runtime = get_runtime(ctx).microsoft
|
|
481
|
+
selected = runtime.profile(profile)
|
|
482
|
+
result = runtime.xdr(selected).hunt(query) if endpoint else runtime.graph(selected).hunt(query)
|
|
483
|
+
statuses = av_statuses(wanted, result)
|
|
484
|
+
render.emit(
|
|
485
|
+
output,
|
|
486
|
+
[
|
|
487
|
+
"DEVICE",
|
|
488
|
+
"MACHINE",
|
|
489
|
+
"OS",
|
|
490
|
+
"SIGNATURE",
|
|
491
|
+
"ENGINE",
|
|
492
|
+
"PLATFORM",
|
|
493
|
+
"MODE",
|
|
494
|
+
"UP TO DATE",
|
|
495
|
+
"REPORTED",
|
|
496
|
+
],
|
|
497
|
+
[_av_row(status, at_least) for status in statuses],
|
|
498
|
+
[_av_record(status, at_least) for status in statuses],
|
|
499
|
+
)
|
|
500
|
+
missing = sum(1 for status in statuses if not status.found)
|
|
501
|
+
stale = sum(1 for status in statuses if status.found and status.up_to_date is False)
|
|
502
|
+
behind = sum(
|
|
503
|
+
1 for status in statuses if at_least and status.found and status.older_than(at_least)
|
|
504
|
+
)
|
|
505
|
+
parts = [f"{len(statuses) - missing} found"]
|
|
506
|
+
parts += [f"{missing} not found"] if missing else []
|
|
507
|
+
parts += [f"{stale} out of date"] if stale else []
|
|
508
|
+
parts += [f"{behind} older than {at_least}"] if behind else []
|
|
509
|
+
render.note(", ".join(parts) + f" (profile {selected.name})")
|
|
510
|
+
if missing or stale or behind:
|
|
511
|
+
raise typer.Exit(ATTENTION)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _av_row(status: AvStatus, at_least: str | None) -> list[render.Cell]:
|
|
515
|
+
if not status.found:
|
|
516
|
+
return [status.query, ("not found", "red"), "", "", "", "", "", "", ""]
|
|
517
|
+
signature: render.Cell = status.signature
|
|
518
|
+
if at_least and status.older_than(at_least):
|
|
519
|
+
signature = (status.signature or "unknown", "yellow")
|
|
520
|
+
fresh = {True: ("yes", "green"), False: ("no", "yellow")}.get(status.up_to_date, "-")
|
|
521
|
+
return [
|
|
522
|
+
status.query,
|
|
523
|
+
status.device_name,
|
|
524
|
+
status.os_platform,
|
|
525
|
+
signature,
|
|
526
|
+
status.engine,
|
|
527
|
+
status.platform,
|
|
528
|
+
status.mode,
|
|
529
|
+
fresh,
|
|
530
|
+
render.when(status.reported),
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _av_record(status: AvStatus, at_least: str | None) -> dict[str, Any]:
|
|
535
|
+
return {
|
|
536
|
+
"query": status.query,
|
|
537
|
+
"found": status.found,
|
|
538
|
+
"device_id": status.device_id or None,
|
|
539
|
+
"device_name": status.device_name or None,
|
|
540
|
+
"os_platform": status.os_platform or None,
|
|
541
|
+
"signature_version": status.signature or None,
|
|
542
|
+
"engine_version": status.engine or None,
|
|
543
|
+
"platform_version": status.platform or None,
|
|
544
|
+
"mode": status.mode or None,
|
|
545
|
+
"up_to_date": status.up_to_date,
|
|
546
|
+
"older_than_minimum": status.older_than(at_least) if at_least and status.found else None,
|
|
547
|
+
"reported": status.reported.isoformat() if status.reported else None,
|
|
548
|
+
}
|