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.
- check_msdefender/__init__.py +5 -0
- check_msdefender/__main__.py +6 -0
- check_msdefender/check_msdefender.py +7 -0
- check_msdefender/cli/__init__.py +15 -0
- check_msdefender/cli/__main__.py +6 -0
- check_msdefender/cli/commands/__init__.py +17 -0
- check_msdefender/cli/commands/detail.py +72 -0
- check_msdefender/cli/commands/lastseen.py +61 -0
- check_msdefender/cli/commands/machines.py +55 -0
- check_msdefender/cli/commands/onboarding.py +61 -0
- check_msdefender/cli/commands/vulnerabilities.py +61 -0
- check_msdefender/cli/decorators.py +18 -0
- check_msdefender/cli/handlers.py +46 -0
- check_msdefender/core/__init__.py +1 -0
- check_msdefender/core/auth.py +46 -0
- check_msdefender/core/config.py +40 -0
- check_msdefender/core/defender.py +176 -0
- check_msdefender/core/exceptions.py +31 -0
- check_msdefender/core/logging_config.py +116 -0
- check_msdefender/core/nagios.py +169 -0
- check_msdefender/services/__init__.py +1 -0
- check_msdefender/services/detail_service.py +77 -0
- check_msdefender/services/lastseen_service.py +70 -0
- check_msdefender/services/machines_service.py +82 -0
- check_msdefender/services/models.py +49 -0
- check_msdefender/services/onboarding_service.py +59 -0
- check_msdefender/services/vulnerabilities_service.py +163 -0
- check_msdefender-1.0.0.dist-info/METADATA +396 -0
- check_msdefender-1.0.0.dist-info/RECORD +33 -0
- check_msdefender-1.0.0.dist-info/WHEEL +5 -0
- check_msdefender-1.0.0.dist-info/entry_points.txt +2 -0
- check_msdefender-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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)}")
|