plexavo 0.2.5__tar.gz → 0.2.7__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 (50) hide show
  1. {plexavo-0.2.5 → plexavo-0.2.7}/PKG-INFO +11 -3
  2. {plexavo-0.2.5 → plexavo-0.2.7}/README.md +10 -2
  3. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/__init__.py +1 -1
  4. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/storage.py +35 -2
  5. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/cli.py +2 -2
  6. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/ai_narration.py +29 -3
  7. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo.egg-info/PKG-INFO +11 -3
  8. {plexavo-0.2.5 → plexavo-0.2.7}/pyproject.toml +1 -1
  9. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_ai_narration_offline.py +14 -2
  10. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_storage_offline.py +28 -1
  11. {plexavo-0.2.5 → plexavo-0.2.7}/LICENSE +0 -0
  12. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/__main__.py +0 -0
  13. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/auth.py +0 -0
  14. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/aws_profile_setup.py +0 -0
  15. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/__init__.py +0 -0
  16. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/encryption.py +0 -0
  17. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/iam.py +0 -0
  18. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/iam_hygiene.py +0 -0
  19. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/logging.py +0 -0
  20. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/network.py +0 -0
  21. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/checks/usage.py +0 -0
  22. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/findings.py +0 -0
  23. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/interactive.py +0 -0
  24. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/principals.py +0 -0
  25. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/__init__.py +0 -0
  26. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
  27. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
  28. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
  29. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
  30. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
  31. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/html_report.py +0 -0
  32. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/pdf.py +0 -0
  33. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/report/templates/report.html.j2 +0 -0
  34. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo/scoring.py +0 -0
  35. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo.egg-info/SOURCES.txt +0 -0
  36. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo.egg-info/dependency_links.txt +0 -0
  37. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo.egg-info/entry_points.txt +0 -0
  38. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo.egg-info/requires.txt +0 -0
  39. {plexavo-0.2.5 → plexavo-0.2.7}/plexavo.egg-info/top_level.txt +0 -0
  40. {plexavo-0.2.5 → plexavo-0.2.7}/setup.cfg +0 -0
  41. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_auth_offline.py +0 -0
  42. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_cli_entrypoint.py +0 -0
  43. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_encryption_offline.py +0 -0
  44. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_iam_hygiene_offline.py +0 -0
  45. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_iam_offline.py +0 -0
  46. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_logging_offline.py +0 -0
  47. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_network_offline.py +0 -0
  48. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_report_offline.py +0 -0
  49. {plexavo-0.2.5 → plexavo-0.2.7}/tests/test_scoring.py +0 -0
  50. {plexavo-0.2.5 → plexavo-0.2.7}/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.5
3
+ Version: 0.2.7
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
@@ -53,14 +53,14 @@ can read, and it's entirely optional. See [Cost](#cost).
53
53
 
54
54
  ## What it checks
55
55
 
56
- 31 checks across 6 categories, run against real AWS accounts:
56
+ 32 checks across 6 categories, run against real AWS accounts:
57
57
 
58
58
  - **IAM**: privilege escalation paths, wildcard admin, cross-account
59
59
  trust, root usage, dormant credentials
60
60
  - **Network**: security groups and RDS instances exposed to the
61
61
  internet
62
62
  - **Storage**: public S3 buckets, via ACLs, bucket policies, or missing
63
- Block Public Access
63
+ Block Public Access; buckets with no access logging configured
64
64
  - **Encryption**: unencrypted EBS volumes, RDS instances, S3 buckets
65
65
  - **Logging**: CloudTrail coverage and encryption, GuardDuty status
66
66
  - **Usage**: permissions granted but never used, roles nobody has
@@ -167,6 +167,14 @@ uv pip install -e .
167
167
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the pattern used to add a
168
168
  new check.
169
169
 
170
+ ## Break Plexavo
171
+
172
+ Think you can make Plexavo miss something, or give confusing guidance?
173
+ [Report it](../../issues/new?template=break-plexavo.yml). Every
174
+ confirmed, genuinely new finding gets fixed and shipped, and you get a
175
+ permanent credit in the [Hall of Bugs](HALL_OF_BUGS.md). No bounty,
176
+ public credit only.
177
+
170
178
  ## Security
171
179
 
172
180
  Found a vulnerability in the tool itself, not a misconfiguration in your
@@ -24,14 +24,14 @@ can read, and it's entirely optional. See [Cost](#cost).
24
24
 
25
25
  ## What it checks
26
26
 
27
- 31 checks across 6 categories, run against real AWS accounts:
27
+ 32 checks across 6 categories, run against real AWS accounts:
28
28
 
29
29
  - **IAM**: privilege escalation paths, wildcard admin, cross-account
30
30
  trust, root usage, dormant credentials
31
31
  - **Network**: security groups and RDS instances exposed to the
32
32
  internet
33
33
  - **Storage**: public S3 buckets, via ACLs, bucket policies, or missing
34
- Block Public Access
34
+ Block Public Access; buckets with no access logging configured
35
35
  - **Encryption**: unencrypted EBS volumes, RDS instances, S3 buckets
36
36
  - **Logging**: CloudTrail coverage and encryption, GuardDuty status
37
37
  - **Usage**: permissions granted but never used, roles nobody has
@@ -138,6 +138,14 @@ uv pip install -e .
138
138
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the pattern used to add a
139
139
  new check.
140
140
 
141
+ ## Break Plexavo
142
+
143
+ Think you can make Plexavo miss something, or give confusing guidance?
144
+ [Report it](../../issues/new?template=break-plexavo.yml). Every
145
+ confirmed, genuinely new finding gets fixed and shipped, and you get a
146
+ permanent credit in the [Hall of Bugs](HALL_OF_BUGS.md). No bounty,
147
+ public credit only.
148
+
141
149
  ## Security
142
150
 
143
151
  Found a vulnerability in the tool itself, not a misconfiguration in your
@@ -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.5"
7
+ __version__ = "0.2.7"
@@ -1,4 +1,5 @@
1
- """Category 3: Storage Exposure — checks 19-21, all Critical.
1
+ """Category 3: Storage Exposure — checks 19-22. 19-21 are Critical
2
+ (public exposure); 22 is Medium (a visibility gap, not exposure itself).
2
3
 
3
4
  Scope note: check_19 checks BUCKET-level PublicAccessBlock only, per the
4
5
  blueprint's literal spec (GetBucketPublicAccessBlock). It does NOT check
@@ -127,8 +128,39 @@ def check_21_public_acl(s3, bucket_names: list) -> list[Finding]:
127
128
  return findings
128
129
 
129
130
 
131
+ def check_22_access_logging_disabled(s3, bucket_names: list) -> list[Finding]:
132
+ """STOR-22: bucket has no server access logging configured.
133
+
134
+ GetBucketLogging always returns 200, even when logging was never set
135
+ up — the response body simply omits the 'LoggingEnabled' key in that
136
+ case. Unlike check_19's PublicAccessBlock, there's no distinct
137
+ "not configured" exception to catch here, confirmed against the real
138
+ S3 service model before writing this.
139
+
140
+ Medium, not Critical: this is a forensics gap, not an exposure by
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."""
143
+ findings = []
144
+ for name in bucket_names:
145
+ config = s3.get_bucket_logging(Bucket=name)
146
+ if "LoggingEnabled" in config:
147
+ continue
148
+ findings.append(Finding(
149
+ check_id="STOR-22",
150
+ title="S3 Bucket Access Logging Not Enabled",
151
+ severity=Severity.MEDIUM,
152
+ 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.",
157
+ account_context=f"bucket={name}",
158
+ ))
159
+ return findings
160
+
161
+
130
162
  def run_all(session) -> list[Finding]:
131
- """Run STOR-19 through STOR-21 against every bucket in the account —
163
+ """Run STOR-19 through STOR-22 against every bucket in the account —
132
164
  not just testbed buckets. A real scan must check everything."""
133
165
  s3 = session.client("s3")
134
166
  bucket_names = list_buckets(s3)
@@ -136,4 +168,5 @@ def run_all(session) -> list[Finding]:
136
168
  findings += check_19_missing_public_access_block(s3, bucket_names)
137
169
  findings += check_20_public_bucket_policy(s3, bucket_names)
138
170
  findings += check_21_public_acl(s3, bucket_names)
171
+ findings += check_22_access_logging_disabled(s3, bucket_names)
139
172
  return findings
@@ -83,7 +83,7 @@ def _build_parser() -> argparse.ArgumentParser:
83
83
  "key. No key set, or a call fails for any reason? That finding falls back to raw "
84
84
  "detail automatically, the scan never stops because of it. Off by default — without "
85
85
  "it, findings still get free, no-API-key-needed remediation text wherever a template "
86
- "exists (10 common check types); --explain always uses live AI instead, for every "
86
+ "exists (11 common check types); --explain always uses live AI instead, for every "
87
87
  "finding, not just the non-templated ones.")
88
88
  scan.add_argument("--explain-limit", type=int, default=25,
89
89
  help="Safety cap on how many findings get a live AI call when --explain is passed, in "
@@ -155,7 +155,7 @@ def _run_scan(args) -> None:
155
155
  stage("Running checks NET-01 through NET-04")
156
156
  findings += network_checks.run_all(session)
157
157
 
158
- stage("Running checks STOR-19 through STOR-21")
158
+ stage("Running checks STOR-19 through STOR-22")
159
159
  findings += storage_checks.run_all(session)
160
160
 
161
161
  stage("Running checks ENC-29 through ENC-31")
@@ -172,7 +172,7 @@ def _parse_sections(raw_text: str) -> tuple[str, str, str]:
172
172
 
173
173
 
174
174
  # ---------------------------------------------------------------------------
175
- # Templates for the 10 most common, narratively-generic check types.
175
+ # Templates for the 11 most common, narratively-generic check types.
176
176
  # Every specific fact (resource names, chain targets) already lives in
177
177
  # the Finding's own fields, computed deterministically in Python — the
178
178
  # template just substitutes them into fixed prose matching the same
@@ -373,6 +373,31 @@ def _template_enc29(f: Finding) -> Explanation:
373
373
  )
374
374
 
375
375
 
376
+ def _template_log25(f: Finding) -> Explanation:
377
+ return Explanation(
378
+ impact=(
379
+ "GuardDuty is not enabled in this region, so there is no automated threat detection watching the "
380
+ "account. GuardDuty continuously analyzes CloudTrail, VPC flow logs, and DNS logs for known-bad "
381
+ "signals — an access key suddenly used from a new country or a Tor exit node, an EC2 instance "
382
+ "calling a known crypto-mining or malware domain, someone running account-wide recon with calls "
383
+ "like iam:ListUsers, ec2:DescribeInstances, and s3:ListAllMyBuckets in quick succession. With it "
384
+ "off, all of that happens with nothing raising an alert, so a compromise is usually only noticed "
385
+ "once it causes visible damage or a surprise bill."
386
+ ),
387
+ how_to_fix=(
388
+ "Enable GuardDuty in this region, then confirm the detector is active:\n"
389
+ "aws guardduty create-detector --enable\n"
390
+ "aws guardduty list-detectors\n"
391
+ "GuardDuty is per-region, so repeat this in every region you run workloads in, or enable it for "
392
+ "the whole organization from your AWS Organizations management account. It is free for the first "
393
+ "30 days, then billed on the volume of logs analyzed (typically a few dollars a month for a small "
394
+ "account)."
395
+ ),
396
+ next_step="Turn GuardDuty on in this region now: aws guardduty create-detector --enable",
397
+ source="template",
398
+ )
399
+
400
+
376
401
  COMMON_CHECK_TEMPLATES = {
377
402
  "IAM-01": _template_iam01,
378
403
  "IAM-06": _template_iam06,
@@ -384,18 +409,19 @@ COMMON_CHECK_TEMPLATES = {
384
409
  "NET-02": _template_net02,
385
410
  "STOR-19": _template_stor19,
386
411
  "ENC-29": _template_enc29,
412
+ "LOG-25": _template_log25,
387
413
  }
388
414
 
389
415
 
390
416
  def explain_finding(finding: Finding, client=None, use_ai: bool = False) -> Explanation | None:
391
417
  """use_ai=False (default): route to the free template (zero API cost,
392
- zero network) for the 10 most common, narratively-generic check
418
+ zero network) for the 11 most common, narratively-generic check
393
419
  types; every other check returns None (raw finding detail only — no
394
420
  API call is ever attempted in this mode, regardless of whether a key
395
421
  is configured). This is the always-on baseline — no flag needed to
396
422
  get free remediation guidance where a template exists.
397
423
 
398
- use_ai=True: every finding, including the 10 templated ones, is sent
424
+ use_ai=True: every finding, including the 11 templated ones, is sent
399
425
  to Claude instead — deliberately bypasses the template shortcut so
400
426
  "AI narration on" always means fully AI-written content, never a mix
401
427
  of template and AI.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexavo
3
- Version: 0.2.5
3
+ Version: 0.2.7
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
@@ -53,14 +53,14 @@ can read, and it's entirely optional. See [Cost](#cost).
53
53
 
54
54
  ## What it checks
55
55
 
56
- 31 checks across 6 categories, run against real AWS accounts:
56
+ 32 checks across 6 categories, run against real AWS accounts:
57
57
 
58
58
  - **IAM**: privilege escalation paths, wildcard admin, cross-account
59
59
  trust, root usage, dormant credentials
60
60
  - **Network**: security groups and RDS instances exposed to the
61
61
  internet
62
62
  - **Storage**: public S3 buckets, via ACLs, bucket policies, or missing
63
- Block Public Access
63
+ Block Public Access; buckets with no access logging configured
64
64
  - **Encryption**: unencrypted EBS volumes, RDS instances, S3 buckets
65
65
  - **Logging**: CloudTrail coverage and encryption, GuardDuty status
66
66
  - **Usage**: permissions granted but never used, roles nobody has
@@ -167,6 +167,14 @@ uv pip install -e .
167
167
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the pattern used to add a
168
168
  new check.
169
169
 
170
+ ## Break Plexavo
171
+
172
+ Think you can make Plexavo miss something, or give confusing guidance?
173
+ [Report it](../../issues/new?template=break-plexavo.yml). Every
174
+ confirmed, genuinely new finding gets fixed and shipped, and you get a
175
+ permanent credit in the [Hall of Bugs](HALL_OF_BUGS.md). No bounty,
176
+ public credit only.
177
+
170
178
  ## Security
171
179
 
172
180
  Found a vulnerability in the tool itself, not a misconfiguration in your
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "plexavo"
7
- version = "0.2.5"
7
+ version = "0.2.7"
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" }
@@ -73,7 +73,7 @@ assert_true(_short_name("arn:aws:iam::111111111111:role/my-role") == "my-role",
73
73
  assert_true(_short_name("arn:aws:s3:::my-bucket") == "my-bucket", "Extracts name after last : when no /")
74
74
  assert_true(_short_name("i-0abc123") == "i-0abc123", "Bare ID with no separators returned as-is")
75
75
 
76
- print("\n=== All 10 templates produce non-empty impact/next_step, with ZERO API contact ===")
76
+ print("\n=== All 11 templates produce non-empty impact/next_step, with ZERO API contact ===")
77
77
  for check_id in COMMON_CHECK_TEMPLATES:
78
78
  finding = f(check_id)
79
79
  result = explain_finding(finding, client=ExplodingClient())
@@ -81,7 +81,19 @@ for check_id in COMMON_CHECK_TEMPLATES:
81
81
  assert_true(bool(result.impact.strip()), f"{check_id} impact is non-empty")
82
82
  assert_true(bool(result.how_to_fix.strip()), f"{check_id} how_to_fix is non-empty")
83
83
  assert_true(bool(result.next_step.strip()), f"{check_id} next_step is non-empty — this is new, every template must set it")
84
- assert_true(len(COMMON_CHECK_TEMPLATES) == 10, f"Exactly 10 templated checks exist (got {len(COMMON_CHECK_TEMPLATES)})")
84
+ assert_true(len(COMMON_CHECK_TEMPLATES) == 11, f"Exactly 11 templated checks exist (got {len(COMMON_CHECK_TEMPLATES)})")
85
+
86
+ print("\n=== LOG-25 (GuardDuty): account-level finding, template does not depend on a resource name ===")
87
+ # LOG-25's real finding has resource_arn="account" — no per-resource
88
+ # identifier to substitute, unlike bucket/volume/user templates. The
89
+ # template must still produce complete, correct guidance.
90
+ finding = f("LOG-25", resource_arn="account", raw_detail="GuardDuty is not enabled in this region.")
91
+ result = explain_finding(finding, client=ExplodingClient())
92
+ assert_true(result.source == "template", f"LOG-25 routes to template (got source={result.source})")
93
+ assert_true("guardduty create-detector" in result.how_to_fix, "LOG-25 fix names the actual enable command")
94
+ assert_true("guardduty create-detector" in result.next_step, "LOG-25 next_step names the actual enable command")
95
+ assert_true("<PLACEHOLDER" not in result.how_to_fix and "<PLACEHOLDER" not in result.next_step,
96
+ "LOG-25 has no per-resource value to fill, so no placeholder should appear")
85
97
 
86
98
  print("\n=== Confidence/evidence are copied from the Finding, not decided by the template ===")
87
99
  finding = f("IAM-01", confidence="Likely — see note", evidence="Grant is scoped by a Condition block (aws:SourceIp)")
@@ -12,11 +12,12 @@ from plexavo.checks import storage
12
12
 
13
13
 
14
14
  class FakeS3:
15
- def __init__(self, buckets, pab=None, policies=None, acls=None):
15
+ def __init__(self, buckets, pab=None, policies=None, acls=None, logging=None):
16
16
  self._buckets = buckets
17
17
  self._pab = pab or {}
18
18
  self._policies = policies or {}
19
19
  self._acls = acls or {}
20
+ self._logging = logging or {}
20
21
 
21
22
  def list_buckets(self):
22
23
  return {"Buckets": [{"Name": b} for b in self._buckets]}
@@ -34,6 +35,13 @@ class FakeS3:
34
35
  def get_bucket_acl(self, Bucket):
35
36
  return self._acls.get(Bucket, {"Grants": []})
36
37
 
38
+ def get_bucket_logging(self, Bucket):
39
+ # Real S3 behavior: always 200, 'LoggingEnabled' simply absent when
40
+ # logging was never configured — never raises.
41
+ if Bucket in self._logging:
42
+ return {"LoggingEnabled": self._logging[Bucket]}
43
+ return {}
44
+
37
45
 
38
46
  FULL_PAB = {"BlockPublicAcls": True, "IgnorePublicAcls": True, "BlockPublicPolicy": True, "RestrictPublicBuckets": True}
39
47
 
@@ -53,6 +61,7 @@ def run_checks(s3, buckets):
53
61
  findings += storage.check_19_missing_public_access_block(s3, buckets)
54
62
  findings += storage.check_20_public_bucket_policy(s3, buckets)
55
63
  findings += storage.check_21_public_acl(s3, buckets)
64
+ findings += storage.check_22_access_logging_disabled(s3, buckets)
56
65
  return findings
57
66
 
58
67
 
@@ -114,5 +123,23 @@ s3 = FakeS3(["owner-only-bucket"], pab={"owner-only-bucket": FULL_PAB}, acls={
114
123
  findings = run_checks(s3, ["owner-only-bucket"])
115
124
  assert_true(not any(f.check_id == "STOR-21" for f in findings), "Does NOT fire on an owner-only ACL grant")
116
125
 
126
+ print("\n=== STOR-22: bucket has no access logging configured ===")
127
+ s3 = FakeS3(["no-logging-bucket"], pab={"no-logging-bucket": FULL_PAB})
128
+ findings = run_checks(s3, ["no-logging-bucket"])
129
+ assert_true(any(f.check_id == "STOR-22" for f in findings), "Fires when access logging was never configured")
130
+
131
+ print("\n=== FALSE POSITIVE GUARD: STOR-22 does not fire when access logging is enabled ===")
132
+ s3 = FakeS3(["logged-bucket"], pab={"logged-bucket": FULL_PAB},
133
+ logging={"logged-bucket": {"TargetBucket": "log-archive", "TargetPrefix": "logged-bucket/"}})
134
+ findings = run_checks(s3, ["logged-bucket"])
135
+ assert_true(not any(f.check_id == "STOR-22" for f in findings), "Does NOT fire when LoggingEnabled is present")
136
+
137
+ print("\n=== REGRESSION: reported bug (Hall of Bugs #1, found by ThePettyReviewer) — a bucket with logging genuinely off must be flagged ===")
138
+ s3 = FakeS3(["reported-bucket"], pab={"reported-bucket": FULL_PAB})
139
+ findings = run_checks(s3, ["reported-bucket"])
140
+ matched = [f for f in findings if f.check_id == "STOR-22"]
141
+ assert_true(bool(matched), "STOR-22 fires on a bucket with logging off (previously: no check existed at all, so nothing ever fired)")
142
+ assert_true(bool(matched) and matched[0].severity.value == "Medium", "Severity is Medium — a visibility gap, not exposure like STOR-19/20/21")
143
+
117
144
  print(f"\n{'ALL PASSED' if failures == 0 else f'{failures} FAILURE(S)'}")
118
145
  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