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