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,137 @@
1
+ """The APIs this tool talks to, as a token sees them, and what features need from a token.
2
+
3
+ A ``Resource`` is an API: the URL a token is requested for and the ``aud`` values that
4
+ mean it. Resources are built per cloud. A ``Requirement`` is declared by a feature
5
+ module (``entra``, ``xdr``, ...) to say which scopes or roles its calls need, so core
6
+ checks tokens without knowing any feature.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+
14
+ from libre_devops_helpers.core.errors import LdoError
15
+ from libre_devops_helpers.microsoft.clouds import PUBLIC, Cloud
16
+
17
+ # First-party application ids. They are the same in every cloud, and a token may carry
18
+ # one as its audience instead of the URL.
19
+ GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000"
20
+ MDE_APP_ID = "fc780465-2017-40d4-a0c5-307022471b92"
21
+ ARM_APP_ID = "797f4846-ba00-4fd7-ba43-dac1f8f63013"
22
+ LOG_ANALYTICS_APP_ID = "ca7f3f0b-7d91-482c-8e09-c5d840d0eac5"
23
+ KEY_VAULT_APP_ID = "cfa8b339-82a2-471a-a3c9-0fc0be7a4093"
24
+
25
+
26
+ def normalise_audience(value: str) -> str:
27
+ """Compare audiences case-insensitively and without a trailing slash."""
28
+ return value.strip().rstrip("/").lower()
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Resource:
33
+ """An API as seen by a token.
34
+
35
+ ``url`` is what is asked for when requesting a token. ``audiences`` are the ``aud``
36
+ claim values that mean this API (normalised).
37
+ """
38
+
39
+ key: str
40
+ url: str
41
+ audiences: frozenset[str]
42
+ description: str = ""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Requirement:
47
+ """What one feature needs from a token for the resource called ``resource``.
48
+
49
+ Every inner tuple of ``all_of`` must be satisfied, each by any one of its scopes or
50
+ roles: ``(("Device.Read.All", "Directory.Read.All"),)`` needs either of the two.
51
+ """
52
+
53
+ feature: str
54
+ resource: str
55
+ all_of: tuple[tuple[str, ...], ...]
56
+
57
+
58
+ def _resource(key: str, url: str, *audiences: str, description: str) -> Resource:
59
+ return Resource(
60
+ key=key,
61
+ url=url,
62
+ audiences=frozenset(normalise_audience(value) for value in (url, *audiences)),
63
+ description=description,
64
+ )
65
+
66
+
67
+ def resources_for(cloud: Cloud) -> dict[str, Resource]:
68
+ """Every API this tool can call in ``cloud``, keyed by resource key."""
69
+ found = [
70
+ _resource(
71
+ "graph",
72
+ cloud.graph_url,
73
+ GRAPH_APP_ID,
74
+ description="Microsoft Graph (Entra ID and Intune)",
75
+ ),
76
+ _resource(
77
+ "arm",
78
+ cloud.arm_url + "/",
79
+ cloud.arm_classic_url,
80
+ ARM_APP_ID,
81
+ description="Azure Resource Manager",
82
+ ),
83
+ _resource(
84
+ "loganalytics",
85
+ cloud.log_analytics_url,
86
+ LOG_ANALYTICS_APP_ID,
87
+ description="Log Analytics query API",
88
+ ),
89
+ _resource(
90
+ "keyvault",
91
+ f"https://{cloud.keyvault_suffix}",
92
+ KEY_VAULT_APP_ID,
93
+ description="Key Vault data plane",
94
+ ),
95
+ ]
96
+ if cloud.mde_url is not None:
97
+ found.append(
98
+ _resource(
99
+ "mde",
100
+ cloud.mde_url,
101
+ "https://securitycenter.onmicrosoft.com/windowsatpservice",
102
+ MDE_APP_ID,
103
+ description="Defender for Endpoint API",
104
+ )
105
+ )
106
+ return {resource.key: resource for resource in found}
107
+
108
+
109
+ # The public cloud's resources, for callers that do not deal in clouds.
110
+ RESOURCES: Mapping[str, Resource] = resources_for(PUBLIC)
111
+ GRAPH = RESOURCES["graph"]
112
+ MDE = RESOURCES["mde"]
113
+ ARM = RESOURCES["arm"]
114
+ LOG_ANALYTICS = RESOURCES["loganalytics"]
115
+ KEY_VAULT = RESOURCES["keyvault"]
116
+
117
+
118
+ def resolve_resource(value: str, cloud: Cloud = PUBLIC) -> Resource:
119
+ """Look up a resource by key (``graph``, ``mde``, ...) or by an https URL.
120
+
121
+ An unknown https URL is wrapped as an ad hoc resource whose only accepted audience
122
+ is the URL itself.
123
+ """
124
+ known = resources_for(cloud)
125
+ key = value.strip()
126
+ if key.lower() in known:
127
+ return known[key.lower()]
128
+ if key.startswith("https://"):
129
+ audience = normalise_audience(key)
130
+ for resource in known.values():
131
+ if audience in resource.audiences:
132
+ return resource
133
+ return Resource(key=key, url=key, audiences=frozenset({audience}))
134
+ raise LdoError(
135
+ f"unknown resource {value!r}",
136
+ hint=f"use one of {', '.join(sorted(known))}, or an https:// resource URL",
137
+ )
@@ -0,0 +1,269 @@
1
+ """Decode an Entra ID access token and check it is the token you meant to get.
2
+
3
+ Only the claims are inspected; the signature is NOT verified. The checks answer
4
+ "is this token for the right API and tenant, in date, with the permissions this
5
+ tool needs?", not "is this token genuine?". Microsoft Graph tokens cannot be
6
+ signature-checked by a client in any case.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ import json
14
+ from collections.abc import Iterable, Mapping
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, datetime, timedelta
17
+ from typing import Any, Literal
18
+
19
+ from libre_devops_helpers.core.errors import TokenError
20
+ from libre_devops_helpers.core.util import format_duration
21
+ from libre_devops_helpers.microsoft.resources import (
22
+ Requirement,
23
+ Resource,
24
+ normalise_audience,
25
+ )
26
+
27
+ Status = Literal["pass", "warn", "fail"]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DecodedToken:
32
+ """The header and claims of a JWT, with accessors for the common claims."""
33
+
34
+ header: Mapping[str, Any]
35
+ claims: Mapping[str, Any]
36
+
37
+ def timestamp(self, claim: str) -> datetime | None:
38
+ """A NumericDate claim (``exp``, ``nbf``, ``iat``) as an aware UTC datetime."""
39
+ value = self.claims.get(claim)
40
+ if isinstance(value, bool) or not isinstance(value, int | float):
41
+ return None
42
+ return datetime.fromtimestamp(value, UTC)
43
+
44
+ @property
45
+ def expires_at(self) -> datetime | None:
46
+ return self.timestamp("exp")
47
+
48
+ @property
49
+ def not_before(self) -> datetime | None:
50
+ return self.timestamp("nbf")
51
+
52
+ @property
53
+ def issued_at(self) -> datetime | None:
54
+ return self.timestamp("iat")
55
+
56
+ @property
57
+ def audiences(self) -> tuple[str, ...]:
58
+ aud = self.claims.get("aud")
59
+ if isinstance(aud, str):
60
+ return (aud,)
61
+ if isinstance(aud, list):
62
+ return tuple(str(item) for item in aud)
63
+ return ()
64
+
65
+ @property
66
+ def tenant_id(self) -> str:
67
+ return str(self.claims.get("tid", ""))
68
+
69
+ @property
70
+ def issuer(self) -> str:
71
+ return str(self.claims.get("iss", ""))
72
+
73
+ @property
74
+ def scopes(self) -> tuple[str, ...]:
75
+ """Delegated permissions (``scp``)."""
76
+ scp = self.claims.get("scp")
77
+ return tuple(scp.split()) if isinstance(scp, str) else ()
78
+
79
+ @property
80
+ def roles(self) -> tuple[str, ...]:
81
+ """Application permissions (``roles``)."""
82
+ roles = self.claims.get("roles")
83
+ return tuple(str(role) for role in roles) if isinstance(roles, list) else ()
84
+
85
+ @property
86
+ def principal(self) -> str:
87
+ """Who the token is for: a user name for delegated tokens, else an app id."""
88
+ for claim in ("upn", "unique_name", "preferred_username", "appid", "azp", "oid"):
89
+ value = self.claims.get(claim)
90
+ if isinstance(value, str) and value:
91
+ return value
92
+ return ""
93
+
94
+ @property
95
+ def app_id(self) -> str:
96
+ """The client application that requested the token."""
97
+ return str(self.claims.get("appid") or self.claims.get("azp") or "")
98
+
99
+ @property
100
+ def identity_type(self) -> str:
101
+ """``user`` (delegated), ``app`` (application), or ``unknown``."""
102
+ idtyp = self.claims.get("idtyp")
103
+ if isinstance(idtyp, str) and idtyp:
104
+ return idtyp
105
+ if self.scopes:
106
+ return "user"
107
+ return "app" if self.roles else "unknown"
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Check:
112
+ """The outcome of one token check."""
113
+
114
+ name: str
115
+ status: Status
116
+ detail: str
117
+
118
+
119
+ def decode_token(token: str) -> DecodedToken:
120
+ """Decode a JWT without verifying it. Accepts an optional ``Bearer`` prefix."""
121
+ raw = token.strip()
122
+ if raw[:7].lower() == "bearer ":
123
+ raw = raw[7:].strip()
124
+ parts = raw.split(".")
125
+ if len(parts) == 5:
126
+ raise TokenError("this is an encrypted token (JWE); its claims cannot be read")
127
+ if len(parts) != 3:
128
+ raise TokenError("not a JWT: expected three dot-separated parts")
129
+ return DecodedToken(header=_segment(parts[0], "header"), claims=_segment(parts[1], "payload"))
130
+
131
+
132
+ def _segment(value: str, name: str) -> dict[str, Any]:
133
+ try:
134
+ data = json.loads(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)))
135
+ except (binascii.Error, ValueError) as exc:
136
+ raise TokenError(f"cannot decode the token {name}: {exc}") from None
137
+ if not isinstance(data, dict):
138
+ raise TokenError(f"the token {name} is not a JSON object")
139
+ return data
140
+
141
+
142
+ def validate_token(
143
+ token: DecodedToken,
144
+ *,
145
+ resource: Resource | None = None,
146
+ tenant_id: str | None = None,
147
+ required: Iterable[str] = (),
148
+ requirements: Iterable[Requirement] = (),
149
+ now: datetime | None = None,
150
+ clock_skew: timedelta = timedelta(minutes=1),
151
+ expiry_warning: timedelta = timedelta(minutes=5),
152
+ ) -> list[Check]:
153
+ """Check a decoded token's claims.
154
+
155
+ Always checks expiry, not-before and that the issuer matches the token's own
156
+ tenant. With ``resource`` it checks the audience, and warns for each of the
157
+ ``requirements`` for that resource the token does not cover (so it says which
158
+ features the token can serve). With ``tenant_id`` it checks the tenant. Each name
159
+ in ``required`` must appear in ``scp`` or ``roles``.
160
+ """
161
+ now = now or datetime.now(UTC)
162
+ checks: list[Check] = []
163
+
164
+ expires = token.expires_at
165
+ if expires is None:
166
+ checks.append(Check("expiry", "fail", "no exp claim"))
167
+ elif expires <= now:
168
+ checks.append(Check("expiry", "fail", f"expired {format_duration(now - expires)} ago"))
169
+ elif expires - now <= expiry_warning:
170
+ checks.append(Check("expiry", "warn", f"expires in {format_duration(expires - now)}"))
171
+ else:
172
+ checks.append(Check("expiry", "pass", f"expires in {format_duration(expires - now)}"))
173
+
174
+ not_before = token.not_before
175
+ if not_before is not None and not_before - now > clock_skew:
176
+ checks.append(
177
+ Check("not-before", "fail", f"not valid for {format_duration(not_before - now)}")
178
+ )
179
+
180
+ if token.tenant_id:
181
+ if token.tenant_id.lower() in token.issuer.lower():
182
+ checks.append(Check("issuer", "pass", token.issuer))
183
+ else:
184
+ checks.append(
185
+ Check("issuer", "fail", f"{token.issuer!r} does not match tid {token.tenant_id}")
186
+ )
187
+
188
+ if tenant_id is not None:
189
+ if token.tenant_id.lower() == tenant_id.lower():
190
+ checks.append(Check("tenant", "pass", token.tenant_id))
191
+ else:
192
+ checks.append(
193
+ Check("tenant", "fail", f"tid {token.tenant_id or '(none)'}, expected {tenant_id}")
194
+ )
195
+
196
+ if resource is not None:
197
+ checks.append(_audience_check(token, resource))
198
+
199
+ granted = {permission.casefold() for permission in (*token.scopes, *token.roles)}
200
+ if resource is not None:
201
+ relevant = [item for item in requirements if item.resource == resource.key]
202
+ checks.extend(_permission_checks(relevant, granted, {*token.scopes, *token.roles}))
203
+ for permission in required:
204
+ if permission.casefold() in granted:
205
+ checks.append(Check(f"requires {permission}", "pass", "present"))
206
+ else:
207
+ checks.append(Check(f"requires {permission}", "fail", "not in scp or roles"))
208
+
209
+ return checks
210
+
211
+
212
+ def passed(checks: Iterable[Check], *, strict: bool = False) -> bool:
213
+ """True when no check failed (and, with ``strict``, none warned)."""
214
+ bad = {"fail", "warn"} if strict else {"fail"}
215
+ return not any(check.status in bad for check in checks)
216
+
217
+
218
+ def _audience_check(token: DecodedToken, resource: Resource) -> Check:
219
+ audiences = token.audiences
220
+ if any(normalise_audience(aud) in resource.audiences for aud in audiences):
221
+ return Check("audience", "pass", f"{', '.join(audiences)} is {resource.key}")
222
+ return Check(
223
+ "audience",
224
+ "fail",
225
+ f"{', '.join(audiences) or '(none)'} is not {resource.key} ({resource.url})",
226
+ )
227
+
228
+
229
+ def _permission_checks(
230
+ requirements: list[Requirement], granted: set[str], names: set[str]
231
+ ) -> list[Check]:
232
+ if not requirements:
233
+ return []
234
+ if not granted:
235
+ return [
236
+ Check(
237
+ "permissions",
238
+ "warn",
239
+ "no scp or roles claim; access then rests on the service's own RBAC",
240
+ )
241
+ ]
242
+ if granted == {"user_impersonation"}:
243
+ # A delegated grant with no granular scopes, as the Azure CLI gets for Defender:
244
+ # what the user can do is decided by their role in the service, not by the token.
245
+ return [
246
+ Check(
247
+ "permissions",
248
+ "warn",
249
+ "only user_impersonation; access then rests on your role in the service",
250
+ )
251
+ ]
252
+ by_name = {name.casefold(): name for name in names}
253
+ checks: list[Check] = []
254
+ for requirement in requirements:
255
+ via: list[str] = []
256
+ missing: list[tuple[str, ...]] = []
257
+ for group in requirement.all_of:
258
+ match = next((p for p in group if p.casefold() in granted), None)
259
+ if match is None:
260
+ missing.append(group)
261
+ else:
262
+ via.append(by_name.get(match.casefold(), match))
263
+ name = f"permissions: {requirement.feature}"
264
+ if missing:
265
+ needs = "; ".join("one of " + ", ".join(group) for group in missing)
266
+ checks.append(Check(name, "warn", f"needs {needs}"))
267
+ else:
268
+ checks.append(Check(name, "pass", "via " + ", ".join(dict.fromkeys(via))))
269
+ return checks
@@ -0,0 +1,33 @@
1
+ """Defender for Endpoint (Defender XDR): machines, alerts, vulnerabilities, indicators, hunting.
2
+
3
+ Depends only on ``core`` and the shared Microsoft layer. Public API::
4
+
5
+ from libre_devops_helpers.microsoft.auth import AzureCliCredential
6
+ from libre_devops_helpers.microsoft.xdr import XdrClient
7
+
8
+ with XdrClient.create(AzureCliCredential(), tenant_id) as xdr:
9
+ lookup = xdr.find_machine("web01.corp.example.com")
10
+ if lookup.machine:
11
+ print(lookup.machine.onboarding_status, lookup.machine.health_status)
12
+ """
13
+
14
+ from libre_devops_helpers.microsoft.xdr.client import XdrClient, parse_severity
15
+ from libre_devops_helpers.microsoft.xdr.models import (
16
+ Alert,
17
+ Indicator,
18
+ Machine,
19
+ MachineLookup,
20
+ Vulnerability,
21
+ )
22
+ from libre_devops_helpers.microsoft.xdr.permissions import REQUIREMENTS
23
+
24
+ __all__ = [
25
+ "REQUIREMENTS",
26
+ "Alert",
27
+ "Indicator",
28
+ "Machine",
29
+ "MachineLookup",
30
+ "Vulnerability",
31
+ "XdrClient",
32
+ "parse_severity",
33
+ ]
@@ -0,0 +1,246 @@
1
+ """Read-only Defender for Endpoint queries: machines, alerts, vulnerabilities, hunting.
2
+
3
+ Device lookups are small, server-side filtered GETs per candidate name, never a
4
+ download of the tenant-wide machine inventory. The FQDN is tried first, then the short
5
+ hostname, because Defender does not report Linux names consistently.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+ import re
12
+ from collections.abc import Iterable
13
+ from datetime import UTC, datetime, timedelta
14
+ from typing import Self
15
+
16
+ import requests
17
+
18
+ from libre_devops_helpers.core.auth import TokenProvider, token_source
19
+ from libre_devops_helpers.core.errors import ApiError, LdoError, NotFoundError
20
+ from libre_devops_helpers.core.http import ApiClient
21
+ from libre_devops_helpers.core.tables import QueryResult
22
+ from libre_devops_helpers.core.util import (
23
+ candidate_names,
24
+ odata_datetime,
25
+ odata_string,
26
+ )
27
+ from libre_devops_helpers.microsoft.clouds import PUBLIC
28
+ from libre_devops_helpers.microsoft.config import Profile
29
+ from libre_devops_helpers.microsoft.xdr.models import (
30
+ Alert,
31
+ Indicator,
32
+ Machine,
33
+ MachineLookup,
34
+ Vulnerability,
35
+ )
36
+
37
+ _MACHINE_ID = re.compile(r"^[0-9a-fA-F]{40}$")
38
+ _NEVER = datetime.min.replace(tzinfo=UTC)
39
+ _SEVERITY_ORDER = {"informational": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
40
+
41
+ # The global Defender resource; regional endpoints accept its tokens.
42
+ MDE_RESOURCE = PUBLIC.mde_url or "https://api.securitycenter.microsoft.com"
43
+
44
+
45
+ class XdrClient:
46
+ """Defender for Endpoint lookups. Close it (or use ``with``) when done."""
47
+
48
+ def __init__(self, api: ApiClient) -> None:
49
+ self.api = api
50
+
51
+ @classmethod
52
+ def create(
53
+ cls,
54
+ tokens: TokenProvider,
55
+ tenant_id: str,
56
+ *,
57
+ api_url: str = MDE_RESOURCE,
58
+ resource: str = MDE_RESOURCE,
59
+ verify: bool | str = True,
60
+ session: requests.Session | None = None,
61
+ ) -> XdrClient:
62
+ """A client for ``tenant_id``. ``api_url`` may be a regional Defender endpoint.
63
+
64
+ ``resource`` is what tokens are requested for: the cloud's Defender resource,
65
+ which regional endpoints accept too.
66
+ """
67
+ api = ApiClient(
68
+ api_url,
69
+ token_source(tokens, resource, tenant_id),
70
+ name="Defender for Endpoint",
71
+ verify=verify,
72
+ session=session,
73
+ )
74
+ return cls(api)
75
+
76
+ @classmethod
77
+ def for_profile(
78
+ cls,
79
+ profile: Profile,
80
+ tokens: TokenProvider,
81
+ *,
82
+ verify: bool | str = True,
83
+ session: requests.Session | None = None,
84
+ ) -> XdrClient:
85
+ """A client for a configured profile's tenant and Defender endpoint."""
86
+ resource = profile.cloud.require_mde()
87
+ return cls.create(
88
+ tokens,
89
+ profile.tenant_id,
90
+ api_url=profile.mde_url or resource,
91
+ resource=resource,
92
+ verify=verify,
93
+ session=session,
94
+ )
95
+
96
+ def close(self) -> None:
97
+ self.api.close()
98
+
99
+ def __enter__(self) -> Self:
100
+ return self
101
+
102
+ def __exit__(self, *exc_info: object) -> None:
103
+ self.close()
104
+
105
+ # Machines ---------------------------------------------------------------------
106
+
107
+ def machines_named(self, name: str) -> list[Machine]:
108
+ """Every machine record whose ``computerDnsName`` equals ``name``."""
109
+ items = self.api.get_all(
110
+ "/api/machines", params={"$filter": f"computerDnsName eq {odata_string(name)}"}
111
+ )
112
+ return [Machine.from_json(item) for item in items]
113
+
114
+ def find_machine(self, name: str) -> MachineLookup:
115
+ """Look one device up by FQDN, falling back to its short hostname."""
116
+ for candidate in candidate_names(name):
117
+ records = self.machines_named(candidate)
118
+ if records:
119
+ newest_first = sorted(
120
+ records, key=lambda machine: machine.last_seen or _NEVER, reverse=True
121
+ )
122
+ return MachineLookup(name, candidate, tuple(newest_first))
123
+ return MachineLookup(name, None)
124
+
125
+ def find_machines(self, names: Iterable[str]) -> list[MachineLookup]:
126
+ """``find_machine`` for each name, in order."""
127
+ return [self.find_machine(name) for name in names]
128
+
129
+ def get_machine(self, machine_id: str) -> Machine:
130
+ """One machine by its Defender id (40 hex characters)."""
131
+ machine_id = _machine_id(machine_id)
132
+ try:
133
+ return Machine.from_json(self.api.get(f"/api/machines/{machine_id}"))
134
+ except ApiError as exc:
135
+ if exc.status == 404:
136
+ raise NotFoundError(f"no MDE machine has id {machine_id}") from None
137
+ raise
138
+
139
+ def stale_machines(
140
+ self, older_than: timedelta, *, now: datetime | None = None
141
+ ) -> list[Machine]:
142
+ """Machines not seen for ``older_than``, longest silent first.
143
+
144
+ The filter runs on the server, so only the stale records are downloaded.
145
+ """
146
+ cutoff = (now or datetime.now(UTC)) - older_than
147
+ items = self.api.get_all(
148
+ "/api/machines", params={"$filter": f"lastSeen lt {odata_datetime(cutoff)}"}
149
+ )
150
+ machines = [Machine.from_json(item) for item in items]
151
+ return sorted(machines, key=lambda machine: machine.last_seen or _NEVER)
152
+
153
+ # Alerts -----------------------------------------------------------------------
154
+
155
+ def alerts(
156
+ self,
157
+ *,
158
+ machine_id: str | None = None,
159
+ since: datetime | None = None,
160
+ min_severity: str | None = None,
161
+ include_resolved: bool = False,
162
+ limit: int = 200,
163
+ ) -> list[Alert]:
164
+ """Alerts for the tenant, or for one machine, newest first.
165
+
166
+ The creation-time filter runs on the server; severity and status are filtered
167
+ here, because the API's support for filtering those varies by endpoint.
168
+ """
169
+ if machine_id is not None:
170
+ items = self.api.get_all(f"/api/machines/{_machine_id(machine_id)}/alerts")
171
+ else:
172
+ params = {"$top": str(max(1, min(limit, 10000)))}
173
+ if since is not None:
174
+ params["$filter"] = f"alertCreationTime ge {odata_datetime(since)}"
175
+ items = self.api.get_all("/api/alerts", params=params)
176
+ floor = parse_severity(min_severity) if min_severity else -1
177
+ alerts = [
178
+ alert
179
+ for alert in (Alert.from_json(item) for item in items)
180
+ if (include_resolved or not alert.resolved)
181
+ and _severity_rank(alert.severity) >= floor
182
+ and (since is None or alert.created is None or alert.created >= since)
183
+ ]
184
+ alerts.sort(key=lambda alert: alert.created or _NEVER, reverse=True)
185
+ return list(itertools.islice(alerts, limit))
186
+
187
+ # Vulnerabilities --------------------------------------------------------------
188
+
189
+ def vulnerabilities(self, machine_id: str) -> list[Vulnerability]:
190
+ """Vulnerabilities on one machine, most severe first."""
191
+ items = self.api.get_all(f"/api/machines/{_machine_id(machine_id)}/vulnerabilities")
192
+ found = [Vulnerability.from_json(item) for item in items]
193
+ return sorted(
194
+ found,
195
+ key=lambda item: (_severity_rank(item.severity), item.cvss or 0.0),
196
+ reverse=True,
197
+ )
198
+
199
+ # Indicators -------------------------------------------------------------------
200
+
201
+ def indicators(self) -> list[Indicator]:
202
+ """Every custom indicator in the tenant."""
203
+ return [Indicator.from_json(item) for item in self.api.get_all("/api/indicators")]
204
+
205
+ # Advanced Hunting -------------------------------------------------------------
206
+
207
+ def hunt(self, query: str) -> QueryResult:
208
+ """Run an Advanced Hunting (KQL) query. The API caps results at 100,000 rows."""
209
+ if not query.strip():
210
+ raise LdoError("the hunting query is empty")
211
+ data = self.api.post("/api/advancedqueries/run", {"Query": query})
212
+ schema = data.get("Schema")
213
+ results = data.get("Results")
214
+ columns = (
215
+ [str(column.get("Name")) for column in schema if isinstance(column, dict)]
216
+ if isinstance(schema, list)
217
+ else []
218
+ )
219
+ rows = (
220
+ [row for row in results if isinstance(row, dict)] if isinstance(results, list) else []
221
+ )
222
+ if not columns:
223
+ return QueryResult.from_records(rows)
224
+ return QueryResult(tuple(columns), tuple(rows))
225
+
226
+
227
+ def parse_severity(severity: str) -> int:
228
+ """The rank of a severity someone typed. Raises for anything unknown."""
229
+ rank = _SEVERITY_ORDER.get(severity.strip().casefold())
230
+ if rank is None:
231
+ raise LdoError(
232
+ f"unknown severity {severity!r}", hint=f"use one of {', '.join(_SEVERITY_ORDER)}"
233
+ )
234
+ return rank
235
+
236
+
237
+ def _severity_rank(severity: str | None) -> int:
238
+ # Values from the API are ranked leniently: an unknown one sorts below the rest.
239
+ return _SEVERITY_ORDER.get((severity or "").casefold(), -1)
240
+
241
+
242
+ def _machine_id(value: str) -> str:
243
+ machine_id = value.strip()
244
+ if not _MACHINE_ID.match(machine_id):
245
+ raise LdoError(f"not an MDE machine id: {machine_id!r}")
246
+ return machine_id