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