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.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. 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}"'