check-msdefender 1.0.0__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 (33) hide show
  1. check_msdefender/__init__.py +5 -0
  2. check_msdefender/__main__.py +6 -0
  3. check_msdefender/check_msdefender.py +7 -0
  4. check_msdefender/cli/__init__.py +15 -0
  5. check_msdefender/cli/__main__.py +6 -0
  6. check_msdefender/cli/commands/__init__.py +17 -0
  7. check_msdefender/cli/commands/detail.py +72 -0
  8. check_msdefender/cli/commands/lastseen.py +61 -0
  9. check_msdefender/cli/commands/machines.py +55 -0
  10. check_msdefender/cli/commands/onboarding.py +61 -0
  11. check_msdefender/cli/commands/vulnerabilities.py +61 -0
  12. check_msdefender/cli/decorators.py +18 -0
  13. check_msdefender/cli/handlers.py +46 -0
  14. check_msdefender/core/__init__.py +1 -0
  15. check_msdefender/core/auth.py +46 -0
  16. check_msdefender/core/config.py +40 -0
  17. check_msdefender/core/defender.py +176 -0
  18. check_msdefender/core/exceptions.py +31 -0
  19. check_msdefender/core/logging_config.py +116 -0
  20. check_msdefender/core/nagios.py +169 -0
  21. check_msdefender/services/__init__.py +1 -0
  22. check_msdefender/services/detail_service.py +77 -0
  23. check_msdefender/services/lastseen_service.py +70 -0
  24. check_msdefender/services/machines_service.py +82 -0
  25. check_msdefender/services/models.py +49 -0
  26. check_msdefender/services/onboarding_service.py +59 -0
  27. check_msdefender/services/vulnerabilities_service.py +163 -0
  28. check_msdefender-1.0.0.dist-info/METADATA +396 -0
  29. check_msdefender-1.0.0.dist-info/RECORD +33 -0
  30. check_msdefender-1.0.0.dist-info/WHEEL +5 -0
  31. check_msdefender-1.0.0.dist-info/entry_points.txt +2 -0
  32. check_msdefender-1.0.0.dist-info/licenses/LICENSE +21 -0
  33. check_msdefender-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,176 @@
1
+ """Microsoft Defender API client."""
2
+
3
+ import time
4
+ import requests
5
+ from typing import Any, Dict, cast
6
+ from check_msdefender.core.exceptions import DefenderAPIError
7
+ from check_msdefender.core.logging_config import get_verbose_logger
8
+
9
+
10
+ class DefenderClient:
11
+ """Client for Microsoft Defender API."""
12
+
13
+ application_json = "application/json"
14
+
15
+ def __init__(
16
+ self, authenticator: Any, timeout: int = 5, region: str = "eu3", verbose_level: int = 0
17
+ ) -> None:
18
+ """Initialize with authenticator and optional region.
19
+
20
+ Args:
21
+ authenticator: Authentication provider
22
+ timeout: Request timeout in seconds
23
+ region: Geographic region (eu, eu3, us, uk)
24
+ verbose_level: Verbosity level for logging
25
+ """
26
+ self.authenticator = authenticator
27
+ self.timeout = timeout
28
+ self.region = region
29
+ self.base_url = self._get_base_url(region)
30
+ self.logger = get_verbose_logger(__name__, verbose_level)
31
+
32
+ def _get_base_url(self, region: str) -> str:
33
+ """Get base URL for the specified region."""
34
+ endpoints = {
35
+ "eu": "https://api-eu.securitycenter.microsoft.com",
36
+ "eu3": "https://api-eu3.securitycenter.microsoft.com",
37
+ "us": "https://api.securitycenter.microsoft.com",
38
+ "uk": "https://api-uk.securitycenter.microsoft.com",
39
+ }
40
+ return endpoints.get(region, endpoints["eu3"])
41
+
42
+ def get_machine_by_dns_name(self, dns_name: str) -> Dict[str, Any]:
43
+ """Get machine information by DNS name."""
44
+ self.logger.method_entry("get_machine_by_dns_name", dns_name=dns_name)
45
+
46
+ token = self._get_token()
47
+
48
+ url = f"{self.base_url}/api/machines"
49
+ headers = {
50
+ "Authorization": f"Bearer {token}",
51
+ "Content-Type": DefenderClient.application_json,
52
+ }
53
+
54
+ params = {"$filter": f"computerDnsName eq '{dns_name}'", "$select": "id"}
55
+
56
+ try:
57
+ start_time = time.time()
58
+ self.logger.info(f"Querying machine by DNS name: {dns_name}")
59
+ response = requests.get(url, headers=headers, params=params, timeout=self.timeout)
60
+ elapsed = time.time() - start_time
61
+
62
+ self.logger.api_call("GET", url, response.status_code, elapsed)
63
+ response.raise_for_status()
64
+
65
+ result = cast(Dict[str, Any], response.json())
66
+ self.logger.json_response(str(result))
67
+ self.logger.method_exit("get_machine_by_dns_name", result)
68
+ return result
69
+ except requests.RequestException as e:
70
+ self.logger.debug(f"API request failed: {str(e)}")
71
+ if hasattr(e, "response") and e.response is not None:
72
+ self.logger.debug(f"Response: {str(e.response.content)}")
73
+ raise DefenderAPIError(f"Failed to query MS Defender API: {str(e)}")
74
+
75
+ def get_machine_by_id(self, machine_id: str) -> Dict[str, Any]:
76
+ """Get machine information by machine ID."""
77
+ self.logger.method_entry("get_machine_by_id", machine_id=machine_id)
78
+
79
+ token = self._get_token()
80
+
81
+ url = f"{self.base_url}/api/machines/{machine_id}"
82
+ headers = {
83
+ "Authorization": f"Bearer {token}",
84
+ "Content-Type": DefenderClient.application_json,
85
+ }
86
+
87
+ try:
88
+ start_time = time.time()
89
+ self.logger.info(f"Querying machine by ID: {machine_id}")
90
+ response = requests.get(url, headers=headers, timeout=self.timeout)
91
+ elapsed = time.time() - start_time
92
+
93
+ self.logger.api_call("GET", url, response.status_code, elapsed)
94
+ response.raise_for_status()
95
+
96
+ result = cast(Dict[str, Any], response.json())
97
+ self.logger.json_response(str(result))
98
+ self.logger.method_exit("get_machine_by_id", result)
99
+ return result
100
+ except requests.RequestException as e:
101
+ self.logger.debug(f"API request failed: {str(e)}")
102
+ if hasattr(e, "response") and e.response is not None:
103
+ self.logger.debug(f"Response: {str(e.response.content)}")
104
+ raise DefenderAPIError(f"Failed to query MS Defender API: {str(e)}")
105
+
106
+ def get_machine_vulnerabilities(self, machine_id: str) -> Dict[str, Any]:
107
+ """Get vulnerabilities for a machine."""
108
+ self.logger.method_entry("get_machine_vulnerabilities", machine_id=machine_id)
109
+
110
+ token = self._get_token()
111
+
112
+ url = f"{self.base_url}/api/machines/{machine_id}/vulnerabilities"
113
+ headers = {
114
+ "Authorization": f"Bearer {token}",
115
+ "Content-Type": DefenderClient.application_json,
116
+ }
117
+
118
+ try:
119
+ start_time = time.time()
120
+ self.logger.info(f"Querying vulnerabilities for machine: {machine_id}")
121
+ response = requests.get(url, headers=headers, timeout=self.timeout)
122
+ elapsed = time.time() - start_time
123
+
124
+ self.logger.api_call("GET", url, response.status_code, elapsed)
125
+ response.raise_for_status()
126
+
127
+ result = cast(Dict[str, Any], response.json())
128
+ self.logger.json_response(str(result))
129
+ self.logger.method_exit("get_machine_vulnerabilities", result)
130
+ return result
131
+ except requests.RequestException as e:
132
+ self.logger.debug(f"API request failed: {str(e)}")
133
+ if hasattr(e, "response") and e.response is not None:
134
+ self.logger.debug(f"Response: {str(e.response.content)}")
135
+ raise DefenderAPIError(f"Failed to query MS Defender API: {str(e)}")
136
+
137
+ def list_machines(self) -> Dict[str, Any]:
138
+ """Get list of all machines."""
139
+ self.logger.method_entry("list_machines")
140
+
141
+ token = self._get_token()
142
+
143
+ url = f"{self.base_url}/api/machines"
144
+ headers = {
145
+ "Authorization": f"Bearer {token}",
146
+ "Content-Type": DefenderClient.application_json,
147
+ }
148
+
149
+ params = {"$select": "id,computerDnsName,onboardingStatus,osPlatform"}
150
+
151
+ try:
152
+ start_time = time.time()
153
+ self.logger.info("Querying all machines")
154
+ response = requests.get(url, headers=headers, params=params, timeout=self.timeout)
155
+ elapsed = time.time() - start_time
156
+
157
+ self.logger.api_call("GET", url, response.status_code, elapsed)
158
+ response.raise_for_status()
159
+
160
+ result = cast(Dict[str, Any], response.json())
161
+ self.logger.json_response(str(result))
162
+ self.logger.method_exit("list_machines", result)
163
+ return result
164
+ except requests.RequestException as e:
165
+ self.logger.debug(f"API request failed: {str(e)}")
166
+ if hasattr(e, "response") and e.response is not None:
167
+ self.logger.debug(f"Response: {str(e.response.content)}")
168
+ raise DefenderAPIError(f"Failed to query MS Defender API: {str(e)}")
169
+
170
+ def _get_token(self) -> str:
171
+ """Get access token from authenticator."""
172
+ self.logger.trace("Getting access token from authenticator")
173
+ scope = "https://api.securitycenter.microsoft.com/.default"
174
+ token = self.authenticator.get_token(scope)
175
+ self.logger.trace(f"Token acquired successfully (expires: {token.expires_on})")
176
+ return str(token.token)
@@ -0,0 +1,31 @@
1
+ """Custom exceptions for check_msdefender."""
2
+
3
+
4
+ class CheckMSDefenderError(Exception):
5
+ """Base exception for check_msdefender."""
6
+
7
+ pass
8
+
9
+
10
+ class ConfigurationError(CheckMSDefenderError):
11
+ """Raised when there's a configuration error."""
12
+
13
+ pass
14
+
15
+
16
+ class AuthenticationError(CheckMSDefenderError):
17
+ """Raised when there's an authentication error."""
18
+
19
+ pass
20
+
21
+
22
+ class DefenderAPIError(CheckMSDefenderError):
23
+ """Raised when there's an error with the Defender API."""
24
+
25
+ pass
26
+
27
+
28
+ class ValidationError(CheckMSDefenderError):
29
+ """Raised when there's a validation error."""
30
+
31
+ pass
@@ -0,0 +1,116 @@
1
+ """Logging configuration for verbose mode."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Optional, Any
6
+
7
+
8
+ class VerboseLogger:
9
+ """Logger configured for different verbosity levels."""
10
+
11
+ def __init__(self, name: str, verbose_level: int = 0):
12
+ """Initialize logger with verbosity level.
13
+
14
+ Args:
15
+ name: Logger name
16
+ verbose_level: Verbosity level (0=none, 1=info, 2=debug, 3+=trace)
17
+ """
18
+ self.logger = logging.getLogger(name)
19
+ self.verbose_level = verbose_level
20
+ self._configure_logger()
21
+
22
+ def _configure_logger(self) -> None:
23
+ """Configure logger based on verbosity level."""
24
+ # Clear any existing handlers
25
+ self.logger.handlers.clear()
26
+
27
+ if self.verbose_level == 0:
28
+ # No verbose logging
29
+ self.logger.setLevel(logging.WARNING)
30
+ return
31
+
32
+ # Create console handler
33
+ handler = logging.StreamHandler(sys.stderr)
34
+
35
+ # Set format based on verbosity
36
+ if self.verbose_level >= 3:
37
+ # Full trace format
38
+ formatter = logging.Formatter(
39
+ "[%(levelname)s] %(asctime)s %(name)s:%(lineno)d - %(message)s", datefmt="%H:%M:%S"
40
+ )
41
+ elif self.verbose_level >= 2:
42
+ # Debug format
43
+ formatter = logging.Formatter("[%(levelname)s] %(name)s - %(message)s")
44
+ else:
45
+ # Basic format
46
+ formatter = logging.Formatter("%(message)s")
47
+
48
+ handler.setFormatter(formatter)
49
+ self.logger.addHandler(handler)
50
+
51
+ # Set level based on verbosity
52
+ if self.verbose_level >= 3:
53
+ self.logger.setLevel(logging.DEBUG)
54
+ elif self.verbose_level >= 2:
55
+ self.logger.setLevel(logging.DEBUG)
56
+ else:
57
+ self.logger.setLevel(logging.INFO)
58
+
59
+ def info(self, message: str, *args: Any, **kwargs: Any) -> None:
60
+ """Log info message if verbose >= 1."""
61
+ if self.verbose_level >= 1:
62
+ self.logger.info(message, *args, **kwargs)
63
+
64
+ def debug(self, message: str, *args: Any, **kwargs: Any) -> None:
65
+ """Log debug message if verbose >= 2."""
66
+ if self.verbose_level >= 2:
67
+ self.logger.debug(message, *args, **kwargs)
68
+
69
+ def trace(self, message: str, *args: Any, **kwargs: Any) -> None:
70
+ """Log trace message if verbose >= 3."""
71
+ if self.verbose_level >= 3:
72
+ self.logger.debug(f"TRACE: {message}", *args, **kwargs)
73
+
74
+ def api_call(
75
+ self,
76
+ method: str,
77
+ url: str,
78
+ status_code: Optional[int] = None,
79
+ response_time: Optional[float] = None,
80
+ ) -> None:
81
+ """Log API call details if verbose >= 2."""
82
+ if self.verbose_level >= 2:
83
+ if status_code and response_time:
84
+ self.logger.debug(f"API {method} {url} -> {status_code} ({response_time:.3f}s)")
85
+ else:
86
+ self.logger.debug(f"API {method} {url}")
87
+
88
+ def json_response(self, data: str) -> None:
89
+ """Log JSON response if verbose >= 2."""
90
+ if self.verbose_level >= 2:
91
+ self.logger.debug(f"JSON Response: {data}")
92
+
93
+ def method_entry(self, method_name: str, **kwargs: Any) -> None:
94
+ """Log method entry if verbose >= 3."""
95
+ if self.verbose_level >= 3:
96
+ args_str = ", ".join(f"{k}={v}" for k, v in kwargs.items())
97
+ self.logger.debug(f"TRACE: -> {method_name}({args_str})")
98
+
99
+ def method_exit(self, method_name: str, result: Any = None) -> None:
100
+ """Log method exit if verbose >= 3."""
101
+ if self.verbose_level >= 3:
102
+ result_str = f" = {result}" if result is not None else ""
103
+ self.logger.debug(f"TRACE: <- {method_name}{result_str}")
104
+
105
+
106
+ def get_verbose_logger(name: str, verbose_level: int = 0) -> VerboseLogger:
107
+ """Get a configured verbose logger.
108
+
109
+ Args:
110
+ name: Logger name (typically __name__)
111
+ verbose_level: Verbosity level from CLI
112
+
113
+ Returns:
114
+ Configured VerboseLogger instance
115
+ """
116
+ return VerboseLogger(name, verbose_level)
@@ -0,0 +1,169 @@
1
+ """Nagios plugin implementation."""
2
+
3
+ import nagiosplugin
4
+ from typing import List, Optional, Union, Any
5
+
6
+
7
+ class DefenderScalarContext(nagiosplugin.ScalarContext):
8
+ """Custom scalar context with modified threshold logic for detail command."""
9
+
10
+ def __init__(
11
+ self,
12
+ name: str,
13
+ warning: Optional[Union[float, int]] = None,
14
+ critical: Optional[Union[float, int]] = None,
15
+ ) -> None:
16
+ """Initialize with custom threshold logic."""
17
+ # Store original values to know what was actually set
18
+ self._original_warning = warning
19
+ self._original_critical = critical
20
+ super().__init__(name, warning, critical)
21
+
22
+ def evaluate(
23
+ self, metric: nagiosplugin.Metric, resource: nagiosplugin.Resource
24
+ ) -> nagiosplugin.Result:
25
+ """Evaluate metric against thresholds with <= logic for detail command."""
26
+ if self.name == "found":
27
+ # For detail command, use <= threshold logic (not < threshold)
28
+ # Use original values instead of Range objects for threshold comparison
29
+ critical_val = self._original_critical
30
+ warning_val = self._original_warning
31
+
32
+ # Check most restrictive threshold first
33
+ warning_triggered = (
34
+ self._original_warning is not None
35
+ and warning_val is not None
36
+ and metric.value <= warning_val
37
+ )
38
+ critical_triggered = (
39
+ self._original_critical is not None
40
+ and critical_val is not None
41
+ and metric.value <= critical_val
42
+ )
43
+
44
+ if critical_triggered and warning_triggered:
45
+ # Both triggered - determine priority based on which threshold is more restrictive
46
+ # For this application, choose the threshold that equals the metric value
47
+ if warning_val == metric.value:
48
+ return self.result_cls(
49
+ nagiosplugin.Warn,
50
+ f"{metric.name} is {metric.value} (outside range {warning_val}:)",
51
+ metric,
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
+ else:
60
+ # If no exact match, use the more severe one (critical)
61
+ return self.result_cls(
62
+ nagiosplugin.Critical,
63
+ f"{metric.name} is {metric.value} (outside range {critical_val}:)",
64
+ metric,
65
+ )
66
+ elif critical_triggered:
67
+ return self.result_cls(
68
+ nagiosplugin.Critical,
69
+ f"{metric.name} is {metric.value} (outside range {critical_val}:)",
70
+ metric,
71
+ )
72
+ elif warning_triggered:
73
+ return self.result_cls(
74
+ nagiosplugin.Warn,
75
+ f"{metric.name} is {metric.value} (outside range {warning_val}:)",
76
+ metric,
77
+ )
78
+ else:
79
+ return self.result_cls(nagiosplugin.Ok, None, metric)
80
+ else:
81
+ # For other commands, use standard threshold logic
82
+ return super().evaluate(metric, resource)
83
+
84
+
85
+ class DefenderSummary(nagiosplugin.Summary):
86
+ """Custom summary class for detailed Nagios output."""
87
+
88
+ def __init__(self, details: Optional[List[str]]) -> None:
89
+ """Initialize with detailed output lines."""
90
+ self.details = details or []
91
+
92
+ def ok(self, results: nagiosplugin.Results) -> str:
93
+ """Return detailed output for OK state."""
94
+ return self._format_details()
95
+
96
+ def problem(self, results: nagiosplugin.Results) -> str:
97
+ """Return detailed output for problem states (WARNING, CRITICAL)."""
98
+ return self._format_details()
99
+
100
+ def _format_details(self) -> str:
101
+ """Format details for output."""
102
+ if not self.details:
103
+ return ""
104
+ return "\n" + "\n".join(self.details)
105
+
106
+
107
+ class NagiosPlugin:
108
+ """Nagios plugin for Microsoft Defender monitoring."""
109
+
110
+ def __init__(self, service: Any, command_name: str) -> None:
111
+ """Initialize with a service and command name."""
112
+ self.service = service
113
+ self.command_name = command_name
114
+
115
+ def check(
116
+ self,
117
+ machine_id: Optional[str] = None,
118
+ dns_name: Optional[str] = None,
119
+ warning: Optional[Union[float, int]] = None,
120
+ critical: Optional[Union[float, int]] = None,
121
+ verbose: int = 0,
122
+ ) -> int:
123
+ """Execute the check and return Nagios exit code."""
124
+ try:
125
+ result = self.service.get_result(machine_id=machine_id, dns_name=dns_name)
126
+ value = result["value"]
127
+ details = result.get("details", [])
128
+
129
+ # Create Nagios check with custom summary
130
+ # Use 'found' as context name for detail command, otherwise use command name
131
+ context_name = "found" if self.command_name == "detail" else self.command_name
132
+ check = nagiosplugin.Check(
133
+ DefenderResource(self.command_name, value),
134
+ DefenderScalarContext(context_name, warning, critical),
135
+ DefenderSummary(details),
136
+ )
137
+
138
+ # Set verbosity
139
+ check.verbosity = verbose
140
+
141
+ # Run check and return exit code instead of exiting
142
+ try:
143
+ check.main()
144
+ return 0 # If main() doesn't exit, it's OK
145
+ except SystemExit as e:
146
+ return int(e.code) if e.code is not None else 0
147
+
148
+ except Exception as e:
149
+ print(f"UNKNOWN: {str(e)}")
150
+ return 3
151
+
152
+
153
+ class DefenderResource(nagiosplugin.Resource):
154
+ """Defender resource for getting values with custom service name."""
155
+
156
+ def __init__(self, command_name: str, value: Union[int, float]) -> None:
157
+ super().__init__()
158
+ self.command_name = command_name
159
+ self.value = value
160
+
161
+ @property
162
+ def name(self) -> str:
163
+ """Return custom service name."""
164
+ return "DEFENDER"
165
+
166
+ def probe(self) -> List[nagiosplugin.Metric]:
167
+ # Use 'found' as metric name for detail command, otherwise use command name
168
+ metric_name = "found" if self.command_name == "detail" else self.command_name
169
+ return [nagiosplugin.Metric(metric_name, self.value)]
@@ -0,0 +1 @@
1
+ """Services module for check_msdefender."""
@@ -0,0 +1,77 @@
1
+ """Detail service implementation."""
2
+
3
+ import json
4
+ from typing import Dict, Optional, Any
5
+ from check_msdefender.core.exceptions import ValidationError
6
+ from check_msdefender.core.logging_config import get_verbose_logger
7
+
8
+
9
+ class DetailService:
10
+ """Service for getting machine details."""
11
+
12
+ def __init__(self, defender_client: Any, verbose_level: int = 0) -> None:
13
+ """Initialize with Defender client."""
14
+ self.defender = defender_client
15
+ self.logger = get_verbose_logger(__name__, verbose_level)
16
+
17
+ def get_result(
18
+ self, machine_id: Optional[str] = None, dns_name: Optional[str] = None
19
+ ) -> Dict[str, Any]:
20
+ """Get machine details result with value and details.
21
+
22
+ Returns:
23
+ dict: Result with value (1 or 0) and details list
24
+ """
25
+ self.logger.method_entry("get_result", machine_id=machine_id, dns_name=dns_name)
26
+
27
+ if not machine_id and not dns_name:
28
+ raise ValidationError("Either machine_id or dns_name must be provided")
29
+
30
+ try:
31
+ # Get machine information
32
+ if dns_name:
33
+ self.logger.info(f"Fetching machine data by DNS name: {dns_name}")
34
+ machines_data = self.defender.get_machine_by_dns_name(dns_name)
35
+ if not machines_data.get("value"):
36
+ self.logger.info(f"Machine not found with DNS name: {dns_name}")
37
+ result = {
38
+ "value": 0,
39
+ "details": [f"Machine not found with DNS name: {dns_name}"],
40
+ }
41
+ self.logger.method_exit("get_result", result)
42
+ return result
43
+ machine_data = machines_data["value"][0]
44
+ self.logger.debug(f"Found machine: {machine_data.get('id', 'unknown')}")
45
+ machine_id = machine_data.get("id")
46
+
47
+ # Get detailed machine information by ID
48
+ self.logger.info(f"Fetching detailed machine data by ID: {machine_id}")
49
+ machine_details = self.defender.get_machine_by_id(machine_id)
50
+
51
+ # Store the details for output formatting
52
+ self._machine_details = machine_details
53
+
54
+ # Create detailed output
55
+ details = []
56
+ details.append(f"Machine ID: {machine_details.get('id', 'Unknown')}")
57
+ details.append(f"Computer Name: {machine_details.get('computerDnsName', 'Unknown')}")
58
+ details.append(f"OS Platform: {machine_details.get('osPlatform', 'Unknown')}")
59
+ details.append(f"OS Version: {machine_details.get('osVersion', 'Unknown')}")
60
+ details.append(f"Health Status: {machine_details.get('healthStatus', 'Unknown')}")
61
+ details.append(f"Risk Score: {machine_details.get('riskScore', 'Unknown')}")
62
+
63
+ result = {"value": 1, "details": details}
64
+
65
+ self.logger.info(f"Machine details retrieved successfully")
66
+ self.logger.method_exit("get_result", result)
67
+ return result
68
+
69
+ except Exception as e:
70
+ self.logger.debug(f"Failed to get machine details: {str(e)}")
71
+ raise
72
+
73
+ def get_machine_details_json(self) -> Optional[str]:
74
+ """Get the machine details as formatted JSON string."""
75
+ if not hasattr(self, "_machine_details"):
76
+ return None
77
+ return json.dumps(self._machine_details, indent=2)
@@ -0,0 +1,70 @@
1
+ """Last seen service implementation."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from typing import Dict, Optional, Any
6
+ from check_msdefender.core.exceptions import ValidationError
7
+ from check_msdefender.core.logging_config import get_verbose_logger
8
+
9
+
10
+ class LastSeenService:
11
+ """Service for checking last seen status."""
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 last seen 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
+ if dns_name:
29
+ self.logger.info(f"Fetching machine data by DNS name: {dns_name}")
30
+ machines_data = self.defender.get_machine_by_dns_name(dns_name)
31
+ if not machines_data.get("value"):
32
+ raise ValidationError(f"Machine not found with DNS name: {dns_name}")
33
+ machine_data = machines_data["value"][0]
34
+ self.logger.debug(f"Found machine: {machine_data.get('id', 'unknown')}")
35
+ machine_id = machine_data.get("id")
36
+
37
+ # Extract last seen timestamp
38
+ machine_details = self.defender.get_machine_by_id(machine_id)
39
+ last_seen_str = machine_details.get("lastSeen")
40
+ if not last_seen_str:
41
+ raise ValidationError("No lastSeen data available for machine")
42
+
43
+ self.logger.debug(f"Last seen timestamp from API: {last_seen_str}")
44
+
45
+ # Parse timestamp and calculate days difference
46
+ try:
47
+ # Handle high-precision microseconds by truncating to 6 digits
48
+ timestamp_str = last_seen_str.replace("Z", "+00:00")
49
+ # Regex to find and truncate microseconds longer than 6 digits
50
+ timestamp_str = re.sub(r"\.(\d{6})\d+", r".\1", timestamp_str)
51
+
52
+ last_seen = datetime.fromisoformat(timestamp_str)
53
+ now = datetime.now(last_seen.tzinfo)
54
+ days_diff = (now - last_seen).days
55
+
56
+ # Create detailed output
57
+ computer_name = machine_details.get("computerDnsName", "Unknown")
58
+ last_seen_formatted = last_seen.strftime("%Y-%m-%d %H:%M:%S %Z")
59
+ details = [
60
+ f"Machine: {computer_name} - Last seen {days_diff} days ago ({last_seen_formatted})"
61
+ ]
62
+
63
+ result = {"value": days_diff, "details": details}
64
+
65
+ self.logger.info(f"Machine last seen {days_diff} days ago ({last_seen_str})")
66
+ self.logger.method_exit("get_result", result)
67
+ return result
68
+ except (ValueError, TypeError) as e:
69
+ self.logger.debug(f"Failed to parse timestamp: {str(e)}")
70
+ raise ValidationError(f"Invalid lastSeen timestamp: {str(e)}")