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