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