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,358 @@
|
|
|
1
|
+
"""Graph commands: who am I, a token, any GET, objects by name, and Advanced Hunting.
|
|
2
|
+
|
|
3
|
+
A fast way to read Microsoft Graph with a profile's sign-in, as ``az rest`` would, but
|
|
4
|
+
knowing Graph: paging (``--all``, ``--limit``), its query options, ``ConsistencyLevel``
|
|
5
|
+
for ``--count`` and ``--search``, v1.0 or ``--beta``, and output as a table, JSON or CSV.
|
|
6
|
+
Everything reads; there is no POST, PATCH or DELETE.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Annotated, Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from libre_devops_helpers.cli import render
|
|
16
|
+
from libre_devops_helpers.cli.commands import token as token_commands
|
|
17
|
+
from libre_devops_helpers.cli.options import (
|
|
18
|
+
OutputOption,
|
|
19
|
+
ProfileOption,
|
|
20
|
+
QueryFileOption,
|
|
21
|
+
duration,
|
|
22
|
+
get_runtime,
|
|
23
|
+
read_query,
|
|
24
|
+
)
|
|
25
|
+
from libre_devops_helpers.cli.render import Output
|
|
26
|
+
from libre_devops_helpers.core.errors import AmbiguousError, ApiError
|
|
27
|
+
from libre_devops_helpers.core.tables import QueryResult
|
|
28
|
+
from libre_devops_helpers.microsoft.graph import not_found
|
|
29
|
+
from libre_devops_helpers.microsoft.resources import resolve_resource
|
|
30
|
+
from libre_devops_helpers.microsoft.tokens import decode_token
|
|
31
|
+
|
|
32
|
+
graph_app = typer.Typer(
|
|
33
|
+
rich_markup_mode="markdown",
|
|
34
|
+
name="graph",
|
|
35
|
+
help="Microsoft Graph: whoami, a token, any GET, objects by name, and hunting.",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Shown first for an object, when it has them; everything else follows.
|
|
40
|
+
_FIRST = (
|
|
41
|
+
"id",
|
|
42
|
+
"displayName",
|
|
43
|
+
"userPrincipalName",
|
|
44
|
+
"mail",
|
|
45
|
+
"appId",
|
|
46
|
+
"deviceId",
|
|
47
|
+
"operatingSystem",
|
|
48
|
+
"accountEnabled",
|
|
49
|
+
"createdDateTime",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register(app: typer.Typer) -> None:
|
|
54
|
+
app.add_typer(graph_app)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
BetaOption = Annotated[bool, typer.Option("--beta", help="Use Graph's beta endpoint.")]
|
|
58
|
+
SelectOption = Annotated[
|
|
59
|
+
str | None,
|
|
60
|
+
typer.Option("--select", help="Properties to return, e.g. id,displayName ($select)."),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@graph_app.command("whoami")
|
|
65
|
+
def whoami(
|
|
66
|
+
ctx: typer.Context, profile: ProfileOption = None, output: OutputOption = Output.TABLE
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Who the profile's Graph token is for, what it may do, and when it expires."""
|
|
69
|
+
runtime = get_runtime(ctx).microsoft
|
|
70
|
+
selected = runtime.profile(profile)
|
|
71
|
+
target = resolve_resource("graph", selected.cloud)
|
|
72
|
+
decoded = decode_token(runtime.tokens(selected).get_token(target.url, selected.tenant_id).token)
|
|
73
|
+
graph = runtime.graph(selected)
|
|
74
|
+
kind = decoded.identity_type
|
|
75
|
+
detail: dict[str, Any] | None = None
|
|
76
|
+
try:
|
|
77
|
+
detail = graph.me() if kind == "user" else graph.service_principal(decoded.app_id)
|
|
78
|
+
except ApiError as exc:
|
|
79
|
+
render.warn(f"could not read the {'user' if kind == 'user' else 'app'}: {exc}")
|
|
80
|
+
permissions = decoded.scopes if kind == "user" else decoded.roles
|
|
81
|
+
record = {
|
|
82
|
+
"profile": selected.name,
|
|
83
|
+
"tenant_id": decoded.tenant_id,
|
|
84
|
+
"kind": kind,
|
|
85
|
+
"principal": decoded.principal,
|
|
86
|
+
"client_app_id": decoded.app_id,
|
|
87
|
+
"client_app": decoded.claims.get("app_displayname"),
|
|
88
|
+
"expires_at": decoded.expires_at.isoformat() if decoded.expires_at else None,
|
|
89
|
+
"scopes": list(decoded.scopes),
|
|
90
|
+
"roles": list(decoded.roles),
|
|
91
|
+
"object": detail,
|
|
92
|
+
}
|
|
93
|
+
if output is not Output.TABLE:
|
|
94
|
+
render.emit(
|
|
95
|
+
output,
|
|
96
|
+
["PROFILE", "KIND", "PRINCIPAL", "TENANT", "EXPIRES", "PERMISSIONS"],
|
|
97
|
+
[
|
|
98
|
+
[
|
|
99
|
+
selected.name,
|
|
100
|
+
kind,
|
|
101
|
+
decoded.principal,
|
|
102
|
+
decoded.tenant_id,
|
|
103
|
+
render.when(decoded.expires_at),
|
|
104
|
+
" ".join(permissions),
|
|
105
|
+
]
|
|
106
|
+
],
|
|
107
|
+
record,
|
|
108
|
+
)
|
|
109
|
+
return
|
|
110
|
+
rows = [
|
|
111
|
+
("Profile", f"{selected.name} ({selected.auth})"),
|
|
112
|
+
("Signed in as", decoded.principal or "-"),
|
|
113
|
+
("Kind", "user (delegated)" if kind == "user" else f"{kind} (application)"),
|
|
114
|
+
]
|
|
115
|
+
if detail:
|
|
116
|
+
rows.append(("Name", str(detail.get("displayName") or "-")))
|
|
117
|
+
rows.append(("Object id", str(detail.get("id") or "-")))
|
|
118
|
+
if detail.get("jobTitle"):
|
|
119
|
+
rows.append(("Job title", str(detail["jobTitle"])))
|
|
120
|
+
rows += [
|
|
121
|
+
("Tenant", decoded.tenant_id),
|
|
122
|
+
("Client app", f"{decoded.claims.get('app_displayname') or '-'} ({decoded.app_id})"),
|
|
123
|
+
("Expires", render.when(decoded.expires_at)),
|
|
124
|
+
(
|
|
125
|
+
"Scopes" if kind == "user" else "Roles",
|
|
126
|
+
", ".join(sorted(permissions)) or "(none)",
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
render.echo(render.pairs(rows))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@graph_app.command("token")
|
|
133
|
+
def token(
|
|
134
|
+
ctx: typer.Context,
|
|
135
|
+
profile: ProfileOption = None,
|
|
136
|
+
raw: Annotated[
|
|
137
|
+
bool, typer.Option("--raw", help="Print only the token, for piping. Still checked first.")
|
|
138
|
+
] = False,
|
|
139
|
+
require: token_commands.RequireOption = None,
|
|
140
|
+
strict: token_commands.StrictOption = False,
|
|
141
|
+
all_claims: token_commands.AllClaimsOption = False,
|
|
142
|
+
output: OutputOption = Output.TABLE,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Get a Graph token and check it: which of this tool's features it covers.
|
|
145
|
+
|
|
146
|
+
The same as 'entra token graph'. The token itself is printed only with --raw, e.g.
|
|
147
|
+
curl -H "Authorization: Bearer $(ldo graph token --raw)" ...
|
|
148
|
+
"""
|
|
149
|
+
token_commands.token(
|
|
150
|
+
ctx,
|
|
151
|
+
"graph",
|
|
152
|
+
profile=profile,
|
|
153
|
+
raw=raw,
|
|
154
|
+
require=require,
|
|
155
|
+
strict=strict,
|
|
156
|
+
all_claims=all_claims,
|
|
157
|
+
output=output,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@graph_app.command("get")
|
|
162
|
+
def get(
|
|
163
|
+
ctx: typer.Context,
|
|
164
|
+
path: Annotated[
|
|
165
|
+
str,
|
|
166
|
+
typer.Argument(help="A Graph path (users, me/memberOf, beta/...) or a full Graph URL."),
|
|
167
|
+
],
|
|
168
|
+
select: SelectOption = None,
|
|
169
|
+
filter_: Annotated[
|
|
170
|
+
str | None, typer.Option("--filter", help="An OData filter ($filter).")
|
|
171
|
+
] = None,
|
|
172
|
+
search: Annotated[
|
|
173
|
+
str | None,
|
|
174
|
+
typer.Option("--search", help='A $search, e.g. "displayName:ana" (sets ConsistencyLevel).'),
|
|
175
|
+
] = None,
|
|
176
|
+
orderby: Annotated[str | None, typer.Option("--orderby", help="$orderby.")] = None,
|
|
177
|
+
expand: Annotated[str | None, typer.Option("--expand", help="$expand.")] = None,
|
|
178
|
+
top: Annotated[int | None, typer.Option("--top", min=1, help="Page size ($top).")] = None,
|
|
179
|
+
count: Annotated[
|
|
180
|
+
bool, typer.Option("--count", help="Ask for the total count (sets ConsistencyLevel).")
|
|
181
|
+
] = False,
|
|
182
|
+
eventual: Annotated[
|
|
183
|
+
bool,
|
|
184
|
+
typer.Option("--eventual", help="Send ConsistencyLevel: eventual, for advanced queries."),
|
|
185
|
+
] = False,
|
|
186
|
+
all_pages: Annotated[bool, typer.Option("--all", help="Follow every page.")] = False,
|
|
187
|
+
limit: Annotated[
|
|
188
|
+
int | None, typer.Option("--limit", "-n", min=1, help="Stop after this many items.")
|
|
189
|
+
] = None,
|
|
190
|
+
beta: BetaOption = False,
|
|
191
|
+
profile: ProfileOption = None,
|
|
192
|
+
output: OutputOption = Output.TABLE,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""GET anything from Graph, paged. Collections come back as rows, objects as fields.
|
|
195
|
+
|
|
196
|
+
Without --all or --limit, one page is shown, and a note says when there are more.
|
|
197
|
+
"""
|
|
198
|
+
params = {
|
|
199
|
+
key: value
|
|
200
|
+
for key, value in (
|
|
201
|
+
("$select", select),
|
|
202
|
+
("$filter", filter_),
|
|
203
|
+
("$search", _quoted(search)),
|
|
204
|
+
("$orderby", orderby),
|
|
205
|
+
("$expand", expand),
|
|
206
|
+
("$top", str(top) if top else None),
|
|
207
|
+
("$count", "true" if count else None),
|
|
208
|
+
)
|
|
209
|
+
if value
|
|
210
|
+
}
|
|
211
|
+
runtime = get_runtime(ctx).microsoft
|
|
212
|
+
graph = runtime.graph(runtime.profile(profile))
|
|
213
|
+
advanced = eventual or count or bool(search)
|
|
214
|
+
data = graph.get(path, params=params, beta=beta, eventual=advanced)
|
|
215
|
+
if not isinstance(data.get("value"), list):
|
|
216
|
+
_show_object(data, output)
|
|
217
|
+
return
|
|
218
|
+
page = graph.page(
|
|
219
|
+
path, params=params, beta=beta, eventual=advanced, limit=limit, all_pages=all_pages
|
|
220
|
+
)
|
|
221
|
+
columns = [name.strip() for name in select.split(",")] if select else None
|
|
222
|
+
_show_items(list(page.items), output, columns)
|
|
223
|
+
note = f"{len(page.items)} item(s)"
|
|
224
|
+
if page.count is not None:
|
|
225
|
+
note += f" of {page.count}"
|
|
226
|
+
if page.more:
|
|
227
|
+
note += "; more exist (--all, or --limit N)"
|
|
228
|
+
render.note(note)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _lookup_command(kind: str, what: str):
|
|
232
|
+
def command(
|
|
233
|
+
ctx: typer.Context,
|
|
234
|
+
ref: Annotated[str, typer.Argument(metavar="NAME_OR_ID", help=f"The {what}'s name or id.")],
|
|
235
|
+
select: SelectOption = None,
|
|
236
|
+
profile: ProfileOption = None,
|
|
237
|
+
output: OutputOption = Output.TABLE,
|
|
238
|
+
) -> None:
|
|
239
|
+
runtime = get_runtime(ctx).microsoft
|
|
240
|
+
graph = runtime.graph(runtime.profile(profile))
|
|
241
|
+
found = graph.lookup(kind, ref, select=select)
|
|
242
|
+
if not found:
|
|
243
|
+
raise not_found(kind, ref)
|
|
244
|
+
if len(found) == 1:
|
|
245
|
+
_show_object(found[0], output)
|
|
246
|
+
return
|
|
247
|
+
if kind not in {"device"}:
|
|
248
|
+
ids = ", ".join(str(item.get("id")) for item in found)
|
|
249
|
+
raise AmbiguousError(
|
|
250
|
+
f"{len(found)} {what}s are named {ref!r}", hint=f"use an id instead: {ids}"
|
|
251
|
+
)
|
|
252
|
+
# Stale registrations keep a device's name, so every match is shown.
|
|
253
|
+
_show_items(found, output, None)
|
|
254
|
+
render.warn(f"{len(found)} devices are named {ref!r}")
|
|
255
|
+
|
|
256
|
+
command.__doc__ = f"One {what}, by name or id, with every property Graph returns."
|
|
257
|
+
return command
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
graph_app.command("get-user")(_lookup_command("user", "user"))
|
|
261
|
+
graph_app.command("get-device")(_lookup_command("device", "device"))
|
|
262
|
+
graph_app.command("get-group")(_lookup_command("group", "group"))
|
|
263
|
+
graph_app.command("get-app")(_lookup_command("app", "app registration"))
|
|
264
|
+
graph_app.command("get-sp")(_lookup_command("sp", "service principal"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@graph_app.command("hunt")
|
|
268
|
+
def hunt(
|
|
269
|
+
ctx: typer.Context,
|
|
270
|
+
query: Annotated[
|
|
271
|
+
str | None,
|
|
272
|
+
typer.Argument(
|
|
273
|
+
help="KQL query. Omit, or pass -, to read it from stdin.", show_default=False
|
|
274
|
+
),
|
|
275
|
+
] = None,
|
|
276
|
+
file: QueryFileOption = None,
|
|
277
|
+
timespan: Annotated[
|
|
278
|
+
str | None,
|
|
279
|
+
typer.Option("--timespan", help="How far back the data goes, e.g. 7d. Default: 30 days."),
|
|
280
|
+
] = None,
|
|
281
|
+
profile: ProfileOption = None,
|
|
282
|
+
output: OutputOption = Output.TABLE,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Advanced Hunting (KQL) over the whole Defender XDR schema, through Graph.
|
|
285
|
+
|
|
286
|
+
Email, identity, cloud app and alert tables as well as the device ones. Needs
|
|
287
|
+
ThreatHunting.Read.All: an interactive or device-code profile whose app has it.
|
|
288
|
+
"""
|
|
289
|
+
run_graph_hunt(ctx, read_query(query, file), timespan, profile, output)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def run_graph_hunt(
|
|
293
|
+
ctx: typer.Context, text: str, timespan: str | None, profile: str | None, output: Output
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Shared by 'graph hunt' and 'xdr hunt', which default to Graph."""
|
|
296
|
+
runtime = get_runtime(ctx).microsoft
|
|
297
|
+
result: QueryResult = runtime.graph(runtime.profile(profile)).hunt(
|
|
298
|
+
text, timespan=duration(timespan)
|
|
299
|
+
)
|
|
300
|
+
render.query_result(result, output)
|
|
301
|
+
render.note(f"{len(result.rows)} row(s)")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# Rendering -------------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _show_object(data: dict[str, Any], output: Output) -> None:
|
|
308
|
+
record = {key: value for key, value in data.items() if not key.startswith("@odata")}
|
|
309
|
+
if output is not Output.TABLE:
|
|
310
|
+
render.emit(output, list(record), [[_cell(value) for value in record.values()]], record)
|
|
311
|
+
return
|
|
312
|
+
ordered = [key for key in _FIRST if key in record] + [
|
|
313
|
+
key for key in record if key not in _FIRST
|
|
314
|
+
]
|
|
315
|
+
render.echo(render.pairs((key, _cell(record[key])) for key in ordered))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _show_items(items: list[dict[str, Any]], output: Output, columns: list[str] | None) -> None:
|
|
319
|
+
if output is Output.JSON:
|
|
320
|
+
render.print_json(items)
|
|
321
|
+
return
|
|
322
|
+
if columns is None:
|
|
323
|
+
seen: dict[str, None] = {}
|
|
324
|
+
for item in items:
|
|
325
|
+
for key in item:
|
|
326
|
+
if not key.startswith("@odata"):
|
|
327
|
+
seen.setdefault(key)
|
|
328
|
+
columns = list(seen)
|
|
329
|
+
if output is Output.TABLE:
|
|
330
|
+
# A table of every property is too wide to read: the familiar ones, or eight.
|
|
331
|
+
familiar = [key for key in _FIRST if key in seen]
|
|
332
|
+
columns = familiar or columns[:8]
|
|
333
|
+
render.emit(
|
|
334
|
+
output,
|
|
335
|
+
[column.upper() for column in columns],
|
|
336
|
+
[[_cell(item.get(column)) for column in columns] for item in items],
|
|
337
|
+
items,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _cell(value: object) -> str:
|
|
342
|
+
if value is None:
|
|
343
|
+
return ""
|
|
344
|
+
if isinstance(value, str):
|
|
345
|
+
return value
|
|
346
|
+
if isinstance(value, bool | int | float):
|
|
347
|
+
return str(value).lower() if isinstance(value, bool) else str(value)
|
|
348
|
+
if isinstance(value, datetime):
|
|
349
|
+
return value.isoformat()
|
|
350
|
+
return json.dumps(value)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _quoted(search: str | None) -> str | None:
|
|
354
|
+
"""Graph wants $search in double quotes; add them when they are missing."""
|
|
355
|
+
if not search:
|
|
356
|
+
return None
|
|
357
|
+
text = search.strip()
|
|
358
|
+
return text if text.startswith('"') else f'"{text}"'
|