check-msdefender 1.1.6__tar.gz → 1.1.8__tar.gz
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.
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/PKG-INFO +1 -1
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/__init__.py +1 -2
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/__init__.py +2 -0
- check_msdefender-1.1.8/check_msdefender/cli/commands/products.py +60 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/decorators.py +1 -3
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/auth.py +1 -3
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/defender.py +56 -15
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/logging_config.py +1 -3
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/nagios.py +2 -10
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/alerts_service.py +5 -15
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/detail_service.py +4 -10
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/lastseen_service.py +1 -3
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/machines_service.py +4 -4
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/onboarding_service.py +1 -3
- check_msdefender-1.1.8/check_msdefender/services/products_service.py +136 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/vulnerabilities_service.py +4 -12
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/pyproject.toml +49 -4
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/integration/test_cli_integration.py +0 -3
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/LICENSE +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/README.md +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/__main__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/check_msdefender.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/__init__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/__main__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/alerts.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/detail.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/lastseen.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/machines.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/onboarding.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/vulnerabilities.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/handlers.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/__init__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/config.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/exceptions.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/__init__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/models.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/__init__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/__init__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/alerts_data.json +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/machine_data.json +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/mock_defender_client.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_alerts_service.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_detail_service.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_lastseen_service.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_onboarding_service.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_vulnerabilities_service.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/vulnerability_data.json +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/integration/__init__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/integration/test_lastseen_integration.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/unit/__init__.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/unit/test_alerts_service.py +0 -0
- {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/unit/test_detail_service.py +0 -0
|
@@ -7,6 +7,7 @@ from .onboarding import register_onboarding_commands
|
|
|
7
7
|
from .machines import register_machines_commands
|
|
8
8
|
from .detail import register_detail_commands
|
|
9
9
|
from .alerts import register_alerts_commands
|
|
10
|
+
from .products import register_products_commands
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def register_all_commands(main_group: Any) -> None:
|
|
@@ -17,3 +18,4 @@ def register_all_commands(main_group: Any) -> None:
|
|
|
17
18
|
register_machines_commands(main_group)
|
|
18
19
|
register_detail_commands(main_group)
|
|
19
20
|
register_alerts_commands(main_group)
|
|
21
|
+
register_products_commands(main_group)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Products commands for CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional, Any
|
|
5
|
+
|
|
6
|
+
from check_msdefender.core.auth import get_authenticator
|
|
7
|
+
from check_msdefender.core.config import load_config
|
|
8
|
+
from check_msdefender.core.defender import DefenderClient
|
|
9
|
+
from check_msdefender.core.nagios import NagiosPlugin
|
|
10
|
+
from check_msdefender.services.products_service import ProductsService
|
|
11
|
+
from ..decorators import common_options
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_products_commands(main_group: Any) -> None:
|
|
15
|
+
"""Register products commands with the main CLI group."""
|
|
16
|
+
|
|
17
|
+
@main_group.command("products")
|
|
18
|
+
@common_options
|
|
19
|
+
def products_cmd(
|
|
20
|
+
config: str,
|
|
21
|
+
verbose: int,
|
|
22
|
+
machine_id: Optional[str],
|
|
23
|
+
dns_name: Optional[str],
|
|
24
|
+
warning: Optional[float],
|
|
25
|
+
critical: Optional[float],
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Check installed products for Microsoft Defender."""
|
|
28
|
+
warning = warning if warning is not None else 5
|
|
29
|
+
critical = critical if critical is not None else 1
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
# Load configuration
|
|
33
|
+
cfg = load_config(config)
|
|
34
|
+
|
|
35
|
+
# Get authenticator
|
|
36
|
+
authenticator = get_authenticator(cfg)
|
|
37
|
+
|
|
38
|
+
# Create Defender client
|
|
39
|
+
client = DefenderClient(authenticator, verbose_level=verbose)
|
|
40
|
+
|
|
41
|
+
# Create the products service
|
|
42
|
+
service = ProductsService(client, verbose_level=verbose)
|
|
43
|
+
|
|
44
|
+
# Create Nagios plugin
|
|
45
|
+
plugin = NagiosPlugin(service, "products")
|
|
46
|
+
|
|
47
|
+
# Execute check
|
|
48
|
+
result = plugin.check(
|
|
49
|
+
machine_id=machine_id,
|
|
50
|
+
dns_name=dns_name,
|
|
51
|
+
warning=warning,
|
|
52
|
+
critical=critical,
|
|
53
|
+
verbose=verbose,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
sys.exit(result or 0)
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
print(f"UNKNOWN: {str(e)}")
|
|
60
|
+
sys.exit(3)
|
|
@@ -10,9 +10,7 @@ def common_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
10
10
|
"-c", "--config", default="check_msdefender.ini", help="Configuration file path"
|
|
11
11
|
)(func)
|
|
12
12
|
func = click.option("-v", "--verbose", count=True, help="Increase verbosity")(func)
|
|
13
|
-
func = click.option("-m", "--machine-id", "-i", "--id", help="Machine ID (GUID)")(
|
|
14
|
-
func
|
|
15
|
-
)
|
|
13
|
+
func = click.option("-m", "--machine-id", "-i", "--id", help="Machine ID (GUID)")(func)
|
|
16
14
|
func = click.option("-d", "--dns-name", help="Computer DNS Name (FQDN)")(func)
|
|
17
15
|
func = click.option("-W", "--warning", type=float, help="Warning threshold")(func)
|
|
18
16
|
func = click.option("-C", "--critical", type=float, help="Critical threshold")(func)
|
|
@@ -20,9 +20,7 @@ def get_authenticator(
|
|
|
20
20
|
tenant_id = auth_section.get("tenant_id")
|
|
21
21
|
|
|
22
22
|
if not client_id or not tenant_id:
|
|
23
|
-
raise ConfigurationError(
|
|
24
|
-
"client_id and tenant_id are required in [auth] section"
|
|
25
|
-
)
|
|
23
|
+
raise ConfigurationError("client_id and tenant_id are required in [auth] section")
|
|
26
24
|
|
|
27
25
|
# Check for client secret authentication
|
|
28
26
|
client_secret = auth_section.get("client_secret")
|
|
@@ -6,6 +6,16 @@ from typing import Any, Dict, cast
|
|
|
6
6
|
from check_msdefender.core.exceptions import DefenderAPIError
|
|
7
7
|
from check_msdefender.core.logging_config import get_verbose_logger
|
|
8
8
|
|
|
9
|
+
PARAM_TOP = "$top"
|
|
10
|
+
|
|
11
|
+
PARAM_EXPAND = "$expand"
|
|
12
|
+
|
|
13
|
+
PARAM_ORDERBY = "$orderby"
|
|
14
|
+
|
|
15
|
+
PARAM_FILTER = "$filter"
|
|
16
|
+
|
|
17
|
+
PARAM_SELECT = "$select"
|
|
18
|
+
|
|
9
19
|
|
|
10
20
|
class DefenderClient:
|
|
11
21
|
"""Client for Microsoft Defender API."""
|
|
@@ -55,14 +65,15 @@ class DefenderClient:
|
|
|
55
65
|
"Content-Type": DefenderClient.application_json,
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
params = {
|
|
68
|
+
params = {
|
|
69
|
+
PARAM_FILTER: f"computerDnsName eq '{dns_name}'",
|
|
70
|
+
PARAM_SELECT: "id"
|
|
71
|
+
}
|
|
59
72
|
|
|
60
73
|
try:
|
|
61
74
|
start_time = time.time()
|
|
62
75
|
self.logger.info(f"Querying machine by DNS name: {dns_name}")
|
|
63
|
-
response = requests.get(
|
|
64
|
-
url, headers=headers, params=params, timeout=self.timeout
|
|
65
|
-
)
|
|
76
|
+
response = requests.get(url, headers=headers, params=params, timeout=self.timeout)
|
|
66
77
|
elapsed = time.time() - start_time
|
|
67
78
|
|
|
68
79
|
self.logger.api_call("GET", url, response.status_code, elapsed)
|
|
@@ -152,14 +163,12 @@ class DefenderClient:
|
|
|
152
163
|
"Content-Type": DefenderClient.application_json,
|
|
153
164
|
}
|
|
154
165
|
|
|
155
|
-
params = {
|
|
166
|
+
params = {PARAM_SELECT: "id,computerDnsName,onboardingStatus,osPlatform"}
|
|
156
167
|
|
|
157
168
|
try:
|
|
158
169
|
start_time = time.time()
|
|
159
170
|
self.logger.info("Querying all machines")
|
|
160
|
-
response = requests.get(
|
|
161
|
-
url, headers=headers, params=params, timeout=self.timeout
|
|
162
|
-
)
|
|
171
|
+
response = requests.get(url, headers=headers, params=params, timeout=self.timeout)
|
|
163
172
|
elapsed = time.time() - start_time
|
|
164
173
|
|
|
165
174
|
self.logger.api_call("GET", url, response.status_code, elapsed)
|
|
@@ -188,18 +197,16 @@ class DefenderClient:
|
|
|
188
197
|
}
|
|
189
198
|
|
|
190
199
|
params = {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
200
|
+
PARAM_TOP: "100",
|
|
201
|
+
PARAM_EXPAND: "evidence",
|
|
202
|
+
PARAM_ORDERBY: "alertCreationTime desc",
|
|
203
|
+
PARAM_SELECT: "status,title,machineId,computerDnsName,alertCreationTime,firstEventTime,lastEventTime,lastUpdateTime,severity",
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
try:
|
|
198
207
|
start_time = time.time()
|
|
199
208
|
self.logger.info("Querying alerts")
|
|
200
|
-
response = requests.get(
|
|
201
|
-
url, headers=headers, params=params, timeout=self.timeout
|
|
202
|
-
)
|
|
209
|
+
response = requests.get(url, headers=headers, params=params, timeout=self.timeout)
|
|
203
210
|
elapsed = time.time() - start_time
|
|
204
211
|
|
|
205
212
|
self.logger.api_call("GET", url, response.status_code, elapsed)
|
|
@@ -215,6 +222,40 @@ class DefenderClient:
|
|
|
215
222
|
self.logger.debug(f"Response: {str(e.response.content)}")
|
|
216
223
|
raise DefenderAPIError(f"Failed to query MS Defender API: {str(e)}")
|
|
217
224
|
|
|
225
|
+
def get_products(self) -> Dict[str, Any]:
|
|
226
|
+
"""Get installed products for a machine."""
|
|
227
|
+
self.logger.method_entry("get_products")
|
|
228
|
+
|
|
229
|
+
token = self._get_token()
|
|
230
|
+
|
|
231
|
+
# Use the TVM API endpoint for products
|
|
232
|
+
url = f"{self.base_url}/api/machines/SoftwareVulnerabilitiesByMachine"
|
|
233
|
+
headers = {
|
|
234
|
+
"Authorization": f"Bearer {token}",
|
|
235
|
+
"Content-Type": DefenderClient.application_json,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
params = {"pageIndex": "1", "pageSize": "50000"}
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
start_time = time.time()
|
|
242
|
+
self.logger.info("Querying products")
|
|
243
|
+
response = requests.get(url, headers=headers, params=params, timeout=self.timeout)
|
|
244
|
+
elapsed = time.time() - start_time
|
|
245
|
+
|
|
246
|
+
self.logger.api_call("GET", url, response.status_code, elapsed)
|
|
247
|
+
response.raise_for_status()
|
|
248
|
+
|
|
249
|
+
result = cast(Dict[str, Any], response.json())
|
|
250
|
+
self.logger.json_response(str(result))
|
|
251
|
+
self.logger.method_exit("get_products", result)
|
|
252
|
+
return result
|
|
253
|
+
except requests.RequestException as e:
|
|
254
|
+
self.logger.debug(f"API request failed: {str(e)}")
|
|
255
|
+
if hasattr(e, "response") and e.response is not None:
|
|
256
|
+
self.logger.debug(f"Response: {str(e.response.content)}")
|
|
257
|
+
raise DefenderAPIError(f"Failed to query MS Defender API: {str(e)}")
|
|
258
|
+
|
|
218
259
|
def _get_token(self) -> str:
|
|
219
260
|
"""Get access token from authenticator."""
|
|
220
261
|
self.logger.trace("Getting access token from authenticator")
|
|
@@ -82,9 +82,7 @@ class VerboseLogger:
|
|
|
82
82
|
"""Log API call details if verbose >= 2."""
|
|
83
83
|
if self.verbose_level >= 2:
|
|
84
84
|
if status_code and response_time:
|
|
85
|
-
self.logger.debug(
|
|
86
|
-
f"API {method} {url} -> {status_code} ({response_time:.3f}s)"
|
|
87
|
-
)
|
|
85
|
+
self.logger.debug(f"API {method} {url} -> {status_code} ({response_time:.3f}s)")
|
|
88
86
|
else:
|
|
89
87
|
self.logger.debug(f"API {method} {url}")
|
|
90
88
|
|
|
@@ -50,12 +50,6 @@ class DefenderScalarContext(nagiosplugin.ScalarContext):
|
|
|
50
50
|
f"{metric.name} is {metric.value} (outside range {warning_val}:)",
|
|
51
51
|
metric,
|
|
52
52
|
)
|
|
53
|
-
elif critical_val == metric.value:
|
|
54
|
-
return self.result_cls(
|
|
55
|
-
nagiosplugin.Critical,
|
|
56
|
-
f"{metric.name} is {metric.value} (outside range {critical_val}:)",
|
|
57
|
-
metric,
|
|
58
|
-
)
|
|
59
53
|
else:
|
|
60
54
|
# If no exact match, use the more severe one (critical)
|
|
61
55
|
return self.result_cls(
|
|
@@ -122,15 +116,13 @@ class NagiosPlugin:
|
|
|
122
116
|
) -> int:
|
|
123
117
|
"""Execute the check and return Nagios exit code."""
|
|
124
118
|
try:
|
|
125
|
-
result = self.service.get_result(
|
|
119
|
+
result = self.service.get_result()
|
|
126
120
|
value = result["value"]
|
|
127
121
|
details = result.get("details", [])
|
|
128
122
|
|
|
129
123
|
# Create Nagios check with custom summary
|
|
130
124
|
# Use 'found' as context name for detail command, otherwise use command name
|
|
131
|
-
context_name =
|
|
132
|
-
"found" if self.command_name == "detail" else self.command_name
|
|
133
|
-
)
|
|
125
|
+
context_name = "found" if self.command_name == "detail" else self.command_name
|
|
134
126
|
check = nagiosplugin.Check(
|
|
135
127
|
DefenderResource(self.command_name, value),
|
|
136
128
|
DefenderScalarContext(context_name, warning, critical),
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/alerts_service.py
RENAMED
|
@@ -53,18 +53,12 @@ class AlertsService:
|
|
|
53
53
|
or alert.get("computerDnsName") == target_dns_name
|
|
54
54
|
]
|
|
55
55
|
|
|
56
|
-
self.logger.info(
|
|
57
|
-
f"Found {len(machine_alerts)} alerts for machine {target_dns_name}"
|
|
58
|
-
)
|
|
56
|
+
self.logger.info(f"Found {len(machine_alerts)} alerts for machine {target_dns_name}")
|
|
59
57
|
|
|
60
58
|
# Categorize alerts by status and severity
|
|
61
|
-
unresolved_alerts = [
|
|
62
|
-
alert for alert in machine_alerts if alert.get("status") != "Resolved"
|
|
63
|
-
]
|
|
59
|
+
unresolved_alerts = [alert for alert in machine_alerts if alert.get("status") != "Resolved"]
|
|
64
60
|
informational_alerts = [
|
|
65
|
-
alert
|
|
66
|
-
for alert in unresolved_alerts
|
|
67
|
-
if alert.get("severity") == "Informational"
|
|
61
|
+
alert for alert in unresolved_alerts if alert.get("severity") == "Informational"
|
|
68
62
|
]
|
|
69
63
|
critical_warning_alerts = [
|
|
70
64
|
alert
|
|
@@ -88,9 +82,7 @@ class AlertsService:
|
|
|
88
82
|
title = alert.get("title", "Unknown alert")
|
|
89
83
|
status = alert.get("status", "Unknown")
|
|
90
84
|
severity = alert.get("severity", "Unknown")
|
|
91
|
-
details.append(
|
|
92
|
-
f"{creation_time} - {title} ({status} {severity.lower()})"
|
|
93
|
-
)
|
|
85
|
+
details.append(f"{creation_time} - {title} ({status} {severity.lower()})")
|
|
94
86
|
|
|
95
87
|
# Return the number of unresolved alerts as the value
|
|
96
88
|
# This will be used by Nagios plugin for determining status based on thresholds
|
|
@@ -101,8 +93,6 @@ class AlertsService:
|
|
|
101
93
|
"details": details,
|
|
102
94
|
}
|
|
103
95
|
|
|
104
|
-
self.logger.info(
|
|
105
|
-
f"Alert analysis complete: {len(unresolved_alerts)} unresolved alerts"
|
|
106
|
-
)
|
|
96
|
+
self.logger.info(f"Alert analysis complete: {len(unresolved_alerts)} unresolved alerts")
|
|
107
97
|
self.logger.method_exit("get_result", result)
|
|
108
98
|
return result
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/detail_service.py
RENAMED
|
@@ -54,21 +54,15 @@ class DetailService:
|
|
|
54
54
|
# Create detailed output
|
|
55
55
|
details = []
|
|
56
56
|
details.append(f"Machine ID: {machine_details.get('id', 'Unknown')}")
|
|
57
|
-
details.append(
|
|
58
|
-
|
|
59
|
-
)
|
|
60
|
-
details.append(
|
|
61
|
-
f"OS Platform: {machine_details.get('osPlatform', 'Unknown')}"
|
|
62
|
-
)
|
|
57
|
+
details.append(f"Computer Name: {machine_details.get('computerDnsName', 'Unknown')}")
|
|
58
|
+
details.append(f"OS Platform: {machine_details.get('osPlatform', 'Unknown')}")
|
|
63
59
|
details.append(f"OS Version: {machine_details.get('osVersion', 'Unknown')}")
|
|
64
|
-
details.append(
|
|
65
|
-
f"Health Status: {machine_details.get('healthStatus', 'Unknown')}"
|
|
66
|
-
)
|
|
60
|
+
details.append(f"Health Status: {machine_details.get('healthStatus', 'Unknown')}")
|
|
67
61
|
details.append(f"Risk Score: {machine_details.get('riskScore', 'Unknown')}")
|
|
68
62
|
|
|
69
63
|
result = {"value": 1, "details": details}
|
|
70
64
|
|
|
71
|
-
self.logger.info(
|
|
65
|
+
self.logger.info("Machine details retrieved successfully")
|
|
72
66
|
self.logger.method_exit("get_result", result)
|
|
73
67
|
return result
|
|
74
68
|
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/lastseen_service.py
RENAMED
|
@@ -62,9 +62,7 @@ class LastSeenService:
|
|
|
62
62
|
|
|
63
63
|
result = {"value": days_diff, "details": details}
|
|
64
64
|
|
|
65
|
-
self.logger.info(
|
|
66
|
-
f"Machine last seen {days_diff} days ago ({last_seen_str})"
|
|
67
|
-
)
|
|
65
|
+
self.logger.info(f"Machine last seen {days_diff} days ago ({last_seen_str})")
|
|
68
66
|
self.logger.method_exit("get_result", result)
|
|
69
67
|
return result
|
|
70
68
|
except (ValueError, TypeError) as e:
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/machines_service.py
RENAMED
|
@@ -13,7 +13,7 @@ class MachinesService:
|
|
|
13
13
|
self.defender = defender_client
|
|
14
14
|
self.logger = get_verbose_logger(__name__, verbose_level)
|
|
15
15
|
|
|
16
|
-
def get_result(self
|
|
16
|
+
def get_result(self) -> Dict[str, Any]:
|
|
17
17
|
"""Get machine count result with value and details."""
|
|
18
18
|
self.logger.method_entry("get_result")
|
|
19
19
|
|
|
@@ -44,8 +44,8 @@ class MachinesService:
|
|
|
44
44
|
sorted_machines = sorted(
|
|
45
45
|
machines,
|
|
46
46
|
key=lambda x: (
|
|
47
|
-
status_priority[x["onboardingStatus"]],
|
|
48
|
-
x["computerDnsName"],
|
|
47
|
+
status_priority[x["onboardingStatus"] or ""],
|
|
48
|
+
x["computerDnsName"] or "",
|
|
49
49
|
),
|
|
50
50
|
)
|
|
51
51
|
for machine in sorted_machines:
|
|
@@ -60,7 +60,7 @@ class MachinesService:
|
|
|
60
60
|
self.logger.method_exit("get_result", result)
|
|
61
61
|
return result
|
|
62
62
|
|
|
63
|
-
def get_details(self
|
|
63
|
+
def get_details(self) -> List[str]:
|
|
64
64
|
"""Get detailed machine information."""
|
|
65
65
|
self.logger.method_entry("get_details")
|
|
66
66
|
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/onboarding_service.py
RENAMED
|
@@ -54,8 +54,6 @@ class OnboardingService:
|
|
|
54
54
|
|
|
55
55
|
result = {"value": result_value, "details": details}
|
|
56
56
|
|
|
57
|
-
self.logger.info(
|
|
58
|
-
f"Machine onboarding status: {onboarding_state} -> {result_value}"
|
|
59
|
-
)
|
|
57
|
+
self.logger.info(f"Machine onboarding status: {onboarding_state} -> {result_value}")
|
|
60
58
|
self.logger.method_exit("get_result", result)
|
|
61
59
|
return result
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Products service implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional, Any
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from check_msdefender.core.exceptions import ValidationError
|
|
7
|
+
from check_msdefender.core.logging_config import get_verbose_logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProductsService:
|
|
11
|
+
"""Service for checking installed products on machines."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, defender_client: Any, verbose_level: int = 0) -> None:
|
|
14
|
+
"""Initialize with Defender client."""
|
|
15
|
+
self.defender = defender_client
|
|
16
|
+
self.logger = get_verbose_logger(__name__, verbose_level)
|
|
17
|
+
|
|
18
|
+
def get_result(
|
|
19
|
+
self, machine_id: Optional[str] = None, dns_name: Optional[str] = None
|
|
20
|
+
) -> Dict[str, Any]:
|
|
21
|
+
"""Get products result with value and details for a machine."""
|
|
22
|
+
self.logger.method_entry("get_result", machine_id=machine_id, dns_name=dns_name)
|
|
23
|
+
|
|
24
|
+
if not machine_id and not dns_name:
|
|
25
|
+
raise ValidationError("Either machine_id or dns_name must be provided")
|
|
26
|
+
|
|
27
|
+
# Get machine information
|
|
28
|
+
target_dns_name = dns_name
|
|
29
|
+
target_machine_id = machine_id
|
|
30
|
+
|
|
31
|
+
if machine_id:
|
|
32
|
+
# Get DNS name from machine_id
|
|
33
|
+
machine_details = self.defender.get_machine_by_id(machine_id)
|
|
34
|
+
target_dns_name = machine_details.get("computerDnsName", "Unknown")
|
|
35
|
+
elif dns_name:
|
|
36
|
+
# Get machine_id from dns_name
|
|
37
|
+
dns_response = self.defender.get_machine_by_dns_name(dns_name)
|
|
38
|
+
machines = dns_response.get("value", [])
|
|
39
|
+
if not machines:
|
|
40
|
+
raise ValidationError(f"Machine not found with DNS name: {dns_name}")
|
|
41
|
+
target_machine_id = machines[0].get("id")
|
|
42
|
+
target_dns_name = dns_name
|
|
43
|
+
|
|
44
|
+
# Get products for the machine
|
|
45
|
+
self.logger.info("Fetching products from Microsoft Defender")
|
|
46
|
+
products_data = self.defender.get_products()
|
|
47
|
+
all_products = products_data.get("value", [])
|
|
48
|
+
products = [
|
|
49
|
+
product for product in all_products if product.get("deviceId") == target_machine_id
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
self.logger.info(f"Found {len(products)} CVE vulnerabilities for machine {target_dns_name}")
|
|
53
|
+
|
|
54
|
+
# Group vulnerabilities by software
|
|
55
|
+
software_vulnerabilities = {}
|
|
56
|
+
for vulnerability in products:
|
|
57
|
+
software_name = vulnerability.get("softwareName", "Unknown")
|
|
58
|
+
software_version = vulnerability.get("softwareVersion", "Unknown")
|
|
59
|
+
software_vendor = vulnerability.get("softwareVendor", "Unknown")
|
|
60
|
+
cve_id = vulnerability.get("cveId", "Unknown")
|
|
61
|
+
cvss_score = vulnerability.get("cvssScore", 0)
|
|
62
|
+
disk_paths = vulnerability.get("diskPaths", [])
|
|
63
|
+
severity = vulnerability.get("vulnerabilitySeverityLevel", "Unknown")
|
|
64
|
+
|
|
65
|
+
software_key = f"{software_name}-{software_version}-{software_vendor}"
|
|
66
|
+
|
|
67
|
+
if software_key not in software_vulnerabilities:
|
|
68
|
+
software_vulnerabilities[software_key] = {
|
|
69
|
+
"name": software_name,
|
|
70
|
+
"version": software_version,
|
|
71
|
+
"vendor": software_vendor,
|
|
72
|
+
"cves": [],
|
|
73
|
+
"paths": set(),
|
|
74
|
+
"max_cvss": 0,
|
|
75
|
+
"severities": set(),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
software_vulnerabilities[software_key]["cves"].append(cve_id)
|
|
79
|
+
software_vulnerabilities[software_key]["paths"].update(disk_paths)
|
|
80
|
+
software_vulnerabilities[software_key]["max_cvss"] = max(
|
|
81
|
+
software_vulnerabilities[software_key]["max_cvss"], cvss_score
|
|
82
|
+
)
|
|
83
|
+
software_vulnerabilities[software_key]["severities"].add(severity)
|
|
84
|
+
|
|
85
|
+
# Count vulnerable software
|
|
86
|
+
vulnerable_software = []
|
|
87
|
+
|
|
88
|
+
for software in software_vulnerabilities.values():
|
|
89
|
+
if len(software["cves"]) > 0:
|
|
90
|
+
vulnerable_software.append(software)
|
|
91
|
+
|
|
92
|
+
# Create details for output
|
|
93
|
+
details = []
|
|
94
|
+
if software_vulnerabilities:
|
|
95
|
+
summary_line = f"{len(products)} CVE found on {target_dns_name}"
|
|
96
|
+
details.append(summary_line)
|
|
97
|
+
|
|
98
|
+
# Add software details (limit to 10)
|
|
99
|
+
for software in list(software_vulnerabilities.values())[:10]:
|
|
100
|
+
cve_count = len(software["cves"])
|
|
101
|
+
unique_cves = list(set(software["cves"]))
|
|
102
|
+
cve_list = ", ".join(unique_cves[:5]) # Show first 5 CVEs
|
|
103
|
+
if len(unique_cves) > 5:
|
|
104
|
+
cve_list += f".. (+{len(unique_cves) - 5} more)"
|
|
105
|
+
|
|
106
|
+
details.append(
|
|
107
|
+
f"{software['name']} {software['version']} ({software['vendor']}) - "
|
|
108
|
+
f"{cve_count} weaknesses ({cve_list})"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Add paths (limit to 4)
|
|
112
|
+
for path in list(software["paths"])[:4]:
|
|
113
|
+
details.append(f" - {path}")
|
|
114
|
+
|
|
115
|
+
# Determine the value based on severity:
|
|
116
|
+
# - Vulnerable software triggers warnings
|
|
117
|
+
# - No vulnerabilities is OK
|
|
118
|
+
if vulnerable_software:
|
|
119
|
+
value = len(vulnerable_software) # Will trigger warning threshold
|
|
120
|
+
else:
|
|
121
|
+
value = 0 # OK status
|
|
122
|
+
|
|
123
|
+
result = {
|
|
124
|
+
"value": value,
|
|
125
|
+
"details": details,
|
|
126
|
+
"vulnerable_count": len(vulnerable_software),
|
|
127
|
+
"total_cves": len(products),
|
|
128
|
+
"total_software": len(software_vulnerabilities),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
self.logger.info(
|
|
132
|
+
f"Products analysis complete: {len(products)} total CVEs, "
|
|
133
|
+
f"{len(vulnerable_software)} vulnerable software"
|
|
134
|
+
)
|
|
135
|
+
self.logger.method_exit("get_result", result)
|
|
136
|
+
return result
|
|
@@ -41,9 +41,7 @@ class VulnerabilitiesService:
|
|
|
41
41
|
|
|
42
42
|
# Process and deduplicate vulnerabilities
|
|
43
43
|
vulnerabilities = self._process_vulnerabilities(raw_vulnerabilities)
|
|
44
|
-
self.logger.info(
|
|
45
|
-
f"Found {len(vulnerabilities)} unique vulnerabilities after deduplication"
|
|
46
|
-
)
|
|
44
|
+
self.logger.info(f"Found {len(vulnerabilities)} unique vulnerabilities after deduplication")
|
|
47
45
|
|
|
48
46
|
# Calculate vulnerability score
|
|
49
47
|
score = VulnerabilityScore()
|
|
@@ -56,9 +54,7 @@ class VulnerabilitiesService:
|
|
|
56
54
|
|
|
57
55
|
for vuln in sorted_vulnerabilities:
|
|
58
56
|
severity = vuln.severity.lower()
|
|
59
|
-
self.logger.debug(
|
|
60
|
-
f"Processing vulnerability {vuln.id} with severity: {severity}"
|
|
61
|
-
)
|
|
57
|
+
self.logger.debug(f"Processing vulnerability {vuln.id} with severity: {severity}")
|
|
62
58
|
|
|
63
59
|
if severity == "critical":
|
|
64
60
|
score.critical += 1
|
|
@@ -130,9 +126,7 @@ class VulnerabilitiesService:
|
|
|
130
126
|
# Sort by severity
|
|
131
127
|
sorted_vulnerabilities = self._sort_by_severity(vulnerabilities)
|
|
132
128
|
|
|
133
|
-
self.logger.method_exit(
|
|
134
|
-
"get_detailed_vulnerabilities", len(sorted_vulnerabilities)
|
|
135
|
-
)
|
|
129
|
+
self.logger.method_exit("get_detailed_vulnerabilities", len(sorted_vulnerabilities))
|
|
136
130
|
return sorted_vulnerabilities
|
|
137
131
|
|
|
138
132
|
def _process_vulnerabilities(
|
|
@@ -163,9 +157,7 @@ class VulnerabilitiesService:
|
|
|
163
157
|
|
|
164
158
|
return unique_vulnerabilities
|
|
165
159
|
|
|
166
|
-
def _sort_by_severity(
|
|
167
|
-
self, vulnerabilities: List[Vulnerability]
|
|
168
|
-
) -> List[Vulnerability]:
|
|
160
|
+
def _sort_by_severity(self, vulnerabilities: List[Vulnerability]) -> List[Vulnerability]:
|
|
169
161
|
"""Sort vulnerabilities by severity (Critical > High > Medium > Low)."""
|
|
170
162
|
return sorted(
|
|
171
163
|
vulnerabilities,
|
|
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "check-msdefender"
|
|
9
|
-
|
|
9
|
+
dynamic = []
|
|
10
10
|
authors = [
|
|
11
11
|
{ name = "ldvchosal", email = "ldvchosal@github.com" },
|
|
12
12
|
]
|
|
@@ -38,6 +38,7 @@ dependencies = [
|
|
|
38
38
|
"azure-identity>=1.12.0",
|
|
39
39
|
"click>=8.0,<9.0",
|
|
40
40
|
]
|
|
41
|
+
version = "1.1.8"
|
|
41
42
|
|
|
42
43
|
[project.license]
|
|
43
44
|
text = "MIT"
|
|
@@ -51,6 +52,9 @@ Documentation = "https://github.com/lduchosal/check_msdefender/blob/main/README.
|
|
|
51
52
|
[project.scripts]
|
|
52
53
|
check_msdefender = "check_msdefender.cli:main"
|
|
53
54
|
|
|
55
|
+
[tool.pdm]
|
|
56
|
+
distribution = true
|
|
57
|
+
|
|
54
58
|
[tool.pdm.dev-dependencies]
|
|
55
59
|
dev = [
|
|
56
60
|
"pytest>=6.0",
|
|
@@ -62,18 +66,44 @@ dev = [
|
|
|
62
66
|
"twine>=6.2.0",
|
|
63
67
|
"pdm>=2.0.0",
|
|
64
68
|
"ruff>=0.13.0",
|
|
69
|
+
"pdm-bump",
|
|
65
70
|
]
|
|
66
71
|
|
|
72
|
+
[tool.pdm.version]
|
|
73
|
+
source = "file"
|
|
74
|
+
path = "check_msdefender/__init__.py"
|
|
75
|
+
|
|
67
76
|
[tool.pdm.scripts]
|
|
68
77
|
format = "ruff format"
|
|
69
78
|
typecheck = "mypy check_msdefender/"
|
|
70
|
-
lint = "flake8 check_msdefender/"
|
|
71
79
|
build = "python -m build"
|
|
72
80
|
publish = "python -m twine upload dist/* --verbose"
|
|
73
|
-
test = "pytest -v tests/"
|
|
74
81
|
install = "pdm install"
|
|
82
|
+
install-dev = "pdm install -G dev"
|
|
75
83
|
clihelp = "check_msdefender --help"
|
|
76
84
|
climachines = "check_msdefender machines"
|
|
85
|
+
test = "env PYTHONPATH=src python -m pytest tests/ -v --cov=check_msdefender --cov-report=term-missing"
|
|
86
|
+
test-quick = "env PYTHONPATH=src python -m pytest tests/ --tb=no -q"
|
|
87
|
+
test-ci = "env PYTHONPATH=src python -m pytest tests/ -v --cov=check_msdefender --cov-report=term-missing -m 'not reverseproxy'"
|
|
88
|
+
test-unit = "env PYTHONPATH=src python -m pytest tests/unit/ -v"
|
|
89
|
+
test-integration = "env PYTHONPATH=src python -m pytest tests/integration/ -v"
|
|
90
|
+
test-cov = "env PYTHONPATH=src python -m pytest tests/ -v --cov=check_msdefender --cov-report=html --cov-report=term-missing"
|
|
91
|
+
lint = "ruff check check_msdefender/ tests/"
|
|
92
|
+
lint-fix = "ruff check --fix check_msdefender/ tests/"
|
|
93
|
+
format-check = "black --check check_msdefender/ tests/"
|
|
94
|
+
check = [
|
|
95
|
+
"lint",
|
|
96
|
+
"format-check",
|
|
97
|
+
"typecheck",
|
|
98
|
+
"test",
|
|
99
|
+
]
|
|
100
|
+
bump-version = "pdm bump -v patch"
|
|
101
|
+
env = "echo ${PATH}"
|
|
102
|
+
clean = "rm -rf .coverage htmlcov/ .pytest_cache/ __pycache__/ check_msdefender/__pycache__/"
|
|
103
|
+
version-show = "python -c 'import check_msdefender; print(xp.__version__)'"
|
|
104
|
+
version-patch = "pdm bump patch"
|
|
105
|
+
version-minor = "pdm bump minor"
|
|
106
|
+
version-major = "pdm bump major"
|
|
77
107
|
|
|
78
108
|
[tool.pdm.scripts.cli]
|
|
79
109
|
composite = [
|
|
@@ -102,10 +132,20 @@ target-version = [
|
|
|
102
132
|
]
|
|
103
133
|
|
|
104
134
|
[tool.mypy]
|
|
105
|
-
python_version = "3.
|
|
135
|
+
python_version = "3.10"
|
|
106
136
|
warn_return_any = true
|
|
107
137
|
warn_unused_configs = true
|
|
108
138
|
disallow_untyped_defs = true
|
|
139
|
+
disallow_incomplete_defs = true
|
|
140
|
+
check_untyped_defs = true
|
|
141
|
+
disallow_untyped_decorators = false
|
|
142
|
+
no_implicit_optional = true
|
|
143
|
+
warn_redundant_casts = true
|
|
144
|
+
warn_unused_ignores = true
|
|
145
|
+
warn_no_return = true
|
|
146
|
+
warn_unreachable = true
|
|
147
|
+
strict_equality = true
|
|
148
|
+
ignore_missing_imports = true
|
|
109
149
|
|
|
110
150
|
[tool.pytest.ini_options]
|
|
111
151
|
testpaths = [
|
|
@@ -115,3 +155,8 @@ python_files = "test_*.py"
|
|
|
115
155
|
python_classes = "Test*"
|
|
116
156
|
python_functions = "test_*"
|
|
117
157
|
addopts = "-v"
|
|
158
|
+
|
|
159
|
+
[tool.pdm-bump]
|
|
160
|
+
version-files = [
|
|
161
|
+
"check_msdefender/__init__.py:__version__",
|
|
162
|
+
]
|
|
@@ -301,7 +301,6 @@ class TestDetailCommand:
|
|
|
301
301
|
assert "DEFENDER OK - Machine ID:" in result.output
|
|
302
302
|
assert "test-machine" in result.output
|
|
303
303
|
mock_service_instance.get_result.assert_called_once_with(
|
|
304
|
-
machine_id="test-machine-123", dns_name=None
|
|
305
304
|
)
|
|
306
305
|
|
|
307
306
|
@patch("check_msdefender.cli.commands.detail.load_config")
|
|
@@ -332,7 +331,6 @@ class TestDetailCommand:
|
|
|
332
331
|
assert result.exit_code == 0
|
|
333
332
|
assert "DEFENDER OK - Machine ID:" in result.output
|
|
334
333
|
mock_service_instance.get_result.assert_called_once_with(
|
|
335
|
-
machine_id="test-machine-456", dns_name=None
|
|
336
334
|
)
|
|
337
335
|
|
|
338
336
|
@patch("check_msdefender.cli.commands.detail.load_config")
|
|
@@ -364,7 +362,6 @@ class TestDetailCommand:
|
|
|
364
362
|
assert "DEFENDER OK - Machine ID:" in result.output
|
|
365
363
|
assert "test.domain.com" in result.output
|
|
366
364
|
mock_service_instance.get_result.assert_called_once_with(
|
|
367
|
-
machine_id=None, dns_name="test.domain.com"
|
|
368
365
|
)
|
|
369
366
|
|
|
370
367
|
@patch("check_msdefender.cli.commands.detail.load_config")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/onboarding.py
RENAMED
|
File without changes
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/vulnerabilities.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_vulnerabilities_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/integration/test_lastseen_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|