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,420 @@
1
+ """Incident commands: Defender XDR's incident queue, Sentinel's incidents included.
2
+
3
+ ``top`` is today's open incidents, most severe first; ``latest`` the newest of any
4
+ status; ``list`` everything in a window; ``summary`` the counts; ``show`` one incident.
5
+ Every one takes the same window (``--today``, ``--yesterday``, ``--since``, or
6
+ ``--from`` and ``--to``, both days whole) and filters.
7
+ """
8
+
9
+ from collections.abc import Callable
10
+ from datetime import datetime, timedelta
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from libre_devops_helpers.cli import render
16
+ from libre_devops_helpers.cli.options import OutputOption, ProfileOption, duration, get_runtime
17
+ from libre_devops_helpers.cli.render import Output
18
+ from libre_devops_helpers.core.timewindow import Window, choose_window, last, today_window
19
+ from libre_devops_helpers.microsoft.incidents import (
20
+ OPEN_STATUSES,
21
+ Incident,
22
+ most_severe,
23
+ newest,
24
+ severities_from,
25
+ summarise,
26
+ )
27
+
28
+ incidents_app = typer.Typer(
29
+ rich_markup_mode="markdown",
30
+ name="incidents",
31
+ help="Defender XDR incidents, Sentinel's included: top, latest, list, summary, show.",
32
+ no_args_is_help=True,
33
+ )
34
+
35
+ # What people type, and Graph's values.
36
+ STATUS_NAMES = {
37
+ "active": ("active",),
38
+ "in-progress": ("inProgress",),
39
+ "awaiting-action": ("awaitingAction",),
40
+ "resolved": ("resolved",),
41
+ "redirected": ("redirected",),
42
+ "open": OPEN_STATUSES,
43
+ "all": (),
44
+ }
45
+ SOURCE_NAMES = {
46
+ "sentinel": "microsoftSentinel",
47
+ "endpoint": "microsoftDefenderForEndpoint",
48
+ "identity": "microsoftDefenderForIdentity",
49
+ "office": "microsoftDefenderForOffice365",
50
+ "cloud-apps": "microsoftDefenderForCloudApps",
51
+ "cloud": "microsoftDefenderForCloud",
52
+ "xdr": "microsoft365Defender",
53
+ "entra": "azureAdIdentityProtection",
54
+ "app-governance": "microsoftAppGovernance",
55
+ "dlp": "dataLossPrevention",
56
+ "insider-risk": "microsoftInsiderRiskManagement",
57
+ }
58
+ _SEVERITY_COLOURS = {"high": "red", "medium": "yellow", "low": "cyan"}
59
+ _STATUS_LABELS = {"inProgress": "in progress", "awaitingAction": "awaiting action"}
60
+
61
+
62
+ def register(app: typer.Typer) -> None:
63
+ app.add_typer(incidents_app)
64
+
65
+
66
+ TodayOption = Annotated[bool, typer.Option("--today", help="Since midnight, local time.")]
67
+ YesterdayOption = Annotated[bool, typer.Option("--yesterday", help="The whole of yesterday.")]
68
+ SinceOption = Annotated[
69
+ str | None, typer.Option("--since", help="The last span of time, e.g. 6h, 7d.")
70
+ ]
71
+ FromOption = Annotated[
72
+ str | None,
73
+ typer.Option("--from", help="From this day (YYYY-MM-DD, today or yesterday), whole."),
74
+ ]
75
+ ToOption = Annotated[
76
+ str | None, typer.Option("--to", help="Up to and including this day (YYYY-MM-DD).")
77
+ ]
78
+ UpdatedOption = Annotated[
79
+ bool,
80
+ typer.Option("--updated", help="Window on when incidents were last updated, not created."),
81
+ ]
82
+ StatusOption = Annotated[
83
+ list[str] | None,
84
+ typer.Option(
85
+ "--status",
86
+ help="open, active, in-progress, awaiting-action, resolved, redirected or all. Repeatable.",
87
+ show_default=False,
88
+ ),
89
+ ]
90
+ SeverityOption = Annotated[
91
+ str | None,
92
+ typer.Option("--severity", help="At least this severity: informational, low, medium, high."),
93
+ ]
94
+ SourceOption = Annotated[
95
+ list[str] | None,
96
+ typer.Option(
97
+ "--source",
98
+ help=f"Only incidents with alerts from here: {', '.join(SOURCE_NAMES)}. Repeatable.",
99
+ show_default=False,
100
+ ),
101
+ ]
102
+ LimitOption = Annotated[int | None, typer.Option("--limit", "-n", min=1, help="Most to show.")]
103
+
104
+
105
+ @incidents_app.command("top")
106
+ def top(
107
+ ctx: typer.Context,
108
+ today: TodayOption = False,
109
+ yesterday: YesterdayOption = False,
110
+ since: SinceOption = None,
111
+ start: FromOption = None,
112
+ end: ToOption = None,
113
+ updated: UpdatedOption = False,
114
+ status: StatusOption = None,
115
+ severity: SeverityOption = None,
116
+ source: SourceOption = None,
117
+ limit: LimitOption = 10,
118
+ profile: ProfileOption = None,
119
+ output: OutputOption = Output.TABLE,
120
+ ) -> None:
121
+ """The most severe open incidents, today unless told otherwise: 10 by default."""
122
+ _show_list(
123
+ ctx,
124
+ _window(today, yesterday, since, start, end, today_window),
125
+ updated=updated,
126
+ statuses=status or ["open"],
127
+ severity=severity,
128
+ sources=source,
129
+ limit=limit,
130
+ order=most_severe,
131
+ profile=profile,
132
+ output=output,
133
+ )
134
+
135
+
136
+ @incidents_app.command("latest")
137
+ def latest(
138
+ ctx: typer.Context,
139
+ today: TodayOption = False,
140
+ yesterday: YesterdayOption = False,
141
+ since: SinceOption = None,
142
+ start: FromOption = None,
143
+ end: ToOption = None,
144
+ updated: UpdatedOption = False,
145
+ status: StatusOption = None,
146
+ severity: SeverityOption = None,
147
+ source: SourceOption = None,
148
+ limit: LimitOption = 10,
149
+ profile: ProfileOption = None,
150
+ output: OutputOption = Output.TABLE,
151
+ ) -> None:
152
+ """The newest incidents of any status, from the last 30 days: 10 by default."""
153
+ _show_list(
154
+ ctx,
155
+ _window(today, yesterday, since, start, end, lambda now: last(timedelta(days=30), now)),
156
+ updated=updated,
157
+ statuses=status or ["all"],
158
+ severity=severity,
159
+ sources=source,
160
+ limit=limit,
161
+ order=newest,
162
+ profile=profile,
163
+ output=output,
164
+ )
165
+
166
+
167
+ @incidents_app.command("list")
168
+ def list_incidents(
169
+ ctx: typer.Context,
170
+ today: TodayOption = False,
171
+ yesterday: YesterdayOption = False,
172
+ since: SinceOption = None,
173
+ start: FromOption = None,
174
+ end: ToOption = None,
175
+ updated: UpdatedOption = False,
176
+ status: StatusOption = None,
177
+ severity: SeverityOption = None,
178
+ source: SourceOption = None,
179
+ limit: LimitOption = None,
180
+ profile: ProfileOption = None,
181
+ output: OutputOption = Output.TABLE,
182
+ ) -> None:
183
+ """Every incident in a window, newest first: the last 24 hours by default.
184
+
185
+ For between days, give --from and --to: ldo xdr incidents list --from 2026-09-01
186
+ --to 2026-09-24 takes both days whole.
187
+ """
188
+ _show_list(
189
+ ctx,
190
+ _window(today, yesterday, since, start, end, lambda now: last(timedelta(hours=24), now)),
191
+ updated=updated,
192
+ statuses=status or ["all"],
193
+ severity=severity,
194
+ sources=source,
195
+ limit=limit,
196
+ order=newest,
197
+ profile=profile,
198
+ output=output,
199
+ )
200
+
201
+
202
+ @incidents_app.command("summary")
203
+ def summary(
204
+ ctx: typer.Context,
205
+ today: TodayOption = False,
206
+ yesterday: YesterdayOption = False,
207
+ since: SinceOption = None,
208
+ start: FromOption = None,
209
+ end: ToOption = None,
210
+ updated: UpdatedOption = False,
211
+ status: StatusOption = None,
212
+ severity: SeverityOption = None,
213
+ source: SourceOption = None,
214
+ profile: ProfileOption = None,
215
+ output: OutputOption = Output.TABLE,
216
+ ) -> None:
217
+ """How many incidents, by severity, status and source: today by default."""
218
+ window = _window(today, yesterday, since, start, end, today_window)
219
+ found = _fetch(ctx, window, updated, status or ["all"], severity, source, profile)
220
+ counts = summarise(found.incidents)
221
+ if output is not Output.TABLE:
222
+ rows = [
223
+ *(["severity", name, str(count)] for name, count in counts.by_severity.items()),
224
+ *(["status", name, str(count)] for name, count in counts.by_status.items()),
225
+ *(["source", name, str(count)] for name, count in counts.by_source.items()),
226
+ ]
227
+ render.emit(
228
+ output,
229
+ ["BY", "VALUE", "INCIDENTS"],
230
+ rows,
231
+ {
232
+ "window": window.label,
233
+ "total": counts.total,
234
+ "by_severity": counts.by_severity,
235
+ "by_status": counts.by_status,
236
+ "by_source": counts.by_source,
237
+ },
238
+ )
239
+ return
240
+ render.echo(render.title(f"{counts.total} incident(s) {window.label}"))
241
+ for heading, values, colour in (
242
+ ("SEVERITY", counts.by_severity, _SEVERITY_COLOURS),
243
+ ("STATUS", {_status(name): n for name, n in counts.by_status.items()}, {}),
244
+ ("SOURCE", counts.by_source, {}),
245
+ ):
246
+ if values:
247
+ render.echo()
248
+ render.echo(
249
+ render.table(
250
+ [heading, "INCIDENTS"],
251
+ [[(name, colour.get(name)), str(n)] for name, n in values.items()],
252
+ )
253
+ )
254
+ _warn_truncated(found.truncated)
255
+
256
+
257
+ @incidents_app.command("show")
258
+ def show(
259
+ ctx: typer.Context,
260
+ incident_id: Annotated[str, typer.Argument(metavar="ID", help="The incident's number.")],
261
+ profile: ProfileOption = None,
262
+ output: OutputOption = Output.TABLE,
263
+ ) -> None:
264
+ """One incident: its alerts, where they came from, the devices and users involved."""
265
+ runtime = get_runtime(ctx).microsoft
266
+ incident = runtime.incidents(runtime.profile(profile)).incident(incident_id)
267
+ if output is not Output.TABLE:
268
+ render.emit(
269
+ output,
270
+ ["CREATED", "SEVERITY", "SOURCE", "TITLE", "ALERT ID"],
271
+ [
272
+ [
273
+ render.when(alert.created),
274
+ alert.severity,
275
+ alert.source_name,
276
+ alert.title,
277
+ alert.id,
278
+ ]
279
+ for alert in incident.alerts
280
+ ],
281
+ dict(incident.raw),
282
+ )
283
+ return
284
+ render.echo(render.title(f"Incident {incident.id}: {incident.title}"))
285
+ render.echo(
286
+ render.pairs(
287
+ [
288
+ ("Severity", incident.severity),
289
+ ("Status", _status(incident.status)),
290
+ ("Created", render.when(incident.created)),
291
+ ("Updated", render.when(incident.updated)),
292
+ ("Assigned to", incident.assigned_to or "(nobody)"),
293
+ ("Classification", incident.classification or "-"),
294
+ ("Determination", incident.determination or "-"),
295
+ ("Sources", ", ".join(incident.source_names) or "-"),
296
+ ("Devices", ", ".join(incident.devices) or "-"),
297
+ ("Users", ", ".join(incident.users) or "-"),
298
+ ("Tags", ", ".join(incident.tags) or "-"),
299
+ ("Link", incident.web_url or "-"),
300
+ ]
301
+ )
302
+ )
303
+ if incident.alerts:
304
+ render.echo()
305
+ render.echo(
306
+ render.table(
307
+ ["CREATED", "SEVERITY", "STATUS", "SOURCE", "TITLE"],
308
+ [
309
+ [
310
+ render.when(alert.created),
311
+ (alert.severity, _SEVERITY_COLOURS.get(alert.severity.lower())),
312
+ alert.status,
313
+ alert.source_name,
314
+ alert.title,
315
+ ]
316
+ for alert in incident.alerts
317
+ ],
318
+ )
319
+ )
320
+
321
+
322
+ # Shared -------------------------------------------------------------------------------
323
+
324
+
325
+ def _window(
326
+ today: bool,
327
+ yesterday: bool,
328
+ since: str | None,
329
+ start: str | None,
330
+ end: str | None,
331
+ default: Callable[[datetime], Window],
332
+ ) -> Window:
333
+ return choose_window(
334
+ today=today,
335
+ yesterday=yesterday,
336
+ since=duration(since),
337
+ start_day=start,
338
+ end_day=end,
339
+ default=default,
340
+ )
341
+
342
+
343
+ def _fetch(
344
+ ctx: typer.Context,
345
+ window: Window,
346
+ updated: bool,
347
+ statuses: list[str],
348
+ severity: str | None,
349
+ sources: list[str] | None,
350
+ profile: str | None,
351
+ ):
352
+ wanted_statuses: list[str] = []
353
+ for name in statuses:
354
+ key = name.strip().lower()
355
+ if key not in STATUS_NAMES:
356
+ raise typer.BadParameter(f"--status must be one of {', '.join(STATUS_NAMES)}")
357
+ wanted_statuses.extend(STATUS_NAMES[key])
358
+ wanted_sources: list[str] = []
359
+ for name in sources or ():
360
+ key = name.strip().lower()
361
+ if key not in SOURCE_NAMES:
362
+ raise typer.BadParameter(f"--source must be one of {', '.join(SOURCE_NAMES)}")
363
+ wanted_sources.append(SOURCE_NAMES[key])
364
+ runtime = get_runtime(ctx).microsoft
365
+ return runtime.incidents(runtime.profile(profile)).incidents(
366
+ start=window.start,
367
+ end=window.end,
368
+ by="lastUpdateDateTime" if updated else "createdDateTime",
369
+ statuses=tuple(dict.fromkeys(wanted_statuses)),
370
+ severities=severities_from(severity) if severity else (),
371
+ sources=tuple(wanted_sources),
372
+ )
373
+
374
+
375
+ def _show_list(
376
+ ctx: typer.Context,
377
+ window: Window,
378
+ *,
379
+ updated: bool,
380
+ statuses: list[str],
381
+ severity: str | None,
382
+ sources: list[str] | None,
383
+ limit: int | None,
384
+ order: Callable[[tuple[Incident, ...]], list[Incident]],
385
+ profile: str | None,
386
+ output: Output,
387
+ ) -> None:
388
+ found = _fetch(ctx, window, updated, statuses, severity, sources, profile)
389
+ ordered = order(found.incidents)
390
+ shown = ordered[:limit] if limit else ordered
391
+ render.emit(
392
+ output,
393
+ ["CREATED", "SEVERITY", "STATUS", "ID", "TITLE", "SOURCES", "ALERTS", "ASSIGNED"],
394
+ [
395
+ [
396
+ render.when(incident.created),
397
+ (incident.severity, _SEVERITY_COLOURS.get(incident.severity.lower())),
398
+ _status(incident.status),
399
+ incident.id,
400
+ incident.title,
401
+ ", ".join(incident.source_names),
402
+ str(len(incident.alerts)),
403
+ incident.assigned_to,
404
+ ]
405
+ for incident in shown
406
+ ],
407
+ [dict(incident.raw) for incident in shown],
408
+ )
409
+ what = "updated" if updated else "created"
410
+ render.note(f"{len(shown)} of {len(ordered)} incident(s) {what} {window.label}")
411
+ _warn_truncated(found.truncated)
412
+
413
+
414
+ def _status(value: str) -> str:
415
+ return _STATUS_LABELS.get(value, value)
416
+
417
+
418
+ def _warn_truncated(truncated: bool) -> None:
419
+ if truncated:
420
+ render.warn("stopped at the incident limit; narrow the window to see them all")
@@ -0,0 +1,79 @@
1
+ """Intune commands: look managed devices up by name."""
2
+
3
+ import typer
4
+
5
+ from libre_devops_helpers.cli import render
6
+ from libre_devops_helpers.cli.exits import ATTENTION
7
+ from libre_devops_helpers.cli.options import (
8
+ ColumnOption,
9
+ FromFileOption,
10
+ NamesArgument,
11
+ OutputOption,
12
+ ProfileOption,
13
+ SheetOption,
14
+ get_runtime,
15
+ names,
16
+ )
17
+ from libre_devops_helpers.cli.render import Output
18
+
19
+ intune_app = typer.Typer(
20
+ rich_markup_mode="markdown",
21
+ help="Intune: managed devices and compliance.",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+
26
+ def register(app: typer.Typer) -> None:
27
+ app.add_typer(intune_app, name="intune")
28
+
29
+
30
+ @intune_app.command("devices")
31
+ def devices(
32
+ ctx: typer.Context,
33
+ devices: NamesArgument = None,
34
+ from_file: FromFileOption = None,
35
+ column: ColumnOption = None,
36
+ sheet: SheetOption = None,
37
+ profile: ProfileOption = None,
38
+ output: OutputOption = Output.TABLE,
39
+ ) -> None:
40
+ """Look devices up in Intune: compliance, last sync, owner and Entra link.
41
+
42
+ Needs DeviceManagementManagedDevices.Read.All, which the Azure CLI's token does not
43
+ carry: use a profile with its own app registration. Exits 3 when any device is not
44
+ enrolled.
45
+ """
46
+ wanted = names(devices, from_file, column, sheet)
47
+ runtime = get_runtime(ctx).microsoft
48
+ intune = runtime.intune(runtime.profile(profile))
49
+ results = [(name, intune.find_devices(name)) for name in wanted]
50
+ rows: list[list[render.Cell]] = []
51
+ for name, found in results:
52
+ if not found:
53
+ rows.append([name, ("not enrolled", "red"), "", "", "", "", ""])
54
+ continue
55
+ for index, device in enumerate(found):
56
+ rows.append(
57
+ [
58
+ name if index == 0 else (" older record", "bright_black"),
59
+ (device.compliance_state, "green" if device.compliant else "yellow"),
60
+ f"{device.operating_system} {device.os_version}".strip(),
61
+ render.when(device.last_sync),
62
+ device.user_principal_name,
63
+ device.azure_ad_device_id,
64
+ device.serial_number,
65
+ ]
66
+ )
67
+ render.emit(
68
+ output,
69
+ ["DEVICE", "COMPLIANCE", "OS", "LAST SYNC", "USER", "ENTRA DEVICE ID", "SERIAL"],
70
+ rows,
71
+ [
72
+ {"query": name, "devices": [dict(device.raw) for device in found]}
73
+ for name, found in results
74
+ ],
75
+ )
76
+ missing = [name for name, found in results if not found]
77
+ render.note(f"{len(results) - len(missing)} of {len(results)} enrolled in Intune")
78
+ if missing:
79
+ raise typer.Exit(ATTENTION)
@@ -0,0 +1,142 @@
1
+ """Key Vault commands: secrets, certificates and keys close to expiry. Never reads values."""
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, ERROR
10
+ from libre_devops_helpers.cli.options import (
11
+ NamesArgument,
12
+ OutputOption,
13
+ ProfileOption,
14
+ duration,
15
+ get_runtime,
16
+ )
17
+ from libre_devops_helpers.cli.render import Output
18
+ from libre_devops_helpers.core.errors import ApiError, InputError
19
+ from libre_devops_helpers.core.inputs import read_names
20
+ from libre_devops_helpers.core.util import format_duration
21
+ from libre_devops_helpers.microsoft.keyvault import KINDS, ItemKind, VaultItem, expiring
22
+
23
+ keyvault_app = typer.Typer(
24
+ rich_markup_mode="markdown",
25
+ help="Key Vault: expiry of secrets, certificates and keys.",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+ # Every vault the credential can see, across subscriptions, in one query.
30
+ _VAULTS_QUERY = (
31
+ "resources | where type =~ 'microsoft.keyvault/vaults' "
32
+ "| project name, subscriptionId, resourceGroup | order by name asc"
33
+ )
34
+
35
+
36
+ def register(app: typer.Typer) -> None:
37
+ app.add_typer(keyvault_app, name="keyvault")
38
+
39
+
40
+ @keyvault_app.command("expiry")
41
+ def expiry(
42
+ ctx: typer.Context,
43
+ vaults: NamesArgument = None,
44
+ all_vaults: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--all-vaults", help="Find every vault through Resource Graph and check each."
48
+ ),
49
+ ] = False,
50
+ within: Annotated[
51
+ str, typer.Option("--within", help="Show items expiring within this, e.g. 30d.")
52
+ ] = "30d",
53
+ kinds: Annotated[
54
+ list[str] | None,
55
+ typer.Option("--kind", help="Only this kind: secret, certificate or key. Repeatable."),
56
+ ] = None,
57
+ include_disabled: Annotated[
58
+ bool, typer.Option("--include-disabled", help="Include disabled items.")
59
+ ] = False,
60
+ profile: ProfileOption = None,
61
+ output: OutputOption = Output.TABLE,
62
+ ) -> None:
63
+ """List secrets, certificates and keys that expire soon, or already have.
64
+
65
+ Exits 3 when anything is expiring, so a scheduled job can alert; exits 1 when no
66
+ item is expiring but a vault could not be read.
67
+ """
68
+ window = duration(within) or timedelta(days=30)
69
+ known: dict[str, ItemKind] = {kind: kind for kind in KINDS}
70
+ chosen: list[ItemKind] = []
71
+ for kind in kinds or KINDS:
72
+ if kind not in known:
73
+ raise typer.BadParameter(f"--kind must be one of {', '.join(KINDS)}")
74
+ chosen.append(known[kind])
75
+ runtime = get_runtime(ctx).microsoft
76
+ selected = runtime.profile(profile)
77
+ names = read_names(vaults or [])
78
+ if all_vaults:
79
+ scope = [selected.subscription_id] if selected.subscription_id else []
80
+ found = runtime.azure(selected).resource_graph(_VAULTS_QUERY, subscriptions=scope)
81
+ names.extend(str(row.get("name")) for row in found.rows if row.get("name"))
82
+ if not names:
83
+ raise InputError("no vaults given", hint="name them, or pass --all-vaults")
84
+
85
+ now = datetime.now(UTC)
86
+ items: list[VaultItem] = []
87
+ failed: list[str] = []
88
+ for name in dict.fromkeys(names):
89
+ try:
90
+ items.extend(runtime.keyvault(selected, name).items(chosen))
91
+ except ApiError as exc:
92
+ failed.append(name)
93
+ render.warn(f"cannot read vault {name}: {exc}")
94
+ if exc.hint:
95
+ render.note(f"hint: {exc.hint}")
96
+ shown = expiring(items, window, now=now, include_disabled=include_disabled)
97
+ render.emit(
98
+ output,
99
+ ["VAULT", "KIND", "NAME", "EXPIRES", "DAYS LEFT", "ENABLED", "CONTENT TYPE"],
100
+ [_row(item, now) for item in shown],
101
+ [
102
+ {
103
+ "vault": item.vault,
104
+ "kind": item.kind,
105
+ "name": item.name,
106
+ "expires": item.expires,
107
+ "days_left": item.days_left(now),
108
+ "enabled": item.enabled,
109
+ "attributes": item.raw.get("attributes"),
110
+ }
111
+ for item in shown
112
+ ],
113
+ )
114
+ checked = len(dict.fromkeys(names)) - len(failed)
115
+ render.note(
116
+ f"{len(shown)} item(s) expire within {format_duration(window)} "
117
+ f"({len(items)} checked in {checked} vault(s))"
118
+ )
119
+ if shown:
120
+ raise typer.Exit(ATTENTION)
121
+ if failed:
122
+ raise typer.Exit(ERROR)
123
+
124
+
125
+ def _row(item: VaultItem, now: datetime) -> list[render.Cell]:
126
+ days = item.days_left(now)
127
+ left: render.Cell = (
128
+ "-"
129
+ if days is None
130
+ else (f"expired {-days}d ago", "red")
131
+ if days < 0
132
+ else (str(days), "yellow")
133
+ )
134
+ return [
135
+ item.vault,
136
+ item.kind,
137
+ item.name,
138
+ render.when(item.expires),
139
+ left,
140
+ render.yes_no(item.enabled),
141
+ item.content_type,
142
+ ]