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.
Files changed (51) hide show
  1. {plexavo-0.2.7 → plexavo-0.2.9}/PKG-INFO +7 -1
  2. {plexavo-0.2.7 → plexavo-0.2.9}/README.md +6 -0
  3. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/__init__.py +1 -1
  4. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/storage.py +11 -5
  5. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/cli.py +56 -0
  6. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/findings.py +13 -0
  7. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/interactive.py +1 -0
  8. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/PKG-INFO +7 -1
  9. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/SOURCES.txt +1 -0
  10. {plexavo-0.2.7 → plexavo-0.2.9}/pyproject.toml +1 -1
  11. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_cli_entrypoint.py +5 -0
  12. plexavo-0.2.9/tests/test_fail_on_offline.py +68 -0
  13. {plexavo-0.2.7 → plexavo-0.2.9}/LICENSE +0 -0
  14. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/__main__.py +0 -0
  15. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/auth.py +0 -0
  16. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/aws_profile_setup.py +0 -0
  17. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/__init__.py +0 -0
  18. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/encryption.py +0 -0
  19. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/iam.py +0 -0
  20. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/iam_hygiene.py +0 -0
  21. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/logging.py +0 -0
  22. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/network.py +0 -0
  23. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/checks/usage.py +0 -0
  24. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/principals.py +0 -0
  25. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/__init__.py +0 -0
  26. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/ai_narration.py +0 -0
  27. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
  28. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
  29. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
  30. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
  31. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
  32. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/html_report.py +0 -0
  33. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/pdf.py +0 -0
  34. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/report/templates/report.html.j2 +0 -0
  35. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo/scoring.py +0 -0
  36. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/dependency_links.txt +0 -0
  37. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/entry_points.txt +0 -0
  38. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/requires.txt +0 -0
  39. {plexavo-0.2.7 → plexavo-0.2.9}/plexavo.egg-info/top_level.txt +0 -0
  40. {plexavo-0.2.7 → plexavo-0.2.9}/setup.cfg +0 -0
  41. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_ai_narration_offline.py +0 -0
  42. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_auth_offline.py +0 -0
  43. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_encryption_offline.py +0 -0
  44. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_iam_hygiene_offline.py +0 -0
  45. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_iam_offline.py +0 -0
  46. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_logging_offline.py +0 -0
  47. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_network_offline.py +0 -0
  48. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_report_offline.py +0 -0
  49. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_scoring.py +0 -0
  50. {plexavo-0.2.7 → plexavo-0.2.9}/tests/test_storage_offline.py +0 -0
  51. {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.7
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,
@@ -4,4 +4,4 @@ Runs entirely with your own local AWS credentials. Nothing is sent to
4
4
  anyone else. See README.md for usage, or `plexavo scan --help`.
5
5
  """
6
6
 
7
- __version__ = "0.2.7"
7
+ __version__ = "0.2.9"
@@ -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, it's just unreviewable after the fact if something happens."""
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"If this bucket is ever accessed, modified, or exfiltrated "
155
- f"from, there is no record of who did what — investigation "
156
- f"after the fact isn't possible.",
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:
@@ -327,4 +327,5 @@ def run_interactive() -> None:
327
327
  explain_limit=25,
328
328
  report_html=options["report_html"],
329
329
  report_pdf=options["report_pdf"],
330
+ fail_on=None, # scheduled-run flag only; a person watching the score doesn't need it
330
331
  ))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexavo
3
- Version: 0.2.7
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"
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