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,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
|
+
]
|