From d2ec6bf03bb3ca8cc41a2e88c49b20d36fa38a7d Mon Sep 17 00:00:00 2001 From: Colby Farley Date: Mon, 13 Apr 2026 06:23:55 -0500 Subject: [PATCH] Decouple escalation-path from privesc footholds --- src/azurefox/chains/compute_control.py | 16 +- src/azurefox/chains/presentation.py | 115 ++++ src/azurefox/chains/registry.py | 26 +- src/azurefox/chains/runner.py | 691 +++++++++++++++++---- src/azurefox/collectors/commands.py | 18 +- src/azurefox/collectors/provider.py | 6 +- src/azurefox/escalation_hints.py | 27 + src/azurefox/help.py | 10 +- src/azurefox/models/common.py | 3 +- src/azurefox/output/writer.py | 7 +- src/azurefox/privesc_hints.py | 22 +- src/azurefox/render/table.py | 111 +--- src/azurefox/role_trust_hints.py | 469 ++++++++++---- tests/fixtures/lab_tenant/permissions.json | 55 ++ tests/fixtures/lab_tenant/privesc.json | 2 - tests/fixtures/lab_tenant/role_trusts.json | 32 + tests/golden/permissions.json | 187 ++++-- tests/golden/privesc.json | 2 - tests/golden/role-trusts.json | 208 ++++--- tests/test_chain_semantics.py | 580 ++++++++++++++++- tests/test_cli_smoke.py | 157 ++++- tests/test_collectors.py | 136 ++-- tests/test_compute_control.py | 12 +- tests/test_models.py | 1 - tests/test_output_writer.py | 90 ++- tests/test_terminal_ux.py | 129 +++- 26 files changed, 2524 insertions(+), 588 deletions(-) create mode 100644 src/azurefox/chains/presentation.py create mode 100644 src/azurefox/escalation_hints.py diff --git a/src/azurefox/chains/compute_control.py b/src/azurefox/chains/compute_control.py index c45ba09..6709596 100644 --- a/src/azurefox/chains/compute_control.py +++ b/src/azurefox/chains/compute_control.py @@ -841,6 +841,11 @@ def _compute_control_required_foothold(surface_row: dict, workload_row: dict) -> if asset_kind == "ContainerInstance" else "this public-facing service" ) + public_token_request_label = ( + "make this public-facing container group ask Azure for its own token" + if asset_kind == "ContainerInstance" + else "make this public-facing service ask Azure for its own token" + ) internal_compute_label = ( "this container group" if asset_kind == "ContainerInstance" else "this workload" ) @@ -849,8 +854,9 @@ def _compute_control_required_foothold(surface_row: dict, workload_row: dict) -> if public_signal: return ( "To turn this into downstream Azure access, an operator would need " - f"server-side execution in {public_compute_label}. AzureFox is a recon tool " - "and does not verify exploitation activity beyond what is explicitly stated here." + f"a way to {public_token_request_label}. AzureFox shows that " + f"{public_compute_label} is public and token-capable, but public reachability " + "alone does not prove that path." ) return ( "To turn this into downstream Azure access, an operator would need a service-side " @@ -863,9 +869,9 @@ def _compute_control_required_foothold(surface_row: dict, workload_row: dict) -> if public_signal: return ( "To turn this into downstream Azure access, an operator would need a " - "server-side request path from this public-facing workload to the Azure VM " - "metadata service. AzureFox is a recon tool and does not verify exploitation " - "activity beyond what is explicitly stated here." + "way to make this public-facing workload reach the Azure VM metadata service. " + "AzureFox shows that the workload is public and IMDS-backed, but public " + "reachability alone does not prove that path." ) return ( f"To turn this into downstream Azure access, an operator would need host-level " diff --git a/src/azurefox/chains/presentation.py b/src/azurefox/chains/presentation.py new file mode 100644 index 0000000..5b700ff --- /dev/null +++ b/src/azurefox/chains/presentation.py @@ -0,0 +1,115 @@ +from __future__ import annotations + + +def compute_control_when_label(urgency: str) -> str: + labels = { + "pivot-now": "act now", + "review-soon": "review soon", + "bookmark": "keep in view", + } + return labels.get(urgency, urgency or "-") + + +def compute_control_token_path_label(insertion_point: str) -> str: + labels = { + "reachable service token request path": "service token request", + "public IMDS token path": "public VM metadata token", + "IMDS token path": "VM metadata token", + } + return labels.get(insertion_point, insertion_point or "-") + + +def compute_control_reach_from_here_label(insertion_point: str) -> str: + if insertion_point in {"reachable service token request path", "public IMDS token path"}: + return "public exposure visible; exploitation not proved" + return "current access does not show the start" + + +def compute_control_identity_label(target_names: list[object]) -> str: + names = [str(value) for value in target_names if str(value).strip()] + if not names: + return "not visible" + if len(names) == 1: + return names[0] + return "multiple possible: " + ", ".join(names) + + +def compute_control_proof_status_label(target_resolution: str) -> str: + labels = { + "path-confirmed": "confirmed", + "identity-choice-corroborated": "best current match", + "narrowed candidates": "multiple identities possible", + "visibility blocked": "limited visibility", + "tenant-wide candidates": "broad match only", + "service hint only": "early signal only", + "named target not visible": "named identity not visible", + } + return labels.get(target_resolution, "bounded") + + +def escalation_path_type_label(path_concept: str) -> str: + labels = { + "current-foothold-direct-control": "current foothold direct control", + "trust-expansion": "trust expansion", + } + return labels.get(path_concept, path_concept or "-") + + +def normalize_chain_payload_for_output(command: str, payload: dict) -> dict: + if command != "chains": + return payload + family = str(payload.get("family") or "") + if family not in {"compute-control", "escalation-path"}: + return payload + paths = payload.get("paths") + if not isinstance(paths, list): + return payload + + normalized_payload = dict(payload) + normalized_payload["paths"] = [ + normalize_chain_path_row(family, row) if isinstance(row, dict) else row for row in paths + ] + return normalized_payload + + +def normalize_chain_path_row(family: str, row: dict) -> dict: + normalized_row = dict(row) + if family == "escalation-path": + normalized_row["starting_foothold"] = str( + row.get("starting_foothold") or row.get("asset_name") or "" + ) + normalized_row["path_type"] = str( + row.get("path_type") + or escalation_path_type_label(str(row.get("path_concept") or "")) + ) + normalized_row["note"] = str(row.get("why_care") or row.get("note") or "") + return normalized_row + + insertion_point = str(row.get("insertion_point") or "") + urgency = str(row.get("urgency") or "") + target_resolution = str(row.get("target_resolution") or "") + target_names = row.get("target_names") or [] + + normalized_row["when"] = str(row.get("when") or compute_control_when_label(urgency)) + normalized_row["reach_from_here"] = str( + row.get("reach_from_here") + or compute_control_reach_from_here_label(insertion_point) + ) + normalized_row["compute_foothold"] = str( + row.get("compute_foothold") or row.get("asset_name") or "" + ) + normalized_row["token_path"] = str( + row.get("token_path") or compute_control_token_path_label(insertion_point) + ) + normalized_row["identity"] = str( + row.get("identity") or compute_control_identity_label(target_names) + ) + normalized_row["azure_access"] = str( + row.get("azure_access") or row.get("stronger_outcome") or row.get("likely_impact") or "" + ) + normalized_row["proof_status"] = str( + row.get("proof_status") + or compute_control_proof_status_label(target_resolution) + ) + normalized_row["note"] = str(row.get("why_care") or row.get("note") or "") + return normalized_row diff --git a/src/azurefox/chains/registry.py b/src/azurefox/chains/registry.py index 76945c6..0b0db70 100644 --- a/src/azurefox/chains/registry.py +++ b/src/azurefox/chains/registry.py @@ -324,39 +324,25 @@ class ChainFamilySpec: "control the stronger identity instead of re-listing relationship-only leads." ), best_current_examples=( - "privesc -> permissions", - "privesc -> role-trusts -> permissions", + "permissions", + "permissions -> role-trusts", ), source_commands=( - ChainSourceSpec( - command="privesc", - minimum_fields=( - "starting_foothold", - "principal_id", - "path_type", - "current_identity", - "proven_path", - "missing_proof", - "next_review", - ), - rationale=( - "Provides the current-foothold escalation triage rows that the chain family " - "can harden into a defended path story." - ), - ), ChainSourceSpec( command="permissions", minimum_fields=( "principal_id", "display_name", + "priority", "high_impact_roles", "scope_count", "scope_ids", "privileged", + "is_current_identity", ), rationale=( - "Provides the visible Azure control power behind the current foothold or " - "linked identity." + "Provides the direct current-identity and visible Azure-control evidence that " + "anchors the chain family's starting foothold." ), ), ChainSourceSpec( diff --git a/src/azurefox/chains/runner.py b/src/azurefox/chains/runner.py index 261b2ba..a43a743 100644 --- a/src/azurefox/chains/runner.py +++ b/src/azurefox/chains/runner.py @@ -27,6 +27,11 @@ from azurefox.collectors.provider import BaseProvider from azurefox.config import GlobalOptions from azurefox.devops_hints import describe_trusted_input +from azurefox.escalation_hints import ( + current_foothold_missing_proof, + current_foothold_next_review_hint, + current_foothold_proven_path, +) from azurefox.models.chains import ( ChainPathRecord, ChainsOutput, @@ -423,7 +428,6 @@ def _build_escalation_path_output( family = get_chain_family_spec(family_name) assert family is not None # pragma: no cover - guarded above - privesc_output = loaded["privesc"] permissions_output = loaded["permissions"] role_trusts_output = loaded["role-trusts"] @@ -432,22 +436,13 @@ def _build_escalation_path_output( for item in permissions_output.permissions if item.principal_id } - current_foothold_row = next( - ( - item.model_dump(mode="json") - for item in privesc_output.paths - if item.current_identity and item.principal_id - ), - None, - ) - current_foothold_id = ( - str(current_foothold_row.get("principal_id")) if current_foothold_row else None - ) - paths: list[ChainPathRecord] = [] - if current_foothold_row: + for current_foothold_row in _current_foothold_contexts_from_permissions( + permissions_output.permissions + ): + current_foothold_id = str(current_foothold_row.get("principal_id") or "") permission = permissions_by_principal.get(current_foothold_id or "") - if current_foothold_row.get("path_type") == "direct-role-abuse" and permission: + if permission and bool(permission.get("privileged")): paths.append( _build_escalation_direct_control_record( family_name, @@ -461,6 +456,7 @@ def _build_escalation_path_output( role_trusts_output.trusts, permissions_by_principal, current_foothold_id=current_foothold_id, + current_foothold_permission=permission, ) if trust_row is not None: paths.append(trust_row) @@ -475,7 +471,7 @@ def _build_escalation_path_output( ) issues: list[CollectionIssue] = [] - for source_name in ("privesc", "permissions", "role-trusts"): + for source_name in ("permissions", "role-trusts"): issues.extend(getattr(loaded[source_name], "issues", [])) return _build_chains_command_output( @@ -487,6 +483,43 @@ def _build_escalation_path_output( ) +def _current_foothold_contexts_from_permissions(permissions: list[object]) -> list[dict]: + candidates = [ + item.model_dump(mode="json") + for item in permissions + if getattr(item, "is_current_identity", False) and getattr(item, "principal_id", None) + ] + if not candidates: + return [] + + candidates.sort( + key=lambda item: ( + not bool(item.get("privileged")), + semantic_priority_sort_value(str(item.get("priority") or "low")), + -(int(item.get("scope_count") or len(item.get("scope_ids") or []) or 0)), + str(item.get("display_name") or item.get("principal_id") or ""), + ) + ) + contexts: list[dict] = [] + for current in candidates: + principal_name = str( + current.get("display_name") or current.get("principal_id") or "unknown" + ) + contexts.append( + { + "principal_id": str(current.get("principal_id") or ""), + "principal": principal_name, + "principal_type": str(current.get("principal_type") or "Principal"), + "starting_foothold": f"{principal_name} (current foothold)", + "related_ids": [ + str(current.get("principal_id") or ""), + *[str(value) for value in current.get("scope_ids") or [] if value], + ], + } + ) + return contexts + + def _build_compute_control_output( options: GlobalOptions, family_name: str, @@ -3055,44 +3088,50 @@ def _target_visibility_issue(issues: list[CollectionIssue]) -> str | None: def _build_escalation_direct_control_record( family_name: str, - privesc_row: dict, + current_foothold: dict, permission_row: dict, ) -> ChainPathRecord: scope_text = _permission_scope_text(permission_row) stronger_outcome = _permission_control_summary(permission_row) + impact_roles = [str(role) for role in permission_row.get("high_impact_roles") or [] if role] semantic = evaluate_chain_semantics( ChainSemanticContext( family=family_name, - clue_type=str(privesc_row.get("path_type") or "direct-role-abuse"), + clue_type="direct-role-abuse", target_service="azure-control", target_resolution="path-confirmed", target_count=max(1, len(permission_row.get("scope_ids") or [])), - source_command="privesc", + source_command="permissions", path_concept="current-foothold-direct-control", ) ) + proven_path = current_foothold_proven_path( + principal_name=str(current_foothold.get("principal") or "unknown current foothold"), + impact_roles=impact_roles, + ) + missing_proof = current_foothold_missing_proof() confidence_boundary = " ".join( part for part in ( - str(privesc_row.get("proven_path") or "").strip(), - str(privesc_row.get("missing_proof") or "").strip(), + proven_path, + missing_proof, ) if part ) - next_review = str(privesc_row.get("next_review") or semantic.next_review) + next_review = current_foothold_next_review_hint() return ChainPathRecord( - chain_id=f"escalation-path::{privesc_row.get('principal_id')}::current-foothold-direct-control", - asset_id=str(privesc_row.get("principal_id") or "unknown"), + chain_id=f"escalation-path::{current_foothold.get('principal_id')}::current-foothold-direct-control", + asset_id=str(current_foothold.get("principal_id") or "unknown"), asset_name=str( - privesc_row.get("starting_foothold") - or privesc_row.get("principal") + current_foothold.get("starting_foothold") + or current_foothold.get("principal") or "unknown current foothold" ), - asset_kind=str(privesc_row.get("principal_type") or "Principal"), - source_command="privesc", - source_context=str(privesc_row.get("principal") or ""), - clue_type=str(privesc_row.get("path_type") or "direct-role-abuse"), + asset_kind=str(current_foothold.get("principal_type") or "Principal"), + source_command="permissions", + source_context=str(current_foothold.get("principal") or ""), + clue_type="direct-role-abuse", confirmation_basis="current-identity-rooted", priority=semantic.priority, urgency=semantic.urgency, @@ -3101,122 +3140,430 @@ def _build_escalation_direct_control_record( path_concept="current-foothold-direct-control", stronger_outcome=stronger_outcome, why_care=( - f"The current foothold already sits on {scope_text} high-impact Azure control, so this " - "is not a speculative lead or a separate pivot hunt. The next move is to confirm the " - "exact assignment boundary and pick the strongest direct abuse route from that control." + f"The current foothold already has {stronger_outcome}. This row is already direct " + "Azure control, not a separate pivot hunt. AzureFox is not narrowing one exact " + "downstream action beyond the control already shown here." ), likely_impact=stronger_outcome, confidence_boundary=confidence_boundary, target_service="azure-control", target_resolution="path-confirmed", - evidence_commands=["privesc", "permissions"], + evidence_commands=["permissions"], joined_surface_types=["current-foothold", "permission-summary"], target_count=max(1, len(permission_row.get("scope_ids") or [])), target_ids=[str(value) for value in permission_row.get("scope_ids") or [] if value], target_names=[scope_text], next_review=next_review, summary=f"{confidence_boundary} {next_review}".strip(), - missing_confirmation=str(privesc_row.get("missing_proof") or ""), - related_ids=[str(value) for value in privesc_row.get("related_ids") or [] if value], + missing_confirmation=missing_proof, + related_ids=[str(value) for value in current_foothold.get("related_ids") or [] if value], ) def _build_escalation_trust_record( family_name: str, - privesc_row: dict, + current_foothold: dict, trusts: list, permissions_by_principal: dict[str, dict], *, current_foothold_id: str | None, + current_foothold_permission: dict | None, ) -> ChainPathRecord | None: if not current_foothold_id: return None + candidates: list[tuple[tuple[int, int, int, str], ChainPathRecord]] = [] + federated_trusts_by_application_id: dict[str, list[dict]] = defaultdict(list) for trust in trusts: trust_row = trust.model_dump(mode="json") - if trust_row.get("source_object_id") != current_foothold_id: + if str(trust_row.get("trust_type") or "") != "federated-credential": continue + application_id = str(trust_row.get("source_object_id") or "").strip() + if application_id: + federated_trusts_by_application_id[application_id].append(trust_row) - target_permission = permissions_by_principal.get( - str(trust_row.get("target_object_id") or "") - ) - escalation_mechanism = str(trust_row.get("escalation_mechanism") or "").strip() - usable_identity_result = str(trust_row.get("usable_identity_result") or "").strip() - defender_cut_point = str(trust_row.get("defender_cut_point") or "").strip() - - if not target_permission or not escalation_mechanism or not usable_identity_result: + for trust in trusts: + trust_row = trust.model_dump(mode="json") + if trust_row.get("source_object_id") != current_foothold_id: continue - target_resolution = "path-confirmed" - stronger_outcome = _permission_control_summary(target_permission) - semantic = evaluate_chain_semantics( - ChainSemanticContext( - family=family_name, - clue_type=str(trust_row.get("trust_type") or "trust-expansion"), - target_service="identity-trust", - target_resolution=target_resolution, - target_count=1, - source_command="role-trusts", - path_concept="trust-expansion", + if str(trust_row.get("trust_type") or "") == "app-owner": + application_id = str(trust_row.get("target_object_id") or "").strip() + for federated_trust in federated_trusts_by_application_id.get(application_id, []): + federated_record = _build_escalation_federated_takeover_record( + family_name=family_name, + current_foothold=current_foothold, + current_trust_row=trust_row, + federated_trust_row=federated_trust, + permissions_by_principal=permissions_by_principal, + current_foothold_id=current_foothold_id, + current_foothold_permission=current_foothold_permission, + ) + if federated_record is not None: + candidates.append( + ( + _escalation_trust_candidate_sort_key( + clue_type="federated-credential", + record=federated_record, + current_foothold_permission=current_foothold_permission, + target_permission=permissions_by_principal.get( + str(federated_trust.get("target_object_id") or "") + ), + ), + federated_record, + ) + ) + + trust_record = _build_escalation_single_trust_record( + family_name=family_name, + current_foothold=current_foothold, + trust_row=trust_row, + permissions_by_principal=permissions_by_principal, + current_foothold_id=current_foothold_id, + current_foothold_permission=current_foothold_permission, + ) + if trust_record is not None: + candidates.append( + ( + _escalation_trust_candidate_sort_key( + clue_type=str(trust_row.get("trust_type") or ""), + record=trust_record, + current_foothold_permission=current_foothold_permission, + target_permission=_escalation_target_permission_for_sort( + trust_row=trust_row, + permissions_by_principal=permissions_by_principal, + ), + ), + trust_record, + ) ) - ) - confidence_boundary = ( - f"{escalation_mechanism} {usable_identity_result} " - "AzureFox can also confirm the stronger target's Azure control. " - "AzureFox does not prove successful conversion of that control path into usable " - "downstream identity access from this row alone." - ).strip() - next_review = str(trust_row.get("next_review") or semantic.next_review) - why_care = ( - "This row names a real control transform from the current foothold into a stronger " - "identity path, not just a nearby trust relationship." - ) - if defender_cut_point: - why_care = f"{why_care} {defender_cut_point}" - - return ChainPathRecord( - chain_id=f"escalation-path::{current_foothold_id}::trust-expansion::{trust_row.get('target_object_id')}", - asset_id=str(current_foothold_id), - asset_name=str( - privesc_row.get("starting_foothold") - or privesc_row.get("principal") - or "unknown current foothold" - ), - asset_kind=str(privesc_row.get("principal_type") or "Principal"), + + if not candidates: + return None + + candidates.sort(key=lambda item: item[0]) + return candidates[0][1] + + +def _build_escalation_single_trust_record( + *, + family_name: str, + current_foothold: dict, + trust_row: dict, + permissions_by_principal: dict[str, dict], + current_foothold_id: str, + current_foothold_permission: dict | None, +) -> ChainPathRecord | None: + target_permission_id = str(trust_row.get("target_object_id") or "").strip() + target_permission_name = str( + trust_row.get("target_name") or trust_row.get("target_object_id") or "unknown target" + ).strip() + target_permission = permissions_by_principal.get(target_permission_id) + backing_service_principal_id = str(trust_row.get("backing_service_principal_id") or "").strip() + backing_service_principal_name = str( + trust_row.get("backing_service_principal_name") or "" + ).strip() + if not target_permission and backing_service_principal_id: + target_permission_id = backing_service_principal_id + target_permission_name = backing_service_principal_name or target_permission_id + target_permission = permissions_by_principal.get(target_permission_id) + escalation_mechanism = str(trust_row.get("escalation_mechanism") or "").strip() + usable_identity_result = str(trust_row.get("usable_identity_result") or "").strip() + defender_cut_point = str(trust_row.get("defender_cut_point") or "").strip() + trust_type = str(trust_row.get("trust_type") or "trust-expansion") + + if not target_permission or not escalation_mechanism or not usable_identity_result: + return None + if not _permission_adds_net_value(current_foothold_permission, target_permission): + return None + + target_resolution = "path-confirmed" + stronger_outcome = _permission_control_summary(target_permission) + semantic = evaluate_chain_semantics( + ChainSemanticContext( + family=family_name, + clue_type=trust_type, + target_service="identity-trust", + target_resolution=target_resolution, + target_count=1, source_command="role-trusts", - source_context=str( - trust_row.get("source_name") or trust_row.get("source_object_id") or "" - ), - clue_type=str(trust_row.get("trust_type") or "trust-expansion"), - confirmation_basis=str(trust_row.get("confidence") or "confirmed"), - priority=semantic.priority, - urgency=semantic.urgency, - visible_path="Current foothold -> trust edge -> higher-value identity", - insertion_point=escalation_mechanism, path_concept="trust-expansion", - stronger_outcome=stronger_outcome, - why_care=why_care, - likely_impact=stronger_outcome, - confidence_boundary=confidence_boundary, + ) + ) + confidence_boundary = ( + f"{escalation_mechanism} {usable_identity_result} " + "AzureFox can also confirm the stronger target's Azure control. " + "AzureFox does not prove successful conversion of that control path into usable " + "downstream identity access from this row alone." + ).strip() + next_review = str(trust_row.get("next_review") or semantic.next_review) + why_care = _escalation_trust_note( + trust_row=trust_row, + current_foothold_permission=current_foothold_permission, + target_permission=target_permission, + target_permission_name=target_permission_name, + ) + if defender_cut_point: + why_care = f"{why_care} {defender_cut_point}" + + return ChainPathRecord( + chain_id=f"escalation-path::{current_foothold_id}::trust-expansion::{target_permission_id}", + asset_id=str(current_foothold_id), + asset_name=str( + current_foothold.get("starting_foothold") + or current_foothold.get("principal") + or "unknown current foothold" + ), + asset_kind=str(current_foothold.get("principal_type") or "Principal"), + source_command="role-trusts", + source_context=str(trust_row.get("source_name") or trust_row.get("source_object_id") or ""), + clue_type=trust_type, + confirmation_basis=str(trust_row.get("confidence") or "confirmed"), + priority=semantic.priority, + urgency=semantic.urgency, + visible_path=_escalation_visible_path(trust_type), + insertion_point=escalation_mechanism, + path_concept="trust-expansion", + stronger_outcome=stronger_outcome, + why_care=why_care, + likely_impact=stronger_outcome, + confidence_boundary=confidence_boundary, + target_service="identity-trust", + target_resolution=target_resolution, + evidence_commands=["role-trusts", "permissions"], + joined_surface_types=["current-foothold", "trust-edge"], + target_count=1, + target_ids=[target_permission_id], + target_names=[target_permission_name], + next_review=next_review, + summary=f"{confidence_boundary} {next_review}".strip(), + missing_confirmation=( + "AzureFox does not prove successful conversion of the visible trust-control path " + "into usable downstream identity access." + ), + related_ids=_merge_related_ids( + [str(value) for value in trust_row.get("related_ids") or [] if value], + [target_permission_id], + ), + ) + + +def _escalation_trust_candidate_sort_key( + *, + clue_type: str, + record: ChainPathRecord, + current_foothold_permission: dict | None, + target_permission: dict | None, +) -> tuple[int, int, int, int, int, str]: + path_preference_rank = { + "service-principal-owner": 0, + "federated-credential": 1, + "app-owner": 2, + "app-to-service-principal": 3, + }.get(clue_type, 9) + return ( + semantic_priority_sort_value(record.priority), + _JOIN_QUALITY_ORDER.get(record.target_resolution, 9), + -len(_permission_new_scope_ids(current_foothold_permission, target_permission)), + -_permission_role_strength(target_permission), + path_preference_rank, + record.chain_id, + ) + + +def _escalation_target_permission_for_sort( + *, + trust_row: dict, + permissions_by_principal: dict[str, dict], +) -> dict | None: + target_permission_id = str(trust_row.get("target_object_id") or "").strip() + target_permission = permissions_by_principal.get(target_permission_id) + if target_permission: + return target_permission + backing_service_principal_id = str(trust_row.get("backing_service_principal_id") or "").strip() + if backing_service_principal_id: + return permissions_by_principal.get(backing_service_principal_id) + return None + + +def _escalation_visible_path(trust_type: str) -> str: + if trust_type == "service-principal-owner": + return "Current foothold -> service principal takeover -> higher-value identity" + if trust_type == "app-owner": + return "Current foothold -> app control -> higher-value identity" + if trust_type == "app-to-service-principal": + return "Current foothold -> app permission -> higher-value identity" + return "Current foothold -> trust edge -> higher-value identity" + + +def _build_escalation_federated_takeover_record( + *, + family_name: str, + current_foothold: dict, + current_trust_row: dict, + federated_trust_row: dict, + permissions_by_principal: dict[str, dict], + current_foothold_id: str, + current_foothold_permission: dict | None, +) -> ChainPathRecord | None: + target_permission_id = str(federated_trust_row.get("target_object_id") or "").strip() + target_permission_name = str( + federated_trust_row.get("target_name") + or federated_trust_row.get("target_object_id") + or "unknown target" + ).strip() + target_permission = permissions_by_principal.get(target_permission_id) + if not target_permission or not _permission_adds_net_value( + current_foothold_permission, target_permission + ): + return None + + app_name = str( + current_trust_row.get("target_name") + or current_trust_row.get("target_object_id") + or "unknown application" + ).strip() + usable_identity_result = str( + federated_trust_row.get("usable_identity_result") or "" + ).strip() + if not usable_identity_result: + return None + + target_resolution = "path-confirmed" + stronger_outcome = _permission_control_summary(target_permission) + semantic = evaluate_chain_semantics( + ChainSemanticContext( + family=family_name, + clue_type="federated-credential", target_service="identity-trust", target_resolution=target_resolution, - evidence_commands=["privesc", "role-trusts", "permissions"], - joined_surface_types=["current-foothold", "trust-edge"], target_count=1, - target_ids=[str(trust_row.get("target_object_id") or "")], - target_names=[ - str(trust_row.get("target_name") or trust_row.get("target_object_id") or "") - ], - next_review=next_review, - summary=f"{confidence_boundary} {next_review}".strip(), - missing_confirmation=( - "AzureFox does not prove successful conversion of the visible trust-control path " - "into usable downstream identity access." - ), - related_ids=[str(value) for value in trust_row.get("related_ids") or [] if value], + source_command="role-trusts", + path_concept="trust-expansion", ) + ) + confidence_boundary = ( + f"The current foothold can control application '{app_name}'. " + f"Application '{app_name}' already has federated trust that can yield service principal " + f"'{target_permission_name}' access. {usable_identity_result} AzureFox can also confirm " + "the stronger target's Azure control. AzureFox does not prove that the current foothold " + "already controls the visible federated subject or has already changed this federated " + "trust from this row alone." + ).strip() + defender_cut_point = str(current_trust_row.get("defender_cut_point") or "").strip() + why_care = _escalation_federated_note( + app_name=app_name, + target_permission_name=target_permission_name, + current_foothold_permission=current_foothold_permission, + target_permission=target_permission, + ) + if defender_cut_point: + why_care = f"{why_care} {defender_cut_point}" - return None + return ChainPathRecord( + chain_id=f"escalation-path::{current_foothold_id}::trust-expansion::{target_permission_id}::federated", + asset_id=str(current_foothold_id), + asset_name=str( + current_foothold.get("starting_foothold") + or current_foothold.get("principal") + or "unknown current foothold" + ), + asset_kind=str(current_foothold.get("principal_type") or "Principal"), + source_command="role-trusts", + source_context=str( + current_trust_row.get("source_name") or current_trust_row.get("source_object_id") or "" + ), + clue_type="federated-credential", + confirmation_basis=str(federated_trust_row.get("confidence") or "confirmed"), + priority=semantic.priority, + urgency=semantic.urgency, + visible_path=( + "Current foothold -> app control -> existing federated trust -> higher-value " + "identity" + ), + insertion_point=( + f"Application '{app_name}' already has federated trust that can yield " + f"service principal '{target_permission_name}' access." + ), + path_concept="trust-expansion", + stronger_outcome=stronger_outcome, + why_care=why_care, + likely_impact=stronger_outcome, + confidence_boundary=confidence_boundary, + target_service="identity-trust", + target_resolution=target_resolution, + evidence_commands=["role-trusts", "permissions"], + joined_surface_types=["current-foothold", "trust-edge", "federated-trust"], + target_count=1, + target_ids=[target_permission_id], + target_names=[target_permission_name], + next_review=str(federated_trust_row.get("next_review") or semantic.next_review), + summary=confidence_boundary, + missing_confirmation=( + "AzureFox does not prove that the current foothold already controls the visible " + "federated subject or has already changed the federated trust." + ), + related_ids=_merge_related_ids( + [str(value) for value in current_trust_row.get("related_ids") or [] if value], + [str(value) for value in federated_trust_row.get("related_ids") or [] if value], + [target_permission_id], + ), + ) + + +def _escalation_trust_note( + *, + trust_row: dict, + current_foothold_permission: dict | None, + target_permission: dict, + target_permission_name: str, +) -> str: + trust_type = str(trust_row.get("trust_type") or "").strip() + target_name = str( + trust_row.get("target_name") or trust_row.get("target_object_id") or "" + ).strip() + backing_service_principal_name = str( + trust_row.get("backing_service_principal_name") or "" + ).strip() + gain_text = _permission_gain_text(current_foothold_permission, target_permission) + + if trust_type == "app-owner" and target_name and backing_service_principal_name: + return ( + f"The current foothold can control application '{target_name}', which backs " + f"service principal '{backing_service_principal_name}'. {gain_text} AzureFox is not " + f"proving that the current foothold has already turned application control into " + f"usable '{backing_service_principal_name}' access." + ) + + if trust_type == "service-principal-owner" and target_name: + return ( + f"The current foothold can take over service principal '{target_name}'. " + f"{gain_text} AzureFox is not proving that the current foothold has already added " + f"or used authentication material for service principal '{target_name}'." + ) + + return ( + f"The current foothold can reach '{target_permission_name}'. {gain_text} AzureFox is " + "not proving that the current foothold has already turned this trust edge into usable " + "downstream identity access." + ) + + +def _escalation_federated_note( + *, + app_name: str, + target_permission_name: str, + current_foothold_permission: dict | None, + target_permission: dict, +) -> str: + gain_text = _permission_gain_text(current_foothold_permission, target_permission) + return ( + f"The current foothold can control application '{app_name}', and that application " + f"already has federated trust into service principal '{target_permission_name}'. " + f"{gain_text} AzureFox is not proving that the current foothold already controls the " + "visible federated subject or has already changed that federated trust to make " + f"'{target_permission_name}' usable." + ) def _merge_related_ids(*groups: list[str]) -> list[str]: @@ -3248,5 +3595,133 @@ def _permission_control_summary(permission_row: dict | None) -> str: role_text = ", ".join(roles) or "high-impact roles" scope_text = _permission_scope_text(permission_row) return f"{role_text} across {scope_text}" + + +def _permission_adds_net_value( + current_permission: dict | None, + target_permission: dict | None, +) -> bool: + if not target_permission: + return False + if not current_permission: + return True + if _permission_new_scope_ids(current_permission, target_permission): + return True + return _permission_role_strength(target_permission) > _permission_role_strength( + current_permission + ) + + +def _permission_new_scope_ids( + current_permission: dict | None, + target_permission: dict | None, +) -> list[str]: + target_scope_ids = [ + str(value) + for value in (target_permission or {}).get("scope_ids") or [] + if value + ] + if not current_permission: + return target_scope_ids + return [ + scope_id + for scope_id in target_scope_ids + if not any( + _scope_applies_to_resource(str(current_scope_id), scope_id) + for current_scope_id in current_permission.get("scope_ids") or [] + if current_scope_id + ) + ] + + +def _permission_role_strength(permission_row: dict | None) -> int: + ranks = { + "contributor": 1, + "user access administrator": 2, + "owner": 3, + } + roles = (permission_row or {}).get("high_impact_roles") or [] + return max( + (ranks.get(_normalize_role_name(role), 0) for role in roles), + default=0, + ) + + +def _permission_capability_text(permission_row: dict | None) -> str: + roles = { + _normalize_role_name(role) + for role in (permission_row or {}).get("high_impact_roles") or [] + } + if "owner" in roles: + return "Owner-level Azure control, including role assignment" + if "user access administrator" in roles: + return "role-assignment control" + if "contributor" in roles: + return "write/change control" + return "meaningful Azure control" + + +def _permission_gain_text( + current_permission: dict | None, + target_permission: dict | None, +) -> str: + target_scope_ids = [ + str(value) + for value in (target_permission or {}).get("scope_ids") or [] + if value + ] + new_scope_ids = _permission_new_scope_ids(current_permission, target_permission) + target_capability = _permission_capability_text(target_permission) + + if new_scope_ids: + return f"That would add {target_capability} on {_scope_list_text(new_scope_ids)}." + + current_capability = _permission_capability_text(current_permission) + if _permission_role_strength(target_permission) > _permission_role_strength(current_permission): + return ( + f"That would upgrade the current foothold from {current_capability} to " + f"{target_capability} on {_scope_list_text(target_scope_ids)}." + ) + + return f"That would reach {target_capability} on {_scope_list_text(target_scope_ids)}." + + +def _scope_list_text(scope_ids: list[str]) -> str: + cleaned = [scope_id for scope_id in scope_ids if scope_id] + if not cleaned: + return "the visible scope" + if len(cleaned) > 3: + return f"{len(cleaned)} visible scopes" + + resource_groups = [ + _arm_scope_name(scope_id) + for scope_id in cleaned + if _arm_scope_kind(scope_id) == "resource_group" + ] + if len(resource_groups) == len(cleaned) and all(resource_groups): + quoted = [f"'{name}'" for name in resource_groups if name] + if len(quoted) == 1: + return f"resource group {quoted[0]}" + return "resource groups " + ", ".join(quoted[:-1]) + f" and {quoted[-1]}" + + labels = [_scope_label(scope_id) for scope_id in cleaned] + if len(labels) == 1: + return labels[0] + return ", ".join(labels[:-1]) + f" and {labels[-1]}" + + +def _scope_label(scope_id: str) -> str: + kind = _arm_scope_kind(scope_id) + if kind == "resource_group": + name = _arm_scope_name(scope_id) or "unknown" + return f"resource group '{name}'" + if kind == "subscription": + return "subscription scope" + if kind == "resource": + name = _arm_scope_name(scope_id) or "unknown resource" + return f"resource '{name}'" + return "visible scope" + + def _source_chain_id(family_name: str, asset_id: str, target_service: str) -> str: return f"{family_name}::{asset_id}::{target_service}" diff --git a/src/azurefox/collectors/commands.py b/src/azurefox/collectors/commands.py index 8ec1ad6..91d0725 100644 --- a/src/azurefox/collectors/commands.py +++ b/src/azurefox/collectors/commands.py @@ -1351,7 +1351,7 @@ def _enrich_permission_rows(permissions: list[dict], principals: list[dict]) -> def _enrich_role_trust_rows(trusts: list[dict]) -> list[dict]: enriched: list[dict] = [] - backing_service_principal_by_application_id: dict[str, str] = {} + backing_service_principal_by_application_id: dict[str, tuple[str, str]] = {} for trust in trusts: if ( @@ -1359,10 +1359,12 @@ def _enrich_role_trust_rows(trusts: list[dict]) -> list[dict]: and str(trust.get("source_type") or "") == "Application" and str(trust.get("target_type") or "") == "ServicePrincipal" and trust.get("source_object_id") + and trust.get("target_object_id") and trust.get("target_name") ): - backing_service_principal_by_application_id[str(trust["source_object_id"])] = str( - trust["target_name"] + backing_service_principal_by_application_id[str(trust["source_object_id"])] = ( + str(trust["target_object_id"]), + str(trust["target_name"]), ) for trust in trusts: @@ -1373,9 +1375,15 @@ def _enrich_role_trust_rows(trusts: list[dict]) -> list[dict]: summary = str(item.get("summary") or "") target_type = str(item.get("target_type") or "identity") source_type = str(item.get("source_type") or "identity") - backing_service_principal_name = backing_service_principal_by_application_id.get( + backing_service_principal = backing_service_principal_by_application_id.get( str(item.get("target_object_id") or "") ) + backing_service_principal_id = ( + backing_service_principal[0] if backing_service_principal else None + ) + backing_service_principal_name = ( + backing_service_principal[1] if backing_service_principal else None + ) controlled_object_type, controlled_object_name = role_trust_controlled_object( trust_type=trust_type, @@ -1390,6 +1398,8 @@ def _enrich_role_trust_rows(trusts: list[dict]) -> list[dict]: ) item["controlled_object_type"] = controlled_object_type item["controlled_object_name"] = controlled_object_name + item["backing_service_principal_id"] = backing_service_principal_id + item["backing_service_principal_name"] = backing_service_principal_name item["escalation_mechanism"] = role_trust_escalation_mechanism( trust_type=trust_type, source_name=source_name, diff --git a/src/azurefox/collectors/provider.py b/src/azurefox/collectors/provider.py index 3184981..a36a9ea 100644 --- a/src/azurefox/collectors/provider.py +++ b/src/azurefox/collectors/provider.py @@ -2052,7 +2052,6 @@ def privesc(self) -> dict: "asset": None, "starting_foothold": starting_foothold, "impact_roles": impact_roles, - "severity": "high" if current_identity else "medium", "priority": "high" if current_identity else "medium", "current_identity": current_identity, "operator_signal": operator_signal, @@ -2109,7 +2108,6 @@ def privesc(self) -> dict: "asset": vm_asset.get("name") or attached_id, "starting_foothold": starting_foothold, "impact_roles": impact_roles, - "severity": "high", "priority": "medium", "current_identity": False, "operator_signal": operator_signal, @@ -4522,15 +4520,13 @@ def _principal_has_high_impact_roles(role_names: list[object]) -> bool: ) -def _privesc_sort_key(item: dict) -> tuple[int, int, bool, int, str, str]: - severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3} +def _privesc_sort_key(item: dict) -> tuple[int, bool, int, str, str]: path_type_rank = { "public-identity-pivot": 0, "direct-role-abuse": 1, } return ( {"high": 0, "medium": 1, "low": 2}.get(str(item.get("priority") or "").lower(), 9), - severity_rank.get(str(item.get("severity") or "").lower(), 9), not bool(item.get("current_identity")), path_type_rank.get(str(item.get("path_type") or ""), 9), str(item.get("principal") or ""), diff --git a/src/azurefox/escalation_hints.py b/src/azurefox/escalation_hints.py new file mode 100644 index 0000000..c9367fe --- /dev/null +++ b/src/azurefox/escalation_hints.py @@ -0,0 +1,27 @@ +from __future__ import annotations + + +def current_foothold_proven_path( + *, + principal_name: str, + impact_roles: list[str], +) -> str: + role_text = ", ".join(impact_roles) or "high-impact roles" + return ( + f"Current foothold '{principal_name}' already holds high-impact RBAC " + f"({role_text}) on visible scope." + ) + + +def current_foothold_missing_proof() -> str: + return ( + "AzureFox does not prove which exact abuse action is the best next step " + "from this row alone." + ) + + +def current_foothold_next_review_hint() -> str: + return ( + "Check rbac for the exact assignment evidence and scope behind this " + "current-identity escalation lead." + ) diff --git a/src/azurefox/help.py b/src/azurefox/help.py index d25768f..c1802d0 100644 --- a/src/azurefox/help.py +++ b/src/azurefox/help.py @@ -779,8 +779,8 @@ class SectionHelpTopic: name="role-trusts", section="identity", summary=( - "Triage Azure app and service-principal trust edges worth abuse review before exact " - "identity-control transforms are proven." + "Triage Azure app and service-principal trust edges, including the clearest visible " + "identity-control transforms, before treating them as direct Azure control." ), offensive_question=( "Which Azure app, service-principal, ownership, and federated relationships deserve " @@ -791,9 +791,9 @@ class SectionHelpTopic: "principals, federated credentials, ownership, and app-role assignments rather " "than delegated or admin consent grants. Fast mode is the default; full mode is " "the explicit slower tenant-wide application sweep that performs per-application " - "owner and federated credential lookups. This command is still relationship-first " - "triage; chain families should only promote rows once the identity-control " - "mechanism is explicit." + "owner and federated credential lookups. This command is still trust-edge triage; " + "chain families should only promote rows once the identity-control mechanism and " + "downstream Azure control are both explicit." ), output_highlights=( "trust_type", diff --git a/src/azurefox/models/common.py b/src/azurefox/models/common.py index 1b34f90..4fd095f 100644 --- a/src/azurefox/models/common.py +++ b/src/azurefox/models/common.py @@ -106,7 +106,6 @@ class PrivescPathSummary(BaseModel): path_type: str asset: str | None = None impact_roles: list[str] = Field(default_factory=list) - severity: str priority: str current_identity: bool = False operator_signal: str | None = None @@ -130,6 +129,8 @@ class RoleTrustSummary(BaseModel): control_primitive: str | None = None controlled_object_type: str | None = None controlled_object_name: str | None = None + backing_service_principal_id: str | None = None + backing_service_principal_name: str | None = None escalation_mechanism: str | None = None usable_identity_result: str | None = None defender_cut_point: str | None = None diff --git a/src/azurefox/output/writer.py b/src/azurefox/output/writer.py index 78531ae..5b80bdf 100644 --- a/src/azurefox/output/writer.py +++ b/src/azurefox/output/writer.py @@ -6,6 +6,7 @@ import typer +from azurefox.chains.presentation import normalize_chain_payload_for_output from azurefox.config import GlobalOptions from azurefox.models.common import OutputMode from azurefox.render.table import render_table @@ -56,6 +57,9 @@ "vmss": "vmss_assets", } +def _normalize_payload_for_output(command: str, payload: dict) -> dict: + return normalize_chain_payload_for_output(command, payload) + def emit_output( command: str, @@ -64,7 +68,7 @@ def emit_output( *, emit_stdout: bool = True, ) -> dict[str, Path]: - payload = model.model_dump(mode="json") + payload = _normalize_payload_for_output(command, model.model_dump(mode="json")) artifact_paths = write_artifacts(command, payload, options) if not emit_stdout: @@ -86,6 +90,7 @@ def emit_output( def write_artifacts(command: str, payload: dict, options: GlobalOptions) -> dict[str, Path]: + payload = _normalize_payload_for_output(command, payload) options.json_dir.mkdir(parents=True, exist_ok=True) options.table_dir.mkdir(parents=True, exist_ok=True) options.csv_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/azurefox/privesc_hints.py b/src/azurefox/privesc_hints.py index 731e516..766055e 100644 --- a/src/azurefox/privesc_hints.py +++ b/src/azurefox/privesc_hints.py @@ -1,5 +1,11 @@ from __future__ import annotations +from azurefox.escalation_hints import ( + current_foothold_missing_proof, + current_foothold_next_review_hint, + current_foothold_proven_path, +) + def privesc_operator_signal(*, path_type: str, current_identity: bool) -> str: if path_type == "public-identity-pivot": @@ -31,9 +37,9 @@ def privesc_proven_path( ) if current_identity: - return ( - f"Current foothold '{principal_name}' already holds high-impact RBAC " - f"({role_text}) on visible scope." + return current_foothold_proven_path( + principal_name=principal_name, + impact_roles=impact_roles, ) return ( @@ -47,10 +53,7 @@ def privesc_missing_proof(*, path_type: str, current_identity: bool) -> str: return "AzureFox does not prove control of the workload or successful token use from it." if current_identity: - return ( - "AzureFox does not prove which exact abuse action is the best next step " - "from this row alone." - ) + return current_foothold_missing_proof() return "AzureFox does not prove the current identity can act as or control this principal." @@ -63,10 +66,7 @@ def privesc_next_review_hint(*, path_type: str, current_identity: bool) -> str: ) if current_identity: - return ( - "Check rbac for the exact assignment evidence and scope behind this " - "current-identity escalation lead." - ) + return current_foothold_next_review_hint() return ( "Check role-trusts for paths that could let the current identity influence " diff --git a/src/azurefox/render/table.py b/src/azurefox/render/table.py index cedfd94..56d57b4 100644 --- a/src/azurefox/render/table.py +++ b/src/azurefox/render/table.py @@ -9,6 +9,15 @@ from rich.table import Table from azurefox.auth.modes import auth_mode_label +from azurefox.chains.presentation import ( + compute_control_identity_label, + compute_control_proof_status_label, + compute_control_reach_from_here_label, + compute_control_token_path_label, + compute_control_when_label, + escalation_path_type_label, + normalize_chain_payload_for_output, +) from azurefox.devops_hints import describe_trusted_input, devops_next_review_hint from azurefox.env_var_hints import env_var_next_review_hint from azurefox.tokens_credential_hints import tokens_credential_next_review_hint @@ -23,6 +32,7 @@ def render_table(command: str, payload: dict) -> str: + payload = normalize_chain_payload_for_output(command, payload) sio = StringIO() console = Console(file=sio, force_terminal=False, color_system=None, width=160) @@ -740,22 +750,18 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis ("asset_name", "starting foothold"), ("path_concept", "path type"), ("stronger_outcome", "stronger outcome"), - ("confidence_boundary", "confidence boundary"), - ("next_review", "next review"), - ("why_care", "why care"), + ("why_care", "note"), ], [ { "priority": item.get("priority"), "urgency": item.get("urgency") or "-", - "asset_name": item.get("asset_name"), - "path_concept": _escalation_path_type(item), + "asset_name": item.get("starting_foothold") or item.get("asset_name"), + "path_concept": item.get("path_type") + or escalation_path_type_label(str(item.get("path_concept") or "")), "stronger_outcome": item.get("stronger_outcome") or item.get("likely_impact"), - "confidence_boundary": item.get("confidence_boundary") - or _chains_note(item, family=family), - "next_review": item.get("next_review"), - "why_care": item.get("why_care"), + "why_care": item.get("note") or item.get("why_care"), } for item in payload.get("paths", []) ], @@ -805,15 +811,25 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis [ { "priority": item.get("priority"), - "when": _compute_control_when(item), - "workload_reach": _compute_control_workload_reach(item), - "asset_name": item.get("asset_name"), - "insertion_point": _compute_control_token_path(item), - "identity": _compute_control_identity(item), - "stronger_outcome": item.get("stronger_outcome") + "when": item.get("when") + or compute_control_when_label(str(item.get("urgency") or "")), + "workload_reach": item.get("reach_from_here") + or compute_control_reach_from_here_label( + str(item.get("insertion_point") or "") + ), + "asset_name": item.get("compute_foothold") or item.get("asset_name"), + "insertion_point": item.get("token_path") + or compute_control_token_path_label(str(item.get("insertion_point") or "")), + "identity": item.get("identity") + or compute_control_identity_label(item.get("target_names") or []), + "stronger_outcome": item.get("azure_access") + or item.get("stronger_outcome") or item.get("likely_impact"), - "proof_status": _compute_control_proof_status(item), - "why_care": item.get("why_care"), + "proof_status": item.get("proof_status") + or compute_control_proof_status_label( + str(item.get("target_resolution") or "") + ), + "why_care": item.get("note") or item.get("why_care"), } for item in payload.get("paths", []) ], @@ -1913,7 +1929,7 @@ def _takeaway_for_command(command: str, payload: dict) -> str: if family == "escalation-path": concepts = Counter(item.get("path_concept") or "unknown" for item in paths) concept_counts = ", ".join( - f"{count} {_escalation_path_type({'path_concept': name})}" + f"{count} {escalation_path_type_label(str(name))}" for name, count in concepts.items() if name != "unknown" ) @@ -2160,15 +2176,6 @@ def _stack_chain_insertion_point(value: object) -> str: return text -def _escalation_path_type(item: dict) -> str: - concept = str(item.get("path_concept") or "") - labels = { - "current-foothold-direct-control": "current foothold direct control", - "trust-expansion": "trust expansion", - } - return labels.get(concept, concept or "-") - - def _compute_control_path_type(item: dict) -> str: concept = str(item.get("path_concept") or "") labels = { @@ -2177,56 +2184,6 @@ def _compute_control_path_type(item: dict) -> str: return labels.get(concept, concept or "-") -def _compute_control_when(item: dict) -> str: - urgency = str(item.get("urgency") or "") - labels = { - "pivot-now": "act now", - "review-soon": "review soon", - "bookmark": "keep in view", - } - return labels.get(urgency, urgency or "-") - - -def _compute_control_token_path(item: dict) -> str: - insertion_point = str(item.get("insertion_point") or "") - labels = { - "reachable service token request path": "service token request", - "public IMDS token path": "public VM metadata token", - "IMDS token path": "VM metadata token", - } - return labels.get(insertion_point, insertion_point or "-") - - -def _compute_control_workload_reach(item: dict) -> str: - insertion_point = str(item.get("insertion_point") or "") - if insertion_point in {"reachable service token request path", "public IMDS token path"}: - return "public exposure visible; exploitation not proved" - return "current access does not show the start" - - -def _compute_control_identity(item: dict) -> str: - names = [str(value) for value in item.get("target_names") or [] if str(value).strip()] - if not names: - return "not visible" - if len(names) == 1: - return names[0] - return "multiple possible: " + ", ".join(names) - - -def _compute_control_proof_status(item: dict) -> str: - resolution = str(item.get("target_resolution") or "") - labels = { - "path-confirmed": "confirmed", - "identity-choice-corroborated": "best current match", - "narrowed candidates": "multiple identities possible", - "visibility blocked": "limited visibility", - "tenant-wide candidates": "broad match only", - "service hint only": "early signal only", - "named target not visible": "named identity not visible", - } - return labels.get(resolution, "bounded") - - def _chains_note(item: dict, *, family: str = "") -> str: resolution = str(item.get("target_resolution") or "") target_service = str(item.get("target_service") or "target") diff --git a/src/azurefox/role_trust_hints.py b/src/azurefox/role_trust_hints.py index 68e0d76..185137b 100644 --- a/src/azurefox/role_trust_hints.py +++ b/src/azurefox/role_trust_hints.py @@ -1,20 +1,51 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass(frozen=True) +class _RoleTrustHintContext: + source_name: str | None = None + source_type: str = "ServicePrincipal" + source_object_id: str = "unknown" + target_name: str | None = None + target_type: str = "ServicePrincipal" + target_object_id: str = "unknown" + backing_service_principal_name: str | None = None + summary: str = "" + next_review: str = "" + + +ControlledObjectResolver = Callable[ + [_RoleTrustHintContext], tuple[str | None, str | None] +] +TextResolver = Callable[[_RoleTrustHintContext], str | None] +RequiredTextResolver = Callable[[_RoleTrustHintContext], str] + + +@dataclass(frozen=True) +class _RoleTrustStrategy: + control_primitive: str | None + controlled_object: ControlledObjectResolver + escalation_mechanism: TextResolver + usable_identity_result: TextResolver + defender_cut_point: TextResolver + operator_signal: str + next_review_hint: RequiredTextResolver + summary_suffix: RequiredTextResolver + def role_trust_control_primitive( *, trust_type: str, target_type: str, ) -> str | None: - if trust_type == "app-owner": - return "change-auth-material" - if trust_type == "service-principal-owner": - return "owner-control" - if trust_type == "federated-credential": - return "existing-federated-credential" - if trust_type == "app-to-service-principal": - return "existing-app-role-assignment" - return None + del target_type + strategy = _role_trust_strategy(trust_type) + if strategy is None: + return None + return strategy.control_primitive def role_trust_controlled_object( @@ -25,9 +56,16 @@ def role_trust_controlled_object( target_name: str | None, target_type: str, ) -> tuple[str | None, str | None]: - if trust_type == "federated-credential": - return source_type or "Application", source_name - return target_type, target_name + strategy = _role_trust_strategy(trust_type) + if strategy is None: + return target_type, target_name + context = _RoleTrustHintContext( + source_name=source_name, + source_type=source_type, + target_name=target_name, + target_type=target_type, + ) + return strategy.controlled_object(context) def role_trust_escalation_mechanism( @@ -38,43 +76,16 @@ def role_trust_escalation_mechanism( target_type: str, backing_service_principal_name: str | None = None, ) -> str | None: - if trust_type == "app-owner": - app_name = target_name or "unknown application" - if backing_service_principal_name: - return ( - f"Control of application '{app_name}' could change authentication material " - f"that makes service principal '{backing_service_principal_name}' usable." - ) - return ( - f"Control of application '{app_name}' could change authentication material Azure " - "accepts for identities backed by that application." - ) - - if trust_type == "service-principal-owner": - principal = _identity_ref(target_name, "unknown", target_type) - return ( - f"Owner-level control over {principal} is visible, but the exact " - "authentication-control transform is not yet explicit." - ) - - if trust_type == "federated-credential": - app_name = source_name or "unknown application" - if target_type == "ServicePrincipal" and target_name: - return ( - f"Application '{app_name}' already has federated trust that can yield " - f"service principal '{target_name}' access." - ) - return f"Application '{app_name}' already has a federated trust path." - - if trust_type == "app-to-service-principal": - source = source_name or "unknown service principal" - target = target_name or "unknown service principal" - return ( - f"Service principal '{source}' already holds an application-permission path into " - f"service principal '{target}'." - ) - - return None + strategy = _role_trust_strategy(trust_type) + if strategy is None: + return None + context = _RoleTrustHintContext( + source_name=source_name, + target_name=target_name, + target_type=target_type, + backing_service_principal_name=backing_service_principal_name, + ) + return strategy.escalation_mechanism(context) def role_trust_usable_identity_result( @@ -85,22 +96,16 @@ def role_trust_usable_identity_result( target_type: str, backing_service_principal_name: str | None = None, ) -> str | None: - if trust_type == "app-owner" and target_name and backing_service_principal_name: - return ( - f"Control of application '{target_name}' could make service principal " - f"'{backing_service_principal_name}' usable." - ) - - if trust_type == "federated-credential" and target_type == "ServicePrincipal" and target_name: - return f"Federated sign-in can yield service principal '{target_name}' access." - - if trust_type == "app-to-service-principal" and source_name and target_name: - return ( - f"Service principal '{source_name}' already has application-permission reach to " - f"'{target_name}'." - ) - - return None + strategy = _role_trust_strategy(trust_type) + if strategy is None: + return None + context = _RoleTrustHintContext( + source_name=source_name, + target_name=target_name, + target_type=target_type, + backing_service_principal_name=backing_service_principal_name, + ) + return strategy.usable_identity_result(context) def role_trust_defender_cut_point( @@ -110,28 +115,15 @@ def role_trust_defender_cut_point( target_name: str | None, target_type: str, ) -> str | None: - if trust_type == "app-owner" and target_name: - return ( - "Remove the ownership path that lets the source control application " - f"'{target_name}'." - ) - - if trust_type == "service-principal-owner": - return ( - "Remove the owner-level control path over " - f"{_identity_ref(target_name, 'unknown', target_type)}." - ) - - if trust_type == "federated-credential" and source_name: - return f"Remove or tighten the federated credential on application '{source_name}'." - - if trust_type == "app-to-service-principal" and source_name and target_name: - return ( - f"Remove the app-role assignment path from service principal '{source_name}' " - f"to '{target_name}'." - ) - - return None + strategy = _role_trust_strategy(trust_type) + if strategy is None: + return None + context = _RoleTrustHintContext( + source_name=source_name, + target_name=target_name, + target_type=target_type, + ) + return strategy.defender_cut_point(context) def role_trust_operator_signal( @@ -148,13 +140,10 @@ def role_trust_operator_signal( summary=summary, ): return "Trust expansion visible; outside-tenant follow-on." - if trust_type in {"app-owner", "service-principal-owner"}: - return "Indirect control visible; ownership review next." - if trust_type == "federated-credential": - return "Trust expansion visible; privilege confirmation next." - if trust_type == "app-to-service-principal": + strategy = _role_trust_strategy(trust_type) + if strategy is None: return "Indirect control visible; privilege confirmation next." - return "Indirect control visible; privilege confirmation next." + return strategy.operator_signal def role_trust_next_review_hint( @@ -178,39 +167,21 @@ def role_trust_next_review_hint( f"paths around {_identity_ref(target_name, target_object_id, target_type)}." ) - if trust_type == "app-owner": + strategy = _role_trust_strategy(trust_type) + if strategy is None: return ( - f"Review ownership around {_identity_ref(target_name, target_object_id, target_type)}; " - "if it backs an Azure-facing identity, confirm that identity in permissions." + "Check permissions or rbac to confirm whether this trust edge reaches meaningful " + "Azure control." ) - - if trust_type == "service-principal-owner": - return ( - f"Review ownership around {_identity_ref(target_name, target_object_id, target_type)}, " - "then confirm Azure control in permissions." - ) - - if trust_type == "federated-credential": - if target_type == "ServicePrincipal": - return ( - "Check permissions for Azure control on " - f"{_identity_ref(target_name, target_object_id, target_type)}." - ) - return ( - "Check permissions for the backing identity behind " - f"{_identity_ref(target_name, target_object_id, target_type)}." - ) - - if trust_type == "app-to-service-principal": - return ( - "Check permissions for Azure control on " - f"{_identity_ref(source_name, source_object_id, 'ServicePrincipal')}." - ) - - return ( - "Check permissions or rbac to confirm whether this trust edge reaches meaningful " - "Azure control." + context = _RoleTrustHintContext( + source_name=source_name, + source_object_id=source_object_id, + target_name=target_name, + target_object_id=target_object_id, + target_type=target_type, + summary=summary, ) + return strategy.next_review_hint(context) def role_trust_summary( @@ -232,25 +203,257 @@ def role_trust_summary( f"confirmation first. {next_review}" ) - if trust_type in {"app-owner", "service-principal-owner"}: + strategy = _role_trust_strategy(trust_type) + if strategy is None: + return f"{summary} {next_review}" + context = _RoleTrustHintContext(summary=summary, next_review=next_review) + return strategy.summary_suffix(context) + + +def _role_trust_strategy(trust_type: str) -> _RoleTrustStrategy | None: + return _ROLE_TRUST_STRATEGIES.get(trust_type) + + +def _default_controlled_object( + context: _RoleTrustHintContext, +) -> tuple[str | None, str | None]: + return context.target_type, context.target_name + + +def _federated_controlled_object( + context: _RoleTrustHintContext, +) -> tuple[str | None, str | None]: + return context.source_type or "Application", context.source_name + + +def _app_owner_escalation_mechanism(context: _RoleTrustHintContext) -> str: + app_name = context.target_name or "unknown application" + if context.backing_service_principal_name: + return ( + f"Control of application '{app_name}' could change authentication material " + f"that makes service principal '{context.backing_service_principal_name}' usable." + ) + return ( + f"Control of application '{app_name}' could change authentication material Azure " + "accepts for identities backed by that application." + ) + + +def _service_principal_owner_escalation_mechanism( + context: _RoleTrustHintContext, +) -> str: + principal = _identity_ref(context.target_name, "unknown", context.target_type) + return ( + f"Owner-level control over {principal} could add or replace authentication material " + f"Azure accepts for {principal}." + ) + + +def _federated_credential_escalation_mechanism( + context: _RoleTrustHintContext, +) -> str: + app_name = context.source_name or "unknown application" + if context.target_type == "ServicePrincipal" and context.target_name: + return ( + f"Application '{app_name}' already has federated trust that can yield " + f"service principal '{context.target_name}' access." + ) + return f"Application '{app_name}' already has a federated trust path." + + +def _app_to_service_principal_escalation_mechanism( + context: _RoleTrustHintContext, +) -> str: + source = context.source_name or "unknown service principal" + target = context.target_name or "unknown service principal" + return ( + f"Service principal '{source}' already holds an application-permission path into " + f"service principal '{target}'." + ) + + +def _app_owner_usable_identity_result( + context: _RoleTrustHintContext, +) -> str | None: + if context.target_name and context.backing_service_principal_name: return ( - f"{summary} This is an indirect-control row: ownership is the visible trust path, " - f"not direct Azure privilege by itself. {next_review}" + f"Control of application '{context.target_name}' could make service principal " + f"'{context.backing_service_principal_name}' usable." ) + return None + - if trust_type == "federated-credential": +def _service_principal_owner_usable_identity_result( + context: _RoleTrustHintContext, +) -> str: + principal = _identity_ref(context.target_name, "unknown", context.target_type) + return f"That could make {principal} usable." + + +def _federated_credential_usable_identity_result( + context: _RoleTrustHintContext, +) -> str | None: + if context.target_type == "ServicePrincipal" and context.target_name: return ( - f"{summary} This row shows trust expansion into the target identity rather than " - f"direct Azure privilege by itself. {next_review}" + f"Federated sign-in can yield service principal '{context.target_name}' access." ) + return None + - if trust_type == "app-to-service-principal": +def _app_to_service_principal_usable_identity_result( + context: _RoleTrustHintContext, +) -> str | None: + if context.source_name and context.target_name: return ( - f"{summary} This row is a trust-edge and application-permission cue; confirm whether " - f"the same identity also holds Azure control. {next_review}" + f"Service principal '{context.source_name}' already has application-permission " + f"reach to '{context.target_name}'." ) + return None + + +def _app_owner_defender_cut_point(context: _RoleTrustHintContext) -> str | None: + if not context.target_name: + return None + return ( + "Remove the ownership path that lets the source control application " + f"'{context.target_name}'." + ) + + +def _service_principal_owner_defender_cut_point( + context: _RoleTrustHintContext, +) -> str: + return ( + "Remove the owner-level control path over " + f"{_identity_ref(context.target_name, 'unknown', context.target_type)}." + ) + + +def _federated_credential_defender_cut_point( + context: _RoleTrustHintContext, +) -> str | None: + if not context.source_name: + return None + return ( + f"Remove or tighten the federated credential on application " + f"'{context.source_name}'." + ) + + +def _app_to_service_principal_defender_cut_point( + context: _RoleTrustHintContext, +) -> str | None: + if not context.source_name or not context.target_name: + return None + return ( + f"Remove the app-role assignment path from service principal " + f"'{context.source_name}' to '{context.target_name}'." + ) + + +def _app_owner_next_review_hint(context: _RoleTrustHintContext) -> str: + return ( + f"Review ownership around " + f"{_identity_ref(context.target_name, context.target_object_id, context.target_type)}; " + "if it backs an Azure-facing identity, confirm that identity in permissions." + ) + + +def _identity_permissions_next_review_hint(context: _RoleTrustHintContext) -> str: + return ( + "Check permissions for Azure control on " + f"{_identity_ref(context.target_name, context.target_object_id, context.target_type)}." + ) + + +def _federated_credential_next_review_hint(context: _RoleTrustHintContext) -> str: + if context.target_type == "ServicePrincipal": + return _identity_permissions_next_review_hint(context) + return ( + "Check permissions for the backing identity behind " + f"{_identity_ref(context.target_name, context.target_object_id, context.target_type)}." + ) + + +def _app_to_service_principal_next_review_hint( + context: _RoleTrustHintContext, +) -> str: + return ( + "Check permissions for Azure control on " + f"{_identity_ref(context.source_name, context.source_object_id, 'ServicePrincipal')}." + ) + + +def _app_owner_summary(context: _RoleTrustHintContext) -> str: + return ( + f"{context.summary} This is an indirect-control row: ownership is the visible trust " + f"path, not direct Azure privilege by itself. {context.next_review}" + ) + + +def _service_principal_owner_summary(context: _RoleTrustHintContext) -> str: + return ( + f"{context.summary} This row shows a service-principal takeover path rather than " + f"direct Azure privilege by itself. {context.next_review}" + ) + + +def _federated_credential_summary(context: _RoleTrustHintContext) -> str: + return ( + f"{context.summary} This row shows trust expansion into the target identity rather " + f"than direct Azure privilege by itself. {context.next_review}" + ) + + +def _app_to_service_principal_summary(context: _RoleTrustHintContext) -> str: + return ( + f"{context.summary} This row is a trust-edge and application-permission cue; confirm " + f"whether the same identity also holds Azure control. {context.next_review}" + ) + - return f"{summary} {next_review}" +_ROLE_TRUST_STRATEGIES: dict[str, _RoleTrustStrategy] = { + "app-owner": _RoleTrustStrategy( + control_primitive="change-auth-material", + controlled_object=_default_controlled_object, + escalation_mechanism=_app_owner_escalation_mechanism, + usable_identity_result=_app_owner_usable_identity_result, + defender_cut_point=_app_owner_defender_cut_point, + operator_signal="Indirect control visible; ownership review next.", + next_review_hint=_app_owner_next_review_hint, + summary_suffix=_app_owner_summary, + ), + "service-principal-owner": _RoleTrustStrategy( + control_primitive="owner-control", + controlled_object=_default_controlled_object, + escalation_mechanism=_service_principal_owner_escalation_mechanism, + usable_identity_result=_service_principal_owner_usable_identity_result, + defender_cut_point=_service_principal_owner_defender_cut_point, + operator_signal="Trust expansion visible; privilege confirmation next.", + next_review_hint=_identity_permissions_next_review_hint, + summary_suffix=_service_principal_owner_summary, + ), + "federated-credential": _RoleTrustStrategy( + control_primitive="existing-federated-credential", + controlled_object=_federated_controlled_object, + escalation_mechanism=_federated_credential_escalation_mechanism, + usable_identity_result=_federated_credential_usable_identity_result, + defender_cut_point=_federated_credential_defender_cut_point, + operator_signal="Trust expansion visible; privilege confirmation next.", + next_review_hint=_federated_credential_next_review_hint, + summary_suffix=_federated_credential_summary, + ), + "app-to-service-principal": _RoleTrustStrategy( + control_primitive="existing-app-role-assignment", + controlled_object=_default_controlled_object, + escalation_mechanism=_app_to_service_principal_escalation_mechanism, + usable_identity_result=_app_to_service_principal_usable_identity_result, + defender_cut_point=_app_to_service_principal_defender_cut_point, + operator_signal="Trust expansion visible; privilege confirmation next.", + next_review_hint=_app_to_service_principal_next_review_hint, + summary_suffix=_app_to_service_principal_summary, + ), +} def _outside_tenant_follow_on( diff --git a/tests/fixtures/lab_tenant/permissions.json b/tests/fixtures/lab_tenant/permissions.json index 963ef76..e615f3e 100644 --- a/tests/fixtures/lab_tenant/permissions.json +++ b/tests/fixtures/lab_tenant/permissions.json @@ -34,6 +34,25 @@ "privileged": false, "is_current_identity": false }, + { + "principal_id": "66666666-6666-6666-6666-666666666666", + "display_name": "build-sp", + "principal_type": "ServicePrincipal", + "high_impact_roles": [ + "Owner" + ], + "all_role_names": [ + "Owner" + ], + "role_assignment_count": 2, + "scope_count": 2, + "scope_ids": [ + "/subscriptions/99999999-9999-9999-9999-999999999999/resourceGroups/rg-build-dr", + "/subscriptions/99999999-9999-9999-9999-999999999999/resourceGroups/rg-identity" + ], + "privileged": true, + "is_current_identity": false + }, { "principal_id": "12121212-1212-1212-1212-121212121212", "display_name": "aa-hybrid-prod-mi", @@ -88,6 +107,42 @@ "privileged": true, "is_current_identity": false }, + { + "principal_id": "abab1111-1111-1111-1111-111111111111", + "display_name": "aca-orders-system", + "principal_type": "ServicePrincipal", + "high_impact_roles": [ + "Contributor" + ], + "all_role_names": [ + "Contributor" + ], + "role_assignment_count": 1, + "scope_count": 1, + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers" + ], + "privileged": true, + "is_current_identity": false + }, + { + "principal_id": "acac1111-1111-1111-1111-111111111111", + "display_name": "aci-public-api-system", + "principal_type": "ServicePrincipal", + "high_impact_roles": [ + "Contributor" + ], + "all_role_names": [ + "Contributor" + ], + "role_assignment_count": 1, + "scope_count": 1, + "scope_ids": [ + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps" + ], + "privileged": true, + "is_current_identity": false + }, { "principal_id": "77770000-0000-0000-0000-000000000001", "display_name": "vmss-edge-01-system", diff --git a/tests/fixtures/lab_tenant/privesc.json b/tests/fixtures/lab_tenant/privesc.json index d8bbae8..b1ef3f5 100644 --- a/tests/fixtures/lab_tenant/privesc.json +++ b/tests/fixtures/lab_tenant/privesc.json @@ -11,7 +11,6 @@ "Owner" ], "priority": "high", - "severity": "high", "current_identity": true, "operator_signal": "Current foothold already has direct control.", "proven_path": "Current foothold 'azurefox-lab-sp' already holds high-impact RBAC (Owner) on visible scope.", @@ -34,7 +33,6 @@ "Owner" ], "priority": "medium", - "severity": "high", "current_identity": false, "operator_signal": "Visible ingress-backed lead; not yet rooted in current foothold.", "proven_path": "Public workload 'vm-web-01' carries identity 'ua-app' with high-impact RBAC (Owner).", diff --git a/tests/fixtures/lab_tenant/role_trusts.json b/tests/fixtures/lab_tenant/role_trusts.json index d9ca05b..68b3dfd 100644 --- a/tests/fixtures/lab_tenant/role_trusts.json +++ b/tests/fixtures/lab_tenant/role_trusts.json @@ -1,6 +1,38 @@ { "issues": [], "trusts": [ + { + "trust_type": "app-owner", + "source_object_id": "33333333-3333-3333-3333-333333333333", + "source_name": "azurefox-lab-sp", + "source_type": "ServicePrincipal", + "target_object_id": "55555555-5555-5555-5555-555555555555", + "target_name": "build-app", + "target_type": "Application", + "evidence_type": "graph-owner", + "confidence": "confirmed", + "summary": "Owner 'azurefox-lab-sp' can modify application 'build-app'.", + "related_ids": [ + "33333333-3333-3333-3333-333333333333", + "55555555-5555-5555-5555-555555555555" + ] + }, + { + "trust_type": "service-principal-owner", + "source_object_id": "33333333-3333-3333-3333-333333333333", + "source_name": "azurefox-lab-sp", + "source_type": "ServicePrincipal", + "target_object_id": "66666666-6666-6666-6666-666666666666", + "target_name": "build-sp", + "target_type": "ServicePrincipal", + "evidence_type": "graph-owner", + "confidence": "confirmed", + "summary": "Owner 'azurefox-lab-sp' can modify service principal 'build-sp'.", + "related_ids": [ + "33333333-3333-3333-3333-333333333333", + "66666666-6666-6666-6666-666666666666" + ] + }, { "trust_type": "app-owner", "source_object_id": "77777777-7777-7777-7777-777777777777", diff --git a/tests/golden/permissions.json b/tests/golden/permissions.json index 4718dc5..5d4e386 100644 --- a/tests/golden/permissions.json +++ b/tests/golden/permissions.json @@ -1,145 +1,212 @@ { - "issues": [], "metadata": { - "auth_mode": null, - "command": "permissions", - "devops_organization": null, - "generated_at": "2026-04-12T18:56:06.241502Z", "schema_version": "1.4.0", - "subscription_id": "22222222-2222-2222-2222-222222222222", + "command": "permissions", + "generated_at": "2026-04-13T04:16:02.407341Z", "tenant_id": "11111111-1111-1111-1111-111111111111", - "token_source": null + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null }, "permissions": [ { - "all_role_names": [ - "Owner" - ], + "principal_id": "33333333-3333-3333-3333-333333333333", "display_name": "azurefox-lab-sp", + "principal_type": "ServicePrincipal", + "priority": "high", "high_impact_roles": [ "Owner" ], - "is_current_identity": true, - "next_review": "Check privesc for the direct abuse or escalation path behind this current identity.", - "operator_signal": "Direct control visible; current foothold.", - "principal_id": "33333333-3333-3333-3333-333333333333", - "principal_type": "ServicePrincipal", - "priority": "high", - "privileged": true, + "all_role_names": [ + "Owner" + ], "role_assignment_count": 1, "scope_count": 1, "scope_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222" ], + "privileged": true, + "is_current_identity": true, + "operator_signal": "Direct control visible; current foothold.", + "next_review": "Check privesc for the direct abuse or escalation path behind this current identity.", "summary": "Current identity 'azurefox-lab-sp' already has direct control visible through Owner across subscription-wide. Check privesc for the direct abuse or escalation path behind this current identity." }, { - "all_role_names": [ - "Contributor" - ], + "principal_id": "eeee3333-3333-3333-3333-333333333333", "display_name": "app-empty-mi-system", + "principal_type": "ServicePrincipal", + "priority": "high", "high_impact_roles": [ "Contributor" ], - "is_current_identity": false, - "next_review": "Check managed-identities for the workload pivot behind this direct control row.", - "operator_signal": "Direct control visible; workload pivot visible.", - "principal_id": "eeee3333-3333-3333-3333-333333333333", - "principal_type": "ServicePrincipal", - "priority": "high", - "privileged": true, + "all_role_names": [ + "Contributor" + ], "role_assignment_count": 1, "scope_count": 1, "scope_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps" ], + "privileged": true, + "is_current_identity": false, + "operator_signal": "Direct control visible; workload pivot visible.", + "next_review": "Check managed-identities for the workload pivot behind this direct control row.", "summary": "ServicePrincipal 'app-empty-mi-system' already has direct control visible through Contributor across subscription-wide, and current scope also shows a workload pivot. Check managed-identities for the workload pivot behind this direct control row." }, { - "all_role_names": [ - "Contributor" - ], + "principal_id": "cccc2222-2222-2222-2222-222222222222", "display_name": "func-orders-system", + "principal_type": "ServicePrincipal", + "priority": "high", "high_impact_roles": [ "Contributor" ], - "is_current_identity": false, - "next_review": "Check managed-identities for the workload pivot behind this direct control row.", - "operator_signal": "Direct control visible; workload pivot visible.", - "principal_id": "cccc2222-2222-2222-2222-222222222222", - "principal_type": "ServicePrincipal", - "priority": "high", - "privileged": true, + "all_role_names": [ + "Contributor" + ], "role_assignment_count": 1, "scope_count": 1, "scope_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps" ], + "privileged": true, + "is_current_identity": false, + "operator_signal": "Direct control visible; workload pivot visible.", + "next_review": "Check managed-identities for the workload pivot behind this direct control row.", "summary": "ServicePrincipal 'func-orders-system' already has direct control visible through Contributor across subscription-wide, and current scope also shows a workload pivot. Check managed-identities for the workload pivot behind this direct control row." }, { - "all_role_names": [ - "Contributor" - ], + "principal_id": "77770000-0000-0000-0000-000000000001", "display_name": "vmss-edge-01-system", + "principal_type": "ServicePrincipal", + "priority": "high", "high_impact_roles": [ "Contributor" ], - "is_current_identity": false, - "next_review": "Check managed-identities for the workload pivot behind this direct control row.", - "operator_signal": "Direct control visible; workload pivot visible.", - "principal_id": "77770000-0000-0000-0000-000000000001", - "principal_type": "ServicePrincipal", - "priority": "high", - "privileged": true, + "all_role_names": [ + "Contributor" + ], "role_assignment_count": 1, "scope_count": 1, "scope_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload" ], + "privileged": true, + "is_current_identity": false, + "operator_signal": "Direct control visible; workload pivot visible.", + "next_review": "Check managed-identities for the workload pivot behind this direct control row.", "summary": "ServicePrincipal 'vmss-edge-01-system' already has direct control visible through Contributor across subscription-wide, and current scope also shows a workload pivot. Check managed-identities for the workload pivot behind this direct control row." }, { + "principal_id": "66666666-6666-6666-6666-666666666666", + "display_name": "build-sp", + "principal_type": "ServicePrincipal", + "priority": "medium", + "high_impact_roles": [ + "Owner" + ], "all_role_names": [ - "Contributor" + "Owner" ], - "display_name": "aa-hybrid-prod-mi", - "high_impact_roles": [ - "Contributor" + "role_assignment_count": 2, + "scope_count": 2, + "scope_ids": [ + "/subscriptions/99999999-9999-9999-9999-999999999999/resourceGroups/rg-build-dr", + "/subscriptions/99999999-9999-9999-9999-999999999999/resourceGroups/rg-identity" ], + "privileged": true, "is_current_identity": false, - "next_review": "Check role-trusts for trust expansion around who can influence this principal.", "operator_signal": "Direct control visible; trust expansion follow-on.", + "next_review": "Check role-trusts for trust expansion around who can influence this principal.", + "summary": "ServicePrincipal 'build-sp' already has direct control visible through Owner across 2 visible scopes. The next useful question is trust expansion, not more privilege ranking. Check role-trusts for trust expansion around who can influence this principal." + }, + { "principal_id": "12121212-1212-1212-1212-121212121212", + "display_name": "aa-hybrid-prod-mi", "principal_type": "ServicePrincipal", "priority": "medium", - "privileged": true, + "high_impact_roles": [ + "Contributor" + ], + "all_role_names": [ + "Contributor" + ], "role_assignment_count": 1, "scope_count": 1, "scope_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops" ], + "privileged": true, + "is_current_identity": false, + "operator_signal": "Direct control visible; trust expansion follow-on.", + "next_review": "Check role-trusts for trust expansion around who can influence this principal.", "summary": "ServicePrincipal 'aa-hybrid-prod-mi' already has direct control visible through Contributor across subscription-wide. The next useful question is trust expansion, not more privilege ranking. Check role-trusts for trust expansion around who can influence this principal." }, { + "principal_id": "abab1111-1111-1111-1111-111111111111", + "display_name": "aca-orders-system", + "principal_type": "ServicePrincipal", + "priority": "medium", + "high_impact_roles": [ + "Contributor" + ], "all_role_names": [ - "Reader" + "Contributor" ], - "display_name": "operator@lab.local", - "high_impact_roles": [], + "role_assignment_count": 1, + "scope_count": 1, + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers" + ], + "privileged": true, "is_current_identity": false, - "next_review": "Check rbac for the exact assignment evidence behind this lower-signal row.", - "operator_signal": "Direct control not confirmed.", + "operator_signal": "Direct control visible; trust expansion follow-on.", + "next_review": "Check role-trusts for trust expansion around who can influence this principal.", + "summary": "ServicePrincipal 'aca-orders-system' already has direct control visible through Contributor across subscription-wide. The next useful question is trust expansion, not more privilege ranking. Check role-trusts for trust expansion around who can influence this principal." + }, + { + "principal_id": "acac1111-1111-1111-1111-111111111111", + "display_name": "aci-public-api-system", + "principal_type": "ServicePrincipal", + "priority": "medium", + "high_impact_roles": [ + "Contributor" + ], + "all_role_names": [ + "Contributor" + ], + "role_assignment_count": 1, + "scope_count": 1, + "scope_ids": [ + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps" + ], + "privileged": true, + "is_current_identity": false, + "operator_signal": "Direct control visible; trust expansion follow-on.", + "next_review": "Check role-trusts for trust expansion around who can influence this principal.", + "summary": "ServicePrincipal 'aci-public-api-system' already has direct control visible through Contributor across subscription-wide. The next useful question is trust expansion, not more privilege ranking. Check role-trusts for trust expansion around who can influence this principal." + }, + { "principal_id": "44444444-4444-4444-4444-444444444444", + "display_name": "operator@lab.local", "principal_type": "User", "priority": "low", - "privileged": false, + "high_impact_roles": [], + "all_role_names": [ + "Reader" + ], "role_assignment_count": 1, "scope_count": 1, "scope_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222" ], + "privileged": false, + "is_current_identity": false, + "operator_signal": "Direct control not confirmed.", + "next_review": "Check rbac for the exact assignment evidence behind this lower-signal row.", "summary": "Principal 'operator@lab.local' does not yet show direct control from visible RBAC. Check rbac for the exact assignment evidence behind this lower-signal row." } - ] + ], + "issues": [] } diff --git a/tests/golden/privesc.json b/tests/golden/privesc.json index e7446e5..93dc4a3 100644 --- a/tests/golden/privesc.json +++ b/tests/golden/privesc.json @@ -31,7 +31,6 @@ "33333333-3333-3333-3333-333333333333", "/subscriptions/22222222-2222-2222-2222-222222222222" ], - "severity": "high", "summary": "Current foothold 'azurefox-lab-sp' already holds high-impact RBAC (Owner) on visible scope. AzureFox does not prove which exact abuse action is the best next step from this row alone. Check rbac for the exact assignment evidence and scope behind this current-identity escalation lead." }, { @@ -56,7 +55,6 @@ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01", "/subscriptions/22222222-2222-2222-2222-222222222222" ], - "severity": "high", "summary": "Public workload 'vm-web-01' carries identity 'ua-app' with high-impact RBAC (Owner). AzureFox does not prove control of the workload or successful token use from it. Check managed-identities for the workload-to-identity anchor behind this ingress-backed lead." } ] diff --git a/tests/golden/role-trusts.json b/tests/golden/role-trusts.json index b35579f..202e797 100644 --- a/tests/golden/role-trusts.json +++ b/tests/golden/role-trusts.json @@ -1,136 +1,200 @@ { - "issues": [], "metadata": { - "command": "role-trusts", - "generated_at": "", "schema_version": "1.4.0", - "subscription_id": "22222222-2222-2222-2222-222222222222", + "command": "role-trusts", + "generated_at": "2026-04-13T04:32:36.867098Z", "tenant_id": "11111111-1111-1111-1111-111111111111", - "token_source": null + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null }, "mode": "fast", "trusts": [ { + "trust_type": "federated-credential", + "source_object_id": "55555555-5555-5555-5555-555555555555", + "source_name": "build-app", + "source_type": "Application", + "target_object_id": "66666666-6666-6666-6666-666666666666", + "target_name": "build-sp", + "target_type": "ServicePrincipal", + "evidence_type": "graph-federated-credential", "confidence": "confirmed", "control_primitive": "existing-federated-credential", - "controlled_object_name": "build-app", "controlled_object_type": "Application", - "defender_cut_point": "Remove or tighten the federated credential on application 'build-app'.", - "evidence_type": "graph-federated-credential", + "controlled_object_name": "build-app", + "backing_service_principal_id": null, + "backing_service_principal_name": null, "escalation_mechanism": "Application 'build-app' already has federated trust that can yield service principal 'build-sp' access.", - "next_review": "Check permissions for Azure control on service principal 'build-sp'.", + "usable_identity_result": "Federated sign-in can yield service principal 'build-sp' access.", + "defender_cut_point": "Remove or tighten the federated credential on application 'build-app'.", "operator_signal": "Trust expansion visible; privilege confirmation next.", + "next_review": "Check permissions for Azure control on service principal 'build-sp'.", + "summary": "Application 'build-app' trusts federated subject 'repo:TacoRocket/AzureFox:ref:refs/heads/main' from issuer 'https://token.actions.githubusercontent.com'. This row shows trust expansion into the target identity rather than direct Azure privilege by itself. Check permissions for Azure control on service principal 'build-sp'.", "related_ids": [ "55555555-5555-5555-5555-555555555555", "fic-build-main", "66666666-6666-6666-6666-666666666666" - ], - "source_name": "build-app", - "source_object_id": "55555555-5555-5555-5555-555555555555", - "source_type": "Application", - "summary": "Application 'build-app' trusts federated subject 'repo:TacoRocket/AzureFox:ref:refs/heads/main' from issuer 'https://token.actions.githubusercontent.com'. This row shows trust expansion into the target identity rather than direct Azure privilege by itself. Check permissions for Azure control on service principal 'build-sp'.", - "target_name": "build-sp", - "target_object_id": "66666666-6666-6666-6666-666666666666", - "target_type": "ServicePrincipal", - "trust_type": "federated-credential", - "usable_identity_result": "Federated sign-in can yield service principal 'build-sp' access." + ] }, { + "trust_type": "service-principal-owner", + "source_object_id": "12121212-1212-1212-1212-121212121212", + "source_name": "aa-hybrid-prod", + "source_type": "ServicePrincipal", + "target_object_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + "target_name": "ops-deploy-sp", + "target_type": "ServicePrincipal", + "evidence_type": "graph-owner", "confidence": "confirmed", "control_primitive": "owner-control", - "controlled_object_name": "ops-deploy-sp", "controlled_object_type": "ServicePrincipal", + "controlled_object_name": "ops-deploy-sp", + "backing_service_principal_id": null, + "backing_service_principal_name": null, + "escalation_mechanism": "Owner-level control over service principal 'ops-deploy-sp' could add or replace authentication material Azure accepts for service principal 'ops-deploy-sp'.", + "usable_identity_result": "That could make service principal 'ops-deploy-sp' usable.", "defender_cut_point": "Remove the owner-level control path over service principal 'ops-deploy-sp'.", - "evidence_type": "graph-owner", - "escalation_mechanism": "Owner-level control over service principal 'ops-deploy-sp' is visible, but the exact authentication-control transform is not yet explicit.", - "next_review": "Review ownership around service principal 'ops-deploy-sp', then confirm Azure control in permissions.", - "operator_signal": "Indirect control visible; ownership review next.", + "operator_signal": "Trust expansion visible; privilege confirmation next.", + "next_review": "Check permissions for Azure control on service principal 'ops-deploy-sp'.", + "summary": "Owner 'aa-hybrid-prod' can modify service principal 'ops-deploy-sp'. This row shows a service-principal takeover path rather than direct Azure privilege by itself. Check permissions for Azure control on service principal 'ops-deploy-sp'.", "related_ids": [ "12121212-1212-1212-1212-121212121212", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" - ], - "source_name": "aa-hybrid-prod", - "source_object_id": "12121212-1212-1212-1212-121212121212", - "source_type": "ServicePrincipal", - "summary": "Owner 'aa-hybrid-prod' can modify service principal 'ops-deploy-sp'. This is an indirect-control row: ownership is the visible trust path, not direct Azure privilege by itself. Review ownership around service principal 'ops-deploy-sp', then confirm Azure control in permissions.", - "target_name": "ops-deploy-sp", - "target_object_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", - "target_type": "ServicePrincipal", - "trust_type": "service-principal-owner", - "usable_identity_result": null + ] }, { + "trust_type": "service-principal-owner", + "source_object_id": "88888888-8888-8888-8888-888888888888", + "source_name": "automation-runner", + "source_type": "ServicePrincipal", + "target_object_id": "66666666-6666-6666-6666-666666666666", + "target_name": "build-sp", + "target_type": "ServicePrincipal", + "evidence_type": "graph-owner", "confidence": "confirmed", "control_primitive": "owner-control", - "controlled_object_name": "build-sp", "controlled_object_type": "ServicePrincipal", + "controlled_object_name": "build-sp", + "backing_service_principal_id": null, + "backing_service_principal_name": null, + "escalation_mechanism": "Owner-level control over service principal 'build-sp' could add or replace authentication material Azure accepts for service principal 'build-sp'.", + "usable_identity_result": "That could make service principal 'build-sp' usable.", "defender_cut_point": "Remove the owner-level control path over service principal 'build-sp'.", - "evidence_type": "graph-owner", - "escalation_mechanism": "Owner-level control over service principal 'build-sp' is visible, but the exact authentication-control transform is not yet explicit.", - "next_review": "Review ownership around service principal 'build-sp', then confirm Azure control in permissions.", - "operator_signal": "Indirect control visible; ownership review next.", + "operator_signal": "Trust expansion visible; privilege confirmation next.", + "next_review": "Check permissions for Azure control on service principal 'build-sp'.", + "summary": "Owner 'automation-runner' can modify service principal 'build-sp'. This row shows a service-principal takeover path rather than direct Azure privilege by itself. Check permissions for Azure control on service principal 'build-sp'.", "related_ids": [ "88888888-8888-8888-8888-888888888888", "66666666-6666-6666-6666-666666666666" - ], - "source_name": "automation-runner", - "source_object_id": "88888888-8888-8888-8888-888888888888", + ] + }, + { + "trust_type": "service-principal-owner", + "source_object_id": "33333333-3333-3333-3333-333333333333", + "source_name": "azurefox-lab-sp", "source_type": "ServicePrincipal", - "summary": "Owner 'automation-runner' can modify service principal 'build-sp'. This is an indirect-control row: ownership is the visible trust path, not direct Azure privilege by itself. Review ownership around service principal 'build-sp', then confirm Azure control in permissions.", - "target_name": "build-sp", "target_object_id": "66666666-6666-6666-6666-666666666666", + "target_name": "build-sp", "target_type": "ServicePrincipal", - "trust_type": "service-principal-owner", - "usable_identity_result": null + "evidence_type": "graph-owner", + "confidence": "confirmed", + "control_primitive": "owner-control", + "controlled_object_type": "ServicePrincipal", + "controlled_object_name": "build-sp", + "backing_service_principal_id": null, + "backing_service_principal_name": null, + "escalation_mechanism": "Owner-level control over service principal 'build-sp' could add or replace authentication material Azure accepts for service principal 'build-sp'.", + "usable_identity_result": "That could make service principal 'build-sp' usable.", + "defender_cut_point": "Remove the owner-level control path over service principal 'build-sp'.", + "operator_signal": "Trust expansion visible; privilege confirmation next.", + "next_review": "Check permissions for Azure control on service principal 'build-sp'.", + "summary": "Owner 'azurefox-lab-sp' can modify service principal 'build-sp'. This row shows a service-principal takeover path rather than direct Azure privilege by itself. Check permissions for Azure control on service principal 'build-sp'.", + "related_ids": [ + "33333333-3333-3333-3333-333333333333", + "66666666-6666-6666-6666-666666666666" + ] }, { + "trust_type": "app-owner", + "source_object_id": "33333333-3333-3333-3333-333333333333", + "source_name": "azurefox-lab-sp", + "source_type": "ServicePrincipal", + "target_object_id": "55555555-5555-5555-5555-555555555555", + "target_name": "build-app", + "target_type": "Application", + "evidence_type": "graph-owner", "confidence": "confirmed", "control_primitive": "change-auth-material", - "controlled_object_name": "build-app", "controlled_object_type": "Application", - "defender_cut_point": "Remove the ownership path that lets the source control application 'build-app'.", - "evidence_type": "graph-owner", + "controlled_object_name": "build-app", + "backing_service_principal_id": "66666666-6666-6666-6666-666666666666", + "backing_service_principal_name": "build-sp", "escalation_mechanism": "Control of application 'build-app' could change authentication material that makes service principal 'build-sp' usable.", - "next_review": "Review ownership around application 'build-app'; if it backs an Azure-facing identity, confirm that identity in permissions.", + "usable_identity_result": "Control of application 'build-app' could make service principal 'build-sp' usable.", + "defender_cut_point": "Remove the ownership path that lets the source control application 'build-app'.", "operator_signal": "Indirect control visible; ownership review next.", + "next_review": "Review ownership around application 'build-app'; if it backs an Azure-facing identity, confirm that identity in permissions.", + "summary": "Owner 'azurefox-lab-sp' can modify application 'build-app'. This is an indirect-control row: ownership is the visible trust path, not direct Azure privilege by itself. Review ownership around application 'build-app'; if it backs an Azure-facing identity, confirm that identity in permissions.", "related_ids": [ - "77777777-7777-7777-7777-777777777777", + "33333333-3333-3333-3333-333333333333", "55555555-5555-5555-5555-555555555555" - ], - "source_name": "ci-admin@lab.local", + ] + }, + { + "trust_type": "app-owner", "source_object_id": "77777777-7777-7777-7777-777777777777", + "source_name": "ci-admin@lab.local", "source_type": "User", - "summary": "Owner 'ci-admin@lab.local' can modify application 'build-app'. This is an indirect-control row: ownership is the visible trust path, not direct Azure privilege by itself. Review ownership around application 'build-app'; if it backs an Azure-facing identity, confirm that identity in permissions.", - "target_name": "build-app", "target_object_id": "55555555-5555-5555-5555-555555555555", + "target_name": "build-app", "target_type": "Application", - "trust_type": "app-owner", - "usable_identity_result": "Control of application 'build-app' could make service principal 'build-sp' usable." + "evidence_type": "graph-owner", + "confidence": "confirmed", + "control_primitive": "change-auth-material", + "controlled_object_type": "Application", + "controlled_object_name": "build-app", + "backing_service_principal_id": "66666666-6666-6666-6666-666666666666", + "backing_service_principal_name": "build-sp", + "escalation_mechanism": "Control of application 'build-app' could change authentication material that makes service principal 'build-sp' usable.", + "usable_identity_result": "Control of application 'build-app' could make service principal 'build-sp' usable.", + "defender_cut_point": "Remove the ownership path that lets the source control application 'build-app'.", + "operator_signal": "Indirect control visible; ownership review next.", + "next_review": "Review ownership around application 'build-app'; if it backs an Azure-facing identity, confirm that identity in permissions.", + "summary": "Owner 'ci-admin@lab.local' can modify application 'build-app'. This is an indirect-control row: ownership is the visible trust path, not direct Azure privilege by itself. Review ownership around application 'build-app'; if it backs an Azure-facing identity, confirm that identity in permissions.", + "related_ids": [ + "77777777-7777-7777-7777-777777777777", + "55555555-5555-5555-5555-555555555555" + ] }, { + "trust_type": "app-to-service-principal", + "source_object_id": "99999999-9999-9999-9999-999999999999", + "source_name": "reporting-sp", + "source_type": "ServicePrincipal", + "target_object_id": "00000003-0000-0000-c000-000000000000", + "target_name": "Microsoft Graph", + "target_type": "ServicePrincipal", + "evidence_type": "graph-app-role-assignment", "confidence": "confirmed", "control_primitive": "existing-app-role-assignment", - "controlled_object_name": "Microsoft Graph", "controlled_object_type": "ServicePrincipal", - "defender_cut_point": "Remove the app-role assignment path from service principal 'reporting-sp' to 'Microsoft Graph'.", - "evidence_type": "graph-app-role-assignment", + "controlled_object_name": "Microsoft Graph", + "backing_service_principal_id": null, + "backing_service_principal_name": null, "escalation_mechanism": "Service principal 'reporting-sp' already holds an application-permission path into service principal 'Microsoft Graph'.", + "usable_identity_result": "Service principal 'reporting-sp' already has application-permission reach to 'Microsoft Graph'.", + "defender_cut_point": "Remove the app-role assignment path from service principal 'reporting-sp' to 'Microsoft Graph'.", + "operator_signal": "Trust expansion visible; privilege confirmation next.", "next_review": "Check permissions for Azure control on service principal 'reporting-sp'.", - "operator_signal": "Indirect control visible; privilege confirmation next.", + "summary": "Service principal 'reporting-sp' holds an application permission or app-role assignment to 'Microsoft Graph'. This row is a trust-edge and application-permission cue; confirm whether the same identity also holds Azure control. Check permissions for Azure control on service principal 'reporting-sp'.", "related_ids": [ "99999999-9999-9999-9999-999999999999", "app-role-graph-1", "00000003-0000-0000-c000-000000000000" - ], - "source_name": "reporting-sp", - "source_object_id": "99999999-9999-9999-9999-999999999999", - "source_type": "ServicePrincipal", - "summary": "Service principal 'reporting-sp' holds an application permission or app-role assignment to 'Microsoft Graph'. This row is a trust-edge and application-permission cue; confirm whether the same identity also holds Azure control. Check permissions for Azure control on service principal 'reporting-sp'.", - "target_name": "Microsoft Graph", - "target_object_id": "00000003-0000-0000-c000-000000000000", - "target_type": "ServicePrincipal", - "trust_type": "app-to-service-principal", - "usable_identity_result": "Service principal 'reporting-sp' already has application-permission reach to 'Microsoft Graph'." + ] } - ] + ], + "issues": [] } diff --git a/tests/test_chain_semantics.py b/tests/test_chain_semantics.py index b7ea9b3..4233c44 100644 --- a/tests/test_chain_semantics.py +++ b/tests/test_chain_semantics.py @@ -1,8 +1,12 @@ from __future__ import annotations +from pathlib import Path +from types import SimpleNamespace + from azurefox.chains.credential_path import _build_candidate_record from azurefox.chains.deployment_path import DeploymentSourceAssessment from azurefox.chains.runner import ( + _build_escalation_path_output, _build_escalation_trust_record, _deployment_confidence_boundary, _deployment_current_operator_suffix, @@ -20,7 +24,8 @@ semantic_priority_sort_value, semantic_urgency_sort_value, ) -from azurefox.models.common import RoleTrustSummary +from azurefox.config import GlobalOptions +from azurefox.models.common import OutputMode, PermissionSummary, RoleTrustSummary def test_credential_path_semantics_promote_named_match() -> None: @@ -338,7 +343,7 @@ def test_escalation_path_semantics_promote_current_foothold_direct_control() -> target_service="azure-control", target_resolution="path-confirmed", target_count=1, - source_command="privesc", + source_command="permissions", path_concept="current-foothold-direct-control", ) ) @@ -400,8 +405,8 @@ def test_escalation_path_trust_rows_require_explicit_transform_and_target_contro controlled_object_type="ServicePrincipal", controlled_object_name="build-sp", escalation_mechanism=( - "Owner-level control over service principal 'build-sp' is visible, but the exact " - "authentication-control transform is not yet explicit." + "Owner-level control over service principal 'build-sp' could add or replace " + "authentication material Azure accepts for service principal 'build-sp'." ), usable_identity_result=None, defender_cut_point="Remove the owner-level control path over service principal 'build-sp'.", @@ -421,11 +426,178 @@ def test_escalation_path_trust_rows_require_explicit_transform_and_target_contro } }, current_foothold_id="sp-1", + current_foothold_permission=None, ) assert record is None +def test_escalation_path_ignores_visible_privileged_principal_that_is_not_current() -> None: + options = GlobalOptions( + tenant="tenant-id", + subscription="sub-id", + output=OutputMode.TABLE, + outdir=Path("/tmp/azurefox-escalation-path-negative"), + debug=False, + ) + + loaded = { + "permissions": SimpleNamespace( + permissions=[ + PermissionSummary( + principal_id="user-1", + display_name="operator@lab.local", + principal_type="User", + priority="low", + high_impact_roles=[], + all_role_names=["Reader"], + scope_count=1, + scope_ids=["/subscriptions/sub-id"], + privileged=False, + is_current_identity=True, + ), + PermissionSummary( + principal_id="sp-1", + display_name="nearby-owner-sp", + principal_type="ServicePrincipal", + priority="medium", + high_impact_roles=["Owner"], + all_role_names=["Owner"], + scope_count=1, + scope_ids=["/subscriptions/other-sub"], + privileged=True, + is_current_identity=False, + ), + ], + issues=[], + ), + "role-trusts": SimpleNamespace(trusts=[], issues=[]), + } + + output = _build_escalation_path_output(options, "escalation-path", loaded) + + assert output.backing_commands == ["permissions", "role-trusts"] + assert output.paths == [] + + +def test_escalation_path_keeps_multiple_current_footholds_instead_of_picking_one() -> None: + options = GlobalOptions( + tenant="tenant-id", + subscription="sub-id", + output=OutputMode.TABLE, + outdir=Path("/tmp/azurefox-escalation-path-multi-current"), + debug=False, + ) + + loaded = { + "permissions": SimpleNamespace( + permissions=[ + PermissionSummary( + principal_id="sp-1", + display_name="current-owner-a", + principal_type="ServicePrincipal", + priority="high", + high_impact_roles=["Owner"], + all_role_names=["Owner"], + scope_count=1, + scope_ids=["/subscriptions/sub-a"], + privileged=True, + is_current_identity=True, + ), + PermissionSummary( + principal_id="sp-2", + display_name="current-owner-b", + principal_type="ServicePrincipal", + priority="high", + high_impact_roles=["Contributor"], + all_role_names=["Contributor"], + scope_count=1, + scope_ids=["/subscriptions/sub-b/resourceGroups/rg-apps"], + privileged=True, + is_current_identity=True, + ), + ], + issues=[], + ), + "role-trusts": SimpleNamespace(trusts=[], issues=[]), + } + + output = _build_escalation_path_output(options, "escalation-path", loaded) + + direct_rows = [ + row for row in output.paths if row.path_concept == "current-foothold-direct-control" + ] + assert [row.asset_name for row in direct_rows] == [ + "current-owner-a (current foothold)", + "current-owner-b (current foothold)", + ] + + +def test_escalation_path_service_principal_takeover_rows_use_explicit_transform_fields() -> None: + privesc_row = { + "starting_foothold": "azurefox-lab-sp (current foothold)", + "principal": "azurefox-lab-sp", + "principal_type": "ServicePrincipal", + } + transform_ready = RoleTrustSummary( + trust_type="service-principal-owner", + source_object_id="sp-current", + source_name="azurefox-lab-sp", + source_type="ServicePrincipal", + target_object_id="sp-1", + target_name="build-sp", + target_type="ServicePrincipal", + evidence_type="graph-owner", + confidence="confirmed", + control_primitive="owner-control", + controlled_object_type="ServicePrincipal", + controlled_object_name="build-sp", + escalation_mechanism=( + "Owner-level control over service principal 'build-sp' could add or replace " + "authentication material Azure accepts for service principal 'build-sp'." + ), + usable_identity_result="That could make service principal 'build-sp' usable.", + defender_cut_point="Remove the owner-level control path over service principal 'build-sp'.", + next_review="Check permissions for Azure control on service principal 'build-sp'.", + summary="test", + ) + + record = _build_escalation_trust_record( + "escalation-path", + privesc_row, + [transform_ready], + { + "sp-1": { + "principal_id": "sp-1", + "high_impact_roles": ["Owner"], + "scope_ids": [ + "/subscriptions/other/resourceGroups/rg-build", + "/subscriptions/other/resourceGroups/rg-shared", + ], + "scope_count": 2, + } + }, + current_foothold_id="sp-current", + current_foothold_permission={ + "principal_id": "sp-current", + "high_impact_roles": ["Owner"], + "scope_ids": ["/subscriptions/sub/resourceGroups/rg-apps"], + "scope_count": 1, + }, + ) + + assert record is not None + assert record.clue_type == "service-principal-owner" + assert record.visible_path == ( + "Current foothold -> service principal takeover -> higher-value identity" + ) + assert "could make service principal 'build-sp' usable" in record.confidence_boundary + assert "can take over service principal 'build-sp'" in (record.why_care or "") + assert "Owner-level Azure control, including role assignment" in (record.why_care or "") + assert "resource groups 'rg-build' and 'rg-shared'" in (record.why_care or "") + assert "Remove the owner-level control path" in (record.why_care or "") + + def test_escalation_path_trust_rows_use_hidden_role_trust_transform_fields() -> None: privesc_row = { "starting_foothold": "ci-admin@lab.local (current foothold)", @@ -445,6 +617,8 @@ def test_escalation_path_trust_rows_use_hidden_role_trust_transform_fields() -> control_primitive="change-auth-material", controlled_object_type="Application", controlled_object_name="build-app", + backing_service_principal_id="sp-1", + backing_service_principal_name="build-sp", escalation_mechanism=( "Control of application 'build-app' could change authentication material that makes " "service principal 'build-sp' usable." @@ -467,23 +641,413 @@ def test_escalation_path_trust_rows_use_hidden_role_trust_transform_fields() -> privesc_row, [transform_ready], { - "app-1": { - "principal_id": "app-1", + "sp-1": { + "principal_id": "sp-1", "high_impact_roles": ["Owner"], - "scope_ids": ["/subscriptions/sub"], - "scope_count": 1, + "scope_ids": [ + "/subscriptions/sub/resourceGroups/rg-build", + "/subscriptions/sub/resourceGroups/rg-shared", + ], + "scope_count": 2, } }, current_foothold_id="user-1", + current_foothold_permission={ + "principal_id": "user-1", + "high_impact_roles": ["Contributor"], + "scope_ids": ["/subscriptions/sub/resourceGroups/rg-apps"], + "scope_count": 1, + }, ) assert record is not None assert record.insertion_point == transform_ready.escalation_mechanism + assert record.target_ids == ["sp-1"] + assert record.target_names == ["build-sp"] assert "could make service principal 'build-sp' usable" in record.confidence_boundary + assert "backs service principal 'build-sp'" in (record.why_care or "") + assert "Owner-level Azure control, including role assignment" in (record.why_care or "") + assert "resource groups 'rg-build' and 'rg-shared'" in (record.why_care or "") assert "Remove the ownership path" in (record.why_care or "") assert record.target_resolution == "path-confirmed" +def test_escalation_path_prefers_visible_federated_takeover_when_app_control_exists() -> None: + privesc_row = { + "starting_foothold": "ci-admin@lab.local (current foothold)", + "principal": "ci-admin@lab.local", + "principal_type": "User", + } + app_owner = RoleTrustSummary( + trust_type="app-owner", + source_object_id="user-1", + source_name="ci-admin@lab.local", + source_type="User", + target_object_id="app-1", + target_name="build-app", + target_type="Application", + evidence_type="graph-owner", + confidence="confirmed", + control_primitive="change-auth-material", + controlled_object_type="Application", + controlled_object_name="build-app", + backing_service_principal_id="sp-1", + backing_service_principal_name="build-sp", + escalation_mechanism=( + "Control of application 'build-app' could change authentication material that makes " + "service principal 'build-sp' usable." + ), + usable_identity_result=( + "Control of application 'build-app' could make service principal 'build-sp' usable." + ), + defender_cut_point=( + "Remove the ownership path that lets the source control application 'build-app'." + ), + next_review="app-owner review", + summary="test", + ) + federated = RoleTrustSummary( + trust_type="federated-credential", + source_object_id="app-1", + source_name="build-app", + source_type="Application", + target_object_id="sp-1", + target_name="build-sp", + target_type="ServicePrincipal", + evidence_type="graph-federated-credential", + confidence="confirmed", + control_primitive="existing-federated-credential", + controlled_object_type="Application", + controlled_object_name="build-app", + escalation_mechanism=( + "Application 'build-app' already has federated trust that can yield service principal " + "'build-sp' access." + ), + usable_identity_result="Federated sign-in can yield service principal 'build-sp' access.", + next_review="Check permissions for Azure control on service principal 'build-sp'.", + summary="test", + related_ids=["app-1", "fic-1", "sp-1"], + ) + + record = _build_escalation_trust_record( + "escalation-path", + privesc_row, + [app_owner, federated], + { + "sp-1": { + "principal_id": "sp-1", + "high_impact_roles": ["Owner"], + "scope_ids": [ + "/subscriptions/sub/resourceGroups/rg-build", + "/subscriptions/sub/resourceGroups/rg-shared", + ], + "scope_count": 2, + } + }, + current_foothold_id="user-1", + current_foothold_permission={ + "principal_id": "user-1", + "high_impact_roles": ["Contributor"], + "scope_ids": ["/subscriptions/sub/resourceGroups/rg-apps"], + "scope_count": 1, + }, + ) + + assert record is not None + assert record.clue_type == "federated-credential" + assert record.insertion_point == ( + "Application 'build-app' already has federated trust that can yield service principal " + "'build-sp' access." + ) + assert ( + "already has federated trust into service principal 'build-sp'" + in (record.why_care or "") + ) + assert "Owner-level Azure control, including role assignment" in (record.why_care or "") + assert "visible federated subject" in (record.why_care or "") + assert ( + record.next_review + == "Check permissions for Azure control on service principal 'build-sp'." + ) + + +def test_escalation_path_prefers_service_principal_takeover_over_app_control_routes() -> None: + privesc_row = { + "starting_foothold": "azurefox-lab-sp (current foothold)", + "principal": "azurefox-lab-sp", + "principal_type": "ServicePrincipal", + } + app_owner = RoleTrustSummary( + trust_type="app-owner", + source_object_id="sp-current", + source_name="azurefox-lab-sp", + source_type="ServicePrincipal", + target_object_id="app-1", + target_name="build-app", + target_type="Application", + evidence_type="graph-owner", + confidence="confirmed", + control_primitive="change-auth-material", + controlled_object_type="Application", + controlled_object_name="build-app", + backing_service_principal_id="sp-1", + backing_service_principal_name="build-sp", + escalation_mechanism=( + "Control of application 'build-app' could change authentication material that makes " + "service principal 'build-sp' usable." + ), + usable_identity_result=( + "Control of application 'build-app' could make service principal 'build-sp' usable." + ), + defender_cut_point=( + "Remove the ownership path that lets the source control application 'build-app'." + ), + next_review="app-owner review", + summary="test", + ) + direct_service_owner = RoleTrustSummary( + trust_type="service-principal-owner", + source_object_id="sp-current", + source_name="azurefox-lab-sp", + source_type="ServicePrincipal", + target_object_id="sp-1", + target_name="build-sp", + target_type="ServicePrincipal", + evidence_type="graph-owner", + confidence="confirmed", + control_primitive="owner-control", + controlled_object_type="ServicePrincipal", + controlled_object_name="build-sp", + escalation_mechanism=( + "Owner-level control over service principal 'build-sp' could add or replace " + "authentication material Azure accepts for service principal 'build-sp'." + ), + usable_identity_result="That could make service principal 'build-sp' usable.", + next_review="Check permissions for Azure control on service principal 'build-sp'.", + summary="test", + related_ids=["sp-current", "sp-1"], + ) + federated = RoleTrustSummary( + trust_type="federated-credential", + source_object_id="app-1", + source_name="build-app", + source_type="Application", + target_object_id="sp-1", + target_name="build-sp", + target_type="ServicePrincipal", + evidence_type="graph-federated-credential", + confidence="confirmed", + control_primitive="existing-federated-credential", + controlled_object_type="Application", + controlled_object_name="build-app", + escalation_mechanism=( + "Application 'build-app' already has federated trust that can yield service principal " + "'build-sp' access." + ), + usable_identity_result="Federated sign-in can yield service principal 'build-sp' access.", + next_review="Check permissions for Azure control on service principal 'build-sp'.", + summary="test", + related_ids=["app-1", "fic-1", "sp-1"], + ) + + record = _build_escalation_trust_record( + "escalation-path", + privesc_row, + [app_owner, direct_service_owner, federated], + { + "sp-1": { + "principal_id": "sp-1", + "high_impact_roles": ["Owner"], + "scope_ids": [ + "/subscriptions/other/resourceGroups/rg-build", + "/subscriptions/other/resourceGroups/rg-shared", + ], + "scope_count": 2, + } + }, + current_foothold_id="sp-current", + current_foothold_permission={ + "principal_id": "sp-current", + "high_impact_roles": ["Owner"], + "scope_ids": ["/subscriptions/sub/resourceGroups/rg-apps"], + "scope_count": 1, + }, + ) + + assert record is not None + assert record.clue_type == "service-principal-owner" + assert "can take over service principal 'build-sp'" in (record.why_care or "") + assert "visible federated subject" not in (record.why_care or "") + + +def test_escalation_path_prefers_higher_value_federated_path_over_lower_value_direct_takeover( +) -> None: + privesc_row = { + "starting_foothold": "azurefox-lab-sp (current foothold)", + "principal": "azurefox-lab-sp", + "principal_type": "ServicePrincipal", + } + app_owner = RoleTrustSummary( + trust_type="app-owner", + source_object_id="sp-current", + source_name="azurefox-lab-sp", + source_type="ServicePrincipal", + target_object_id="app-1", + target_name="build-app", + target_type="Application", + evidence_type="graph-owner", + confidence="confirmed", + control_primitive="change-auth-material", + controlled_object_type="Application", + controlled_object_name="build-app", + backing_service_principal_id="sp-owner", + backing_service_principal_name="owner-sp", + escalation_mechanism=( + "Control of application 'build-app' could change authentication material that makes " + "service principal 'owner-sp' usable." + ), + usable_identity_result=( + "Control of application 'build-app' could make service principal 'owner-sp' usable." + ), + next_review="Check permissions for Azure control on service principal 'owner-sp'.", + summary="test", + ) + federated = RoleTrustSummary( + trust_type="federated-credential", + source_object_id="app-1", + source_name="build-app", + source_type="Application", + target_object_id="sp-owner", + target_name="owner-sp", + target_type="ServicePrincipal", + evidence_type="graph-federated-credential", + confidence="confirmed", + control_primitive="existing-federated-credential", + controlled_object_type="Application", + controlled_object_name="build-app", + escalation_mechanism=( + "Application 'build-app' already has federated trust that can yield service principal " + "'owner-sp' access." + ), + usable_identity_result="Federated sign-in can yield service principal 'owner-sp' access.", + next_review="Check permissions for Azure control on service principal 'owner-sp'.", + summary="test", + ) + lower_value_service_owner = RoleTrustSummary( + trust_type="service-principal-owner", + source_object_id="sp-current", + source_name="azurefox-lab-sp", + source_type="ServicePrincipal", + target_object_id="sp-low", + target_name="low-sp", + target_type="ServicePrincipal", + evidence_type="graph-owner", + confidence="confirmed", + control_primitive="owner-control", + controlled_object_type="ServicePrincipal", + controlled_object_name="low-sp", + escalation_mechanism=( + "Owner-level control over service principal 'low-sp' could add or replace " + "authentication material Azure accepts for service principal 'low-sp'." + ), + usable_identity_result="That could make service principal 'low-sp' usable.", + next_review="Check permissions for Azure control on service principal 'low-sp'.", + summary="test", + ) + + record = _build_escalation_trust_record( + "escalation-path", + privesc_row, + [app_owner, federated, lower_value_service_owner], + { + "sp-owner": { + "principal_id": "sp-owner", + "high_impact_roles": ["Owner"], + "scope_ids": [ + "/subscriptions/other/resourceGroups/rg-owner-a", + "/subscriptions/other/resourceGroups/rg-owner-b", + ], + "scope_count": 2, + }, + "sp-low": { + "principal_id": "sp-low", + "high_impact_roles": ["Contributor"], + "scope_ids": ["/subscriptions/other/resourceGroups/rg-low"], + "scope_count": 1, + }, + }, + current_foothold_id="sp-current", + current_foothold_permission={ + "principal_id": "sp-current", + "high_impact_roles": ["Contributor"], + "scope_ids": ["/subscriptions/sub/resourceGroups/rg-apps"], + "scope_count": 1, + }, + ) + + assert record is not None + assert record.clue_type == "federated-credential" + assert record.target_names == ["owner-sp"] + + +def test_escalation_path_trust_row_suppresses_when_no_net_gain() -> None: + privesc_row = { + "starting_foothold": "azurefox-lab-sp (current foothold)", + "principal": "azurefox-lab-sp", + "principal_type": "ServicePrincipal", + } + transform_ready = RoleTrustSummary( + trust_type="app-owner", + source_object_id="sp-current", + source_name="azurefox-lab-sp", + source_type="ServicePrincipal", + target_object_id="app-1", + target_name="build-app", + target_type="Application", + evidence_type="graph-owner", + confidence="confirmed", + control_primitive="change-auth-material", + controlled_object_type="Application", + controlled_object_name="build-app", + backing_service_principal_id="sp-1", + backing_service_principal_name="build-sp", + escalation_mechanism=( + "Control of application 'build-app' could change authentication material that makes " + "service principal 'build-sp' usable." + ), + usable_identity_result=( + "Control of application 'build-app' could make service principal 'build-sp' usable." + ), + summary="test", + ) + + record = _build_escalation_trust_record( + "escalation-path", + privesc_row, + [transform_ready], + { + "sp-1": { + "principal_id": "sp-1", + "high_impact_roles": ["Owner"], + "scope_ids": [ + "/subscriptions/sub/resourceGroups/rg-build", + "/subscriptions/sub/resourceGroups/rg-shared", + ], + "scope_count": 2, + } + }, + current_foothold_id="sp-current", + current_foothold_permission={ + "principal_id": "sp-current", + "high_impact_roles": ["Owner"], + "scope_ids": ["/subscriptions/sub"], + "scope_count": 1, + }, + ) + + assert record is None + + def test_devops_joined_permission_ignores_service_connection_id_matches() -> None: joined = _devops_joined_permission( { diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 2e44f31..9547437 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -361,20 +361,74 @@ def test_cli_smoke_chains_compute_control_json(tmp_path: Path) -> None: "managed-identities", "permissions", ] - assert len(payload["paths"]) == 4 + assert len(payload["paths"]) == 6 names = [item["asset_name"] for item in payload["paths"]] - assert names == ["app-empty-mi", "func-orders", "vm-web-01", "vmss-edge-01"] + assert names == [ + "aca-orders", + "aci-public-api", + "app-empty-mi", + "func-orders", + "vm-web-01", + "vmss-edge-01", + ] assert {item["target_resolution"] for item in payload["paths"]} == { "path-confirmed", "identity-choice-corroborated", } + aca_row = next(item for item in payload["paths"] if item["asset_name"] == "aca-orders") + assert aca_row["asset_kind"] == "ContainerApp" + assert aca_row["path_concept"] == "direct-token-opportunity" + assert aca_row["priority"] == "high" + assert aca_row["urgency"] == "pivot-now" + assert aca_row["when"] == "act now" + assert aca_row["reach_from_here"] == "public exposure visible; exploitation not proved" + assert aca_row["compute_foothold"] == "aca-orders" + assert aca_row["token_path"] == "service token request" + assert aca_row["identity"] == "aca-orders system identity" + assert aca_row["azure_access"] == "Contributor across subscription-wide scope" + assert aca_row["proof_status"] == "confirmed" + assert aca_row["stronger_outcome"] == "Contributor across subscription-wide scope" + assert "ContainerApp 'aca-orders' can request tokens as aca-orders system identity" in aca_row[ + "note" + ] + assert "public-facing service" in aca_row["note"] + assert "Check workloads for the compute foothold" in aca_row["next_review"] + + aci_row = next(item for item in payload["paths"] if item["asset_name"] == "aci-public-api") + assert aci_row["asset_kind"] == "ContainerInstance" + assert aci_row["path_concept"] == "direct-token-opportunity" + assert aci_row["priority"] == "high" + assert aci_row["urgency"] == "pivot-now" + assert aci_row["when"] == "act now" + assert aci_row["reach_from_here"] == "public exposure visible; exploitation not proved" + assert aci_row["compute_foothold"] == "aci-public-api" + assert aci_row["token_path"] == "service token request" + assert aci_row["identity"] == "aci-public-api system identity" + assert aci_row["azure_access"] == "Contributor across subscription-wide scope" + assert aci_row["proof_status"] == "confirmed" + assert aci_row["stronger_outcome"] == "Contributor across subscription-wide scope" + assert ( + "ContainerInstance 'aci-public-api' can request tokens as aci-public-api system identity" + in aci_row["note"] + ) + assert "public-facing container group" in aci_row["note"] + assert "Check workloads for the compute foothold" in aci_row["next_review"] + app_row = next(item for item in payload["paths"] if item["asset_name"] == "app-empty-mi") assert app_row["asset_kind"] == "AppService" assert app_row["path_concept"] == "direct-token-opportunity" assert app_row["priority"] == "high" assert app_row["urgency"] == "pivot-now" + assert app_row["when"] == "act now" + assert app_row["reach_from_here"] == "public exposure visible; exploitation not proved" + assert app_row["compute_foothold"] == "app-empty-mi" + assert app_row["token_path"] == "service token request" + assert app_row["identity"] == "app-empty-mi-system" + assert app_row["azure_access"] == "Contributor across subscription-wide scope" + assert app_row["proof_status"] == "confirmed" assert app_row["stronger_outcome"] == "Contributor across subscription-wide scope" + assert "can request tokens as app-empty-mi-system" in app_row["note"] assert "Check app-services for the running service foothold" in app_row["next_review"] func_row = next(item for item in payload["paths"] if item["asset_name"] == "func-orders") @@ -382,18 +436,33 @@ def test_cli_smoke_chains_compute_control_json(tmp_path: Path) -> None: assert func_row["path_concept"] == "direct-token-opportunity" assert func_row["priority"] == "high" assert func_row["urgency"] == "pivot-now" + assert func_row["when"] == "act now" + assert func_row["reach_from_here"] == "public exposure visible; exploitation not proved" + assert func_row["compute_foothold"] == "func-orders" + assert func_row["token_path"] == "service token request" + assert func_row["identity"] == "func-orders-system" + assert func_row["azure_access"] == "Contributor across subscription-wide scope" + assert func_row["proof_status"] == "best current match" assert func_row["target_names"] == ["func-orders-system"] assert func_row["target_resolution"] == "identity-choice-corroborated" assert func_row["confirmation_basis"] == "mixed-identity-corroborated-permission-join" assert "cannot directly verify" in func_row["confidence_boundary"] assert "SystemAssigned" in func_row["confidence_boundary"] assert "does not directly verify" in func_row["missing_confirmation"] + assert "best current lead" in func_row["note"] assert "already narrows this path to the identity shown here" in func_row["next_review"] vm_row = next(item for item in payload["paths"] if item["asset_name"] == "vm-web-01") assert vm_row["asset_kind"] == "VM" assert vm_row["path_concept"] == "direct-token-opportunity" assert vm_row["insertion_point"] == "public IMDS token path" + assert vm_row["when"] == "act now" + assert vm_row["reach_from_here"] == "public exposure visible; exploitation not proved" + assert vm_row["compute_foothold"] == "vm-web-01" + assert vm_row["token_path"] == "public VM metadata token" + assert vm_row["identity"] == "ua-app" + assert vm_row["azure_access"] == "Owner across subscription-wide scope" + assert vm_row["proof_status"] == "confirmed" assert vm_row["stronger_outcome"] == "Owner across subscription-wide scope" assert vm_row["priority"] == "high" assert vm_row["urgency"] == "pivot-now" @@ -401,13 +470,22 @@ def test_cli_smoke_chains_compute_control_json(tmp_path: Path) -> None: assert "token-capable compute foothold" in vm_row["confidence_boundary"] assert "Check vms for the host foothold" in vm_row["next_review"] assert "can request tokens as ua-app" in vm_row["why_care"] + assert "can request tokens as ua-app" in vm_row["note"] vmss_row = next(item for item in payload["paths"] if item["asset_name"] == "vmss-edge-01") assert vmss_row["asset_kind"] == "VMSS" assert vmss_row["path_concept"] == "direct-token-opportunity" assert vmss_row["priority"] == "medium" assert vmss_row["urgency"] == "review-soon" + assert vmss_row["when"] == "review soon" + assert vmss_row["reach_from_here"] == "current access does not show the start" + assert vmss_row["compute_foothold"] == "vmss-edge-01" + assert vmss_row["token_path"] == "VM metadata token" + assert vmss_row["identity"] == "vmss-edge-01-system" + assert vmss_row["azure_access"] == "Contributor across subscription-wide scope" + assert vmss_row["proof_status"] == "confirmed" assert vmss_row["stronger_outcome"] == "Contributor across subscription-wide scope" + assert "host-level execution or admin access" in vmss_row["note"] assert "Check vmss for the fleet foothold" in vmss_row["next_review"] @@ -428,21 +506,50 @@ def test_cli_smoke_chains_escalation_path_json(tmp_path: Path) -> None: assert payload["artifact_preference_order"] == [] assert payload["source_artifacts"] == [] assert payload["backing_commands"] == [ - "privesc", "permissions", "role-trusts", ] - assert len(payload["paths"]) == 1 - row = payload["paths"][0] - assert row["asset_name"] == "azurefox-lab-sp (current foothold)" - assert row["path_concept"] == "current-foothold-direct-control" - assert row["priority"] == "high" - assert row["urgency"] == "pivot-now" - assert row["stronger_outcome"] == "Owner across subscription-wide scope" - assert row["target_resolution"] == "path-confirmed" - assert row["evidence_commands"] == ["privesc", "permissions"] - assert "not a speculative lead" in row["why_care"] - assert "already holds high-impact RBAC" in row["confidence_boundary"] + assert len(payload["paths"]) == 2 + + direct_row = next( + item + for item in payload["paths"] + if item["path_concept"] == "current-foothold-direct-control" + ) + trust_row = next( + item for item in payload["paths"] if item["path_concept"] == "trust-expansion" + ) + + assert direct_row["asset_name"] == "azurefox-lab-sp (current foothold)" + assert direct_row["starting_foothold"] == "azurefox-lab-sp (current foothold)" + assert direct_row["path_type"] == "current foothold direct control" + assert direct_row["priority"] == "high" + assert direct_row["urgency"] == "pivot-now" + assert direct_row["stronger_outcome"] == "Owner across subscription-wide scope" + assert direct_row["target_resolution"] == "path-confirmed" + assert direct_row["evidence_commands"] == ["permissions"] + assert "direct Azure control" in direct_row["why_care"] + assert direct_row["note"] == direct_row["why_care"] + assert "already holds high-impact RBAC" in direct_row["confidence_boundary"] + + assert trust_row["asset_name"] == "azurefox-lab-sp (current foothold)" + assert trust_row["starting_foothold"] == "azurefox-lab-sp (current foothold)" + assert trust_row["path_type"] == "trust expansion" + assert trust_row["clue_type"] == "service-principal-owner" + assert trust_row["stronger_outcome"] == "Owner across 2 visible scopes" + assert trust_row["target_resolution"] == "path-confirmed" + assert trust_row["evidence_commands"] == ["role-trusts", "permissions"] + assert trust_row["target_names"] == ["build-sp"] + assert "build-sp" in trust_row["note"] + assert "can take over service principal 'build-sp'" in trust_row["note"] + assert "Owner-level Azure control, including role assignment" in trust_row["note"] + assert "resource groups 'rg-build-dr' and 'rg-identity'" in trust_row["note"] + assert "added or used authentication material" in trust_row["note"] + assert ( + "could add or replace authentication material Azure accepts for service principal " + "'build-sp'" + in trust_row["confidence_boundary"] + ) def test_cli_smoke_chains_escalation_path_table_output(tmp_path: Path) -> None: @@ -455,12 +562,22 @@ def test_cli_smoke_chains_escalation_path_table_output(tmp_path: Path) -> None: ) assert result.exit_code == 0 + normalized_output = " ".join(result.stdout.split()) assert "azurefox chains" in result.stdout assert "starting foothold" in result.stdout assert "path type" in result.stdout assert "stronger outcome" in result.stdout - assert "confidence boundary" in result.stdout assert "note" in result.stdout + assert "confidence boundary" not in result.stdout + assert "next review" not in result.stdout + assert "direct Azure control" in result.stdout + assert "AzureFox is not" in result.stdout + assert "narrowing one exact downstream action" in result.stdout + assert "trust expansion" in result.stdout + assert "build-sp" in result.stdout + assert "take over service principal" in result.stdout + assert "resource groups" in normalized_output + assert "'rg-build-dr' and 'rg-identity'" in normalized_output def test_cli_smoke_chains_compute_control_table_output(tmp_path: Path) -> None: @@ -482,6 +599,8 @@ def test_cli_smoke_chains_compute_control_table_output(tmp_path: Path) -> None: assert "azure access" in result.stdout.lower() assert "proof status" in result.stdout assert "app-empty-mi" in result.stdout + assert "aca-orders" in result.stdout + assert "aci-public-api" in result.stdout assert "func-orders" in result.stdout assert "vm-web-01" in result.stdout assert "vmss-edge-01" in result.stdout @@ -489,13 +608,9 @@ def test_cli_smoke_chains_compute_control_table_output(tmp_path: Path) -> None: assert "public vm metadata token" in result.stdout.lower() assert "public exposure visible" in normalized_output assert "exploitation not proved" in normalized_output - assert "azurefox is a recon tool" in normalized_output - assert ( - "does not verify exploitation activity beyond what is explicitly stated here" - in normalized_output - ) + assert "public reachability alone does not prove that path" in normalized_output assert "does not yet show that start from the current foothold" in normalized_output - assert "server-side execution" in normalized_output + assert "ask azure for its own token" in normalized_output assert "metadata service" in normalized_output assert "host-level execution or admin access" in normalized_output assert "Owner across subscription-wide scope" in result.stdout diff --git a/tests/test_collectors.py b/tests/test_collectors.py index 82147e4..524f29b 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -3700,7 +3700,7 @@ def test_principal_sort_key_prioritizes_high_impact_then_workload_attachment() - def test_collect_permissions(fixture_provider, options) -> None: output = collect_permissions(fixture_provider, options) - assert len(output.permissions) == 6 + assert len(output.permissions) == 9 assert output.permissions[0].priority == "high" assert output.permissions[0].privileged is True assert output.permissions[0].high_impact_roles == ["Owner"] @@ -3716,6 +3716,20 @@ def test_collect_permissions(fixture_provider, options) -> None: assert by_name["aa-hybrid-prod-mi"].next_review == ( "Check role-trusts for trust expansion around who can influence this principal." ) + assert by_name["aca-orders-system"].priority == "medium" + assert by_name["aca-orders-system"].operator_signal == ( + "Direct control visible; trust expansion follow-on." + ) + assert by_name["aca-orders-system"].next_review == ( + "Check role-trusts for trust expansion around who can influence this principal." + ) + assert by_name["aci-public-api-system"].priority == "medium" + assert by_name["aci-public-api-system"].operator_signal == ( + "Direct control visible; trust expansion follow-on." + ) + assert by_name["aci-public-api-system"].next_review == ( + "Check role-trusts for trust expansion around who can influence this principal." + ) assert by_name["app-empty-mi-system"].operator_signal == ( "Direct control visible; workload pivot visible." ) @@ -3902,7 +3916,6 @@ def test_collect_privesc(fixture_provider, options) -> None: output = collect_privesc(fixture_provider, options) assert len(output.paths) == 2 assert [item.priority for item in output.paths] == ["high", "medium"] - assert [item.severity for item in output.paths] == ["high", "high"] assert output.paths[0].path_type == "direct-role-abuse" assert output.paths[0].starting_foothold == "azurefox-lab-sp (current foothold)" assert output.paths[0].operator_signal == "Current foothold already has direct control." @@ -3928,7 +3941,6 @@ def test_collect_privesc(fixture_provider, options) -> None: def test_privesc_sort_key_prioritizes_priority_then_current_identity_then_path_type() -> None: paths = [ { - "severity": "medium", "priority": "medium", "current_identity": False, "path_type": "direct-role-abuse", @@ -3936,7 +3948,6 @@ def test_privesc_sort_key_prioritizes_priority_then_current_identity_then_path_t "asset": None, }, { - "severity": "high", "priority": "medium", "current_identity": False, "path_type": "public-identity-pivot", @@ -3944,7 +3955,6 @@ def test_privesc_sort_key_prioritizes_priority_then_current_identity_then_path_t "asset": "vm-edge-01", }, { - "severity": "high", "priority": "high", "current_identity": True, "path_type": "direct-role-abuse", @@ -3965,57 +3975,95 @@ def test_privesc_sort_key_prioritizes_priority_then_current_identity_then_path_t def test_collect_role_trusts(fixture_provider, options) -> None: output = collect_role_trusts(fixture_provider, options) assert output.mode == "fast" - assert len(output.trusts) == 5 - assert output.trusts[0].trust_type == "federated-credential" - assert output.trusts[1].trust_type == "service-principal-owner" - assert output.trusts[1].source_name == "aa-hybrid-prod" - assert output.trusts[2].trust_type == "service-principal-owner" - assert output.trusts[2].source_name == "automation-runner" - assert output.trusts[3].trust_type == "app-owner" + assert len(output.trusts) == 7 + + trust_by_source = { + (trust.trust_type, trust.source_name): trust for trust in output.trusts + } + + federated = trust_by_source[("federated-credential", "build-app")] + current_service_owner = trust_by_source[("service-principal-owner", "azurefox-lab-sp")] + service_owner_ops = trust_by_source[("service-principal-owner", "aa-hybrid-prod")] + service_owner_build = trust_by_source[("service-principal-owner", "automation-runner")] + current_app_owner = trust_by_source[("app-owner", "azurefox-lab-sp")] + user_app_owner = trust_by_source[("app-owner", "ci-admin@lab.local")] + + assert federated.operator_signal == "Trust expansion visible; privilege confirmation next." assert ( - output.trusts[0].operator_signal == "Trust expansion visible; privilege confirmation next." + federated.next_review + == "Check permissions for Azure control on service principal 'build-sp'." ) assert ( - output.trusts[0].next_review - == "Check permissions for Azure control on service principal 'build-sp'." + current_service_owner.operator_signal + == "Trust expansion visible; privilege confirmation next." ) - assert output.trusts[1].operator_signal == "Indirect control visible; ownership review next." - assert output.trusts[1].next_review == ( - "Review ownership around service principal 'ops-deploy-sp', then confirm " - "Azure control in permissions." + assert current_service_owner.next_review == ( + "Check permissions for Azure control on service principal 'build-sp'." ) - assert output.trusts[2].operator_signal == "Indirect control visible; ownership review next." - assert output.trusts[2].next_review == ( - "Review ownership around service principal 'build-sp', then confirm " - "Azure control in permissions." + assert ( + service_owner_ops.operator_signal + == "Trust expansion visible; privilege confirmation next." ) - assert output.trusts[0].control_primitive == "existing-federated-credential" - assert output.trusts[0].controlled_object_type == "Application" - assert output.trusts[0].controlled_object_name == "build-app" - assert output.trusts[0].usable_identity_result == ( + assert service_owner_ops.next_review == ( + "Check permissions for Azure control on service principal 'ops-deploy-sp'." + ) + assert ( + service_owner_build.operator_signal + == "Trust expansion visible; privilege confirmation next." + ) + assert service_owner_build.next_review == ( + "Check permissions for Azure control on service principal 'build-sp'." + ) + assert federated.control_primitive == "existing-federated-credential" + assert federated.controlled_object_type == "Application" + assert federated.controlled_object_name == "build-app" + assert federated.usable_identity_result == ( "Federated sign-in can yield service principal 'build-sp' access." ) - assert output.trusts[0].defender_cut_point == ( + assert federated.defender_cut_point == ( "Remove or tighten the federated credential on application 'build-app'." ) - assert output.trusts[1].control_primitive == "owner-control" - assert output.trusts[1].controlled_object_type == "ServicePrincipal" - assert output.trusts[1].controlled_object_name == "ops-deploy-sp" - assert output.trusts[1].usable_identity_result is None - assert "authentication-control transform is not yet explicit" in ( - output.trusts[1].escalation_mechanism or "" - ) - assert output.trusts[2].control_primitive == "owner-control" - assert output.trusts[2].controlled_object_type == "ServicePrincipal" - assert output.trusts[2].controlled_object_name == "build-sp" - assert output.trusts[2].usable_identity_result is None - assert output.trusts[3].control_primitive == "change-auth-material" - assert output.trusts[3].controlled_object_type == "Application" - assert output.trusts[3].controlled_object_name == "build-app" - assert output.trusts[3].usable_identity_result == ( + assert current_service_owner.control_primitive == "owner-control" + assert current_service_owner.controlled_object_type == "ServicePrincipal" + assert current_service_owner.controlled_object_name == "build-sp" + assert current_service_owner.usable_identity_result == ( + "That could make service principal 'build-sp' usable." + ) + assert "could add or replace authentication material" in ( + current_service_owner.escalation_mechanism or "" + ) + assert service_owner_ops.control_primitive == "owner-control" + assert service_owner_ops.controlled_object_type == "ServicePrincipal" + assert service_owner_ops.controlled_object_name == "ops-deploy-sp" + assert service_owner_ops.usable_identity_result == ( + "That could make service principal 'ops-deploy-sp' usable." + ) + assert "could add or replace authentication material" in ( + service_owner_ops.escalation_mechanism or "" + ) + assert service_owner_build.control_primitive == "owner-control" + assert service_owner_build.controlled_object_type == "ServicePrincipal" + assert service_owner_build.controlled_object_name == "build-sp" + assert service_owner_build.usable_identity_result == ( + "That could make service principal 'build-sp' usable." + ) + assert current_app_owner.control_primitive == "change-auth-material" + assert current_app_owner.controlled_object_type == "Application" + assert current_app_owner.controlled_object_name == "build-app" + assert current_app_owner.backing_service_principal_id == "66666666-6666-6666-6666-666666666666" + assert current_app_owner.backing_service_principal_name == "build-sp" + assert current_app_owner.usable_identity_result == ( + "Control of application 'build-app' could make service principal 'build-sp' usable." + ) + assert user_app_owner.control_primitive == "change-auth-material" + assert user_app_owner.controlled_object_type == "Application" + assert user_app_owner.controlled_object_name == "build-app" + assert user_app_owner.backing_service_principal_id == "66666666-6666-6666-6666-666666666666" + assert user_app_owner.backing_service_principal_name == "build-sp" + assert user_app_owner.usable_identity_result == ( "Control of application 'build-app' could make service principal 'build-sp' usable." ) - assert output.trusts[3].defender_cut_point == ( + assert user_app_owner.defender_cut_point == ( "Remove the ownership path that lets the source control application 'build-app'." ) diff --git a/tests/test_compute_control.py b/tests/test_compute_control.py index 211f51a..d2774b2 100644 --- a/tests/test_compute_control.py +++ b/tests/test_compute_control.py @@ -281,7 +281,11 @@ def test_compute_control_admits_container_app_via_workload_principal() -> None: "workload-principal", "permissions", ] - assert "server-side execution in this public-facing service" in (row.why_care or "") + assert ( + "a way to make this public-facing service ask Azure for its own token" + in (row.why_care or "") + ) + assert "public reachability alone does not prove that path" in (row.why_care or "") def test_compute_control_admits_container_instance_via_workload_principal() -> None: @@ -320,7 +324,11 @@ def test_compute_control_admits_container_instance_via_workload_principal() -> N "workload-principal", "permissions", ] - assert "server-side execution in this public-facing container group" in (row.why_care or "") + assert ( + "a way to make this public-facing container group ask Azure for its own token" + in (row.why_care or "") + ) + assert "public reachability alone does not prove that path" in (row.why_care or "") def test_compute_control_prefers_explicit_system_identity_anchor_when_present() -> None: diff --git a/tests/test_models.py b/tests/test_models.py index d235906..692e874 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -247,7 +247,6 @@ def test_privesc_path_defaults() -> None: principal_id="p-1", principal_type="ServicePrincipal", path_type="direct-role-abuse", - severity="high", priority="high", summary="test", ) diff --git a/tests/test_output_writer.py b/tests/test_output_writer.py index 55d2432..be835b9 100644 --- a/tests/test_output_writer.py +++ b/tests/test_output_writer.py @@ -184,13 +184,11 @@ def test_write_artifacts_loot_uses_semantic_high_band_for_privesc(tmp_path: Path { "principal": "current-sp", "priority": "high", - "severity": "high", "summary": "direct role abuse", }, { "principal": "vm-pivot", "priority": "medium", - "severity": "high", "summary": "public identity pivot", }, ] @@ -250,6 +248,94 @@ def test_write_artifacts_loot_caps_semantic_high_band_at_limit(tmp_path: Path) - } +def test_write_artifacts_enriches_compute_control_json_contract(tmp_path: Path) -> None: + payload = { + "metadata": { + "schema_version": SCHEMA_VERSION, + "command": "chains", + "generated_at": "2026-04-06T12:00:00Z", + }, + "grouped_command_name": "chains", + "family": "compute-control", + "input_mode": "live", + "command_state": "extraction-only", + "summary": "test summary", + "claim_boundary": "test claim boundary", + "current_gap": "test current gap", + "artifact_preference_order": [], + "backing_commands": ["tokens-credentials", "permissions"], + "source_artifacts": [], + "paths": [ + { + "asset_name": "app-empty-mi", + "priority": "high", + "urgency": "pivot-now", + "insertion_point": "reachable service token request path", + "target_names": ["app-empty-mi-system"], + "target_resolution": "path-confirmed", + "stronger_outcome": "Contributor across subscription-wide scope", + "why_care": "AppService note", + "next_review": "Check app-services.", + } + ], + "issues": [], + } + + artifact_paths = write_artifacts("chains", payload, _options(tmp_path)) + json_payload = json.loads(artifact_paths["json"].read_text(encoding="utf-8")) + row = json_payload["paths"][0] + + assert row["when"] == "act now" + assert row["reach_from_here"] == "public exposure visible; exploitation not proved" + assert row["compute_foothold"] == "app-empty-mi" + assert row["token_path"] == "service token request" + assert row["identity"] == "app-empty-mi-system" + assert row["azure_access"] == "Contributor across subscription-wide scope" + assert row["proof_status"] == "confirmed" + assert row["note"] == "AppService note" + + +def test_write_artifacts_enriches_escalation_path_json_contract(tmp_path: Path) -> None: + payload = { + "metadata": { + "schema_version": SCHEMA_VERSION, + "command": "chains", + "generated_at": "2026-04-06T12:00:00Z", + }, + "grouped_command_name": "chains", + "family": "escalation-path", + "input_mode": "live", + "command_state": "extraction-only", + "summary": "test summary", + "claim_boundary": "test claim boundary", + "current_gap": "test current gap", + "artifact_preference_order": [], + "backing_commands": ["permissions", "role-trusts"], + "source_artifacts": [], + "paths": [ + { + "asset_name": "azurefox-lab-sp (current foothold)", + "path_concept": "current-foothold-direct-control", + "priority": "high", + "urgency": "pivot-now", + "stronger_outcome": "Owner across subscription-wide scope", + "confidence_boundary": "bounded current-foothold proof", + "why_care": "Current foothold already carries Azure control.", + "next_review": "Check rbac for exact assignment evidence.", + } + ], + "issues": [], + } + + artifact_paths = write_artifacts("chains", payload, _options(tmp_path)) + json_payload = json.loads(artifact_paths["json"].read_text(encoding="utf-8")) + row = json_payload["paths"][0] + + assert row["starting_foothold"] == "azurefox-lab-sp (current foothold)" + assert row["path_type"] == "current foothold direct control" + assert row["note"] == "Current foothold already carries Azure control." + + def test_write_artifacts_loot_keeps_nonempty_findings_and_issues(tmp_path: Path) -> None: payload = { "metadata": { diff --git a/tests/test_terminal_ux.py b/tests/test_terminal_ux.py index 4e2cba5..82b8833 100644 --- a/tests/test_terminal_ux.py +++ b/tests/test_terminal_ux.py @@ -35,9 +35,9 @@ def test_role_trusts_table_mode_includes_narration_and_takeaway(tmp_path: Path) assert "confirmation next." in result.stdout assert "Federated sign-in can yield" in normalized_output assert "service principal 'build-sp' access." in normalized_output - assert "authentication-control transform is not yet explicit." in normalized_output + assert "That could make service principal 'build-sp' usable." in normalized_output assert "Check permissions for Azure control" in result.stdout - assert "Takeaway: 5 trust edges surfaced in fast mode" in result.stdout + assert "Takeaway: 7 trust edges surfaced in fast mode" in result.stdout assert "privilege-confirmation follow-ons" in result.stdout assert "Delegated and admin" in result.stdout assert "out of scope for this" in result.stdout @@ -765,7 +765,7 @@ def test_permissions_table_mode_surfaces_next_review(tmp_path: Path) -> None: assert "foothold." in normalized_output assert "Check privesc" in result.stdout assert "aa-hybrid-prod-mi" in result.stdout - assert "Takeaway: 5 of 6 principals hold high-impact RBAC roles;" in result.stdout + assert "Takeaway: 8 of 9 principals hold high-impact RBAC roles;" in result.stdout def test_chains_table_mode_surfaces_priority_and_next_review(tmp_path: Path) -> None: @@ -872,6 +872,8 @@ def test_escalation_chains_table_mode_renders_defended_current_foothold_story( assert "path type" in result.stdout assert "stronger outcome" in result.stdout assert "note" in result.stdout + assert "confidence boundary" not in result.stdout + assert "next review" not in result.stdout assert "azurefox-lab-sp" in normalized_output assert "(current" in normalized_output assert "foothold)" in normalized_output @@ -879,9 +881,20 @@ def test_escalation_chains_table_mode_renders_defended_current_foothold_story( assert "Owner across" in normalized_output assert "subscription-wide scope" in normalized_output assert ( - "The current foothold already sits on subscription-wide scope high-impact Azure control" + "The current foothold already has Owner across subscription-wide scope" in normalized_output ) + assert "direct Azure control" in normalized_output + assert "AzureFox is not" in normalized_output + assert "narrowing one exact downstream action" in normalized_output + assert "trust expansion" in normalized_output + assert "build-sp" in normalized_output + assert "take over service principal 'build-sp'" in normalized_output + assert "Owner-level Azure control" in normalized_output + assert "including role" in normalized_output + assert "assignment on resource groups" in normalized_output + assert "rg-build-dr" in normalized_output + assert "rg-identity" in normalized_output assert "Takeaway:" not in result.stdout @@ -1034,6 +1047,114 @@ def test_chains_named_keyvault_not_visible_prefers_inventory_boundary() -> None: assert "current inventory." in normalized +def test_compute_control_table_prefers_analyst_facing_json_fields() -> None: + payload = { + "metadata": {"command": "chains"}, + "family": "compute-control", + "summary": ( + "Follow token-capable compute footholds toward the identity-backed Azure control they " + "can reach next." + ), + "claim_boundary": ( + "Can claim a direct token opportunity only when AzureFox can show the compute-side " + "token path, the attached identity, and the stronger Azure control behind that " + "identity." + ), + "current_gap": ( + "The live family is intentionally narrow in v1: direct token-opportunity rows only." + ), + "paths": [ + { + "priority": "high", + "when": "act now", + "reach_from_here": "public exposure visible; exploitation not proved", + "compute_foothold": "app-empty-mi", + "token_path": "service token request", + "identity": "app-empty-mi-system", + "azure_access": "Contributor across subscription-wide scope", + "proof_status": "confirmed", + "note": ( + "AppService 'app-empty-mi' can request tokens as app-empty-mi-system; " + "that identity already maps to Contributor across subscription-wide scope." + ), + "next_review": "Check app-services for the running service foothold.", + } + ], + "issues": [], + } + + rendered = render_table("chains", payload) + normalized = " ".join(rendered.split()) + + assert "reach from here" in rendered + assert "compute foothold" in rendered + assert "token path" in rendered + assert "identity" in rendered + assert "Azure access" in rendered + assert "proof status" in rendered + assert "public exposure visible;" in normalized + assert "exploitation not proved" in normalized + assert "service token request" in normalized + assert "app-empty-mi-system" in normalized + + +def test_escalation_path_table_prefers_analyst_facing_json_fields() -> None: + payload = { + "metadata": {"command": "chains"}, + "family": "escalation-path", + "summary": ( + "Follow the strongest current-foothold escalation stories toward the next defended " + "identity or control step." + ), + "claim_boundary": ( + "Can claim that visible evidence suggests a current-foothold escalation story." + ), + "current_gap": ( + "Trust-backed rows still need deeper transformation data before every row reads like " + "a defended control path." + ), + "paths": [ + { + "priority": "high", + "urgency": "pivot-now", + "starting_foothold": "azurefox-lab-sp (current foothold)", + "path_type": "current foothold direct control", + "stronger_outcome": "Owner across subscription-wide scope", + "confidence_boundary": ( + "Current foothold already holds high-impact RBAC on visible scope." + ), + "next_review": "Check rbac for exact assignment evidence and scope.", + "note": ( + "The current foothold already has Owner across subscription-wide scope, so " + "this row is direct Azure control, not a separate pivot hunt. AzureFox is " + "not narrowing one exact downstream action beyond the control already shown " + "here." + ), + } + ], + "issues": [], + } + + rendered = render_table("chains", payload) + normalized = " ".join(rendered.split()) + + assert "starting foothold" in rendered + assert "path type" in rendered + assert "stronger outcome" in rendered + assert "confidence boundary" not in rendered + assert "next review" not in rendered + assert "azurefox-lab-sp" in normalized + assert "(current" in normalized + assert "foothold)" in normalized + assert "current foothold direct" in normalized + assert "control" in normalized + assert "Owner across" in normalized + assert "subscription-wide scope" in normalized + assert "direct Azure control" in normalized + assert "AzureFox is not" in normalized + assert "narrowing one exact downstream action" in normalized + + def test_app_services_partial_read_surfaces_collection_issue() -> None: payload = { "metadata": {"command": "app-services"},