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,260 @@
|
|
|
1
|
+
"""Read-only Azure Resource Manager queries.
|
|
2
|
+
|
|
3
|
+
Subscriptions, Azure Resource Graph, RBAC role assignments, and Defender for Cloud
|
|
4
|
+
(secure score, controls, recommendations, plans). Access rests on Azure RBAC (Reader is
|
|
5
|
+
enough for all of it), not on token scopes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import Any, Self
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from libre_devops_helpers.core.auth import TokenProvider, token_source
|
|
16
|
+
from libre_devops_helpers.core.errors import ApiError, LdoError
|
|
17
|
+
from libre_devops_helpers.core.http import ApiClient
|
|
18
|
+
from libre_devops_helpers.core.tables import QueryResult
|
|
19
|
+
from libre_devops_helpers.core.util import is_guid
|
|
20
|
+
from libre_devops_helpers.microsoft.azure.models import (
|
|
21
|
+
Assessment,
|
|
22
|
+
AzureRoleAssignment,
|
|
23
|
+
DefenderPlan,
|
|
24
|
+
SecureScore,
|
|
25
|
+
SecureScoreControl,
|
|
26
|
+
Subscription,
|
|
27
|
+
)
|
|
28
|
+
from libre_devops_helpers.microsoft.clouds import PUBLIC
|
|
29
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
30
|
+
|
|
31
|
+
SUBSCRIPTIONS_API = "2022-12-01"
|
|
32
|
+
RESOURCE_GRAPH_API = "2022-10-01"
|
|
33
|
+
AUTHORIZATION_API = "2022-04-01"
|
|
34
|
+
SECURE_SCORE_API = "2020-01-01"
|
|
35
|
+
ASSESSMENTS_API = "2021-06-01"
|
|
36
|
+
PRICINGS_API = "2024-01-01"
|
|
37
|
+
|
|
38
|
+
# Resource Graph returns at most 1000 rows a page.
|
|
39
|
+
_GRAPH_PAGE = 1000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AzureClient:
|
|
43
|
+
"""Azure Resource Manager lookups. Close it (or use ``with``) when done."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, api: ApiClient) -> None:
|
|
46
|
+
self.api = api
|
|
47
|
+
self._role_names: dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def create(
|
|
51
|
+
cls,
|
|
52
|
+
tokens: TokenProvider,
|
|
53
|
+
tenant_id: str,
|
|
54
|
+
*,
|
|
55
|
+
arm_url: str = PUBLIC.arm_url,
|
|
56
|
+
verify: bool | str = True,
|
|
57
|
+
session: requests.Session | None = None,
|
|
58
|
+
) -> AzureClient:
|
|
59
|
+
"""A client for ``tenant_id`` that takes its ARM tokens from ``tokens``."""
|
|
60
|
+
api = ApiClient(
|
|
61
|
+
arm_url,
|
|
62
|
+
token_source(tokens, arm_url.rstrip("/") + "/", tenant_id),
|
|
63
|
+
name="Azure Resource Manager",
|
|
64
|
+
verify=verify,
|
|
65
|
+
session=session,
|
|
66
|
+
)
|
|
67
|
+
return cls(api)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def for_profile(
|
|
71
|
+
cls,
|
|
72
|
+
profile: Profile,
|
|
73
|
+
tokens: TokenProvider,
|
|
74
|
+
*,
|
|
75
|
+
verify: bool | str = True,
|
|
76
|
+
session: requests.Session | None = None,
|
|
77
|
+
) -> AzureClient:
|
|
78
|
+
"""A client for a configured profile's tenant, in the profile's cloud."""
|
|
79
|
+
return cls.create(
|
|
80
|
+
tokens, profile.tenant_id, arm_url=profile.cloud.arm_url, verify=verify, session=session
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def close(self) -> None:
|
|
84
|
+
self.api.close()
|
|
85
|
+
|
|
86
|
+
def __enter__(self) -> Self:
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
# Subscriptions ----------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def subscriptions(self, tenant_id: str | None = None) -> list[Subscription]:
|
|
95
|
+
"""Subscriptions the credential can see, in ``tenant_id`` when given, by name."""
|
|
96
|
+
items = self.api.get_all(
|
|
97
|
+
"/subscriptions", params={"api-version": SUBSCRIPTIONS_API}, next_link="nextLink"
|
|
98
|
+
)
|
|
99
|
+
found = [Subscription.from_json(item) for item in items]
|
|
100
|
+
if tenant_id is not None:
|
|
101
|
+
found = [item for item in found if item.tenant_id == tenant_id.lower()]
|
|
102
|
+
return sorted(found, key=lambda item: item.name.casefold())
|
|
103
|
+
|
|
104
|
+
# Resource Graph ---------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def resource_graph(
|
|
107
|
+
self, query: str, *, subscriptions: Iterable[str] = (), limit: int = 1000
|
|
108
|
+
) -> QueryResult:
|
|
109
|
+
"""Run an Azure Resource Graph (KQL) query, following ``$skipToken`` up to ``limit``.
|
|
110
|
+
|
|
111
|
+
Without ``subscriptions`` the query covers every subscription the credential can
|
|
112
|
+
read in the tenant.
|
|
113
|
+
"""
|
|
114
|
+
if not query.strip():
|
|
115
|
+
raise LdoError("the Resource Graph query is empty")
|
|
116
|
+
scope = [_subscription_id(item) for item in subscriptions]
|
|
117
|
+
rows: list[dict[str, Any]] = []
|
|
118
|
+
skip_token: str | None = None
|
|
119
|
+
truncated = False
|
|
120
|
+
while True:
|
|
121
|
+
options: dict[str, Any] = {
|
|
122
|
+
"resultFormat": "objectArray",
|
|
123
|
+
"$top": min(_GRAPH_PAGE, limit - len(rows)),
|
|
124
|
+
}
|
|
125
|
+
if skip_token:
|
|
126
|
+
options["$skipToken"] = skip_token
|
|
127
|
+
body: dict[str, Any] = {"query": query, "options": options}
|
|
128
|
+
if scope:
|
|
129
|
+
body["subscriptions"] = scope
|
|
130
|
+
page = self.api.post(
|
|
131
|
+
"/providers/Microsoft.ResourceGraph/resources",
|
|
132
|
+
body,
|
|
133
|
+
params={"api-version": RESOURCE_GRAPH_API},
|
|
134
|
+
)
|
|
135
|
+
data = page.get("data")
|
|
136
|
+
if isinstance(data, list):
|
|
137
|
+
rows.extend(row for row in data if isinstance(row, dict))
|
|
138
|
+
token = page.get("$skipToken")
|
|
139
|
+
skip_token = token if isinstance(token, str) and token else None
|
|
140
|
+
if skip_token is None:
|
|
141
|
+
break
|
|
142
|
+
if len(rows) >= limit:
|
|
143
|
+
truncated = True
|
|
144
|
+
break
|
|
145
|
+
return QueryResult.from_records(rows, truncated=truncated)
|
|
146
|
+
|
|
147
|
+
# RBAC -------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def role_assignments(
|
|
150
|
+
self, principal_id: str, subscription_ids: Iterable[str]
|
|
151
|
+
) -> list[AzureRoleAssignment]:
|
|
152
|
+
"""Every role assignment that applies to ``principal_id`` in the subscriptions.
|
|
153
|
+
|
|
154
|
+
``assignedTo()`` includes assignments made to groups the principal belongs to,
|
|
155
|
+
and those inherited from management groups above each subscription.
|
|
156
|
+
"""
|
|
157
|
+
principal_id = _guid(principal_id, "principal id")
|
|
158
|
+
seen: dict[str, AzureRoleAssignment] = {}
|
|
159
|
+
for subscription_id in subscription_ids:
|
|
160
|
+
items = self.api.get_all(
|
|
161
|
+
f"/subscriptions/{_subscription_id(subscription_id)}"
|
|
162
|
+
"/providers/Microsoft.Authorization/roleAssignments",
|
|
163
|
+
params={
|
|
164
|
+
"api-version": AUTHORIZATION_API,
|
|
165
|
+
"$filter": f"assignedTo('{principal_id}')",
|
|
166
|
+
},
|
|
167
|
+
next_link="nextLink",
|
|
168
|
+
)
|
|
169
|
+
for item in items:
|
|
170
|
+
assignment_id = str(item.get("id") or "")
|
|
171
|
+
if assignment_id and assignment_id not in seen:
|
|
172
|
+
properties = item.get("properties")
|
|
173
|
+
definition = (
|
|
174
|
+
str(properties.get("roleDefinitionId") or "")
|
|
175
|
+
if isinstance(properties, dict)
|
|
176
|
+
else ""
|
|
177
|
+
)
|
|
178
|
+
seen[assignment_id] = AzureRoleAssignment.from_json(
|
|
179
|
+
item, self._role_name(definition)
|
|
180
|
+
)
|
|
181
|
+
return sorted(seen.values(), key=lambda item: (item.scope.casefold(), item.role_name))
|
|
182
|
+
|
|
183
|
+
def _role_name(self, definition_id: str) -> str:
|
|
184
|
+
if not definition_id:
|
|
185
|
+
return ""
|
|
186
|
+
if definition_id not in self._role_names:
|
|
187
|
+
try:
|
|
188
|
+
data = self.api.get(definition_id, params={"api-version": AUTHORIZATION_API})
|
|
189
|
+
properties = data.get("properties")
|
|
190
|
+
name = str(properties.get("roleName") or "") if isinstance(properties, dict) else ""
|
|
191
|
+
except ApiError:
|
|
192
|
+
# The assignment is still worth showing without a friendly name.
|
|
193
|
+
name = ""
|
|
194
|
+
self._role_names[definition_id] = name or definition_id.rsplit("/", 1)[-1]
|
|
195
|
+
return self._role_names[definition_id]
|
|
196
|
+
|
|
197
|
+
# Defender for Cloud -----------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def secure_score(self, subscription_id: str) -> SecureScore | None:
|
|
200
|
+
"""The subscription's secure score, or None where Defender for Cloud has none."""
|
|
201
|
+
subscription_id = _subscription_id(subscription_id)
|
|
202
|
+
try:
|
|
203
|
+
data = self.api.get(
|
|
204
|
+
f"/subscriptions/{subscription_id}/providers/Microsoft.Security/secureScores/ascScore",
|
|
205
|
+
params={"api-version": SECURE_SCORE_API},
|
|
206
|
+
)
|
|
207
|
+
except ApiError as exc:
|
|
208
|
+
if exc.status == 404:
|
|
209
|
+
return None
|
|
210
|
+
raise
|
|
211
|
+
return SecureScore.from_json(subscription_id, data)
|
|
212
|
+
|
|
213
|
+
def secure_score_controls(self, subscription_id: str) -> list[SecureScoreControl]:
|
|
214
|
+
"""Security controls, the ones costing the most points first."""
|
|
215
|
+
items = self.api.get_all(
|
|
216
|
+
f"/subscriptions/{_subscription_id(subscription_id)}"
|
|
217
|
+
"/providers/Microsoft.Security/secureScoreControls",
|
|
218
|
+
params={"api-version": SECURE_SCORE_API, "$expand": "definition"},
|
|
219
|
+
next_link="nextLink",
|
|
220
|
+
)
|
|
221
|
+
controls = [SecureScoreControl.from_json(item) for item in items]
|
|
222
|
+
return sorted(controls, key=lambda control: control.points_lost, reverse=True)
|
|
223
|
+
|
|
224
|
+
def assessments(self, subscription_id: str, *, unhealthy_only: bool = True) -> list[Assessment]:
|
|
225
|
+
"""Defender for Cloud recommendation results, most severe first."""
|
|
226
|
+
items = self.api.get_all(
|
|
227
|
+
f"/subscriptions/{_subscription_id(subscription_id)}"
|
|
228
|
+
"/providers/Microsoft.Security/assessments",
|
|
229
|
+
params={"api-version": ASSESSMENTS_API, "$expand": "metadata"},
|
|
230
|
+
next_link="nextLink",
|
|
231
|
+
)
|
|
232
|
+
found = [Assessment.from_json(item) for item in items]
|
|
233
|
+
if unhealthy_only:
|
|
234
|
+
found = [item for item in found if item.unhealthy]
|
|
235
|
+
order = {"high": 0, "medium": 1, "low": 2}
|
|
236
|
+
return sorted(found, key=lambda item: (order.get(item.severity.casefold(), 3), item.name))
|
|
237
|
+
|
|
238
|
+
def defender_plans(self, subscription_id: str) -> list[DefenderPlan]:
|
|
239
|
+
"""Every Defender for Cloud plan on the subscription and its tier."""
|
|
240
|
+
data = self.api.get(
|
|
241
|
+
f"/subscriptions/{_subscription_id(subscription_id)}/providers/Microsoft.Security/pricings",
|
|
242
|
+
params={"api-version": PRICINGS_API},
|
|
243
|
+
)
|
|
244
|
+
items = data.get("value")
|
|
245
|
+
plans = [
|
|
246
|
+
DefenderPlan.from_json(item)
|
|
247
|
+
for item in (items if isinstance(items, list) else [])
|
|
248
|
+
if isinstance(item, dict)
|
|
249
|
+
]
|
|
250
|
+
return sorted(plans, key=lambda plan: plan.name.casefold())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _guid(value: str, what: str) -> str:
|
|
254
|
+
if not is_guid(value):
|
|
255
|
+
raise LdoError(f"not a {what}: {value!r}")
|
|
256
|
+
return value.strip().lower()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _subscription_id(value: str) -> str:
|
|
260
|
+
return _guid(value, "subscription id")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Azure Resource Manager records, trimmed to the fields used here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.core.util import parse_datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _map(value: object) -> Mapping[str, Any]:
|
|
14
|
+
return value if isinstance(value, Mapping) else {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _text(data: Mapping[str, Any], key: str) -> str:
|
|
18
|
+
return str(data.get(key) or "")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _number(value: object) -> float | None:
|
|
22
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
23
|
+
return None
|
|
24
|
+
return float(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Subscription:
|
|
29
|
+
"""An Azure subscription the credential can see."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
state: str
|
|
34
|
+
tenant_id: str
|
|
35
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_json(cls, data: Mapping[str, Any]) -> Subscription:
|
|
39
|
+
return cls(
|
|
40
|
+
id=_text(data, "subscriptionId").lower(),
|
|
41
|
+
name=_text(data, "displayName"),
|
|
42
|
+
state=_text(data, "state"),
|
|
43
|
+
tenant_id=_text(data, "tenantId").lower(),
|
|
44
|
+
raw=dict(data),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class AzureRoleAssignment:
|
|
50
|
+
"""An Azure RBAC role assignment. ``role_name`` is resolved from the definition."""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
scope: str
|
|
54
|
+
role_name: str
|
|
55
|
+
role_definition_id: str
|
|
56
|
+
principal_id: str
|
|
57
|
+
principal_type: str
|
|
58
|
+
condition: str
|
|
59
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_json(cls, data: Mapping[str, Any], role_name: str) -> AzureRoleAssignment:
|
|
63
|
+
properties = _map(data.get("properties"))
|
|
64
|
+
return cls(
|
|
65
|
+
id=_text(data, "id"),
|
|
66
|
+
scope=_text(properties, "scope"),
|
|
67
|
+
role_name=role_name,
|
|
68
|
+
role_definition_id=_text(properties, "roleDefinitionId"),
|
|
69
|
+
principal_id=_text(properties, "principalId"),
|
|
70
|
+
principal_type=_text(properties, "principalType"),
|
|
71
|
+
condition=_text(properties, "condition"),
|
|
72
|
+
raw=dict(data),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class SecureScore:
|
|
78
|
+
"""A subscription's Defender for Cloud secure score."""
|
|
79
|
+
|
|
80
|
+
subscription_id: str
|
|
81
|
+
current: float | None
|
|
82
|
+
max: float | None
|
|
83
|
+
percentage: float | None
|
|
84
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_json(cls, subscription_id: str, data: Mapping[str, Any]) -> SecureScore:
|
|
88
|
+
score = _map(_map(data.get("properties")).get("score"))
|
|
89
|
+
return cls(
|
|
90
|
+
subscription_id=subscription_id,
|
|
91
|
+
current=_number(score.get("current")),
|
|
92
|
+
max=_number(score.get("max")),
|
|
93
|
+
percentage=_number(score.get("percentage")),
|
|
94
|
+
raw=dict(data),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class SecureScoreControl:
|
|
100
|
+
"""One security control and how much of the secure score it is costing."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
current: float | None
|
|
104
|
+
max: float | None
|
|
105
|
+
percentage: float | None
|
|
106
|
+
healthy: int
|
|
107
|
+
unhealthy: int
|
|
108
|
+
not_applicable: int
|
|
109
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def points_lost(self) -> float:
|
|
113
|
+
return (self.max or 0.0) - (self.current or 0.0)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_json(cls, data: Mapping[str, Any]) -> SecureScoreControl:
|
|
117
|
+
properties = _map(data.get("properties"))
|
|
118
|
+
score = _map(properties.get("score"))
|
|
119
|
+
|
|
120
|
+
def count(key: str) -> int:
|
|
121
|
+
value = properties.get(key)
|
|
122
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
|
123
|
+
|
|
124
|
+
return cls(
|
|
125
|
+
name=_text(properties, "displayName") or _text(data, "name"),
|
|
126
|
+
current=_number(score.get("current")),
|
|
127
|
+
max=_number(score.get("max")),
|
|
128
|
+
percentage=_number(score.get("percentage")),
|
|
129
|
+
healthy=count("healthyResourceCount"),
|
|
130
|
+
unhealthy=count("unhealthyResourceCount"),
|
|
131
|
+
not_applicable=count("notApplicableResourceCount"),
|
|
132
|
+
raw=dict(data),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class Assessment:
|
|
138
|
+
"""A Defender for Cloud recommendation's result for one resource."""
|
|
139
|
+
|
|
140
|
+
id: str
|
|
141
|
+
name: str
|
|
142
|
+
status: str
|
|
143
|
+
severity: str
|
|
144
|
+
resource_id: str
|
|
145
|
+
cause: str
|
|
146
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def unhealthy(self) -> bool:
|
|
150
|
+
return self.status.casefold() == "unhealthy"
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_json(cls, data: Mapping[str, Any]) -> Assessment:
|
|
154
|
+
properties = _map(data.get("properties"))
|
|
155
|
+
status = _map(properties.get("status"))
|
|
156
|
+
metadata = _map(properties.get("metadata"))
|
|
157
|
+
assessment_id = _text(data, "id")
|
|
158
|
+
# The assessment id is the resource id with the assessment appended.
|
|
159
|
+
resource_id = assessment_id.split("/providers/Microsoft.Security/assessments/", 1)[0]
|
|
160
|
+
return cls(
|
|
161
|
+
id=assessment_id,
|
|
162
|
+
name=_text(properties, "displayName") or _text(data, "name"),
|
|
163
|
+
status=_text(status, "code"),
|
|
164
|
+
severity=_text(metadata, "severity"),
|
|
165
|
+
resource_id=resource_id,
|
|
166
|
+
cause=_text(status, "cause") or _text(status, "description"),
|
|
167
|
+
raw=dict(data),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class DefenderPlan:
|
|
173
|
+
"""A Defender for Cloud plan and whether it is on (Standard) or off (Free)."""
|
|
174
|
+
|
|
175
|
+
name: str
|
|
176
|
+
pricing_tier: str
|
|
177
|
+
sub_plan: str
|
|
178
|
+
trial_remaining: str
|
|
179
|
+
enabled_since: datetime | None
|
|
180
|
+
deprecated: bool
|
|
181
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def enabled(self) -> bool:
|
|
185
|
+
return self.pricing_tier.casefold() == "standard"
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_json(cls, data: Mapping[str, Any]) -> DefenderPlan:
|
|
189
|
+
properties = _map(data.get("properties"))
|
|
190
|
+
return cls(
|
|
191
|
+
name=_text(data, "name"),
|
|
192
|
+
pricing_tier=_text(properties, "pricingTier"),
|
|
193
|
+
sub_plan=_text(properties, "subPlan"),
|
|
194
|
+
trial_remaining=_text(properties, "freeTrialRemainingTime"),
|
|
195
|
+
enabled_since=parse_datetime(properties.get("enablementTime")),
|
|
196
|
+
deprecated=properties.get("deprecated") is True,
|
|
197
|
+
raw=dict(data),
|
|
198
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Microsoft cloud environments and the endpoints each one uses.
|
|
2
|
+
|
|
3
|
+
A profile names its cloud (``public`` unless set), and every client takes its base URL
|
|
4
|
+
and token resource from here, so nothing else in the package hard-codes a host. The
|
|
5
|
+
Azure CLI must be pointed at the same cloud (``az cloud set``) for its tokens to work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from libre_devops_helpers.core.errors import ConfigError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Cloud:
|
|
17
|
+
"""The endpoints of one Microsoft cloud. ``mde_url`` is None where Defender is absent."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
login_url: str
|
|
22
|
+
graph_url: str
|
|
23
|
+
arm_url: str
|
|
24
|
+
# The older ARM audience that some tokens still carry.
|
|
25
|
+
arm_classic_url: str
|
|
26
|
+
mde_url: str | None
|
|
27
|
+
log_analytics_url: str
|
|
28
|
+
keyvault_suffix: str
|
|
29
|
+
|
|
30
|
+
def require_mde(self) -> str:
|
|
31
|
+
"""The Defender for Endpoint API URL, or ConfigError where this cloud has none."""
|
|
32
|
+
if self.mde_url is None:
|
|
33
|
+
raise ConfigError(f"Defender for Endpoint is not available in the {self.name} cloud")
|
|
34
|
+
return self.mde_url
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PUBLIC = Cloud(
|
|
38
|
+
name="public",
|
|
39
|
+
description="Azure public cloud",
|
|
40
|
+
login_url="https://login.microsoftonline.com",
|
|
41
|
+
graph_url="https://graph.microsoft.com",
|
|
42
|
+
arm_url="https://management.azure.com",
|
|
43
|
+
arm_classic_url="https://management.core.windows.net",
|
|
44
|
+
mde_url="https://api.securitycenter.microsoft.com",
|
|
45
|
+
log_analytics_url="https://api.loganalytics.io",
|
|
46
|
+
keyvault_suffix="vault.azure.net",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
USGOV = Cloud(
|
|
50
|
+
name="usgov",
|
|
51
|
+
description="Azure US Government (GCC High)",
|
|
52
|
+
login_url="https://login.microsoftonline.us",
|
|
53
|
+
graph_url="https://graph.microsoft.us",
|
|
54
|
+
arm_url="https://management.usgovcloudapi.net",
|
|
55
|
+
arm_classic_url="https://management.core.usgovcloudapi.net",
|
|
56
|
+
mde_url="https://api-gov.securitycenter.microsoft.us",
|
|
57
|
+
log_analytics_url="https://api.loganalytics.us",
|
|
58
|
+
keyvault_suffix="vault.usgovcloudapi.net",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
CHINA = Cloud(
|
|
62
|
+
name="china",
|
|
63
|
+
description="Azure operated by 21Vianet",
|
|
64
|
+
login_url="https://login.chinacloudapi.cn",
|
|
65
|
+
graph_url="https://microsoftgraph.chinacloudapi.cn",
|
|
66
|
+
arm_url="https://management.chinacloudapi.cn",
|
|
67
|
+
arm_classic_url="https://management.core.chinacloudapi.cn",
|
|
68
|
+
mde_url=None,
|
|
69
|
+
log_analytics_url="https://api.loganalytics.azure.cn",
|
|
70
|
+
keyvault_suffix="vault.azure.cn",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
CLOUDS: dict[str, Cloud] = {cloud.name: cloud for cloud in (PUBLIC, USGOV, CHINA)}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_cloud(name: str) -> Cloud:
|
|
77
|
+
"""A cloud by name. Raises ConfigError listing the known names when absent."""
|
|
78
|
+
try:
|
|
79
|
+
return CLOUDS[name.strip().lower()]
|
|
80
|
+
except KeyError:
|
|
81
|
+
raise ConfigError(
|
|
82
|
+
f"unknown cloud {name!r}", hint=f"use one of {', '.join(CLOUDS)}"
|
|
83
|
+
) from None
|