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.
Files changed (52) hide show
  1. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/PKG-INFO +1 -1
  2. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/__init__.py +1 -2
  3. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/__init__.py +2 -0
  4. check_msdefender-1.1.8/check_msdefender/cli/commands/products.py +60 -0
  5. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/decorators.py +1 -3
  6. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/auth.py +1 -3
  7. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/defender.py +56 -15
  8. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/logging_config.py +1 -3
  9. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/nagios.py +2 -10
  10. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/alerts_service.py +5 -15
  11. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/detail_service.py +4 -10
  12. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/lastseen_service.py +1 -3
  13. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/machines_service.py +4 -4
  14. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/onboarding_service.py +1 -3
  15. check_msdefender-1.1.8/check_msdefender/services/products_service.py +136 -0
  16. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/vulnerabilities_service.py +4 -12
  17. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/pyproject.toml +49 -4
  18. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/integration/test_cli_integration.py +0 -3
  19. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/LICENSE +0 -0
  20. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/README.md +0 -0
  21. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/__main__.py +0 -0
  22. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/check_msdefender.py +0 -0
  23. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/__init__.py +0 -0
  24. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/__main__.py +0 -0
  25. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/alerts.py +0 -0
  26. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/detail.py +0 -0
  27. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/lastseen.py +0 -0
  28. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/machines.py +0 -0
  29. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/onboarding.py +0 -0
  30. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/commands/vulnerabilities.py +0 -0
  31. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/cli/handlers.py +0 -0
  32. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/__init__.py +0 -0
  33. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/config.py +0 -0
  34. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/core/exceptions.py +0 -0
  35. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/__init__.py +0 -0
  36. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/check_msdefender/services/models.py +0 -0
  37. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/__init__.py +0 -0
  38. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/__init__.py +0 -0
  39. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/alerts_data.json +0 -0
  40. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/machine_data.json +0 -0
  41. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/mock_defender_client.py +0 -0
  42. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_alerts_service.py +0 -0
  43. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_detail_service.py +0 -0
  44. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_lastseen_service.py +0 -0
  45. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_onboarding_service.py +0 -0
  46. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/test_vulnerabilities_service.py +0 -0
  47. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/fixtures/vulnerability_data.json +0 -0
  48. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/integration/__init__.py +0 -0
  49. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/integration/test_lastseen_integration.py +0 -0
  50. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/unit/__init__.py +0 -0
  51. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/unit/test_alerts_service.py +0 -0
  52. {check_msdefender-1.1.6 → check_msdefender-1.1.8}/tests/unit/test_detail_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: check-msdefender
3
- Version: 1.1.6
3
+ Version: 1.1.8
4
4
  Summary: A Nagios plugin for monitoring Microsoft Defender API endpoints
5
5
  Keywords: nagios,monitoring,microsoft,graph,api,azure
6
6
  Author-Email: ldvchosal <ldvchosal@github.com>
@@ -1,5 +1,4 @@
1
1
  """Check Microsoft Defender API endpoints and check values - Nagios plugin."""
2
-
3
- __version__ = "1.0.0"
2
+ __version__ = "1.1.8"
4
3
  __author__ = "ldvchosal"
5
4
  __email__ = "ldvchosa@github.com"
@@ -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 = {"$filter": f"computerDnsName eq '{dns_name}'", "$select": "id"}
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 = {"$select": "id,computerDnsName,onboardingStatus,osPlatform"}
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
- "$top": "100",
192
- "$expand": "evidence",
193
- "$orderby": "alertCreationTime desc",
194
- "$select": "status,title,machineId,computerDnsName,alertCreationTime,firstEventTime,lastEventTime,lastUpdateTime,severity",
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(machine_id=machine_id, dns_name=dns_name)
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),
@@ -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
@@ -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
- f"Computer Name: {machine_details.get('computerDnsName', 'Unknown')}"
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(f"Machine details retrieved successfully")
65
+ self.logger.info("Machine details retrieved successfully")
72
66
  self.logger.method_exit("get_result", result)
73
67
  return result
74
68
 
@@ -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:
@@ -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, **kwargs: Any) -> Dict[str, Any]:
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, **kwargs: Any) -> List[str]:
63
+ def get_details(self) -> List[str]:
64
64
  """Get detailed machine information."""
65
65
  self.logger.method_entry("get_details")
66
66
 
@@ -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
- version = "1.1.6"
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.9"
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")