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.
- {plexavo-0.4.7 → plexavo-0.4.8}/PKG-INFO +1 -1
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/__init__.py +1 -1
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/usage.py +47 -3
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/PKG-INFO +1 -1
- {plexavo-0.4.7 → plexavo-0.4.8}/pyproject.toml +1 -1
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_usage_offline.py +34 -2
- {plexavo-0.4.7 → plexavo-0.4.8}/LICENSE +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/README.md +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/__main__.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/attack_paths.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/auth.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/aws_profile_setup.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/__init__.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/compute.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/encryption.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/iam.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/iam_hygiene.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/logging.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/network.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/checks/storage.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/cli.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/findings.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/interactive.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/principals.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/__init__.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/ai_narration.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans-Bold.ttf +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans-BoldOblique.ttf +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans-Oblique.ttf +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/DejaVuSans.ttf +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/fonts/GEIST-FONT-LICENSE.txt +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/html_report.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/json_report.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/pdf.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/report/templates/report.html.j2 +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo/scoring.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/SOURCES.txt +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/dependency_links.txt +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/entry_points.txt +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/requires.txt +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/plexavo.egg-info/top_level.txt +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/setup.cfg +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_ai_narration_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_attack_paths_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_auth_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_cli_entrypoint.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_compute_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_encryption_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_fail_on_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_iam_hygiene_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_iam_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_json_report_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_logging_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_network_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_principals_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_report_offline.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_scoring.py +0 -0
- {plexavo-0.4.7 → plexavo-0.4.8}/tests/test_storage_offline.py +0 -0
|
@@ -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=
|
|
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
|
-
|
|
337
|
+
confidence=confidence,
|
|
338
|
+
evidence=f"last_used={last_used_str}.{ambiguity_note}".strip(),
|
|
295
339
|
))
|
|
296
340
|
return findings
|
|
297
341
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "plexavo"
|
|
7
|
-
version = "0.4.
|
|
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
|
|
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
|