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,353 @@
|
|
|
1
|
+
"""Defender for Endpoint commands: machines, stale machines, alerts, vulnerabilities, hunting."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from libre_devops_helpers.cli import render
|
|
9
|
+
from libre_devops_helpers.cli.exits import ATTENTION
|
|
10
|
+
from libre_devops_helpers.cli.options import (
|
|
11
|
+
ColumnOption,
|
|
12
|
+
FromFileOption,
|
|
13
|
+
NamesArgument,
|
|
14
|
+
OutputOption,
|
|
15
|
+
ProfileOption,
|
|
16
|
+
QueryFileOption,
|
|
17
|
+
SheetOption,
|
|
18
|
+
duration,
|
|
19
|
+
get_runtime,
|
|
20
|
+
names,
|
|
21
|
+
read_query,
|
|
22
|
+
)
|
|
23
|
+
from libre_devops_helpers.cli.render import Output
|
|
24
|
+
from libre_devops_helpers.core.errors import LdoError, NotFoundError
|
|
25
|
+
from libre_devops_helpers.core.util import format_duration
|
|
26
|
+
from libre_devops_helpers.microsoft.xdr import Machine, MachineLookup, parse_severity
|
|
27
|
+
|
|
28
|
+
xdr_app = typer.Typer(
|
|
29
|
+
rich_markup_mode="markdown",
|
|
30
|
+
help=(
|
|
31
|
+
"Defender XDR: incidents (Sentinel's included), and Defender for Endpoint "
|
|
32
|
+
"machines, alerts, vulnerabilities and hunting."
|
|
33
|
+
),
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_SEVERITY_COLOURS = {"critical": "red", "high": "red", "medium": "yellow"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def register(app: typer.Typer) -> None:
|
|
41
|
+
app.add_typer(xdr_app, name="xdr")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@xdr_app.command("machines")
|
|
45
|
+
def machines(
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
devices: NamesArgument = None,
|
|
48
|
+
from_file: FromFileOption = None,
|
|
49
|
+
column: ColumnOption = None,
|
|
50
|
+
sheet: SheetOption = None,
|
|
51
|
+
profile: ProfileOption = None,
|
|
52
|
+
all_records: Annotated[
|
|
53
|
+
bool, typer.Option("--all-records", help="Also list older duplicate Defender records.")
|
|
54
|
+
] = False,
|
|
55
|
+
output: OutputOption = Output.TABLE,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Look devices up in Defender: onboarding, health, last seen, tags and device group.
|
|
58
|
+
|
|
59
|
+
Each device is looked up by FQDN, then by short hostname. Exits 3 when any device
|
|
60
|
+
has no Defender record.
|
|
61
|
+
"""
|
|
62
|
+
wanted = names(devices, from_file, column, sheet)
|
|
63
|
+
runtime = get_runtime(ctx).microsoft
|
|
64
|
+
selected = runtime.profile(profile)
|
|
65
|
+
lookups = runtime.xdr(selected).find_machines(wanted)
|
|
66
|
+
render.emit(
|
|
67
|
+
output,
|
|
68
|
+
[
|
|
69
|
+
"DEVICE",
|
|
70
|
+
"MATCHED",
|
|
71
|
+
"ONBOARDING",
|
|
72
|
+
"HEALTH",
|
|
73
|
+
"LAST SEEN",
|
|
74
|
+
"OS",
|
|
75
|
+
"TAGS",
|
|
76
|
+
"DEVICE GROUP",
|
|
77
|
+
"RECORDS",
|
|
78
|
+
"MACHINE ID",
|
|
79
|
+
],
|
|
80
|
+
_machine_rows(lookups, all_records=all_records),
|
|
81
|
+
[
|
|
82
|
+
{
|
|
83
|
+
"query": lookup.query,
|
|
84
|
+
"matched_name": lookup.matched_name,
|
|
85
|
+
"found": lookup.found,
|
|
86
|
+
"records": [dict(record.raw) for record in lookup.records],
|
|
87
|
+
}
|
|
88
|
+
for lookup in lookups
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
missing = [lookup.query for lookup in lookups if not lookup.found]
|
|
92
|
+
render.note(
|
|
93
|
+
f"{len(lookups) - len(missing)} of {len(lookups)} found in Defender "
|
|
94
|
+
f"(profile {selected.name})"
|
|
95
|
+
)
|
|
96
|
+
if missing:
|
|
97
|
+
raise typer.Exit(ATTENTION)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _machine_rows(lookups: list[MachineLookup], *, all_records: bool) -> list[list[render.Cell]]:
|
|
101
|
+
rows: list[list[render.Cell]] = []
|
|
102
|
+
for lookup in lookups:
|
|
103
|
+
if lookup.machine is None:
|
|
104
|
+
rows.append([lookup.query, ("not found", "red"), "", "", "", "", "", "", "0", ""])
|
|
105
|
+
continue
|
|
106
|
+
matched = "fqdn" if lookup.matched_name == lookup.query.strip().rstrip(".") else "short"
|
|
107
|
+
rows.append(
|
|
108
|
+
[
|
|
109
|
+
lookup.query,
|
|
110
|
+
matched,
|
|
111
|
+
*_machine_cells(lookup.machine),
|
|
112
|
+
str(len(lookup.records)),
|
|
113
|
+
lookup.machine.id,
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
if all_records:
|
|
117
|
+
for older in lookup.records[1:]:
|
|
118
|
+
rows.append(
|
|
119
|
+
[(" older record", "bright_black"), "", *_machine_cells(older), "", older.id]
|
|
120
|
+
)
|
|
121
|
+
return rows
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _machine_cells(machine: Machine) -> list[render.Cell]:
|
|
125
|
+
onboarded = machine.onboarding_status == "Onboarded"
|
|
126
|
+
health_colour = {"Active": "green", "Inactive": "yellow"}.get(machine.health_status, "red")
|
|
127
|
+
return [
|
|
128
|
+
(machine.onboarding_status, "green" if onboarded else "yellow"),
|
|
129
|
+
(machine.health_status, health_colour),
|
|
130
|
+
render.when(machine.last_seen),
|
|
131
|
+
f"{machine.os_platform} {machine.os_version}".strip(),
|
|
132
|
+
",".join(machine.machine_tags),
|
|
133
|
+
machine.device_group,
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@xdr_app.command("stale")
|
|
138
|
+
def stale(
|
|
139
|
+
ctx: typer.Context,
|
|
140
|
+
older_than: Annotated[
|
|
141
|
+
str, typer.Option("--older-than", help="Not seen for at least this long, e.g. 30d.")
|
|
142
|
+
] = "30d",
|
|
143
|
+
profile: ProfileOption = None,
|
|
144
|
+
output: OutputOption = Output.TABLE,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""List machines Defender has not seen recently, longest silent first.
|
|
147
|
+
|
|
148
|
+
Exits 3 when any are found.
|
|
149
|
+
"""
|
|
150
|
+
window = duration(older_than) or timedelta(days=30)
|
|
151
|
+
runtime = get_runtime(ctx).microsoft
|
|
152
|
+
found = runtime.xdr(runtime.profile(profile)).stale_machines(window)
|
|
153
|
+
render.emit(
|
|
154
|
+
output,
|
|
155
|
+
["DEVICE", "LAST SEEN", "HEALTH", "ONBOARDING", "OS", "TAGS", "MACHINE ID"],
|
|
156
|
+
[
|
|
157
|
+
[
|
|
158
|
+
machine.computer_dns_name,
|
|
159
|
+
render.when(machine.last_seen),
|
|
160
|
+
machine.health_status,
|
|
161
|
+
machine.onboarding_status,
|
|
162
|
+
f"{machine.os_platform} {machine.os_version}".strip(),
|
|
163
|
+
",".join(machine.machine_tags),
|
|
164
|
+
machine.id,
|
|
165
|
+
]
|
|
166
|
+
for machine in found
|
|
167
|
+
],
|
|
168
|
+
[dict(machine.raw) for machine in found],
|
|
169
|
+
)
|
|
170
|
+
render.note(f"{len(found)} machine(s) not seen for {format_duration(window)}")
|
|
171
|
+
if found:
|
|
172
|
+
raise typer.Exit(ATTENTION)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@xdr_app.command("alerts")
|
|
176
|
+
def alerts(
|
|
177
|
+
ctx: typer.Context,
|
|
178
|
+
device: Annotated[
|
|
179
|
+
str | None, typer.Option("--device", "-d", help="Only alerts for this device name.")
|
|
180
|
+
] = None,
|
|
181
|
+
since: Annotated[str, typer.Option("--since", help="How far back, e.g. 24h, 7d.")] = "7d",
|
|
182
|
+
severity: Annotated[
|
|
183
|
+
str | None,
|
|
184
|
+
typer.Option("--severity", help="At least this severity: low, medium or high."),
|
|
185
|
+
] = None,
|
|
186
|
+
include_resolved: Annotated[
|
|
187
|
+
bool, typer.Option("--include-resolved", help="Include resolved alerts.")
|
|
188
|
+
] = False,
|
|
189
|
+
limit: Annotated[int, typer.Option("--limit", min=1, help="Most alerts to show.")] = 200,
|
|
190
|
+
profile: ProfileOption = None,
|
|
191
|
+
output: OutputOption = Output.TABLE,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""List Defender alerts, newest first: open ones unless --include-resolved."""
|
|
194
|
+
if severity is not None:
|
|
195
|
+
parse_severity(severity)
|
|
196
|
+
window = duration(since) or timedelta(days=7)
|
|
197
|
+
runtime = get_runtime(ctx).microsoft
|
|
198
|
+
xdr = runtime.xdr(runtime.profile(profile))
|
|
199
|
+
machine_id = None
|
|
200
|
+
if device is not None:
|
|
201
|
+
lookup = xdr.find_machine(device)
|
|
202
|
+
if lookup.machine is None:
|
|
203
|
+
raise NotFoundError(f"no Defender record for {device!r}")
|
|
204
|
+
machine_id = lookup.machine.id
|
|
205
|
+
found = xdr.alerts(
|
|
206
|
+
machine_id=machine_id,
|
|
207
|
+
since=datetime.now(UTC) - window,
|
|
208
|
+
min_severity=severity,
|
|
209
|
+
include_resolved=include_resolved,
|
|
210
|
+
limit=limit,
|
|
211
|
+
)
|
|
212
|
+
render.emit(
|
|
213
|
+
output,
|
|
214
|
+
["CREATED", "SEVERITY", "STATUS", "TITLE", "DEVICE", "CATEGORY", "SOURCE", "ALERT ID"],
|
|
215
|
+
[
|
|
216
|
+
[
|
|
217
|
+
render.when(alert.created),
|
|
218
|
+
(alert.severity, _SEVERITY_COLOURS.get(alert.severity.casefold())),
|
|
219
|
+
alert.status,
|
|
220
|
+
alert.title,
|
|
221
|
+
alert.computer_dns_name,
|
|
222
|
+
alert.category,
|
|
223
|
+
alert.detection_source,
|
|
224
|
+
alert.id,
|
|
225
|
+
]
|
|
226
|
+
for alert in found
|
|
227
|
+
],
|
|
228
|
+
[dict(alert.raw) for alert in found],
|
|
229
|
+
)
|
|
230
|
+
render.note(f"{len(found)} alert(s) in the last {format_duration(window)}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@xdr_app.command("vulns")
|
|
234
|
+
def vulns(
|
|
235
|
+
ctx: typer.Context,
|
|
236
|
+
device: Annotated[str, typer.Argument(help="Device name: FQDN or short hostname.")],
|
|
237
|
+
severity: Annotated[
|
|
238
|
+
str | None,
|
|
239
|
+
typer.Option("--severity", help="At least this severity: low, medium, high, critical."),
|
|
240
|
+
] = None,
|
|
241
|
+
profile: ProfileOption = None,
|
|
242
|
+
output: OutputOption = Output.TABLE,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""List the vulnerabilities Defender reports on a device, most severe first."""
|
|
245
|
+
floor = parse_severity(severity) if severity else None
|
|
246
|
+
runtime = get_runtime(ctx).microsoft
|
|
247
|
+
xdr = runtime.xdr(runtime.profile(profile))
|
|
248
|
+
lookup = xdr.find_machine(device)
|
|
249
|
+
if lookup.machine is None:
|
|
250
|
+
raise NotFoundError(f"no Defender record for {device!r}")
|
|
251
|
+
found = xdr.vulnerabilities(lookup.machine.id)
|
|
252
|
+
if floor is not None:
|
|
253
|
+
found = [item for item in found if _rank(item.severity) >= floor]
|
|
254
|
+
render.emit(
|
|
255
|
+
output,
|
|
256
|
+
["CVE", "SEVERITY", "CVSS", "EXPLOIT", "PUBLISHED", "NAME"],
|
|
257
|
+
[
|
|
258
|
+
[
|
|
259
|
+
item.id,
|
|
260
|
+
(item.severity, _SEVERITY_COLOURS.get(item.severity.casefold())),
|
|
261
|
+
"" if item.cvss is None else f"{item.cvss:.1f}",
|
|
262
|
+
"verified" if item.exploit_verified else "public" if item.public_exploit else "",
|
|
263
|
+
render.when(item.published),
|
|
264
|
+
item.name,
|
|
265
|
+
]
|
|
266
|
+
for item in found
|
|
267
|
+
],
|
|
268
|
+
[dict(item.raw) for item in found],
|
|
269
|
+
)
|
|
270
|
+
render.note(f"{len(found)} vulnerabilit{'y' if len(found) == 1 else 'ies'} on {device}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _rank(severity: str) -> int:
|
|
274
|
+
try:
|
|
275
|
+
return parse_severity(severity)
|
|
276
|
+
except LdoError:
|
|
277
|
+
return -1
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@xdr_app.command("indicators")
|
|
281
|
+
def indicators(
|
|
282
|
+
ctx: typer.Context,
|
|
283
|
+
profile: ProfileOption = None,
|
|
284
|
+
output: OutputOption = Output.TABLE,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""List custom indicators of compromise (hashes, IPs, URLs, domains, certificates)."""
|
|
287
|
+
runtime = get_runtime(ctx).microsoft
|
|
288
|
+
found = runtime.xdr(runtime.profile(profile)).indicators()
|
|
289
|
+
render.emit(
|
|
290
|
+
output,
|
|
291
|
+
["TYPE", "VALUE", "ACTION", "SEVERITY", "TITLE", "EXPIRES", "CREATED BY"],
|
|
292
|
+
[
|
|
293
|
+
[
|
|
294
|
+
item.indicator_type,
|
|
295
|
+
item.value,
|
|
296
|
+
item.action,
|
|
297
|
+
item.severity,
|
|
298
|
+
item.title,
|
|
299
|
+
render.when(item.expires),
|
|
300
|
+
item.created_by,
|
|
301
|
+
]
|
|
302
|
+
for item in found
|
|
303
|
+
],
|
|
304
|
+
[dict(item.raw) for item in found],
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@xdr_app.command("hunt")
|
|
309
|
+
def hunt(
|
|
310
|
+
ctx: typer.Context,
|
|
311
|
+
query: Annotated[
|
|
312
|
+
str | None,
|
|
313
|
+
typer.Argument(
|
|
314
|
+
help="KQL query. Omit, or pass -, to read it from stdin.", show_default=False
|
|
315
|
+
),
|
|
316
|
+
] = None,
|
|
317
|
+
file: QueryFileOption = None,
|
|
318
|
+
endpoint: Annotated[
|
|
319
|
+
bool,
|
|
320
|
+
typer.Option(
|
|
321
|
+
"--endpoint",
|
|
322
|
+
help=(
|
|
323
|
+
"Through the Defender for Endpoint API instead: device tables only, but the "
|
|
324
|
+
"Azure CLI's sign-in can use it."
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
] = False,
|
|
328
|
+
timespan: Annotated[
|
|
329
|
+
str | None,
|
|
330
|
+
typer.Option("--timespan", help="How far back the data goes, e.g. 7d. Default: 30 days."),
|
|
331
|
+
] = None,
|
|
332
|
+
profile: ProfileOption = None,
|
|
333
|
+
output: OutputOption = Output.TABLE,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Run an Advanced Hunting (KQL) query over Defender XDR.
|
|
336
|
+
|
|
337
|
+
Through Microsoft Graph, which covers every Defender XDR table: devices, email,
|
|
338
|
+
identity, cloud apps and alerts. It needs ThreatHunting.Read.All, which the Azure
|
|
339
|
+
CLI's token never has: use an interactive or device-code profile whose app has it,
|
|
340
|
+
or --endpoint for the device tables with the Azure CLI's sign-in.
|
|
341
|
+
"""
|
|
342
|
+
text = read_query(query, file)
|
|
343
|
+
if not endpoint:
|
|
344
|
+
from libre_devops_helpers.cli.commands.graph import run_graph_hunt
|
|
345
|
+
|
|
346
|
+
run_graph_hunt(ctx, text, timespan, profile, output)
|
|
347
|
+
return
|
|
348
|
+
if timespan:
|
|
349
|
+
raise typer.BadParameter("--timespan is not available with --endpoint; put it in the query")
|
|
350
|
+
runtime = get_runtime(ctx).microsoft
|
|
351
|
+
result = runtime.xdr(runtime.profile(profile)).hunt(text)
|
|
352
|
+
render.query_result(result, output)
|
|
353
|
+
render.note(f"{len(result.rows)} row(s)")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Exit codes, the same for every command, so scripts can branch on them."""
|
|
2
|
+
|
|
3
|
+
OK = 0
|
|
4
|
+
# An error, or a token check that failed.
|
|
5
|
+
ERROR = 1
|
|
6
|
+
# A usage error (bad option or argument); Click uses this code itself.
|
|
7
|
+
USAGE = 2
|
|
8
|
+
# The command ran, but found something that needs attention: a device missing or not in
|
|
9
|
+
# the expected state, or a credential or secret close to expiry.
|
|
10
|
+
ATTENTION = 3
|
|
11
|
+
# Stopped with Ctrl-C.
|
|
12
|
+
INTERRUPTED = 130
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Options shared by several commands, and access to the per-invocation Runtime."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.cli.render import Output
|
|
11
|
+
from libre_devops_helpers.cli.runtime import Runtime
|
|
12
|
+
from libre_devops_helpers.core import brand
|
|
13
|
+
from libre_devops_helpers.core.errors import InputError, LdoError
|
|
14
|
+
from libre_devops_helpers.core.inputs import read_names
|
|
15
|
+
from libre_devops_helpers.core.util import parse_duration
|
|
16
|
+
from libre_devops_helpers.microsoft.config import load_config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_runtime(ctx: typer.Context) -> Runtime:
|
|
20
|
+
"""The Runtime the root command stored on the context."""
|
|
21
|
+
runtime = ctx.find_object(Runtime)
|
|
22
|
+
if runtime is None:
|
|
23
|
+
raise RuntimeError("the CLI runtime was not initialised")
|
|
24
|
+
return runtime
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def complete_profile(incomplete: str) -> list[str]:
|
|
28
|
+
"""Shell completion for profile names. Silent when there is no usable config."""
|
|
29
|
+
try:
|
|
30
|
+
names = load_config().profiles
|
|
31
|
+
except LdoError:
|
|
32
|
+
return []
|
|
33
|
+
return [name for name in sorted(names) if name.startswith(incomplete)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def duration(value: str | None) -> timedelta | None:
|
|
37
|
+
"""Parse a duration option (``15m``, ``2h``, ``7d``), reporting a bad one as usage."""
|
|
38
|
+
if value is None:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return parse_duration(value)
|
|
42
|
+
except InputError as exc:
|
|
43
|
+
raise typer.BadParameter(str(exc)) from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def names(
|
|
47
|
+
values: list[str] | None,
|
|
48
|
+
from_file: Path | None,
|
|
49
|
+
column: str | None,
|
|
50
|
+
sheet: str | None = None,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
"""Names from arguments, ``-`` for stdin, and ``--from-file``; at least one is needed."""
|
|
53
|
+
found = read_names(
|
|
54
|
+
values or [], stdin=sys.stdin, from_file=from_file, column=column, sheet=sheet
|
|
55
|
+
)
|
|
56
|
+
if not found:
|
|
57
|
+
raise InputError("no names given", hint="pass names, '-' for stdin, or --from-file")
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_query(query: str | None, file: Path | None) -> str:
|
|
62
|
+
"""A query from the argument, from ``--file``, or from stdin, in that order."""
|
|
63
|
+
if query and query != "-":
|
|
64
|
+
return query
|
|
65
|
+
if file is not None:
|
|
66
|
+
try:
|
|
67
|
+
return file.read_text(encoding="utf-8")
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
raise InputError(f"cannot read {file}: {exc}") from None
|
|
70
|
+
if query == "-" or not sys.stdin.isatty():
|
|
71
|
+
text = sys.stdin.read()
|
|
72
|
+
if text.strip():
|
|
73
|
+
return text
|
|
74
|
+
raise InputError("no query given", hint="pass it as an argument, with --file, or on stdin")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
ProfileOption = Annotated[
|
|
78
|
+
str | None,
|
|
79
|
+
typer.Option(
|
|
80
|
+
"--profile",
|
|
81
|
+
"-p",
|
|
82
|
+
envvar=brand.PROFILE_ENV,
|
|
83
|
+
help="Profile from the config file. Default: default_profile, else the az active account.",
|
|
84
|
+
autocompletion=complete_profile,
|
|
85
|
+
show_default=False,
|
|
86
|
+
),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
OutputOption = Annotated[
|
|
90
|
+
Output,
|
|
91
|
+
typer.Option(
|
|
92
|
+
"--output", "-o", help="table for people, json for scripts, csv for spreadsheets."
|
|
93
|
+
),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
NamesArgument = Annotated[
|
|
97
|
+
list[str] | None,
|
|
98
|
+
typer.Argument(
|
|
99
|
+
help='Names: "a,b,c", several arguments, or - to read stdin.', show_default=False
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
FromFileOption = Annotated[
|
|
104
|
+
Path | None,
|
|
105
|
+
typer.Option(
|
|
106
|
+
"--from-file",
|
|
107
|
+
"-f",
|
|
108
|
+
help=(
|
|
109
|
+
"Read names from a file: one per line, or a column of a CSV or Excel workbook "
|
|
110
|
+
"(.xlsx, .xlsm, .xltx, .xltm; see --column and --sheet)."
|
|
111
|
+
),
|
|
112
|
+
dir_okay=False,
|
|
113
|
+
exists=True,
|
|
114
|
+
show_default=False,
|
|
115
|
+
),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
ColumnOption = Annotated[
|
|
119
|
+
str | None,
|
|
120
|
+
typer.Option(
|
|
121
|
+
"--column",
|
|
122
|
+
help="Column holding the names, matched by header. Title rows above it are skipped.",
|
|
123
|
+
),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
SheetOption = Annotated[
|
|
127
|
+
str | None,
|
|
128
|
+
typer.Option(
|
|
129
|
+
"--sheet",
|
|
130
|
+
help=(
|
|
131
|
+
"Workbook sheet (tab) to read. Default: the one visible sheet with --column, "
|
|
132
|
+
"or the first visible sheet."
|
|
133
|
+
),
|
|
134
|
+
show_default=False,
|
|
135
|
+
),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
QueryFileOption = Annotated[
|
|
139
|
+
Path | None,
|
|
140
|
+
typer.Option("--file", help="Read the query from a file.", dir_okay=False, exists=True),
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
DirectOption = Annotated[
|
|
144
|
+
bool,
|
|
145
|
+
typer.Option("--direct", help="Direct memberships only. Default includes nested groups."),
|
|
146
|
+
]
|