plexavo 0.2.7__tar.gz → 0.2.9__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.
- {plexavo-0.2.7 → plexavo-0.2.9}/PKG-INFO +7 -1
- {plexavo-0.2.7 → plexavo-0.2.9}/README.md +6 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/__init__.py +1 -1
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/storage.py +11 -5
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/cli.py +56 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/findings.py +13 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/interactive.py +1 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/PKG-INFO +7 -1
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/SOURCES.txt +1 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/pyproject.toml +1 -1
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_cli_entrypoint.py +5 -0
- plexavo-0.2.9/tests/test_fail_on_offline.py +68 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/LICENSE +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/__main__.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/auth.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/aws_profile_setup.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/__init__.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/encryption.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/iam.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/iam_hygiene.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/logging.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/network.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/usage.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/principals.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/__init__.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/ai_narration.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/html_report.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/pdf.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/templates/report.html.j2 +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/scoring.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/dependency_links.txt +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/entry_points.txt +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/requires.txt +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/top_level.txt +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/setup.cfg +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_ai_narration_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_auth_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_encryption_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_iam_hygiene_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_iam_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_logging_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_network_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_report_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_scoring.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_storage_offline.py +0 -0
- {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_usage_offline.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexavo
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Open-source AWS misconfiguration scanner — runs with your own local AWS credentials, nothing sent to anyone else.
|
|
5
5
|
Author: Kavee
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
@@ -134,6 +134,12 @@ python -m plexavo # Windows Option 2
|
|
|
134
134
|
<img src="assets/screenshot-cli.png" alt="Plexavo interactive CLI" width="700">
|
|
135
135
|
</div>
|
|
136
136
|
|
|
137
|
+
### Running it on a schedule
|
|
138
|
+
|
|
139
|
+
Plexavo can also run unattended from cron or a GitHub Actions workflow,
|
|
140
|
+
and flag a run when something regresses so you get notified without
|
|
141
|
+
opening a report. See [`docs/automation.md`](docs/automation.md).
|
|
142
|
+
|
|
137
143
|
## The report
|
|
138
144
|
|
|
139
145
|
Reports are generated as HTML, PDF, or both. Every finding gets a free,
|
|
@@ -105,6 +105,12 @@ python -m plexavo # Windows Option 2
|
|
|
105
105
|
<img src="assets/screenshot-cli.png" alt="Plexavo interactive CLI" width="700">
|
|
106
106
|
</div>
|
|
107
107
|
|
|
108
|
+
### Running it on a schedule
|
|
109
|
+
|
|
110
|
+
Plexavo can also run unattended from cron or a GitHub Actions workflow,
|
|
111
|
+
and flag a run when something regresses so you get notified without
|
|
112
|
+
opening a report. See [`docs/automation.md`](docs/automation.md).
|
|
113
|
+
|
|
108
114
|
## The report
|
|
109
115
|
|
|
110
116
|
Reports are generated as HTML, PDF, or both. Every finding gets a free,
|
|
@@ -139,7 +139,10 @@ def check_22_access_logging_disabled(s3, bucket_names: list) -> list[Finding]:
|
|
|
139
139
|
|
|
140
140
|
Medium, not Critical: this is a forensics gap, not an exposure by
|
|
141
141
|
itself — a bucket with logging off isn't more reachable than one with
|
|
142
|
-
it on
|
|
142
|
+
it on. Whether object-level access is actually unreviewable after the
|
|
143
|
+
fact depends on whether it's captured another way (e.g. CloudTrail S3
|
|
144
|
+
data events), which Plexavo does not check — so the finding language
|
|
145
|
+
stays careful not to claim investigation is impossible."""
|
|
143
146
|
findings = []
|
|
144
147
|
for name in bucket_names:
|
|
145
148
|
config = s3.get_bucket_logging(Bucket=name)
|
|
@@ -150,10 +153,13 @@ def check_22_access_logging_disabled(s3, bucket_names: list) -> list[Finding]:
|
|
|
150
153
|
title="S3 Bucket Access Logging Not Enabled",
|
|
151
154
|
severity=Severity.MEDIUM,
|
|
152
155
|
resource_arn=f"arn:aws:s3:::{name}",
|
|
153
|
-
raw_detail=f"Bucket '{name}' has no server access logging configured. "
|
|
154
|
-
f"
|
|
155
|
-
f"
|
|
156
|
-
f"after the fact
|
|
156
|
+
raw_detail=f"Bucket '{name}' has no S3 server access logging configured. "
|
|
157
|
+
f"Server access logging records object-level requests to the "
|
|
158
|
+
f"bucket, which supports reconstructing who accessed or changed "
|
|
159
|
+
f"what after the fact. If this bucket's access is already "
|
|
160
|
+
f"tightly scoped and audited another way (for example CloudTrail "
|
|
161
|
+
f"S3 data events, which Plexavo does not check), this finding may "
|
|
162
|
+
f"be lower priority for that specific setup.",
|
|
157
163
|
account_context=f"bucket={name}",
|
|
158
164
|
))
|
|
159
165
|
return findings
|
|
@@ -16,6 +16,7 @@ import os
|
|
|
16
16
|
import random
|
|
17
17
|
import sys
|
|
18
18
|
import time
|
|
19
|
+
from collections import Counter
|
|
19
20
|
from contextlib import nullcontext
|
|
20
21
|
|
|
21
22
|
from rich.console import Console
|
|
@@ -32,6 +33,7 @@ from plexavo.checks import storage as storage_checks
|
|
|
32
33
|
from plexavo.checks import encryption as encryption_checks
|
|
33
34
|
from plexavo.checks import logging as logging_checks
|
|
34
35
|
from plexavo.checks import usage as usage_checks
|
|
36
|
+
from plexavo.findings import Severity
|
|
35
37
|
from plexavo.scoring import calculate_score
|
|
36
38
|
from plexavo.report.ai_narration import explain_finding, COMMON_CHECK_TEMPLATES
|
|
37
39
|
from plexavo.report.html_report import build_report_data, generate_html
|
|
@@ -59,6 +61,49 @@ FLAVOR_WORDS = [
|
|
|
59
61
|
"Scrutinizing bucket policies",
|
|
60
62
|
]
|
|
61
63
|
|
|
64
|
+
# --fail-on keyword -> Severity. One entry per Severity member; the test
|
|
65
|
+
# suite asserts that stays true.
|
|
66
|
+
FAIL_ON_LEVELS = {
|
|
67
|
+
"critical": Severity.CRITICAL,
|
|
68
|
+
"high": Severity.HIGH,
|
|
69
|
+
"medium": Severity.MEDIUM,
|
|
70
|
+
"low": Severity.LOW,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Exit status when --fail-on trips. Deliberately not 1: exit 1 already
|
|
74
|
+
# means "the scan could not run" (bad creds, no region), which a
|
|
75
|
+
# scheduled job wants to treat differently from "the scan ran fine and
|
|
76
|
+
# found something at your threshold".
|
|
77
|
+
FAIL_ON_EXIT_CODE = 2
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _findings_at_or_above(findings, level: str) -> list:
|
|
81
|
+
"""Findings whose severity is at or above `level` (a FAIL_ON_LEVELS key)."""
|
|
82
|
+
threshold = FAIL_ON_LEVELS[level].rank
|
|
83
|
+
return [f for f in findings if f.severity.rank >= threshold]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _apply_fail_on(level, findings) -> None:
|
|
87
|
+
"""When --fail-on is set, exit FAIL_ON_EXIT_CODE if any finding is at
|
|
88
|
+
or above `level`. Called only after any report has been written, so a
|
|
89
|
+
failing run still leaves you the report."""
|
|
90
|
+
if not level:
|
|
91
|
+
return
|
|
92
|
+
triggering = _findings_at_or_above(findings, level)
|
|
93
|
+
label = level.capitalize()
|
|
94
|
+
if not triggering:
|
|
95
|
+
console.print(f"\n[green]--fail-on {level}: no findings at or above {label}.[/green]")
|
|
96
|
+
return
|
|
97
|
+
counts = Counter(f.severity.value for f in triggering)
|
|
98
|
+
breakdown = ", ".join(
|
|
99
|
+
f"{counts[s]} {s}" for s in ("Critical", "High", "Medium", "Low") if counts.get(s)
|
|
100
|
+
)
|
|
101
|
+
console.print(
|
|
102
|
+
f"\n[bold red]--fail-on {level}: {len(triggering)} finding(s) at or above "
|
|
103
|
+
f"{label} ({breakdown}). Exiting with status {FAIL_ON_EXIT_CODE}.[/bold red]"
|
|
104
|
+
)
|
|
105
|
+
sys.exit(FAIL_ON_EXIT_CODE)
|
|
106
|
+
|
|
62
107
|
|
|
63
108
|
def _build_parser() -> argparse.ArgumentParser:
|
|
64
109
|
parser = argparse.ArgumentParser(
|
|
@@ -95,6 +140,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
95
140
|
"--explain replaces that with a full AI narrative for every finding instead.")
|
|
96
141
|
scan.add_argument("--report-pdf", metavar="PATH", default=None,
|
|
97
142
|
help="Write a PDF report to PATH. Same --explain behavior as --report-html.")
|
|
143
|
+
scan.add_argument("--fail-on", choices=["critical", "high", "medium", "low"], default=None,
|
|
144
|
+
help="After the scan, exit with status 2 if any finding is at or above this "
|
|
145
|
+
"severity. Any report is still written first. Meant for scheduled runs: a "
|
|
146
|
+
"non-zero exit makes GitHub Actions or cron flag the run, so a regression "
|
|
147
|
+
"reaches you without opening a report by hand. Exit 1 still means the scan "
|
|
148
|
+
"itself could not run. Off by default. See docs/automation.md.")
|
|
98
149
|
return parser
|
|
99
150
|
|
|
100
151
|
|
|
@@ -178,6 +229,7 @@ def _run_scan(args) -> None:
|
|
|
178
229
|
|
|
179
230
|
if not findings:
|
|
180
231
|
console.print("[green]No findings.[/green]")
|
|
232
|
+
_apply_fail_on(args.fail_on, findings)
|
|
181
233
|
return
|
|
182
234
|
|
|
183
235
|
table = Table(title=f"Findings ({len(findings)})")
|
|
@@ -285,6 +337,10 @@ def _run_scan(args) -> None:
|
|
|
285
337
|
generate_pdf(report_data, args.report_pdf)
|
|
286
338
|
console.print(f"[green]PDF report written to {os.path.abspath(args.report_pdf)}[/green]")
|
|
287
339
|
|
|
340
|
+
# Last thing in the scan: any report is on disk by now, so a non-zero
|
|
341
|
+
# exit here never costs you the report.
|
|
342
|
+
_apply_fail_on(args.fail_on, findings)
|
|
343
|
+
|
|
288
344
|
|
|
289
345
|
def main():
|
|
290
346
|
parser = _build_parser()
|
|
@@ -19,6 +19,19 @@ class Severity(Enum):
|
|
|
19
19
|
Severity.LOW: 1,
|
|
20
20
|
}[self]
|
|
21
21
|
|
|
22
|
+
@property
|
|
23
|
+
def rank(self) -> int:
|
|
24
|
+
"""Ordering for threshold comparisons, Critical highest. Kept
|
|
25
|
+
separate from score_penalty: that one is a scoring weight, this
|
|
26
|
+
one is just an ordinal so `--fail-on` can ask "at or above X".
|
|
27
|
+
"""
|
|
28
|
+
return {
|
|
29
|
+
Severity.CRITICAL: 4,
|
|
30
|
+
Severity.HIGH: 3,
|
|
31
|
+
Severity.MEDIUM: 2,
|
|
32
|
+
Severity.LOW: 1,
|
|
33
|
+
}[self]
|
|
34
|
+
|
|
22
35
|
|
|
23
36
|
@dataclass
|
|
24
37
|
class Finding:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plexavo
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
4
4
|
Summary: Open-source AWS misconfiguration scanner — runs with your own local AWS credentials, nothing sent to anyone else.
|
|
5
5
|
Author: Kavee
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
@@ -134,6 +134,12 @@ python -m plexavo # Windows Option 2
|
|
|
134
134
|
<img src="assets/screenshot-cli.png" alt="Plexavo interactive CLI" width="700">
|
|
135
135
|
</div>
|
|
136
136
|
|
|
137
|
+
### Running it on a schedule
|
|
138
|
+
|
|
139
|
+
Plexavo can also run unattended from cron or a GitHub Actions workflow,
|
|
140
|
+
and flag a run when something regresses so you get notified without
|
|
141
|
+
opening a report. See [`docs/automation.md`](docs/automation.md).
|
|
142
|
+
|
|
137
143
|
## The report
|
|
138
144
|
|
|
139
145
|
Reports are generated as HTML, PDF, or both. Every finding gets a free,
|
|
@@ -38,6 +38,7 @@ tests/test_ai_narration_offline.py
|
|
|
38
38
|
tests/test_auth_offline.py
|
|
39
39
|
tests/test_cli_entrypoint.py
|
|
40
40
|
tests/test_encryption_offline.py
|
|
41
|
+
tests/test_fail_on_offline.py
|
|
41
42
|
tests/test_iam_hygiene_offline.py
|
|
42
43
|
tests/test_iam_offline.py
|
|
43
44
|
tests/test_logging_offline.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "plexavo"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.9"
|
|
8
8
|
description = "Open-source AWS misconfiguration scanner — runs with your own local AWS credentials, nothing sent to anyone else."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "AGPL-3.0-or-later" }
|
|
@@ -47,5 +47,10 @@ assert_true("scan" in helptext.stdout, "help lists the scan subcommand")
|
|
|
47
47
|
assert_true("usage: plexavo" in helptext.stdout,
|
|
48
48
|
"usage line shows 'plexavo', not '__main__.py'")
|
|
49
49
|
|
|
50
|
+
print("\n=== python -m plexavo scan --help documents --fail-on ===")
|
|
51
|
+
scanhelp = run("-m", "plexavo", "scan", "--help")
|
|
52
|
+
assert_true(scanhelp.returncode == 0, f"scan --help exits 0 (got {scanhelp.returncode})")
|
|
53
|
+
assert_true("--fail-on" in scanhelp.stdout, "scan --help lists --fail-on")
|
|
54
|
+
|
|
50
55
|
print(f"\n{'ALL PASSED' if failures == 0 else f'{failures} FAILURE(S)'}")
|
|
51
56
|
sys.exit(1 if failures else 0)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Offline tests for `plexavo scan --fail-on`: the severity threshold that
|
|
2
|
+
makes a scheduled run exit non-zero when something is at or above it.
|
|
3
|
+
|
|
4
|
+
Run: python test_fail_on_offline.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from plexavo.cli import FAIL_ON_LEVELS, FAIL_ON_EXIT_CODE, _findings_at_or_above
|
|
10
|
+
from plexavo.findings import Finding, Severity
|
|
11
|
+
|
|
12
|
+
failures = 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def assert_true(cond, msg):
|
|
16
|
+
global failures
|
|
17
|
+
status = "PASS" if cond else "FAIL"
|
|
18
|
+
print(f"[{status}] {msg}")
|
|
19
|
+
if not cond:
|
|
20
|
+
failures += 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def finding(severity):
|
|
24
|
+
return Finding(
|
|
25
|
+
check_id="TST-01",
|
|
26
|
+
title="test",
|
|
27
|
+
severity=severity,
|
|
28
|
+
resource_arn="arn:aws:iam::123456789012:user/test",
|
|
29
|
+
raw_detail="test finding",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
print("=== Severity.rank ordering ===")
|
|
34
|
+
assert_true(
|
|
35
|
+
Severity.CRITICAL.rank > Severity.HIGH.rank > Severity.MEDIUM.rank > Severity.LOW.rank,
|
|
36
|
+
"Critical > High > Medium > Low",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
print("\n=== FAIL_ON_LEVELS covers every severity, once ===")
|
|
40
|
+
assert_true(
|
|
41
|
+
set(FAIL_ON_LEVELS) == {"critical", "high", "medium", "low"},
|
|
42
|
+
"one --fail-on keyword per Severity member",
|
|
43
|
+
)
|
|
44
|
+
assert_true(FAIL_ON_EXIT_CODE != 0 and FAIL_ON_EXIT_CODE != 1, "trip exit code is non-zero and not 1")
|
|
45
|
+
|
|
46
|
+
print("\n=== _findings_at_or_above ===")
|
|
47
|
+
mixed = [
|
|
48
|
+
finding(Severity.LOW),
|
|
49
|
+
finding(Severity.MEDIUM),
|
|
50
|
+
finding(Severity.HIGH),
|
|
51
|
+
finding(Severity.CRITICAL),
|
|
52
|
+
]
|
|
53
|
+
assert_true(len(_findings_at_or_above(mixed, "high")) == 2,
|
|
54
|
+
"fail-on high matches the High and the Critical")
|
|
55
|
+
assert_true(len(_findings_at_or_above(mixed, "critical")) == 1,
|
|
56
|
+
"fail-on critical matches only the Critical")
|
|
57
|
+
assert_true(len(_findings_at_or_above(mixed, "medium")) == 3,
|
|
58
|
+
"fail-on medium matches Medium, High, Critical")
|
|
59
|
+
assert_true(len(_findings_at_or_above(mixed, "low")) == 4,
|
|
60
|
+
"fail-on low matches everything")
|
|
61
|
+
|
|
62
|
+
assert_true(_findings_at_or_above([], "critical") == [],
|
|
63
|
+
"no findings, nothing to trip on")
|
|
64
|
+
assert_true(_findings_at_or_above([finding(Severity.LOW), finding(Severity.MEDIUM)], "high") == [],
|
|
65
|
+
"only Low/Medium present, fail-on high stays clean")
|
|
66
|
+
|
|
67
|
+
print(f"\n{'ALL PASSED' if failures == 0 else f'{failures} FAILURE(S)'}")
|
|
68
|
+
sys.exit(1 if failures else 0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|