plexavo 0.4.7__tar.gz → 0.4.8__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 (58) hide show
  1. {plexavo-0.4.7 → plexavo-0.4.8}/PKG-INFO +1 -1
  2. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/__init__.py +1 -1
  3. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/usage.py +47 -3
  4. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/PKG-INFO +1 -1
  5. {plexavo-0.4.7 → plexavo-0.4.8}/pyproject.toml +1 -1
  6. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_usage_offline.py +34 -2
  7. {plexavo-0.4.7 → plexavo-0.4.8}/LICENSE +0 -0
  8. {plexavo-0.4.7 → plexavo-0.4.8}/README.md +0 -0
  9. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/__main__.py +0 -0
  10. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/attack_paths.py +0 -0
  11. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/auth.py +0 -0
  12. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/aws_profile_setup.py +0 -0
  13. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/__init__.py +0 -0
  14. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/compute.py +0 -0
  15. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/encryption.py +0 -0
  16. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/iam.py +0 -0
  17. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/iam_hygiene.py +0 -0
  18. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/logging.py +0 -0
  19. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/network.py +0 -0
  20. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/storage.py +0 -0
  21. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/cli.py +0 -0
  22. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/findings.py +0 -0
  23. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/interactive.py +0 -0
  24. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/principals.py +0 -0
  25. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/__init__.py +0 -0
  26. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/ai_narration.py +0 -0
  27. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
  28. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
  29. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
  30. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
  31. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
  32. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/html_report.py +0 -0
  33. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/json_report.py +0 -0
  34. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/pdf.py +0 -0
  35. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/templates/report.html.j2 +0 -0
  36. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/scoring.py +0 -0
  37. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/SOURCES.txt +0 -0
  38. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/dependency_links.txt +0 -0
  39. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/entry_points.txt +0 -0
  40. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/requires.txt +0 -0
  41. {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/top_level.txt +0 -0
  42. {plexavo-0.4.7 → plexavo-0.4.8}/setup.cfg +0 -0
  43. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_ai_narration_offline.py +0 -0
  44. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_attack_paths_offline.py +0 -0
  45. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_auth_offline.py +0 -0
  46. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_cli_entrypoint.py +0 -0
  47. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_compute_offline.py +0 -0
  48. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_encryption_offline.py +0 -0
  49. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_fail_on_offline.py +0 -0
  50. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_iam_hygiene_offline.py +0 -0
  51. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_iam_offline.py +0 -0
  52. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_json_report_offline.py +0 -0
  53. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_logging_offline.py +0 -0
  54. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_network_offline.py +0 -0
  55. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_principals_offline.py +0 -0
  56. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_report_offline.py +0 -0
  57. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_scoring.py +0 -0
  58. {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_storage_offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexavo
3
- Version: 0.4.7
3
+ Version: 0.4.8
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
@@ -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.4.7"
7
+ __version__ = "0.4.8"
@@ -125,6 +125,13 @@ _KNOWN_DATA_PLANE_ACTIONS = {
125
125
  "sqs:sendmessage", "sqs:receivemessage", "sqs:deletemessage",
126
126
  }
127
127
 
128
+ # How long after a role's CreateDate an empty RoleLastUsed is treated as
129
+ # "AWS may not have propagated this yet" rather than "confirmed never
130
+ # used" - see check_27_roles_not_assumed's docstring. 24h is a deliberate
131
+ # multiple of the delay actually observed live (RoleLastUsed still empty
132
+ # 20+ minutes after a genuine sts:AssumeRole call), not a guess.
133
+ ROLE_LAST_USED_GRACE_HOURS = 24
134
+
128
135
 
129
136
  def _is_action_used(granted_action: str, used_actions: set) -> bool:
130
137
  """True if granted_action was actually used — checked directly, or
@@ -263,16 +270,52 @@ def check_27_roles_not_assumed(iam_roles_raw: list, lookback_days: int = 90) ->
263
270
  against real data before this. More importantly: recommending
264
271
  `iam:DeleteServiceLinkedRole` is genuinely risky if AWS is silently,
265
272
  legitimately depending on one — this check has no way to confirm
266
- that either way, so it doesn't guess."""
273
+ that either way, so it doesn't guess.
274
+
275
+ A recently-created role with an empty RoleLastUsed is genuinely
276
+ ambiguous, not confidently "never assumed" — confirmed live while
277
+ testing the get_role fix above: a role assumed via a real
278
+ sts:AssumeRole call still showed RoleLastUsed={} more than 20 minutes
279
+ later, and AWS's own documentation states this data can take up to
280
+ several hours to propagate after actual use. This isn't specific to
281
+ a compressed test timeline either — the exact same thing happens to
282
+ any real role created and used for the first time shortly before a
283
+ scan (a CI/CD pipeline spinning up a role and immediately assuming
284
+ it, for example). ROLE_LAST_USED_GRACE_HOURS below downgrades that
285
+ specific, narrow case (empty RoleLastUsed AND created within the
286
+ grace window) to "Likely — see note" at reduced severity, rather
287
+ than asserting Confirmed either way — never silently suppressed
288
+ (a role that's genuinely never been used still gets a full-confidence
289
+ finding once the grace window passes), never falsely certain while
290
+ AWS's own data could still be catching up. Deliberately scoped
291
+ per-role, not account-wide: an account where truly nothing has been
292
+ used yet is a real, common, accurately-reported case (see module
293
+ docstring's low-activity-account note) — silencing this check
294
+ whenever no role anywhere shows RoleLastUsed would hide exactly that
295
+ signal instead of fixing an ambiguity."""
267
296
  findings = []
268
297
  cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days)
298
+ grace_cutoff = datetime.now(timezone.utc) - timedelta(hours=ROLE_LAST_USED_GRACE_HOURS)
269
299
  for role in iam_roles_raw:
270
300
  if role.get("Path", "").startswith("/aws-service-role/"):
271
301
  continue
272
302
  last_used = role.get("RoleLastUsed", {}).get("LastUsedDate")
273
303
  role_name = role["RoleName"]
274
304
  role_arn = role["Arn"]
305
+ severity = Severity.HIGH
306
+ confidence = "Confirmed"
307
+ ambiguity_note = ""
275
308
  if last_used is None:
309
+ create_date = role.get("CreateDate")
310
+ if create_date is not None and create_date > grace_cutoff:
311
+ severity = Severity.MEDIUM
312
+ confidence = "Likely — see note"
313
+ ambiguity_note = (
314
+ f" Role was created less than {ROLE_LAST_USED_GRACE_HOURS}h ago — "
315
+ f"AWS's RoleLastUsed data can take hours to propagate after actual use, "
316
+ f"so an empty value here doesn't necessarily mean the role has never been "
317
+ f"used, only that no use has been recorded yet."
318
+ )
276
319
  detail = f"Role '{role_name}' has never been assumed since it was created."
277
320
  last_used_str = "never"
278
321
  elif last_used < cutoff:
@@ -284,14 +327,15 @@ def check_27_roles_not_assumed(iam_roles_raw: list, lookback_days: int = 90) ->
284
327
  findings.append(Finding(
285
328
  check_id="USE-27",
286
329
  title="Role Not Assumed Recently",
287
- severity=Severity.HIGH,
330
+ severity=severity,
288
331
  resource_arn=role_arn,
289
332
  raw_detail=(
290
333
  f"{detail} An unused role is standing attack surface with no current legitimate "
291
334
  f"purpose — confirm it's still needed, or remove it."
292
335
  ),
293
336
  account_context=f"last_used={last_used_str}",
294
- evidence=f"last_used={last_used_str}",
337
+ confidence=confidence,
338
+ evidence=f"last_used={last_used_str}.{ambiguity_note}".strip(),
295
339
  ))
296
340
  return findings
297
341
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexavo
3
- Version: 0.4.7
3
+ Version: 0.4.8
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "plexavo"
7
- version = "0.4.7"
7
+ version = "0.4.8"
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" }
@@ -51,11 +51,12 @@ def assumed_role_event(role_arn, event_source, event_name):
51
51
  }
52
52
 
53
53
 
54
- def role_dict(name, last_used=None):
54
+ def role_dict(name, last_used=None, create_date=None):
55
55
  return {
56
56
  "RoleName": name,
57
57
  "Arn": f"arn:aws:iam::111111111111:role/{name}",
58
58
  "RoleLastUsed": {"LastUsedDate": last_used} if last_used is not None else {},
59
+ "CreateDate": create_date,
59
60
  }
60
61
 
61
62
 
@@ -128,10 +129,12 @@ user_principal = [Principal(type="user", name="lab-admin", arn="arn:aws:iam::111
128
129
  findings = usage.check_26_unused_permissions(FakeCloudTrail([]), user_principal)
129
130
  assert_true(len(findings) == 0, "IAM users are not evaluated by this check — it's role-specific by design")
130
131
 
131
- print("\n=== check_27: fires on a role never assumed (RoleLastUsed empty) ===")
132
+ print("\n=== check_27: fires on a role never assumed (RoleLastUsed empty), no CreateDate at all ===")
132
133
  roles = [role_dict("never-used-role")]
133
134
  findings = usage.check_27_roles_not_assumed(roles)
134
135
  assert_true(len(findings) == 1 and "never been assumed" in findings[0].raw_detail, "Fires with 'never assumed' wording")
136
+ assert_true(findings[0].severity.value == "High" and findings[0].confidence == "Confirmed",
137
+ "No CreateDate at all falls through to the unchanged Confirmed/High behavior (defensive default)")
135
138
 
136
139
  print("\n=== check_27: fires on a role last assumed 120 days ago (over the 90-day window) ===")
137
140
  old_date = datetime.now(timezone.utc) - timedelta(days=120)
@@ -145,6 +148,35 @@ roles = [role_dict("active-role", last_used=recent_date)]
145
148
  findings = usage.check_27_roles_not_assumed(roles)
146
149
  assert_true(len(findings) == 0, "Does NOT fire on a recently-assumed role")
147
150
 
151
+ print("\n=== FIX: a freshly-created role with empty RoleLastUsed downgrades instead of asserting Confirmed ===")
152
+ # Confirmed live (2026-09-28): a role assumed via a real sts:AssumeRole call
153
+ # still showed RoleLastUsed={} more than 20 minutes later — AWS's own
154
+ # propagation can genuinely take hours, not just in this compressed test.
155
+ just_created = datetime.now(timezone.utc) - timedelta(hours=1)
156
+ roles = [role_dict("brand-new-role", create_date=just_created)]
157
+ findings = usage.check_27_roles_not_assumed(roles)
158
+ assert_true(len(findings) == 1, "Still fires — a Condition/grace period downgrades, never fully suppresses")
159
+ assert_true(findings[0].severity.value == "Medium", f"Downgraded to Medium, not High (got: {findings[0].severity.value})")
160
+ assert_true(findings[0].confidence == "Likely — see note", f"Confidence downgraded (got: {findings[0].confidence})")
161
+ assert_true("propagate" in findings[0].evidence.lower(), "Evidence explains the AWS propagation ambiguity, not buried in raw_detail")
162
+
163
+ print("\n=== REGRESSION: a role created well past the grace window stays at full Confirmed/High ===")
164
+ long_ago = datetime.now(timezone.utc) - timedelta(hours=48)
165
+ roles = [role_dict("old-unused-role", create_date=long_ago)]
166
+ findings = usage.check_27_roles_not_assumed(roles)
167
+ assert_true(len(findings) == 1, "Fires")
168
+ assert_true(findings[0].severity.value == "High" and findings[0].confidence == "Confirmed",
169
+ "Past the grace window, stays at full Confirmed/High — the fix doesn't quietly weaken the check generally")
170
+
171
+ print("\n=== REGRESSION: the grace period only applies to the empty-RoleLastUsed case, never a stale-but-real timestamp ===")
172
+ stale_but_recent_role = datetime.now(timezone.utc) - timedelta(hours=1)
173
+ old_last_used = datetime.now(timezone.utc) - timedelta(days=120)
174
+ roles = [role_dict("stale-fresh-role", last_used=old_last_used, create_date=stale_but_recent_role)]
175
+ findings = usage.check_27_roles_not_assumed(roles)
176
+ assert_true(len(findings) == 1, "Fires")
177
+ assert_true(findings[0].severity.value == "High" and findings[0].confidence == "Confirmed",
178
+ "A real (non-empty) stale LastUsedDate is unambiguous regardless of CreateDate — full Confirmed/High, no grace-period downgrade")
179
+
148
180
  print("\n=== check_27: exactly-90-days boundary — must fire (>=90, not >90) ===")
149
181
  boundary_date = datetime.now(timezone.utc) - timedelta(days=91) # safely past 90 to avoid test-runtime flakiness
150
182
  roles = [role_dict("boundary-role", last_used=boundary_date)]
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