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,244 @@
|
|
|
1
|
+
"""Microsoft profiles: the ``[microsoft]`` section of the config file.
|
|
2
|
+
|
|
3
|
+
A profile is a tenant, optionally pinned to one subscription, in one cloud, with one way
|
|
4
|
+
of getting tokens. Tenant and subscription ids describe an environment, not code, so
|
|
5
|
+
they live in the config file rather than the repository; a client secret never does,
|
|
6
|
+
and is read from the environment instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from libre_devops_helpers.core import brand
|
|
17
|
+
from libre_devops_helpers.core.config import (
|
|
18
|
+
ConfigFile,
|
|
19
|
+
check_name,
|
|
20
|
+
guid,
|
|
21
|
+
https_url,
|
|
22
|
+
load_config_file,
|
|
23
|
+
parse_config_file,
|
|
24
|
+
reject_unknown,
|
|
25
|
+
table,
|
|
26
|
+
text,
|
|
27
|
+
)
|
|
28
|
+
from libre_devops_helpers.core.errors import ConfigError
|
|
29
|
+
from libre_devops_helpers.core.token_store import DEFAULT_TOKEN_CACHE, TOKEN_CACHES
|
|
30
|
+
from libre_devops_helpers.microsoft.clouds import CLOUDS, PUBLIC, Cloud, get_cloud
|
|
31
|
+
|
|
32
|
+
SECTION = "microsoft"
|
|
33
|
+
PLACEHOLDER_ID = "00000000-0000-0000-0000-000000000000"
|
|
34
|
+
|
|
35
|
+
# How a profile gets tokens. microsoft.auth.credential_for turns each into a credential.
|
|
36
|
+
AUTH_METHODS = (
|
|
37
|
+
"azure-cli",
|
|
38
|
+
"interactive",
|
|
39
|
+
"device-code",
|
|
40
|
+
"client-secret",
|
|
41
|
+
"workload-identity",
|
|
42
|
+
"managed-identity",
|
|
43
|
+
)
|
|
44
|
+
_NEEDS_CLIENT_ID = frozenset({"interactive", "device-code", "client-secret", "workload-identity"})
|
|
45
|
+
# The methods whose sign-in comes with a refresh token this tool keeps (token_cache).
|
|
46
|
+
DELEGATED_AUTH = frozenset({"interactive", "device-code"})
|
|
47
|
+
_SECTION_KEYS = frozenset({"default_profile", "profiles"})
|
|
48
|
+
_PROFILE_KEYS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"description",
|
|
51
|
+
"tenant_id",
|
|
52
|
+
"subscription_id",
|
|
53
|
+
"cloud",
|
|
54
|
+
"mde_url",
|
|
55
|
+
"auth",
|
|
56
|
+
"client_id",
|
|
57
|
+
"workspace_id",
|
|
58
|
+
"token_cache",
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
CONFIG_TEMPLATE = f"""\
|
|
63
|
+
# Microsoft profiles. Each is a tenant, optionally pinned to a subscription.
|
|
64
|
+
# Replace the placeholder ids, then run: {brand.COMMAND} profiles
|
|
65
|
+
[microsoft]
|
|
66
|
+
# Used when neither --profile nor {brand.PROFILE_ENV} is given.
|
|
67
|
+
default_profile = "prod-tenant"
|
|
68
|
+
|
|
69
|
+
[microsoft.profiles.prod]
|
|
70
|
+
description = "Production subscription"
|
|
71
|
+
tenant_id = "{PLACEHOLDER_ID}"
|
|
72
|
+
subscription_id = "{PLACEHOLDER_ID}"
|
|
73
|
+
|
|
74
|
+
[microsoft.profiles.dev]
|
|
75
|
+
description = "Development subscription"
|
|
76
|
+
tenant_id = "{PLACEHOLDER_ID}"
|
|
77
|
+
subscription_id = "{PLACEHOLDER_ID}"
|
|
78
|
+
|
|
79
|
+
[microsoft.profiles.prod-tenant]
|
|
80
|
+
description = "Production tenant"
|
|
81
|
+
tenant_id = "{PLACEHOLDER_ID}"
|
|
82
|
+
|
|
83
|
+
[microsoft.profiles.test-tenant]
|
|
84
|
+
description = "Test tenant"
|
|
85
|
+
tenant_id = "{PLACEHOLDER_ID}"
|
|
86
|
+
# A regional Defender endpoint can be set per profile:
|
|
87
|
+
# mde_url = "https://api-eu.securitycenter.microsoft.com"
|
|
88
|
+
|
|
89
|
+
# Other optional profile keys:
|
|
90
|
+
#
|
|
91
|
+
# cloud = "public" public (the default), usgov or china; match it with 'az cloud set'
|
|
92
|
+
# workspace_id = "<guid>" the Log Analytics workspace 'logs query' uses by default
|
|
93
|
+
#
|
|
94
|
+
# auth picks how the profile gets tokens. The default is your Azure CLI sign-in.
|
|
95
|
+
# auth = "interactive" needs client_id of your own public client app; signs you in
|
|
96
|
+
# in a browser, for delegated scopes the Azure CLI lacks (PIM)
|
|
97
|
+
# auth = "device-code" the same, with a code to enter, for SSH, WSL and headless use
|
|
98
|
+
# auth = "client-secret" needs client_id; the secret is read from AZURE_CLIENT_SECRET
|
|
99
|
+
# auth = "workload-identity" needs client_id; the federated token is read from
|
|
100
|
+
# AZURE_FEDERATED_TOKEN_FILE, or requested from GitHub Actions
|
|
101
|
+
# auth = "managed-identity" client_id is optional and picks a user-assigned identity
|
|
102
|
+
# client_id = "<app or identity client id>"
|
|
103
|
+
#
|
|
104
|
+
# For interactive and device-code, token_cache says where the sign-in is kept between
|
|
105
|
+
# commands, so you are not asked to sign in for each one:
|
|
106
|
+
# token_cache = "file" the default: a plaintext file only your account can read
|
|
107
|
+
# (0600), which works headless
|
|
108
|
+
# token_cache = "keychain" the operating system's keychain (needs the keychain extra
|
|
109
|
+
# on macOS and Linux; DPAPI encryption on Windows)
|
|
110
|
+
# token_cache = "memory" nowhere; each command signs in afresh
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Profile:
|
|
116
|
+
"""One Azure context: a tenant, optionally pinned to a subscription."""
|
|
117
|
+
|
|
118
|
+
name: str
|
|
119
|
+
tenant_id: str
|
|
120
|
+
subscription_id: str | None = None
|
|
121
|
+
description: str = ""
|
|
122
|
+
cloud: Cloud = PUBLIC
|
|
123
|
+
# A regional Defender endpoint; the token is still for the cloud's Defender resource.
|
|
124
|
+
mde_url: str | None = None
|
|
125
|
+
auth: str = "azure-cli"
|
|
126
|
+
client_id: str | None = None
|
|
127
|
+
workspace_id: str | None = None
|
|
128
|
+
# Where an interactive or device-code sign-in is kept between commands.
|
|
129
|
+
token_cache: str = DEFAULT_TOKEN_CACHE
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def kind(self) -> str:
|
|
133
|
+
"""``subscription`` when pinned to a subscription, otherwise ``tenant``."""
|
|
134
|
+
return "subscription" if self.subscription_id else "tenant"
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def has_placeholder_ids(self) -> bool:
|
|
138
|
+
"""True while the profile still carries the template's all-zero ids."""
|
|
139
|
+
return PLACEHOLDER_ID in (self.tenant_id, self.subscription_id)
|
|
140
|
+
|
|
141
|
+
def require_real_ids(self) -> None:
|
|
142
|
+
"""Raise ConfigError while the profile still carries placeholder ids."""
|
|
143
|
+
if self.has_placeholder_ids:
|
|
144
|
+
raise ConfigError(
|
|
145
|
+
f"profile {self.name!r} still has placeholder ids",
|
|
146
|
+
hint="set its tenant_id (and subscription_id) in the config file",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class MicrosoftConfig:
|
|
152
|
+
"""The parsed ``[microsoft]`` section."""
|
|
153
|
+
|
|
154
|
+
path: Path
|
|
155
|
+
profiles: Mapping[str, Profile]
|
|
156
|
+
default_profile: str | None = None
|
|
157
|
+
|
|
158
|
+
def get(self, name: str) -> Profile:
|
|
159
|
+
"""The named profile. Raises ConfigError listing the known names when absent."""
|
|
160
|
+
try:
|
|
161
|
+
return self.profiles[name]
|
|
162
|
+
except KeyError:
|
|
163
|
+
known = ", ".join(sorted(self.profiles)) or "none"
|
|
164
|
+
raise ConfigError(
|
|
165
|
+
f"unknown Microsoft profile {name!r} (configured: {known})",
|
|
166
|
+
hint=f"edit {self.path}",
|
|
167
|
+
) from None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_config(path: Path | None = None) -> MicrosoftConfig:
|
|
171
|
+
"""Read the config file and parse its ``[microsoft]`` section."""
|
|
172
|
+
return from_file(load_config_file(path))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_config(data: Mapping[str, Any], path: Path) -> MicrosoftConfig:
|
|
176
|
+
"""Parse already-loaded TOML (the whole file) for its ``[microsoft]`` section."""
|
|
177
|
+
return from_file(parse_config_file(data, path))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def from_file(file: ConfigFile) -> MicrosoftConfig:
|
|
181
|
+
"""Validate the ``[microsoft]`` section. Unknown keys are errors, so typos surface."""
|
|
182
|
+
where = f"{file.path}: [{SECTION}]"
|
|
183
|
+
section = file.section(SECTION)
|
|
184
|
+
if section is None:
|
|
185
|
+
raise ConfigError(
|
|
186
|
+
f"{file.path} has no [{SECTION}] section",
|
|
187
|
+
hint=f"add [microsoft.profiles.<name>] tables; {brand.command('config init')} "
|
|
188
|
+
"writes a template",
|
|
189
|
+
)
|
|
190
|
+
reject_unknown(section, _SECTION_KEYS, where)
|
|
191
|
+
tables = section.get("profiles")
|
|
192
|
+
if not isinstance(tables, dict) or not tables:
|
|
193
|
+
raise ConfigError(f"{where}: define at least one [microsoft.profiles.<name>] table")
|
|
194
|
+
profiles = {
|
|
195
|
+
str(name): _parse_profile(str(name), value, str(file.path))
|
|
196
|
+
for name, value in tables.items()
|
|
197
|
+
}
|
|
198
|
+
default = section.get("default_profile")
|
|
199
|
+
if default is not None and (not isinstance(default, str) or default not in profiles):
|
|
200
|
+
raise ConfigError(f"{where}: default_profile {default!r} is not a configured profile")
|
|
201
|
+
return MicrosoftConfig(path=file.path, profiles=profiles, default_profile=default)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_profile(name: str, value: object, path: str) -> Profile:
|
|
205
|
+
where = f"{path}: [microsoft.profiles.{name}]"
|
|
206
|
+
check_name(name, where)
|
|
207
|
+
data = table(value, where)
|
|
208
|
+
reject_unknown(data, _PROFILE_KEYS, where)
|
|
209
|
+
|
|
210
|
+
tenant_id = guid(data, "tenant_id", where)
|
|
211
|
+
if tenant_id is None:
|
|
212
|
+
raise ConfigError(f"{where}: tenant_id is required")
|
|
213
|
+
|
|
214
|
+
cloud_name = text(data, "cloud", where)
|
|
215
|
+
if cloud_name is not None and cloud_name.lower() not in CLOUDS:
|
|
216
|
+
raise ConfigError(f"{where}: cloud must be one of {', '.join(CLOUDS)}")
|
|
217
|
+
|
|
218
|
+
auth = text(data, "auth", where) or "azure-cli"
|
|
219
|
+
if auth not in AUTH_METHODS:
|
|
220
|
+
raise ConfigError(f"{where}: auth must be one of {', '.join(AUTH_METHODS)}")
|
|
221
|
+
client_id = guid(data, "client_id", where)
|
|
222
|
+
if auth in _NEEDS_CLIENT_ID and client_id is None:
|
|
223
|
+
raise ConfigError(f"{where}: auth = {auth!r} needs a client_id")
|
|
224
|
+
token_cache = text(data, "token_cache", where)
|
|
225
|
+
if token_cache is not None and token_cache not in TOKEN_CACHES:
|
|
226
|
+
raise ConfigError(f"{where}: token_cache must be one of {', '.join(TOKEN_CACHES)}")
|
|
227
|
+
if token_cache is not None and auth not in DELEGATED_AUTH:
|
|
228
|
+
raise ConfigError(
|
|
229
|
+
f'{where}: token_cache applies to auth = "interactive" or "device-code"',
|
|
230
|
+
hint="the Azure CLI keeps its own sign-in, and the other methods have none to keep",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return Profile(
|
|
234
|
+
name=name,
|
|
235
|
+
tenant_id=tenant_id,
|
|
236
|
+
subscription_id=guid(data, "subscription_id", where),
|
|
237
|
+
description=text(data, "description", where) or "",
|
|
238
|
+
cloud=get_cloud(cloud_name) if cloud_name else PUBLIC,
|
|
239
|
+
mde_url=https_url(data, "mde_url", where),
|
|
240
|
+
auth=auth,
|
|
241
|
+
client_id=client_id,
|
|
242
|
+
workspace_id=guid(data, "workspace_id", where),
|
|
243
|
+
token_cache=token_cache or DEFAULT_TOKEN_CACHE,
|
|
244
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Devices across Entra, Defender and Intune: checks, watches, a combined view, AV versions.
|
|
2
|
+
|
|
3
|
+
The one composite module: it depends on ``core`` and on the ``entra``, ``xdr`` and
|
|
4
|
+
``intune`` feature modules, and nothing else. Public API::
|
|
5
|
+
|
|
6
|
+
from libre_devops_helpers.core import PollLimits
|
|
7
|
+
from libre_devops_helpers.microsoft.devices import DeviceChecker, Expectations, watch
|
|
8
|
+
|
|
9
|
+
checker = DeviceChecker(entra=entra, xdr=xdr)
|
|
10
|
+
run = checker.check(["web01", "web02"], Expectations(tags=("linux-servers",)))
|
|
11
|
+
outcome = watch(checker, names, Expectations(), PollLimits(interval=300, timeout=7200))
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from libre_devops_helpers.microsoft.devices.antivirus import (
|
|
15
|
+
AvStatus,
|
|
16
|
+
av_query,
|
|
17
|
+
av_statuses,
|
|
18
|
+
version_key,
|
|
19
|
+
)
|
|
20
|
+
from libre_devops_helpers.microsoft.devices.check import DeviceChecker, watch
|
|
21
|
+
from libre_devops_helpers.microsoft.devices.inspect import inspect_device
|
|
22
|
+
from libre_devops_helpers.microsoft.devices.models import (
|
|
23
|
+
CheckRun,
|
|
24
|
+
DeviceReport,
|
|
25
|
+
DeviceView,
|
|
26
|
+
Expectations,
|
|
27
|
+
Finding,
|
|
28
|
+
Outcome,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AvStatus",
|
|
33
|
+
"CheckRun",
|
|
34
|
+
"DeviceChecker",
|
|
35
|
+
"DeviceReport",
|
|
36
|
+
"DeviceView",
|
|
37
|
+
"Expectations",
|
|
38
|
+
"Finding",
|
|
39
|
+
"Outcome",
|
|
40
|
+
"av_query",
|
|
41
|
+
"av_statuses",
|
|
42
|
+
"inspect_device",
|
|
43
|
+
"version_key",
|
|
44
|
+
"watch",
|
|
45
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Defender Antivirus versions on devices: the signature, engine and platform, by KQL.
|
|
2
|
+
|
|
3
|
+
The versions come from Advanced Hunting, as you would look them up by hand:
|
|
4
|
+
|
|
5
|
+
- ``DeviceTvmInfoGathering`` holds what each device last reported, with the signature,
|
|
6
|
+
engine and platform versions and the antivirus mode in its ``AdditionalFields``;
|
|
7
|
+
- ``DeviceTvmSecureConfigurationAssessment`` holds the "antivirus definitions up to
|
|
8
|
+
date" check (``scid-2011``), which says whether that signature is current.
|
|
9
|
+
|
|
10
|
+
The query is built here; running it is the caller's (``GraphClient.hunt`` or
|
|
11
|
+
``XdrClient.hunt``), so either Advanced Hunting API will do.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from libre_devops_helpers.core.errors import InputError
|
|
24
|
+
from libre_devops_helpers.core.tables import QueryResult
|
|
25
|
+
from libre_devops_helpers.core.util import candidate_names, parse_datetime, short_name
|
|
26
|
+
|
|
27
|
+
# Device names go into the query, so only what a host name can hold is let in.
|
|
28
|
+
_HOST = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$")
|
|
29
|
+
UP_TO_DATE_CHECK = "scid-2011"
|
|
30
|
+
# AdditionalFields.AvMode is a number; these are the ones Defender documents.
|
|
31
|
+
AV_MODES = {"0": "active", "1": "passive", "4": "EDR block"}
|
|
32
|
+
|
|
33
|
+
_QUERY = """\
|
|
34
|
+
let wanted = dynamic({names});
|
|
35
|
+
let versions = DeviceTvmInfoGathering
|
|
36
|
+
| where DeviceName in~ (wanted) or tostring(split(DeviceName, ".")[0]) in~ (wanted)
|
|
37
|
+
| summarize arg_max(Timestamp, DeviceName, OSPlatform, AdditionalFields) by DeviceId
|
|
38
|
+
| extend Fields = todynamic(AdditionalFields)
|
|
39
|
+
| project DeviceId, DeviceName, OSPlatform, Reported = Timestamp,
|
|
40
|
+
AvSignatureVersion = tostring(Fields.AvSignatureVersion),
|
|
41
|
+
AvEngineVersion = tostring(Fields.AvEngineVersion),
|
|
42
|
+
AvPlatformVersion = tostring(Fields.AvPlatformVersion),
|
|
43
|
+
AvMode = tostring(Fields.AvMode);
|
|
44
|
+
let freshness = DeviceTvmSecureConfigurationAssessment
|
|
45
|
+
| where ConfigurationId == "{check}"
|
|
46
|
+
| where DeviceName in~ (wanted) or tostring(split(DeviceName, ".")[0]) in~ (wanted)
|
|
47
|
+
| summarize arg_max(Timestamp, IsCompliant, IsApplicable, Context) by DeviceId
|
|
48
|
+
| project DeviceId, SignatureUpToDate = iff(IsApplicable, IsCompliant, bool(null)),
|
|
49
|
+
AssessmentContext = tostring(Context);
|
|
50
|
+
versions
|
|
51
|
+
| join kind=leftouter freshness on DeviceId
|
|
52
|
+
| project-away DeviceId1
|
|
53
|
+
| order by DeviceName asc"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class AvStatus:
|
|
58
|
+
"""One device's antivirus versions, as Advanced Hunting last saw them.
|
|
59
|
+
|
|
60
|
+
``found`` is False for a name Advanced Hunting had nothing for; ``up_to_date`` is
|
|
61
|
+
None when the definitions check does not apply or has not run.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
query: str
|
|
65
|
+
found: bool
|
|
66
|
+
device_id: str = ""
|
|
67
|
+
device_name: str = ""
|
|
68
|
+
os_platform: str = ""
|
|
69
|
+
signature: str = ""
|
|
70
|
+
engine: str = ""
|
|
71
|
+
platform: str = ""
|
|
72
|
+
mode: str = ""
|
|
73
|
+
up_to_date: bool | None = None
|
|
74
|
+
reported: datetime | None = None
|
|
75
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
|
|
76
|
+
|
|
77
|
+
def older_than(self, minimum: str) -> bool:
|
|
78
|
+
"""True when the signature is below ``minimum`` (or unknown)."""
|
|
79
|
+
return not self.signature or version_key(self.signature) < version_key(minimum)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def av_query(names: Sequence[str]) -> str:
|
|
83
|
+
"""The KQL for ``names``: each device's FQDN and short name are both looked for."""
|
|
84
|
+
wanted: list[str] = []
|
|
85
|
+
for name in names:
|
|
86
|
+
if not _HOST.match(name.strip()):
|
|
87
|
+
raise InputError(f"{name!r} is not a device name that can go in a query")
|
|
88
|
+
wanted.extend(candidate_names(name))
|
|
89
|
+
if not wanted:
|
|
90
|
+
raise InputError("no devices named")
|
|
91
|
+
unique = list(dict.fromkeys(item.lower() for item in wanted))
|
|
92
|
+
return _QUERY.format(names=json.dumps(unique), check=UP_TO_DATE_CHECK)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def av_statuses(names: Iterable[str], result: QueryResult) -> list[AvStatus]:
|
|
96
|
+
"""One status per name asked for, in order, matched on the FQDN or short name.
|
|
97
|
+
|
|
98
|
+
A name that matches several devices (stale records keep a name) gives one status
|
|
99
|
+
for each, newest report first.
|
|
100
|
+
"""
|
|
101
|
+
rows = [dict(row) for row in result.rows]
|
|
102
|
+
statuses: list[AvStatus] = []
|
|
103
|
+
for name in names:
|
|
104
|
+
full = name.strip().rstrip(".").lower()
|
|
105
|
+
short = short_name(full).lower()
|
|
106
|
+
matched = [
|
|
107
|
+
row
|
|
108
|
+
for row in rows
|
|
109
|
+
if str(row.get("DeviceName", "")).lower() in {full, short}
|
|
110
|
+
or short_name(str(row.get("DeviceName", ""))).lower() == short
|
|
111
|
+
]
|
|
112
|
+
if not matched:
|
|
113
|
+
statuses.append(AvStatus(query=name, found=False))
|
|
114
|
+
continue
|
|
115
|
+
matched.sort(key=lambda row: str(row.get("Reported") or ""), reverse=True)
|
|
116
|
+
statuses.extend(_status(name, row) for row in matched)
|
|
117
|
+
return statuses
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def version_key(version: str) -> tuple[int, ...]:
|
|
121
|
+
"""``1.419.123.0`` -> (1, 419, 123, 0), for comparing versions numerically."""
|
|
122
|
+
parts = re.findall(r"\d+", version)
|
|
123
|
+
if not parts:
|
|
124
|
+
raise InputError(f"{version!r} is not a version number")
|
|
125
|
+
return tuple(int(part) for part in parts)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def mode_label(value: object) -> str:
|
|
129
|
+
"""``0`` -> ``active``; an unknown mode is kept as it came."""
|
|
130
|
+
text = "" if value is None else str(value).strip()
|
|
131
|
+
return AV_MODES.get(text, text)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _status(query: str, row: Mapping[str, Any]) -> AvStatus:
|
|
135
|
+
up_to_date = row.get("SignatureUpToDate")
|
|
136
|
+
return AvStatus(
|
|
137
|
+
query=query,
|
|
138
|
+
found=True,
|
|
139
|
+
device_id=str(row.get("DeviceId") or ""),
|
|
140
|
+
device_name=str(row.get("DeviceName") or ""),
|
|
141
|
+
os_platform=str(row.get("OSPlatform") or ""),
|
|
142
|
+
signature=str(row.get("AvSignatureVersion") or ""),
|
|
143
|
+
engine=str(row.get("AvEngineVersion") or ""),
|
|
144
|
+
platform=str(row.get("AvPlatformVersion") or ""),
|
|
145
|
+
mode=mode_label(row.get("AvMode")),
|
|
146
|
+
up_to_date=up_to_date if isinstance(up_to_date, bool) else None,
|
|
147
|
+
reported=parse_datetime(row.get("Reported")),
|
|
148
|
+
raw=dict(row),
|
|
149
|
+
)
|