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,165 @@
|
|
|
1
|
+
"""Defender XDR incidents and their alerts, as the Graph security API returns them.
|
|
2
|
+
|
|
3
|
+
In the unified security operations platform, one incident queue holds Defender's own
|
|
4
|
+
incidents and those from Microsoft Sentinel. Each alert names the service that raised
|
|
5
|
+
it (``serviceSource``), which is how an incident is known to come from Sentinel.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from libre_devops_helpers.core.util import parse_datetime
|
|
16
|
+
|
|
17
|
+
# Graph's serviceSource values, and a short name for each.
|
|
18
|
+
SOURCES = {
|
|
19
|
+
"microsoftDefenderForEndpoint": "Endpoint",
|
|
20
|
+
"microsoftDefenderForIdentity": "Identity",
|
|
21
|
+
"microsoftDefenderForCloudApps": "Cloud Apps",
|
|
22
|
+
"microsoftDefenderForOffice365": "Office 365",
|
|
23
|
+
"microsoft365Defender": "XDR",
|
|
24
|
+
"azureAdIdentityProtection": "Entra ID Protection",
|
|
25
|
+
"microsoftAppGovernance": "App Governance",
|
|
26
|
+
"dataLossPrevention": "DLP",
|
|
27
|
+
"microsoftDefenderForCloud": "Defender for Cloud",
|
|
28
|
+
"microsoftSentinel": "Sentinel",
|
|
29
|
+
"microsoftInsiderRiskManagement": "Insider Risk",
|
|
30
|
+
}
|
|
31
|
+
# Most severe first; unknown values rank below informational.
|
|
32
|
+
SEVERITY_ORDER = ("high", "medium", "low", "informational")
|
|
33
|
+
# Graph's status values; "open" means any of the first three.
|
|
34
|
+
OPEN_STATUSES = ("active", "inProgress", "awaitingAction")
|
|
35
|
+
STATUSES = (*OPEN_STATUSES, "resolved", "redirected")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def severity_rank(severity: str) -> int:
|
|
39
|
+
"""0 for high, up to 4 for unknown: sort ascending for most severe first."""
|
|
40
|
+
try:
|
|
41
|
+
return SEVERITY_ORDER.index(severity.lower())
|
|
42
|
+
except ValueError:
|
|
43
|
+
return len(SEVERITY_ORDER)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text(record: Mapping[str, Any], key: str) -> str:
|
|
47
|
+
value = record.get(key)
|
|
48
|
+
return "" if value is None else str(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _when(record: Mapping[str, Any], key: str) -> datetime | None:
|
|
52
|
+
return parse_datetime(record.get(key))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class IncidentAlert:
|
|
57
|
+
"""One alert in an incident."""
|
|
58
|
+
|
|
59
|
+
id: str
|
|
60
|
+
title: str
|
|
61
|
+
severity: str
|
|
62
|
+
status: str
|
|
63
|
+
source: str # Graph's serviceSource
|
|
64
|
+
detection_source: str
|
|
65
|
+
created: datetime | None
|
|
66
|
+
devices: tuple[str, ...] = ()
|
|
67
|
+
users: tuple[str, ...] = ()
|
|
68
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def source_name(self) -> str:
|
|
72
|
+
return SOURCES.get(self.source, self.source or "unknown")
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_graph(cls, record: Mapping[str, Any]) -> IncidentAlert:
|
|
76
|
+
devices: list[str] = []
|
|
77
|
+
users: list[str] = []
|
|
78
|
+
for evidence in record.get("evidence") or ():
|
|
79
|
+
if not isinstance(evidence, Mapping):
|
|
80
|
+
continue
|
|
81
|
+
kind = str(evidence.get("@odata.type", ""))
|
|
82
|
+
if kind.endswith("deviceEvidence"):
|
|
83
|
+
name = evidence.get("deviceDnsName") or evidence.get("hostName")
|
|
84
|
+
if name:
|
|
85
|
+
devices.append(str(name))
|
|
86
|
+
elif kind.endswith("userEvidence"):
|
|
87
|
+
account = evidence.get("userAccount") or {}
|
|
88
|
+
name = account.get("userPrincipalName") or account.get("accountName")
|
|
89
|
+
if name:
|
|
90
|
+
users.append(str(name))
|
|
91
|
+
return cls(
|
|
92
|
+
id=_text(record, "id"),
|
|
93
|
+
title=_text(record, "title"),
|
|
94
|
+
severity=_text(record, "severity"),
|
|
95
|
+
status=_text(record, "status"),
|
|
96
|
+
source=_text(record, "serviceSource"),
|
|
97
|
+
detection_source=_text(record, "detectionSource"),
|
|
98
|
+
created=_when(record, "createdDateTime"),
|
|
99
|
+
devices=tuple(dict.fromkeys(devices)),
|
|
100
|
+
users=tuple(dict.fromkeys(users)),
|
|
101
|
+
raw=dict(record),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Incident:
|
|
107
|
+
"""One incident, with its alerts."""
|
|
108
|
+
|
|
109
|
+
id: str
|
|
110
|
+
title: str
|
|
111
|
+
severity: str
|
|
112
|
+
status: str
|
|
113
|
+
created: datetime | None
|
|
114
|
+
updated: datetime | None
|
|
115
|
+
assigned_to: str
|
|
116
|
+
classification: str
|
|
117
|
+
determination: str
|
|
118
|
+
web_url: str
|
|
119
|
+
tags: tuple[str, ...]
|
|
120
|
+
alerts: tuple[IncidentAlert, ...]
|
|
121
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def open(self) -> bool:
|
|
125
|
+
return self.status in OPEN_STATUSES
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def sources(self) -> tuple[str, ...]:
|
|
129
|
+
"""The services that raised its alerts (Graph values), in first-seen order."""
|
|
130
|
+
return tuple(dict.fromkeys(alert.source for alert in self.alerts if alert.source))
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def source_names(self) -> tuple[str, ...]:
|
|
134
|
+
return tuple(SOURCES.get(source, source) for source in self.sources)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def devices(self) -> tuple[str, ...]:
|
|
138
|
+
return tuple(dict.fromkeys(name for alert in self.alerts for name in alert.devices))
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def users(self) -> tuple[str, ...]:
|
|
142
|
+
return tuple(dict.fromkeys(name for alert in self.alerts for name in alert.users))
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_graph(cls, record: Mapping[str, Any]) -> Incident:
|
|
146
|
+
tags = [*(record.get("customTags") or ()), *(record.get("systemTags") or ())]
|
|
147
|
+
return cls(
|
|
148
|
+
id=_text(record, "id"),
|
|
149
|
+
title=_text(record, "displayName"),
|
|
150
|
+
severity=_text(record, "severity"),
|
|
151
|
+
status=_text(record, "status"),
|
|
152
|
+
created=_when(record, "createdDateTime"),
|
|
153
|
+
updated=_when(record, "lastUpdateDateTime"),
|
|
154
|
+
assigned_to=_text(record, "assignedTo"),
|
|
155
|
+
classification=_text(record, "classification"),
|
|
156
|
+
determination=_text(record, "determination"),
|
|
157
|
+
web_url=_text(record, "incidentWebUrl"),
|
|
158
|
+
tags=tuple(str(tag) for tag in tags),
|
|
159
|
+
alerts=tuple(
|
|
160
|
+
IncidentAlert.from_graph(alert)
|
|
161
|
+
for alert in record.get("alerts") or ()
|
|
162
|
+
if isinstance(alert, Mapping)
|
|
163
|
+
),
|
|
164
|
+
raw=dict(record),
|
|
165
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""What the incidents feature needs from a Graph token.
|
|
2
|
+
|
|
3
|
+
The Azure CLI's Graph token never carries these, so incidents need an interactive or
|
|
4
|
+
device-code profile whose app registration has been granted them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from libre_devops_helpers.microsoft.resources import Requirement
|
|
8
|
+
|
|
9
|
+
REQUIREMENTS = (
|
|
10
|
+
Requirement(
|
|
11
|
+
"xdr incidents",
|
|
12
|
+
"graph",
|
|
13
|
+
(("SecurityIncident.Read.All", "SecurityIncident.ReadWrite.All"),),
|
|
14
|
+
),
|
|
15
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Intune through Microsoft Graph: managed devices and their compliance.
|
|
2
|
+
|
|
3
|
+
Depends only on ``core`` and the shared Microsoft layer. The Azure CLI's Graph token
|
|
4
|
+
cannot read Intune, so this needs a profile whose credential has
|
|
5
|
+
``DeviceManagementManagedDevices.Read.All``. Public API::
|
|
6
|
+
|
|
7
|
+
from libre_devops_helpers.microsoft.intune import IntuneClient
|
|
8
|
+
|
|
9
|
+
with IntuneClient.for_profile(profile, tokens) as intune:
|
|
10
|
+
for device in intune.find_devices("laptop-042"):
|
|
11
|
+
print(device.device_name, device.compliance_state)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from libre_devops_helpers.microsoft.intune.client import IntuneClient
|
|
15
|
+
from libre_devops_helpers.microsoft.intune.models import ManagedDevice
|
|
16
|
+
from libre_devops_helpers.microsoft.intune.permissions import REQUIREMENTS
|
|
17
|
+
|
|
18
|
+
__all__ = ["REQUIREMENTS", "IntuneClient", "ManagedDevice"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Read-only Intune managed device queries through Microsoft Graph v1.0."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.core.auth import TokenProvider, token_source
|
|
11
|
+
from libre_devops_helpers.core.errors import LdoError
|
|
12
|
+
from libre_devops_helpers.core.http import ApiClient
|
|
13
|
+
from libre_devops_helpers.core.util import candidate_names, is_guid, odata_string
|
|
14
|
+
from libre_devops_helpers.microsoft.clouds import PUBLIC
|
|
15
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
16
|
+
from libre_devops_helpers.microsoft.intune.models import ManagedDevice
|
|
17
|
+
|
|
18
|
+
_PATH = "/v1.0/deviceManagement/managedDevices"
|
|
19
|
+
_NEVER = datetime.min.replace(tzinfo=UTC)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IntuneClient:
|
|
23
|
+
"""Intune managed device lookups. Close it (or use ``with``) when done."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, api: ApiClient) -> None:
|
|
26
|
+
self.api = api
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def create(
|
|
30
|
+
cls,
|
|
31
|
+
tokens: TokenProvider,
|
|
32
|
+
tenant_id: str,
|
|
33
|
+
*,
|
|
34
|
+
graph_url: str = PUBLIC.graph_url,
|
|
35
|
+
verify: bool | str = True,
|
|
36
|
+
session: requests.Session | None = None,
|
|
37
|
+
) -> IntuneClient:
|
|
38
|
+
"""A client for ``tenant_id`` that takes its Graph tokens from ``tokens``."""
|
|
39
|
+
api = ApiClient(
|
|
40
|
+
graph_url,
|
|
41
|
+
token_source(tokens, graph_url, tenant_id),
|
|
42
|
+
name="Intune (Microsoft Graph)",
|
|
43
|
+
verify=verify,
|
|
44
|
+
session=session,
|
|
45
|
+
)
|
|
46
|
+
return cls(api)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def for_profile(
|
|
50
|
+
cls,
|
|
51
|
+
profile: Profile,
|
|
52
|
+
tokens: TokenProvider,
|
|
53
|
+
*,
|
|
54
|
+
verify: bool | str = True,
|
|
55
|
+
session: requests.Session | None = None,
|
|
56
|
+
) -> IntuneClient:
|
|
57
|
+
"""A client for a configured profile's tenant, in the profile's cloud."""
|
|
58
|
+
return cls.create(
|
|
59
|
+
tokens,
|
|
60
|
+
profile.tenant_id,
|
|
61
|
+
graph_url=profile.cloud.graph_url,
|
|
62
|
+
verify=verify,
|
|
63
|
+
session=session,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self.api.close()
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> Self:
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
def find_devices(self, name: str) -> list[ManagedDevice]:
|
|
76
|
+
"""Managed devices named ``name``, else its short hostname, newest sync first."""
|
|
77
|
+
for candidate in candidate_names(name):
|
|
78
|
+
found = self._query(f"deviceName eq {odata_string(candidate)}")
|
|
79
|
+
if found:
|
|
80
|
+
return found
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
def for_entra_device(self, device_id: str) -> list[ManagedDevice]:
|
|
84
|
+
"""Managed devices linked to an Entra ``deviceId`` (not its object id)."""
|
|
85
|
+
if not is_guid(device_id):
|
|
86
|
+
raise LdoError(f"not an Entra device id: {device_id!r}")
|
|
87
|
+
return self._query(f"azureADDeviceId eq {odata_string(device_id.strip())}")
|
|
88
|
+
|
|
89
|
+
def _query(self, expression: str) -> list[ManagedDevice]:
|
|
90
|
+
items = self.api.get_all(
|
|
91
|
+
_PATH, params={"$filter": expression, "$select": ManagedDevice.SELECT}
|
|
92
|
+
)
|
|
93
|
+
devices = [ManagedDevice.from_json(item) for item in items]
|
|
94
|
+
return sorted(devices, key=lambda device: device.last_sync or _NEVER, reverse=True)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Intune managed devices as returned by Microsoft Graph, 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, ClassVar
|
|
9
|
+
|
|
10
|
+
from libre_devops_helpers.core.util import parse_datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ManagedDevice:
|
|
15
|
+
"""An Intune managed device. ``azure_ad_device_id`` links it to the Entra device."""
|
|
16
|
+
|
|
17
|
+
SELECT: ClassVar[str] = (
|
|
18
|
+
"id,deviceName,operatingSystem,osVersion,complianceState,managementAgent,"
|
|
19
|
+
"lastSyncDateTime,enrolledDateTime,azureADDeviceId,userPrincipalName,"
|
|
20
|
+
"serialNumber,managedDeviceOwnerType"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
device_name: str
|
|
25
|
+
operating_system: str
|
|
26
|
+
os_version: str
|
|
27
|
+
compliance_state: str
|
|
28
|
+
management_agent: str
|
|
29
|
+
last_sync: datetime | None
|
|
30
|
+
enrolled: datetime | None
|
|
31
|
+
azure_ad_device_id: str
|
|
32
|
+
user_principal_name: str
|
|
33
|
+
serial_number: str
|
|
34
|
+
owner_type: str
|
|
35
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def compliant(self) -> bool:
|
|
39
|
+
return self.compliance_state.casefold() == "compliant"
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_json(cls, data: Mapping[str, Any]) -> ManagedDevice:
|
|
43
|
+
def text(key: str) -> str:
|
|
44
|
+
return str(data.get(key) or "")
|
|
45
|
+
|
|
46
|
+
return cls(
|
|
47
|
+
id=text("id"),
|
|
48
|
+
device_name=text("deviceName"),
|
|
49
|
+
operating_system=text("operatingSystem"),
|
|
50
|
+
os_version=text("osVersion"),
|
|
51
|
+
compliance_state=text("complianceState"),
|
|
52
|
+
management_agent=text("managementAgent"),
|
|
53
|
+
last_sync=parse_datetime(data.get("lastSyncDateTime")),
|
|
54
|
+
enrolled=parse_datetime(data.get("enrolledDateTime")),
|
|
55
|
+
# Intune reports an unlinked device with the all-zero GUID.
|
|
56
|
+
azure_ad_device_id=""
|
|
57
|
+
if text("azureADDeviceId") == "00000000-0000-0000-0000-000000000000"
|
|
58
|
+
else text("azureADDeviceId"),
|
|
59
|
+
user_principal_name=text("userPrincipalName"),
|
|
60
|
+
serial_number=text("serialNumber"),
|
|
61
|
+
owner_type=text("managedDeviceOwnerType"),
|
|
62
|
+
raw=dict(data),
|
|
63
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""What the Intune feature needs from a Graph token. The Azure CLI's token has neither."""
|
|
2
|
+
|
|
3
|
+
from libre_devops_helpers.microsoft.resources import Requirement
|
|
4
|
+
|
|
5
|
+
REQUIREMENTS = (
|
|
6
|
+
Requirement(
|
|
7
|
+
"intune devices",
|
|
8
|
+
"graph",
|
|
9
|
+
(
|
|
10
|
+
(
|
|
11
|
+
"DeviceManagementManagedDevices.Read.All",
|
|
12
|
+
"DeviceManagementManagedDevices.ReadWrite.All",
|
|
13
|
+
),
|
|
14
|
+
),
|
|
15
|
+
),
|
|
16
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Key Vault: secret, certificate and key metadata for expiry checks. Never reads values.
|
|
2
|
+
|
|
3
|
+
Depends only on ``core`` and the shared Microsoft layer. Access rests on Key Vault
|
|
4
|
+
data-plane RBAC or access policies, so there are no token scope requirements to declare.
|
|
5
|
+
Finding vaults across subscriptions is Resource Graph's job (``azure``); this module
|
|
6
|
+
takes vault names. Public API::
|
|
7
|
+
|
|
8
|
+
from libre_devops_helpers.microsoft.keyvault import KeyVaultClient, expiring
|
|
9
|
+
|
|
10
|
+
with KeyVaultClient.for_profile(profile, tokens, "kv-app-prd") as vault:
|
|
11
|
+
soon = expiring(vault.items(), timedelta(days=30), now=datetime.now(UTC))
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from libre_devops_helpers.microsoft.keyvault.client import (
|
|
15
|
+
KINDS,
|
|
16
|
+
ItemKind,
|
|
17
|
+
KeyVaultClient,
|
|
18
|
+
VaultItem,
|
|
19
|
+
expiring,
|
|
20
|
+
vault_url,
|
|
21
|
+
)
|
|
22
|
+
from libre_devops_helpers.microsoft.resources import Requirement
|
|
23
|
+
|
|
24
|
+
REQUIREMENTS: tuple[Requirement, ...] = ()
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"KINDS",
|
|
28
|
+
"REQUIREMENTS",
|
|
29
|
+
"ItemKind",
|
|
30
|
+
"KeyVaultClient",
|
|
31
|
+
"VaultItem",
|
|
32
|
+
"expiring",
|
|
33
|
+
"vault_url",
|
|
34
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Key Vault secret, certificate and key metadata, for expiry checks.
|
|
2
|
+
|
|
3
|
+
Only list operations are used, and they return attributes (dates, enabled, content
|
|
4
|
+
type) but never a secret value. Reading them needs a data-plane role such as Key Vault
|
|
5
|
+
Reader, or a list permission in an access policy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from typing import Any, Literal, Self
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from libre_devops_helpers.core.auth import TokenProvider, token_source
|
|
20
|
+
from libre_devops_helpers.core.errors import LdoError
|
|
21
|
+
from libre_devops_helpers.core.http import ApiClient
|
|
22
|
+
from libre_devops_helpers.microsoft.clouds import PUBLIC, Cloud
|
|
23
|
+
from libre_devops_helpers.microsoft.config import Profile
|
|
24
|
+
|
|
25
|
+
API_VERSION = "7.4"
|
|
26
|
+
_VAULT_NAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9-]{1,22}[a-zA-Z0-9]$")
|
|
27
|
+
|
|
28
|
+
ItemKind = Literal["secret", "certificate", "key"]
|
|
29
|
+
KINDS: tuple[ItemKind, ...] = ("secret", "certificate", "key")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def vault_url(vault: str, cloud: Cloud = PUBLIC) -> str:
|
|
33
|
+
"""The data-plane URL for a vault given by name or by URL, checked against the cloud.
|
|
34
|
+
|
|
35
|
+
A URL must be on the cloud's Key Vault domain, so a token is never sent elsewhere.
|
|
36
|
+
"""
|
|
37
|
+
value = vault.strip()
|
|
38
|
+
if "://" in value:
|
|
39
|
+
parts = urlsplit(value)
|
|
40
|
+
host = (parts.hostname or "").lower()
|
|
41
|
+
if parts.scheme != "https" or not host.endswith("." + cloud.keyvault_suffix):
|
|
42
|
+
raise LdoError(
|
|
43
|
+
f"{vault!r} is not a Key Vault URL in the {cloud.name} cloud",
|
|
44
|
+
hint=f"expected https://<name>.{cloud.keyvault_suffix}",
|
|
45
|
+
)
|
|
46
|
+
return f"https://{host}"
|
|
47
|
+
if not _VAULT_NAME.match(value):
|
|
48
|
+
raise LdoError(f"not a Key Vault name: {vault!r}")
|
|
49
|
+
return f"https://{value.lower()}.{cloud.keyvault_suffix}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class VaultItem:
|
|
54
|
+
"""One secret, certificate or key, by its attributes. Never holds a value."""
|
|
55
|
+
|
|
56
|
+
vault: str
|
|
57
|
+
kind: ItemKind
|
|
58
|
+
name: str
|
|
59
|
+
enabled: bool | None
|
|
60
|
+
expires: datetime | None
|
|
61
|
+
not_before: datetime | None
|
|
62
|
+
updated: datetime | None
|
|
63
|
+
content_type: str
|
|
64
|
+
raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
|
|
65
|
+
|
|
66
|
+
def days_left(self, now: datetime) -> int | None:
|
|
67
|
+
"""Whole days until expiry (negative once expired), or None with no expiry set."""
|
|
68
|
+
return None if self.expires is None else (self.expires - now).days
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_json(cls, vault: str, kind: ItemKind, data: Mapping[str, Any]) -> VaultItem:
|
|
72
|
+
attributes = data.get("attributes")
|
|
73
|
+
attributes = attributes if isinstance(attributes, Mapping) else {}
|
|
74
|
+
identifier = str(data.get("kid") or data.get("id") or "")
|
|
75
|
+
enabled = attributes.get("enabled")
|
|
76
|
+
return cls(
|
|
77
|
+
vault=vault,
|
|
78
|
+
kind=kind,
|
|
79
|
+
name=identifier.rstrip("/").rsplit("/", 1)[-1],
|
|
80
|
+
enabled=enabled if isinstance(enabled, bool) else None,
|
|
81
|
+
expires=_epoch(attributes.get("exp")),
|
|
82
|
+
not_before=_epoch(attributes.get("nbf")),
|
|
83
|
+
updated=_epoch(attributes.get("updated")),
|
|
84
|
+
content_type=str(data.get("contentType") or ""),
|
|
85
|
+
raw=dict(data),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class KeyVaultClient:
|
|
90
|
+
"""Metadata listing for one vault. Close it (or use ``with``) when done."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, api: ApiClient, vault: str) -> None:
|
|
93
|
+
self.api = api
|
|
94
|
+
self.vault = vault
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def create(
|
|
98
|
+
cls,
|
|
99
|
+
tokens: TokenProvider,
|
|
100
|
+
tenant_id: str,
|
|
101
|
+
vault: str,
|
|
102
|
+
*,
|
|
103
|
+
cloud: Cloud = PUBLIC,
|
|
104
|
+
verify: bool | str = True,
|
|
105
|
+
session: requests.Session | None = None,
|
|
106
|
+
) -> KeyVaultClient:
|
|
107
|
+
"""A client for one vault (a name or URL) in ``tenant_id``."""
|
|
108
|
+
url = vault_url(vault, cloud)
|
|
109
|
+
api = ApiClient(
|
|
110
|
+
url,
|
|
111
|
+
token_source(tokens, f"https://{cloud.keyvault_suffix}", tenant_id),
|
|
112
|
+
name=f"Key Vault {urlsplit(url).hostname}",
|
|
113
|
+
verify=verify,
|
|
114
|
+
session=session,
|
|
115
|
+
)
|
|
116
|
+
return cls(api, (urlsplit(url).hostname or "").split(".", 1)[0])
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def for_profile(
|
|
120
|
+
cls,
|
|
121
|
+
profile: Profile,
|
|
122
|
+
tokens: TokenProvider,
|
|
123
|
+
vault: str,
|
|
124
|
+
*,
|
|
125
|
+
verify: bool | str = True,
|
|
126
|
+
session: requests.Session | None = None,
|
|
127
|
+
) -> KeyVaultClient:
|
|
128
|
+
"""A client for one vault in a configured profile's tenant and cloud."""
|
|
129
|
+
return cls.create(
|
|
130
|
+
tokens, profile.tenant_id, vault, cloud=profile.cloud, verify=verify, session=session
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
self.api.close()
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> Self:
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
def items(self, kinds: Iterable[ItemKind] = KINDS) -> list[VaultItem]:
|
|
143
|
+
"""Secrets, certificates and keys in the vault, by kind then name.
|
|
144
|
+
|
|
145
|
+
A certificate's backing secret and key are ``managed`` and left out, so each
|
|
146
|
+
certificate is listed once.
|
|
147
|
+
"""
|
|
148
|
+
found: list[VaultItem] = []
|
|
149
|
+
for kind in kinds:
|
|
150
|
+
for item in self.api.get_all(
|
|
151
|
+
f"/{kind}s", params={"api-version": API_VERSION}, next_link="nextLink"
|
|
152
|
+
):
|
|
153
|
+
if item.get("managed") is True:
|
|
154
|
+
continue
|
|
155
|
+
found.append(VaultItem.from_json(self.vault, kind, item))
|
|
156
|
+
return sorted(found, key=lambda item: (KINDS.index(item.kind), item.name.casefold()))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def expiring(
|
|
160
|
+
items: Iterable[VaultItem],
|
|
161
|
+
within: timedelta,
|
|
162
|
+
*,
|
|
163
|
+
now: datetime,
|
|
164
|
+
include_expired: bool = True,
|
|
165
|
+
include_disabled: bool = False,
|
|
166
|
+
) -> list[VaultItem]:
|
|
167
|
+
"""Items that expire within ``within`` of ``now`` (and expired ones), soonest first.
|
|
168
|
+
|
|
169
|
+
Disabled items are left out unless asked for: nothing can use them anyway.
|
|
170
|
+
"""
|
|
171
|
+
selected = [
|
|
172
|
+
item
|
|
173
|
+
for item in items
|
|
174
|
+
if item.expires is not None
|
|
175
|
+
and item.expires - now <= within
|
|
176
|
+
and (include_expired or item.expires >= now)
|
|
177
|
+
and (include_disabled or item.enabled is not False)
|
|
178
|
+
]
|
|
179
|
+
return sorted(selected, key=lambda item: item.expires or now)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _epoch(value: object) -> datetime | None:
|
|
183
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
184
|
+
return None
|
|
185
|
+
return datetime.fromtimestamp(value, UTC)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Log Analytics: run KQL against a workspace (Sentinel workspaces included).
|
|
2
|
+
|
|
3
|
+
Depends only on ``core`` and the shared Microsoft layer. Access rests on Azure RBAC on
|
|
4
|
+
the workspace, so there are no token scope requirements to declare. Public API::
|
|
5
|
+
|
|
6
|
+
from libre_devops_helpers.microsoft.loganalytics import LogAnalyticsClient
|
|
7
|
+
|
|
8
|
+
with LogAnalyticsClient.for_profile(profile, tokens) as logs:
|
|
9
|
+
result = logs.query(workspace_id, "Heartbeat | summarize count() by Computer")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from libre_devops_helpers.microsoft.loganalytics.client import LogAnalyticsClient
|
|
13
|
+
from libre_devops_helpers.microsoft.resources import Requirement
|
|
14
|
+
|
|
15
|
+
REQUIREMENTS: tuple[Requirement, ...] = ()
|
|
16
|
+
|
|
17
|
+
__all__ = ["REQUIREMENTS", "LogAnalyticsClient"]
|