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,258 @@
1
+ """Azure commands: subscriptions, Resource Graph, RBAC and Defender for Cloud."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from libre_devops_helpers.cli import render
8
+ from libre_devops_helpers.cli.options import (
9
+ OutputOption,
10
+ ProfileOption,
11
+ QueryFileOption,
12
+ get_runtime,
13
+ read_query,
14
+ )
15
+ from libre_devops_helpers.cli.render import Output
16
+ from libre_devops_helpers.core.util import is_guid
17
+
18
+ azure_app = typer.Typer(
19
+ rich_markup_mode="markdown",
20
+ help="Azure: subscriptions, Resource Graph, RBAC and Defender for Cloud.",
21
+ no_args_is_help=True,
22
+ )
23
+
24
+ SubscriptionOption = Annotated[
25
+ list[str] | None,
26
+ typer.Option(
27
+ "--subscription",
28
+ "-s",
29
+ help="Subscription id to cover. Repeatable. Default: the profile's subscription, "
30
+ "else every one in the tenant.",
31
+ show_default=False,
32
+ ),
33
+ ]
34
+
35
+
36
+ def register(app: typer.Typer) -> None:
37
+ app.add_typer(azure_app, name="azure")
38
+
39
+
40
+ @azure_app.command("subscriptions")
41
+ def subscriptions(
42
+ ctx: typer.Context, profile: ProfileOption = None, output: OutputOption = Output.TABLE
43
+ ) -> None:
44
+ """List the subscriptions the profile's credential can see in its tenant."""
45
+ runtime = get_runtime(ctx).microsoft
46
+ selected = runtime.profile(profile)
47
+ found = runtime.azure(selected).subscriptions(selected.tenant_id)
48
+ render.emit(
49
+ output,
50
+ ["SUBSCRIPTION", "ID", "STATE"],
51
+ [[item.name, item.id, item.state] for item in found],
52
+ [dict(item.raw) for item in found],
53
+ )
54
+
55
+
56
+ @azure_app.command("resource-graph")
57
+ def resource_graph(
58
+ ctx: typer.Context,
59
+ query: Annotated[
60
+ str | None,
61
+ typer.Argument(
62
+ help="KQL query. Omit, or pass -, to read it from stdin.", show_default=False
63
+ ),
64
+ ] = None,
65
+ file: QueryFileOption = None,
66
+ limit: Annotated[int, typer.Option("--limit", min=1, help="Most rows to fetch.")] = 1000,
67
+ subscription: SubscriptionOption = None,
68
+ profile: ProfileOption = None,
69
+ output: OutputOption = Output.TABLE,
70
+ ) -> None:
71
+ """Run an Azure Resource Graph (KQL) query across subscriptions."""
72
+ text = read_query(query, file)
73
+ runtime = get_runtime(ctx).microsoft
74
+ selected = runtime.profile(profile)
75
+ scope = subscription or ([selected.subscription_id] if selected.subscription_id else [])
76
+ result = runtime.azure(selected).resource_graph(text, subscriptions=scope, limit=limit)
77
+ render.query_result(result, output)
78
+ render.note(f"{len(result.rows)} row(s)")
79
+
80
+
81
+ @azure_app.command("rbac")
82
+ def rbac(
83
+ ctx: typer.Context,
84
+ principal: Annotated[
85
+ str,
86
+ typer.Argument(help="User principal name, group or service principal name, or object id."),
87
+ ],
88
+ subscription: SubscriptionOption = None,
89
+ profile: ProfileOption = None,
90
+ output: OutputOption = Output.TABLE,
91
+ ) -> None:
92
+ """List every Azure role assignment that applies to a principal, across subscriptions.
93
+
94
+ Includes assignments made to groups the principal is in, and those inherited from
95
+ management groups.
96
+ """
97
+ runtime = get_runtime(ctx).microsoft
98
+ selected = runtime.profile(profile)
99
+ if is_guid(principal):
100
+ principal_id, label = principal.strip().lower(), principal
101
+ else:
102
+ found = runtime.entra(selected).find_principal(principal)
103
+ principal_id, label = found.id, f"{found.display_name} ({found.kind})"
104
+ subscriptions = runtime.subscription_ids(selected, subscription)
105
+ assignments = runtime.azure(selected).role_assignments(principal_id, subscriptions)
106
+ if output is Output.TABLE:
107
+ render.echo(render.title(f"{label} object {principal_id}"))
108
+ render.emit(
109
+ output,
110
+ ["ROLE", "SCOPE", "ASSIGNED TO", "CONDITION"],
111
+ [
112
+ [
113
+ item.role_name,
114
+ item.scope,
115
+ "this principal"
116
+ if item.principal_id.lower() == principal_id
117
+ else f"{item.principal_type} {item.principal_id}",
118
+ "yes" if item.condition else "",
119
+ ]
120
+ for item in assignments
121
+ ],
122
+ [dict(item.raw) | {"roleName": item.role_name} for item in assignments],
123
+ )
124
+ render.note(f"{len(assignments)} assignment(s) across {len(subscriptions)} subscription(s)")
125
+
126
+
127
+ @azure_app.command("secure-score")
128
+ def secure_score(
129
+ ctx: typer.Context,
130
+ controls: Annotated[
131
+ bool, typer.Option("--controls", help="Also list the controls costing the most points.")
132
+ ] = False,
133
+ subscription: SubscriptionOption = None,
134
+ profile: ProfileOption = None,
135
+ output: OutputOption = Output.TABLE,
136
+ ) -> None:
137
+ """Show the Defender for Cloud secure score for each subscription."""
138
+ runtime = get_runtime(ctx).microsoft
139
+ selected = runtime.profile(profile)
140
+ azure = runtime.azure(selected)
141
+ scores = [azure.secure_score(item) for item in runtime.subscription_ids(selected, subscription)]
142
+ found = [score for score in scores if score is not None]
143
+ render.emit(
144
+ output,
145
+ ["SUBSCRIPTION", "SCORE", "MAX", "PERCENT"],
146
+ [
147
+ [
148
+ score.subscription_id,
149
+ _number(score.current),
150
+ _number(score.max),
151
+ "" if score.percentage is None else f"{score.percentage * 100:.0f}%",
152
+ ]
153
+ for score in found
154
+ ],
155
+ [dict(score.raw) for score in found],
156
+ )
157
+ if len(found) < len(scores):
158
+ render.warn(f"{len(scores) - len(found)} subscription(s) have no secure score")
159
+ if controls and output is not Output.JSON:
160
+ for score in found:
161
+ render.echo()
162
+ render.echo(render.title(f"controls for {score.subscription_id}"))
163
+ items = azure.secure_score_controls(score.subscription_id)
164
+ render.emit(
165
+ output,
166
+ ["CONTROL", "POINTS LOST", "SCORE", "MAX", "UNHEALTHY", "HEALTHY"],
167
+ [
168
+ [
169
+ item.name,
170
+ f"{item.points_lost:.2f}",
171
+ _number(item.current),
172
+ _number(item.max),
173
+ str(item.unhealthy),
174
+ str(item.healthy),
175
+ ]
176
+ for item in items
177
+ if item.max
178
+ ],
179
+ None,
180
+ )
181
+
182
+
183
+ @azure_app.command("recommendations")
184
+ def recommendations(
185
+ ctx: typer.Context,
186
+ show_all: Annotated[
187
+ bool, typer.Option("--all", help="Include healthy and not applicable results.")
188
+ ] = False,
189
+ severity: Annotated[
190
+ str | None, typer.Option("--severity", help="Only this severity: high, medium or low.")
191
+ ] = None,
192
+ subscription: SubscriptionOption = None,
193
+ profile: ProfileOption = None,
194
+ output: OutputOption = Output.TABLE,
195
+ ) -> None:
196
+ """List Defender for Cloud recommendations that resources fail, most severe first."""
197
+ if severity is not None and severity.casefold() not in {"high", "medium", "low"}:
198
+ raise typer.BadParameter("--severity must be high, medium or low")
199
+ runtime = get_runtime(ctx).microsoft
200
+ selected = runtime.profile(profile)
201
+ azure = runtime.azure(selected)
202
+ found = [
203
+ item
204
+ for sub in runtime.subscription_ids(selected, subscription)
205
+ for item in azure.assessments(sub, unhealthy_only=not show_all)
206
+ if severity is None or item.severity.casefold() == severity.casefold()
207
+ ]
208
+ colours = {"high": "red", "medium": "yellow"}
209
+ render.emit(
210
+ output,
211
+ ["SEVERITY", "STATUS", "RECOMMENDATION", "RESOURCE"],
212
+ [
213
+ [
214
+ (item.severity, colours.get(item.severity.casefold())),
215
+ item.status,
216
+ item.name,
217
+ item.resource_id,
218
+ ]
219
+ for item in found
220
+ ],
221
+ [dict(item.raw) for item in found],
222
+ )
223
+ render.note(f"{len(found)} result(s)")
224
+
225
+
226
+ @azure_app.command("defender-plans")
227
+ def defender_plans(
228
+ ctx: typer.Context,
229
+ subscription: SubscriptionOption = None,
230
+ profile: ProfileOption = None,
231
+ output: OutputOption = Output.TABLE,
232
+ ) -> None:
233
+ """List every Defender for Cloud plan on each subscription and whether it is on."""
234
+ runtime = get_runtime(ctx).microsoft
235
+ selected = runtime.profile(profile)
236
+ azure = runtime.azure(selected)
237
+ rows: list[list[render.Cell]] = []
238
+ records: list[dict[str, object]] = []
239
+ for sub in runtime.subscription_ids(selected, subscription):
240
+ for plan in azure.defender_plans(sub):
241
+ rows.append(
242
+ [
243
+ sub,
244
+ plan.name,
245
+ ("on", "green") if plan.enabled else ("off", "yellow"),
246
+ plan.sub_plan,
247
+ render.when(plan.enabled_since) if plan.enabled_since else "",
248
+ "deprecated" if plan.deprecated else "",
249
+ ]
250
+ )
251
+ records.append({"subscriptionId": sub, **plan.raw})
252
+ render.emit(
253
+ output, ["SUBSCRIPTION", "PLAN", "STATE", "SUB PLAN", "SINCE", "NOTE"], rows, records
254
+ )
255
+
256
+
257
+ def _number(value: float | None) -> str:
258
+ return "" if value is None else f"{value:g}"
@@ -0,0 +1,54 @@
1
+ """Config commands: create the config file from the template, or print where it lives."""
2
+
3
+ import os
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from libre_devops_helpers.cli import render
9
+ from libre_devops_helpers.cli.options import get_runtime
10
+ from libre_devops_helpers.core import brand
11
+ from libre_devops_helpers.core.config import CONFIG_HEADER, default_config_path
12
+ from libre_devops_helpers.core.errors import ConfigError
13
+ from libre_devops_helpers.microsoft.config import CONFIG_TEMPLATE as MICROSOFT_TEMPLATE
14
+ from libre_devops_helpers.servicenow.config import CONFIG_TEMPLATE as SERVICENOW_TEMPLATE
15
+
16
+ # The file 'config init' writes: shared settings, then each vendor's section.
17
+ TEMPLATE = "\n".join([CONFIG_HEADER, MICROSOFT_TEMPLATE, SERVICENOW_TEMPLATE])
18
+
19
+ config_app = typer.Typer(
20
+ rich_markup_mode="markdown", help="Create or locate the config file.", no_args_is_help=True
21
+ )
22
+
23
+
24
+ def register(app: typer.Typer) -> None:
25
+ app.add_typer(config_app, name="config")
26
+
27
+
28
+ @config_app.command("init")
29
+ def init(
30
+ ctx: typer.Context,
31
+ force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
32
+ ) -> None:
33
+ """Write a config template, with example Microsoft profiles to fill in."""
34
+ path = get_runtime(ctx).config_path or default_config_path()
35
+ render.banner()
36
+ if path.exists() and not force:
37
+ raise ConfigError(f"{path} already exists", hint="pass --force to overwrite it")
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ # Created 0600, rather than written and then narrowed, so no other account can read it
40
+ # even for a moment. The template holds no secrets, but the ids filled in later are
41
+ # nobody else's business.
42
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
43
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
44
+ handle.write(TEMPLATE)
45
+ if os.name == "posix":
46
+ path.chmod(0o600) # open keeps an existing file's mode, so --force narrows it here
47
+ render.echo(f"Wrote {path}")
48
+ render.note(f"Next: replace the placeholder ids, then run {brand.command('profiles')}.")
49
+
50
+
51
+ @config_app.command("path")
52
+ def path(ctx: typer.Context) -> None:
53
+ """Print the path of the config file in use."""
54
+ render.echo(str(get_runtime(ctx).config_path or default_config_path()))