check-msdefender 1.1.2__tar.gz → 1.1.4__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.2 → check_msdefender-1.1.4}/PKG-INFO +2 -2
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/README.md +0 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/decorators.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/core/auth.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/core/defender.py +19 -9
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/core/logging_config.py +5 -2
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/core/nagios.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/alerts_service.py +15 -5
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/detail_service.py +9 -3
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/lastseen_service.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/machines_service.py +9 -2
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/onboarding_service.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/vulnerabilities_service.py +16 -6
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/pyproject.toml +14 -17
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/mock_defender_client.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/test_alerts_service.py +5 -2
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/test_detail_service.py +9 -3
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/test_lastseen_service.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/test_onboarding_service.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/test_vulnerabilities_service.py +3 -1
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/integration/test_cli_integration.py +60 -16
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/integration/test_lastseen_integration.py +17 -3
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/unit/test_alerts_service.py +9 -3
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/unit/test_detail_service.py +22 -7
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/LICENSE +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/__main__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/check_msdefender.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/__main__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/alerts.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/detail.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/lastseen.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/machines.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/onboarding.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/vulnerabilities.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/cli/handlers.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/core/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/core/config.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/core/exceptions.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/models.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/alerts_data.json +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/machine_data.json +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/vulnerability_data.json +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/integration/__init__.py +0 -0
- {check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/unit/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: check-msdefender
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.4
|
|
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>
|
|
@@ -32,6 +32,7 @@ Requires-Dist: flake8>=3.8; extra == "dev"
|
|
|
32
32
|
Requires-Dist: mypy>=0.800; extra == "dev"
|
|
33
33
|
Requires-Dist: twine>=6.2.0; extra == "dev"
|
|
34
34
|
Requires-Dist: pdm>=2.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.13.0; extra == "dev"
|
|
35
36
|
Description-Content-Type: text/markdown
|
|
36
37
|
|
|
37
38
|
# 🛡️ Check MS Defender
|
|
@@ -294,7 +295,6 @@ source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
|
294
295
|
|
|
295
296
|
# Install in development mode
|
|
296
297
|
pip install -e .
|
|
297
|
-
pip install -r requirements-dev.txt
|
|
298
298
|
```
|
|
299
299
|
|
|
300
300
|
### Code Quality Tools
|
|
@@ -10,7 +10,9 @@ 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)")(
|
|
13
|
+
func = click.option("-m", "--machine-id", "-i", "--id", help="Machine ID (GUID)")(
|
|
14
|
+
func
|
|
15
|
+
)
|
|
14
16
|
func = click.option("-d", "--dns-name", help="Computer DNS Name (FQDN)")(func)
|
|
15
17
|
func = click.option("-W", "--warning", type=float, help="Warning threshold")(func)
|
|
16
18
|
func = click.option("-C", "--critical", type=float, help="Critical threshold")(func)
|
|
@@ -20,7 +20,9 @@ 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(
|
|
23
|
+
raise ConfigurationError(
|
|
24
|
+
"client_id and tenant_id are required in [auth] section"
|
|
25
|
+
)
|
|
24
26
|
|
|
25
27
|
# Check for client secret authentication
|
|
26
28
|
client_secret = auth_section.get("client_secret")
|
|
@@ -13,7 +13,11 @@ class DefenderClient:
|
|
|
13
13
|
application_json = "application/json"
|
|
14
14
|
|
|
15
15
|
def __init__(
|
|
16
|
-
self,
|
|
16
|
+
self,
|
|
17
|
+
authenticator: Any,
|
|
18
|
+
timeout: int = 5,
|
|
19
|
+
region: str = "eu",
|
|
20
|
+
verbose_level: int = 0,
|
|
17
21
|
) -> None:
|
|
18
22
|
"""Initialize with authenticator and optional region.
|
|
19
23
|
|
|
@@ -32,12 +36,12 @@ class DefenderClient:
|
|
|
32
36
|
def _get_base_url(self, region: str) -> str:
|
|
33
37
|
"""Get base URL for the specified region."""
|
|
34
38
|
endpoints = {
|
|
35
|
-
"eu": "https://
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
+
"eu": "https://eu.api.security.microsoft.com",
|
|
40
|
+
"us": "https://us.api.security.microsoft.com",
|
|
41
|
+
"uk": "https://uk.api.security.microsoft.com",
|
|
42
|
+
"api": "https://api.security.microsoft.com",
|
|
39
43
|
}
|
|
40
|
-
return endpoints.get(region, endpoints["
|
|
44
|
+
return endpoints.get(region, endpoints["eu"])
|
|
41
45
|
|
|
42
46
|
def get_machine_by_dns_name(self, dns_name: str) -> Dict[str, Any]:
|
|
43
47
|
"""Get machine information by DNS name."""
|
|
@@ -56,7 +60,9 @@ class DefenderClient:
|
|
|
56
60
|
try:
|
|
57
61
|
start_time = time.time()
|
|
58
62
|
self.logger.info(f"Querying machine by DNS name: {dns_name}")
|
|
59
|
-
response = requests.get(
|
|
63
|
+
response = requests.get(
|
|
64
|
+
url, headers=headers, params=params, timeout=self.timeout
|
|
65
|
+
)
|
|
60
66
|
elapsed = time.time() - start_time
|
|
61
67
|
|
|
62
68
|
self.logger.api_call("GET", url, response.status_code, elapsed)
|
|
@@ -151,7 +157,9 @@ class DefenderClient:
|
|
|
151
157
|
try:
|
|
152
158
|
start_time = time.time()
|
|
153
159
|
self.logger.info("Querying all machines")
|
|
154
|
-
response = requests.get(
|
|
160
|
+
response = requests.get(
|
|
161
|
+
url, headers=headers, params=params, timeout=self.timeout
|
|
162
|
+
)
|
|
155
163
|
elapsed = time.time() - start_time
|
|
156
164
|
|
|
157
165
|
self.logger.api_call("GET", url, response.status_code, elapsed)
|
|
@@ -189,7 +197,9 @@ class DefenderClient:
|
|
|
189
197
|
try:
|
|
190
198
|
start_time = time.time()
|
|
191
199
|
self.logger.info("Querying alerts")
|
|
192
|
-
response = requests.get(
|
|
200
|
+
response = requests.get(
|
|
201
|
+
url, headers=headers, params=params, timeout=self.timeout
|
|
202
|
+
)
|
|
193
203
|
elapsed = time.time() - start_time
|
|
194
204
|
|
|
195
205
|
self.logger.api_call("GET", url, response.status_code, elapsed)
|
|
@@ -36,7 +36,8 @@ class VerboseLogger:
|
|
|
36
36
|
if self.verbose_level >= 3:
|
|
37
37
|
# Full trace format
|
|
38
38
|
formatter = logging.Formatter(
|
|
39
|
-
"[%(levelname)s] %(asctime)s %(name)s:%(lineno)d - %(message)s",
|
|
39
|
+
"[%(levelname)s] %(asctime)s %(name)s:%(lineno)d - %(message)s",
|
|
40
|
+
datefmt="%H:%M:%S",
|
|
40
41
|
)
|
|
41
42
|
elif self.verbose_level >= 2:
|
|
42
43
|
# Debug format
|
|
@@ -81,7 +82,9 @@ class VerboseLogger:
|
|
|
81
82
|
"""Log API call details if verbose >= 2."""
|
|
82
83
|
if self.verbose_level >= 2:
|
|
83
84
|
if status_code and response_time:
|
|
84
|
-
self.logger.debug(
|
|
85
|
+
self.logger.debug(
|
|
86
|
+
f"API {method} {url} -> {status_code} ({response_time:.3f}s)"
|
|
87
|
+
)
|
|
85
88
|
else:
|
|
86
89
|
self.logger.debug(f"API {method} {url}")
|
|
87
90
|
|
|
@@ -128,7 +128,9 @@ class NagiosPlugin:
|
|
|
128
128
|
|
|
129
129
|
# Create Nagios check with custom summary
|
|
130
130
|
# Use 'found' as context name for detail command, otherwise use command name
|
|
131
|
-
context_name =
|
|
131
|
+
context_name = (
|
|
132
|
+
"found" if self.command_name == "detail" else self.command_name
|
|
133
|
+
)
|
|
132
134
|
check = nagiosplugin.Check(
|
|
133
135
|
DefenderResource(self.command_name, value),
|
|
134
136
|
DefenderScalarContext(context_name, warning, critical),
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/alerts_service.py
RENAMED
|
@@ -53,12 +53,18 @@ class AlertsService:
|
|
|
53
53
|
or alert.get("computerDnsName") == target_dns_name
|
|
54
54
|
]
|
|
55
55
|
|
|
56
|
-
self.logger.info(
|
|
56
|
+
self.logger.info(
|
|
57
|
+
f"Found {len(machine_alerts)} alerts for machine {target_dns_name}"
|
|
58
|
+
)
|
|
57
59
|
|
|
58
60
|
# Categorize alerts by status and severity
|
|
59
|
-
unresolved_alerts = [
|
|
61
|
+
unresolved_alerts = [
|
|
62
|
+
alert for alert in machine_alerts if alert.get("status") != "Resolved"
|
|
63
|
+
]
|
|
60
64
|
informational_alerts = [
|
|
61
|
-
alert
|
|
65
|
+
alert
|
|
66
|
+
for alert in unresolved_alerts
|
|
67
|
+
if alert.get("severity") == "Informational"
|
|
62
68
|
]
|
|
63
69
|
critical_warning_alerts = [
|
|
64
70
|
alert
|
|
@@ -82,7 +88,9 @@ class AlertsService:
|
|
|
82
88
|
title = alert.get("title", "Unknown alert")
|
|
83
89
|
status = alert.get("status", "Unknown")
|
|
84
90
|
severity = alert.get("severity", "Unknown")
|
|
85
|
-
details.append(
|
|
91
|
+
details.append(
|
|
92
|
+
f"{creation_time} - {title} ({status} {severity.lower()})"
|
|
93
|
+
)
|
|
86
94
|
|
|
87
95
|
# Return the number of unresolved alerts as the value
|
|
88
96
|
# This will be used by Nagios plugin for determining status based on thresholds
|
|
@@ -93,6 +101,8 @@ class AlertsService:
|
|
|
93
101
|
"details": details,
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
self.logger.info(
|
|
104
|
+
self.logger.info(
|
|
105
|
+
f"Alert analysis complete: {len(unresolved_alerts)} unresolved alerts"
|
|
106
|
+
)
|
|
97
107
|
self.logger.method_exit("get_result", result)
|
|
98
108
|
return result
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/detail_service.py
RENAMED
|
@@ -54,10 +54,16 @@ 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
|
-
|
|
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
|
+
)
|
|
59
63
|
details.append(f"OS Version: {machine_details.get('osVersion', 'Unknown')}")
|
|
60
|
-
details.append(
|
|
64
|
+
details.append(
|
|
65
|
+
f"Health Status: {machine_details.get('healthStatus', 'Unknown')}"
|
|
66
|
+
)
|
|
61
67
|
details.append(f"Risk Score: {machine_details.get('riskScore', 'Unknown')}")
|
|
62
68
|
|
|
63
69
|
result = {"value": 1, "details": details}
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/lastseen_service.py
RENAMED
|
@@ -62,7 +62,9 @@ class LastSeenService:
|
|
|
62
62
|
|
|
63
63
|
result = {"value": days_diff, "details": details}
|
|
64
64
|
|
|
65
|
-
self.logger.info(
|
|
65
|
+
self.logger.info(
|
|
66
|
+
f"Machine last seen {days_diff} days ago ({last_seen_str})"
|
|
67
|
+
)
|
|
66
68
|
self.logger.method_exit("get_result", result)
|
|
67
69
|
return result
|
|
68
70
|
except (ValueError, TypeError) as e:
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/machines_service.py
RENAMED
|
@@ -23,7 +23,10 @@ class MachinesService:
|
|
|
23
23
|
|
|
24
24
|
if not machines_data.get("value"):
|
|
25
25
|
self.logger.info("No machines found")
|
|
26
|
-
result = {
|
|
26
|
+
result = {
|
|
27
|
+
"value": 0,
|
|
28
|
+
"details": ["No machines found in Microsoft Defender"],
|
|
29
|
+
}
|
|
27
30
|
self.logger.method_exit("get_result", result)
|
|
28
31
|
return result
|
|
29
32
|
|
|
@@ -39,7 +42,11 @@ class MachinesService:
|
|
|
39
42
|
|
|
40
43
|
# Sort by priority
|
|
41
44
|
sorted_machines = sorted(
|
|
42
|
-
machines,
|
|
45
|
+
machines,
|
|
46
|
+
key=lambda x: (
|
|
47
|
+
status_priority[x["onboardingStatus"]],
|
|
48
|
+
x["computerDnsName"],
|
|
49
|
+
),
|
|
43
50
|
)
|
|
44
51
|
for machine in sorted_machines:
|
|
45
52
|
onboarded = "✓" if machine["onboardingStatus"] == "Onboarded" else "✗"
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/check_msdefender/services/onboarding_service.py
RENAMED
|
@@ -54,6 +54,8 @@ class OnboardingService:
|
|
|
54
54
|
|
|
55
55
|
result = {"value": result_value, "details": details}
|
|
56
56
|
|
|
57
|
-
self.logger.info(
|
|
57
|
+
self.logger.info(
|
|
58
|
+
f"Machine onboarding status: {onboarding_state} -> {result_value}"
|
|
59
|
+
)
|
|
58
60
|
self.logger.method_exit("get_result", result)
|
|
59
61
|
return result
|
|
@@ -41,7 +41,9 @@ class VulnerabilitiesService:
|
|
|
41
41
|
|
|
42
42
|
# Process and deduplicate vulnerabilities
|
|
43
43
|
vulnerabilities = self._process_vulnerabilities(raw_vulnerabilities)
|
|
44
|
-
self.logger.info(
|
|
44
|
+
self.logger.info(
|
|
45
|
+
f"Found {len(vulnerabilities)} unique vulnerabilities after deduplication"
|
|
46
|
+
)
|
|
45
47
|
|
|
46
48
|
# Calculate vulnerability score
|
|
47
49
|
score = VulnerabilityScore()
|
|
@@ -54,7 +56,9 @@ class VulnerabilitiesService:
|
|
|
54
56
|
|
|
55
57
|
for vuln in sorted_vulnerabilities:
|
|
56
58
|
severity = vuln.severity.lower()
|
|
57
|
-
self.logger.debug(
|
|
59
|
+
self.logger.debug(
|
|
60
|
+
f"Processing vulnerability {vuln.id} with severity: {severity}"
|
|
61
|
+
)
|
|
58
62
|
|
|
59
63
|
if severity == "critical":
|
|
60
64
|
score.critical += 1
|
|
@@ -75,7 +79,8 @@ class VulnerabilitiesService:
|
|
|
75
79
|
self.logger.info(f"Total vulnerability score: {score.total_score}")
|
|
76
80
|
|
|
77
81
|
details.insert(
|
|
78
|
-
0,
|
|
82
|
+
0,
|
|
83
|
+
f"Vulnerabilities: {len(raw_vulnerabilities)}, score: {score.total_score}",
|
|
79
84
|
)
|
|
80
85
|
|
|
81
86
|
result = {"value": score.total_score, "details": details}
|
|
@@ -125,7 +130,9 @@ class VulnerabilitiesService:
|
|
|
125
130
|
# Sort by severity
|
|
126
131
|
sorted_vulnerabilities = self._sort_by_severity(vulnerabilities)
|
|
127
132
|
|
|
128
|
-
self.logger.method_exit(
|
|
133
|
+
self.logger.method_exit(
|
|
134
|
+
"get_detailed_vulnerabilities", len(sorted_vulnerabilities)
|
|
135
|
+
)
|
|
129
136
|
return sorted_vulnerabilities
|
|
130
137
|
|
|
131
138
|
def _process_vulnerabilities(
|
|
@@ -156,8 +163,11 @@ class VulnerabilitiesService:
|
|
|
156
163
|
|
|
157
164
|
return unique_vulnerabilities
|
|
158
165
|
|
|
159
|
-
def _sort_by_severity(
|
|
166
|
+
def _sort_by_severity(
|
|
167
|
+
self, vulnerabilities: List[Vulnerability]
|
|
168
|
+
) -> List[Vulnerability]:
|
|
160
169
|
"""Sort vulnerabilities by severity (Critical > High > Medium > Low)."""
|
|
161
170
|
return sorted(
|
|
162
|
-
vulnerabilities,
|
|
171
|
+
vulnerabilities,
|
|
172
|
+
key=lambda v: self._severity_order.get(v.severity.lower(), 999),
|
|
163
173
|
)
|
|
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "check-msdefender"
|
|
9
|
-
version = "1.1.
|
|
9
|
+
version = "1.1.4"
|
|
10
10
|
authors = [
|
|
11
11
|
{ name = "ldvchosal", email = "ldvchosal@github.com" },
|
|
12
12
|
]
|
|
@@ -52,6 +52,7 @@ dev = [
|
|
|
52
52
|
"mypy>=0.800",
|
|
53
53
|
"twine>=6.2.0",
|
|
54
54
|
"pdm>=2.0.0",
|
|
55
|
+
"ruff>=0.13.0",
|
|
55
56
|
]
|
|
56
57
|
|
|
57
58
|
[project.urls]
|
|
@@ -90,31 +91,27 @@ python_functions = "test_*"
|
|
|
90
91
|
addopts = "-v"
|
|
91
92
|
|
|
92
93
|
[tool.pdm.scripts]
|
|
93
|
-
format = "
|
|
94
|
+
format = "ruff format"
|
|
94
95
|
typecheck = "mypy check_msdefender/"
|
|
95
96
|
lint = "flake8 check_msdefender/"
|
|
96
97
|
build = "python -m build"
|
|
97
98
|
publish = "python -m twine upload dist/* --verbose"
|
|
98
99
|
test = "pytest -v tests/"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
install = "pdm install"
|
|
101
|
+
clihelp = "check_msdefender --help"
|
|
102
|
+
climachines = "check_msdefender machines"
|
|
103
|
+
|
|
104
|
+
[tool.pdm.scripts.cli]
|
|
105
|
+
composite = [
|
|
106
|
+
"clihelp",
|
|
107
|
+
"climachines",
|
|
108
|
+
]
|
|
106
109
|
|
|
107
110
|
[tool.pdm.scripts.all]
|
|
108
111
|
composite = [
|
|
112
|
+
"install",
|
|
109
113
|
"format",
|
|
110
114
|
"build",
|
|
111
115
|
"test",
|
|
112
|
-
"
|
|
113
|
-
"lint",
|
|
114
|
-
]
|
|
115
|
-
|
|
116
|
-
[tool.pdm.scripts.msdall]
|
|
117
|
-
composite = [
|
|
118
|
-
"msdhelp",
|
|
119
|
-
"msdmachines",
|
|
116
|
+
"cli",
|
|
120
117
|
]
|
|
@@ -38,7 +38,9 @@ class MockDefenderClient:
|
|
|
38
38
|
|
|
39
39
|
def get_machine_vulnerabilities(self, machine_id):
|
|
40
40
|
"""Get vulnerabilities for machine from fixtures."""
|
|
41
|
-
return self.vulnerability_data["vulnerabilities_by_machine"].get(
|
|
41
|
+
return self.vulnerability_data["vulnerabilities_by_machine"].get(
|
|
42
|
+
machine_id, {"value": []}
|
|
43
|
+
)
|
|
42
44
|
|
|
43
45
|
def get_alerts(self):
|
|
44
46
|
"""Get all alerts from fixtures."""
|
|
@@ -67,7 +67,9 @@ class TestAlertsServiceFixtures:
|
|
|
67
67
|
|
|
68
68
|
def test_get_result_no_parameters(self):
|
|
69
69
|
"""Test error when no parameters provided."""
|
|
70
|
-
with pytest.raises(
|
|
70
|
+
with pytest.raises(
|
|
71
|
+
ValidationError, match="Either machine_id or dns_name must be provided"
|
|
72
|
+
):
|
|
71
73
|
self.service.get_result()
|
|
72
74
|
|
|
73
75
|
def test_get_result_nonexistent_dns_name(self):
|
|
@@ -135,7 +137,8 @@ class TestAlertsServiceFixtures:
|
|
|
135
137
|
# Should include alert titles and severity/status
|
|
136
138
|
assert "suspicious activity detected (new high)" in details_text.lower()
|
|
137
139
|
assert (
|
|
138
|
-
"automated investigation started manually (new informational)"
|
|
140
|
+
"automated investigation started manually (new informational)"
|
|
141
|
+
in details_text.lower()
|
|
139
142
|
)
|
|
140
143
|
|
|
141
144
|
def test_dns_name_matching(self):
|
|
@@ -56,7 +56,9 @@ class TestDetailServiceFixtures:
|
|
|
56
56
|
|
|
57
57
|
def test_get_result_no_parameters(self):
|
|
58
58
|
"""Test error when no parameters provided."""
|
|
59
|
-
with pytest.raises(
|
|
59
|
+
with pytest.raises(
|
|
60
|
+
ValidationError, match="Either machine_id or dns_name must be provided"
|
|
61
|
+
):
|
|
60
62
|
self.service.get_result()
|
|
61
63
|
|
|
62
64
|
def test_get_result_nonexistent_dns_name(self):
|
|
@@ -70,7 +72,9 @@ class TestDetailServiceFixtures:
|
|
|
70
72
|
|
|
71
73
|
def test_get_result_nonexistent_machine_id(self):
|
|
72
74
|
"""Test error when machine ID doesn't exist."""
|
|
73
|
-
with pytest.raises(
|
|
75
|
+
with pytest.raises(
|
|
76
|
+
ValidationError, match="Machine not found: nonexistent-machine"
|
|
77
|
+
):
|
|
74
78
|
self.service.get_result(machine_id="nonexistent-machine")
|
|
75
79
|
|
|
76
80
|
def test_machine_details_comprehensive(self):
|
|
@@ -96,7 +100,9 @@ class TestDetailServiceFixtures:
|
|
|
96
100
|
]
|
|
97
101
|
|
|
98
102
|
for field in expected_fields:
|
|
99
|
-
assert field in details_dict,
|
|
103
|
+
assert field in details_dict, (
|
|
104
|
+
f"Field '{field}' missing from machine details"
|
|
105
|
+
)
|
|
100
106
|
|
|
101
107
|
# Verify specific values for test-machine-3
|
|
102
108
|
assert details_dict["id"] == "test-machine-3"
|
|
@@ -44,7 +44,9 @@ class TestLastSeenServiceFixtures:
|
|
|
44
44
|
|
|
45
45
|
def test_get_result_no_parameters(self):
|
|
46
46
|
"""Test error when no parameters provided."""
|
|
47
|
-
with pytest.raises(
|
|
47
|
+
with pytest.raises(
|
|
48
|
+
ValidationError, match="Either machine_id or dns_name must be provided"
|
|
49
|
+
):
|
|
48
50
|
self.service.get_result()
|
|
49
51
|
|
|
50
52
|
def test_get_result_nonexistent_dns_name(self):
|
|
@@ -43,7 +43,9 @@ class TestOnboardingServiceFixtures:
|
|
|
43
43
|
|
|
44
44
|
def test_get_result_no_parameters(self):
|
|
45
45
|
"""Test error when no parameters provided."""
|
|
46
|
-
with pytest.raises(
|
|
46
|
+
with pytest.raises(
|
|
47
|
+
ValidationError, match="Either machine_id or dns_name must be provided"
|
|
48
|
+
):
|
|
47
49
|
self.service.get_result()
|
|
48
50
|
|
|
49
51
|
def test_get_result_nonexistent_dns_name(self):
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/fixtures/test_vulnerabilities_service.py
RENAMED
|
@@ -41,7 +41,9 @@ class TestVulnerabilitiesServiceFixtures:
|
|
|
41
41
|
|
|
42
42
|
def test_get_result_no_parameters(self):
|
|
43
43
|
"""Test error when no parameters provided."""
|
|
44
|
-
with pytest.raises(
|
|
44
|
+
with pytest.raises(
|
|
45
|
+
ValidationError, match="Either machine_id or dns_name must be provided"
|
|
46
|
+
):
|
|
45
47
|
self.service.get_result()
|
|
46
48
|
|
|
47
49
|
def test_get_result_nonexistent_dns_name(self):
|
|
@@ -21,7 +21,10 @@ class TestHelpCommand:
|
|
|
21
21
|
result = cli_runner.invoke(main, ["--help"])
|
|
22
22
|
|
|
23
23
|
assert result.exit_code == 0
|
|
24
|
-
assert
|
|
24
|
+
assert (
|
|
25
|
+
"Check Microsoft Defender API endpoints and validate values."
|
|
26
|
+
in result.output
|
|
27
|
+
)
|
|
25
28
|
assert "Commands:" in result.output
|
|
26
29
|
assert "lastseen" in result.output
|
|
27
30
|
assert "onboarding" in result.output
|
|
@@ -33,7 +36,10 @@ class TestHelpCommand:
|
|
|
33
36
|
result = cli_runner.invoke(main, ["--help"])
|
|
34
37
|
|
|
35
38
|
assert result.exit_code == 0
|
|
36
|
-
assert
|
|
39
|
+
assert (
|
|
40
|
+
"Check Microsoft Defender API endpoints and validate values."
|
|
41
|
+
in result.output
|
|
42
|
+
)
|
|
37
43
|
|
|
38
44
|
|
|
39
45
|
class TestLastSeenCommand:
|
|
@@ -88,7 +94,11 @@ class TestLastSeenCommand:
|
|
|
88
94
|
|
|
89
95
|
assert result.exit_code == 0
|
|
90
96
|
mock_plugin.check.assert_called_once_with(
|
|
91
|
-
machine_id=None,
|
|
97
|
+
machine_id=None,
|
|
98
|
+
dns_name="machine.domain.tld",
|
|
99
|
+
warning=7,
|
|
100
|
+
critical=30,
|
|
101
|
+
verbose=0,
|
|
92
102
|
)
|
|
93
103
|
|
|
94
104
|
@patch("check_msdefender.cli.commands.lastseen.load_config")
|
|
@@ -129,7 +139,11 @@ class TestOnboardingCommand:
|
|
|
129
139
|
|
|
130
140
|
assert result.exit_code == 0
|
|
131
141
|
mock_plugin.check.assert_called_once_with(
|
|
132
|
-
machine_id=None,
|
|
142
|
+
machine_id=None,
|
|
143
|
+
dns_name="machine.domain.tld",
|
|
144
|
+
warning=1,
|
|
145
|
+
critical=2,
|
|
146
|
+
verbose=0,
|
|
133
147
|
)
|
|
134
148
|
|
|
135
149
|
@patch("check_msdefender.cli.commands.onboarding.load_config")
|
|
@@ -165,11 +179,17 @@ class TestVulnerabilitiesCommand:
|
|
|
165
179
|
mock_nagios.return_value = mock_plugin
|
|
166
180
|
mock_plugin.check.return_value = 0
|
|
167
181
|
|
|
168
|
-
result = cli_runner.invoke(
|
|
182
|
+
result = cli_runner.invoke(
|
|
183
|
+
main, ["vulnerabilities", "-d", "machine.domain.tld"]
|
|
184
|
+
)
|
|
169
185
|
|
|
170
186
|
assert result.exit_code == 0
|
|
171
187
|
mock_plugin.check.assert_called_once_with(
|
|
172
|
-
machine_id=None,
|
|
188
|
+
machine_id=None,
|
|
189
|
+
dns_name="machine.domain.tld",
|
|
190
|
+
warning=10,
|
|
191
|
+
critical=100,
|
|
192
|
+
verbose=0,
|
|
173
193
|
)
|
|
174
194
|
|
|
175
195
|
@patch("check_msdefender.cli.commands.vulnerabilities.load_config")
|
|
@@ -191,11 +211,17 @@ class TestVulnerabilitiesCommand:
|
|
|
191
211
|
mock_nagios.return_value = mock_plugin
|
|
192
212
|
mock_plugin.check.return_value = 0
|
|
193
213
|
|
|
194
|
-
result = cli_runner.invoke(
|
|
214
|
+
result = cli_runner.invoke(
|
|
215
|
+
main, ["vulnerabilities", "-d", "machine.domain.tld", "-v"]
|
|
216
|
+
)
|
|
195
217
|
|
|
196
218
|
assert result.exit_code == 0
|
|
197
219
|
mock_plugin.check.assert_called_once_with(
|
|
198
|
-
machine_id=None,
|
|
220
|
+
machine_id=None,
|
|
221
|
+
dns_name="machine.domain.tld",
|
|
222
|
+
warning=10,
|
|
223
|
+
critical=100,
|
|
224
|
+
verbose=1,
|
|
199
225
|
)
|
|
200
226
|
|
|
201
227
|
@patch("check_msdefender.cli.commands.vulnerabilities.load_config")
|
|
@@ -217,11 +243,17 @@ class TestVulnerabilitiesCommand:
|
|
|
217
243
|
mock_nagios.return_value = mock_plugin
|
|
218
244
|
mock_plugin.check.return_value = 0
|
|
219
245
|
|
|
220
|
-
result = cli_runner.invoke(
|
|
246
|
+
result = cli_runner.invoke(
|
|
247
|
+
main, ["vulnerabilities", "-d", "machine.domain.tld", "-vvvv"]
|
|
248
|
+
)
|
|
221
249
|
|
|
222
250
|
assert result.exit_code == 0
|
|
223
251
|
mock_plugin.check.assert_called_once_with(
|
|
224
|
-
machine_id=None,
|
|
252
|
+
machine_id=None,
|
|
253
|
+
dns_name="machine.domain.tld",
|
|
254
|
+
warning=10,
|
|
255
|
+
critical=100,
|
|
256
|
+
verbose=4,
|
|
225
257
|
)
|
|
226
258
|
|
|
227
259
|
@patch("check_msdefender.cli.commands.vulnerabilities.load_config")
|
|
@@ -229,7 +261,9 @@ class TestVulnerabilitiesCommand:
|
|
|
229
261
|
"""Test vulnerabilities command error handling."""
|
|
230
262
|
mock_config.side_effect = Exception("Service unavailable")
|
|
231
263
|
|
|
232
|
-
result = cli_runner.invoke(
|
|
264
|
+
result = cli_runner.invoke(
|
|
265
|
+
main, ["vulnerabilities", "-d", "machine.domain.tld"]
|
|
266
|
+
)
|
|
233
267
|
|
|
234
268
|
assert result.exit_code == 3
|
|
235
269
|
assert "UNKNOWN: Service unavailable" in result.output
|
|
@@ -257,7 +291,9 @@ class TestDetailCommand:
|
|
|
257
291
|
"value": 1,
|
|
258
292
|
"details": ["Machine ID: test-machine", "Computer Name: test-pc"],
|
|
259
293
|
}
|
|
260
|
-
mock_service_instance.get_machine_details_json.return_value =
|
|
294
|
+
mock_service_instance.get_machine_details_json.return_value = (
|
|
295
|
+
'{"id": "test-machine"}'
|
|
296
|
+
)
|
|
261
297
|
|
|
262
298
|
result = cli_runner.invoke(main, ["detail", "-i", "test-machine-123"])
|
|
263
299
|
|
|
@@ -287,7 +323,9 @@ class TestDetailCommand:
|
|
|
287
323
|
"value": 1,
|
|
288
324
|
"details": ["Machine ID: test-machine", "Computer Name: test-pc"],
|
|
289
325
|
}
|
|
290
|
-
mock_service_instance.get_machine_details_json.return_value =
|
|
326
|
+
mock_service_instance.get_machine_details_json.return_value = (
|
|
327
|
+
'{"id": "test-machine"}'
|
|
328
|
+
)
|
|
291
329
|
|
|
292
330
|
result = cli_runner.invoke(main, ["detail", "-m", "test-machine-456"])
|
|
293
331
|
|
|
@@ -349,7 +387,9 @@ class TestDetailCommand:
|
|
|
349
387
|
"details": ["Machine not found with DNS name: nonexistent.domain.com"],
|
|
350
388
|
}
|
|
351
389
|
|
|
352
|
-
result = cli_runner.invoke(
|
|
390
|
+
result = cli_runner.invoke(
|
|
391
|
+
main, ["detail", "-d", "nonexistent.domain.com", "-W", "0"]
|
|
392
|
+
)
|
|
353
393
|
|
|
354
394
|
assert result.exit_code == 1 # Warning
|
|
355
395
|
assert "DEFENDER WARNING - Machine not found" in result.output
|
|
@@ -375,7 +415,9 @@ class TestDetailCommand:
|
|
|
375
415
|
"details": ["Machine not found with DNS name: nonexistent.domain.com"],
|
|
376
416
|
}
|
|
377
417
|
|
|
378
|
-
result = cli_runner.invoke(
|
|
418
|
+
result = cli_runner.invoke(
|
|
419
|
+
main, ["detail", "-d", "nonexistent.domain.com", "-C", "0"]
|
|
420
|
+
)
|
|
379
421
|
|
|
380
422
|
assert result.exit_code == 2 # Critical
|
|
381
423
|
assert "DEFENDER CRITICAL - Machine not found" in result.output
|
|
@@ -396,6 +438,8 @@ class TestDetailCommand:
|
|
|
396
438
|
result = cli_runner.invoke(main, ["detail", "--help"])
|
|
397
439
|
|
|
398
440
|
assert result.exit_code == 0
|
|
399
|
-
assert
|
|
441
|
+
assert (
|
|
442
|
+
"Get detailed machine information from Microsoft Defender." in result.output
|
|
443
|
+
)
|
|
400
444
|
assert "-m, -i, --machine-id, --id TEXT" in result.output
|
|
401
445
|
assert "-d, --dns-name TEXT" in result.output
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/tests/integration/test_lastseen_integration.py
RENAMED
|
@@ -48,14 +48,26 @@ def test_lastseen_command_success(
|
|
|
48
48
|
# Execute command
|
|
49
49
|
result = cli_runner.invoke(
|
|
50
50
|
mock_app,
|
|
51
|
-
[
|
|
51
|
+
[
|
|
52
|
+
"lastseen",
|
|
53
|
+
"--machine-id",
|
|
54
|
+
"test-machine-id",
|
|
55
|
+
"--warning",
|
|
56
|
+
"5",
|
|
57
|
+
"--critical",
|
|
58
|
+
"10",
|
|
59
|
+
],
|
|
52
60
|
)
|
|
53
61
|
|
|
54
62
|
# Verify result
|
|
55
63
|
assert result.exit_code == 0
|
|
56
64
|
mock_nagios.assert_called_once_with(mock_service_instance, "lastseen")
|
|
57
65
|
mock_plugin.check.assert_called_once_with(
|
|
58
|
-
machine_id="test-machine-id",
|
|
66
|
+
machine_id="test-machine-id",
|
|
67
|
+
dns_name=None,
|
|
68
|
+
warning=5,
|
|
69
|
+
critical=10,
|
|
70
|
+
verbose=False,
|
|
59
71
|
)
|
|
60
72
|
|
|
61
73
|
|
|
@@ -66,7 +78,9 @@ def test_lastseen_command_exception(mock_config, cli_runner, mock_app):
|
|
|
66
78
|
mock_config.side_effect = Exception("Test error")
|
|
67
79
|
|
|
68
80
|
# Execute command
|
|
69
|
-
result = cli_runner.invoke(
|
|
81
|
+
result = cli_runner.invoke(
|
|
82
|
+
mock_app, ["lastseen", "--machine-id", "test-machine-id"]
|
|
83
|
+
)
|
|
70
84
|
|
|
71
85
|
# Verify error handling
|
|
72
86
|
assert result.exit_code == 3 # Now properly returns exit code 3 for UNKNOWN
|
|
@@ -101,7 +101,9 @@ class TestAlertsService:
|
|
|
101
101
|
assert len(result["details"]) == 2 # Summary line + 1 alert detail
|
|
102
102
|
|
|
103
103
|
# Should call DNS lookup
|
|
104
|
-
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
104
|
+
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
105
|
+
"test.example.com"
|
|
106
|
+
)
|
|
105
107
|
self.mock_client.get_alerts.assert_called_once()
|
|
106
108
|
|
|
107
109
|
def test_get_result_by_dns_name_not_found(self):
|
|
@@ -113,12 +115,16 @@ class TestAlertsService:
|
|
|
113
115
|
with pytest.raises(ValidationError, match="Machine not found with DNS name"):
|
|
114
116
|
self.service.get_result(dns_name="nonexistent.domain.com")
|
|
115
117
|
|
|
116
|
-
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
118
|
+
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
119
|
+
"nonexistent.domain.com"
|
|
120
|
+
)
|
|
117
121
|
self.mock_client.get_alerts.assert_not_called()
|
|
118
122
|
|
|
119
123
|
def test_get_result_no_parameters(self):
|
|
120
124
|
"""Test error when no parameters provided."""
|
|
121
|
-
with pytest.raises(
|
|
125
|
+
with pytest.raises(
|
|
126
|
+
ValidationError, match="Either machine_id or dns_name must be provided"
|
|
127
|
+
):
|
|
122
128
|
self.service.get_result()
|
|
123
129
|
|
|
124
130
|
self.mock_client.get_machine_by_dns_name.assert_not_called()
|
|
@@ -49,7 +49,10 @@ class TestDetailService:
|
|
|
49
49
|
"""Test successful retrieval by DNS name."""
|
|
50
50
|
# Mock DNS lookup response
|
|
51
51
|
mock_dns_response = {"value": [{"id": "test-machine-456"}]}
|
|
52
|
-
mock_machine_data = {
|
|
52
|
+
mock_machine_data = {
|
|
53
|
+
"id": "test-machine-456",
|
|
54
|
+
"computerDnsName": "test.domain.com",
|
|
55
|
+
}
|
|
53
56
|
|
|
54
57
|
self.mock_client.get_machine_by_dns_name.return_value = mock_dns_response
|
|
55
58
|
self.mock_client.get_machine_by_id.return_value = mock_machine_data
|
|
@@ -60,7 +63,9 @@ class TestDetailService:
|
|
|
60
63
|
assert result["value"] == 1
|
|
61
64
|
|
|
62
65
|
# Should call both DNS lookup and machine ID retrieval
|
|
63
|
-
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
66
|
+
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
67
|
+
"test.domain.com"
|
|
68
|
+
)
|
|
64
69
|
self.mock_client.get_machine_by_id.assert_called_once_with("test-machine-456")
|
|
65
70
|
|
|
66
71
|
def test_get_result_by_dns_name_not_found(self):
|
|
@@ -75,12 +80,16 @@ class TestDetailService:
|
|
|
75
80
|
assert result["value"] == 0
|
|
76
81
|
|
|
77
82
|
# Should not call get_machine_by_id since DNS lookup failed
|
|
78
|
-
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
83
|
+
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
84
|
+
"nonexistent.domain.com"
|
|
85
|
+
)
|
|
79
86
|
self.mock_client.get_machine_by_id.assert_not_called()
|
|
80
87
|
|
|
81
88
|
def test_get_result_no_parameters(self):
|
|
82
89
|
"""Test error when no parameters provided."""
|
|
83
|
-
with pytest.raises(
|
|
90
|
+
with pytest.raises(
|
|
91
|
+
ValidationError, match="Either machine_id or dns_name must be provided"
|
|
92
|
+
):
|
|
84
93
|
self.service.get_result()
|
|
85
94
|
|
|
86
95
|
# Should not make any API calls
|
|
@@ -152,9 +161,15 @@ class TestDetailService:
|
|
|
152
161
|
self.mock_client.get_machine_by_id.return_value = mock_machine_data
|
|
153
162
|
|
|
154
163
|
# Call with both parameters - DNS name should be used first
|
|
155
|
-
result = self.service.get_result(
|
|
164
|
+
result = self.service.get_result(
|
|
165
|
+
machine_id="direct-machine", dns_name="test.domain.com"
|
|
166
|
+
)
|
|
156
167
|
|
|
157
168
|
assert result["value"] == 1
|
|
158
169
|
# Should resolve via DNS first
|
|
159
|
-
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
160
|
-
|
|
170
|
+
self.mock_client.get_machine_by_dns_name.assert_called_once_with(
|
|
171
|
+
"test.domain.com"
|
|
172
|
+
)
|
|
173
|
+
self.mock_client.get_machine_by_id.assert_called_once_with(
|
|
174
|
+
"dns-resolved-machine"
|
|
175
|
+
)
|
|
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.2 → check_msdefender-1.1.4}/check_msdefender/cli/commands/onboarding.py
RENAMED
|
File without changes
|
{check_msdefender-1.1.2 → check_msdefender-1.1.4}/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
|