@@ -450,23 +450,20 @@ def _build_escalation_path_output(
450450 permission ,
451451 )
452452 )
453- trust_row = _build_escalation_trust_record (
453+ trust_rows = _build_escalation_trust_records (
454454 family_name ,
455455 current_foothold_row ,
456456 role_trusts_output .trusts ,
457457 permissions_by_principal ,
458458 current_foothold_id = current_foothold_id ,
459459 current_foothold_permission = permission ,
460460 )
461- if trust_row is not None :
462- paths .append (trust_row )
461+ paths .extend (trust_rows )
463462
464463 paths .sort (
465- key = lambda item : (
466- semantic_priority_sort_value (item .priority ),
467- _JOIN_QUALITY_ORDER .get (item .target_resolution , 9 ),
468- item .asset_name ,
469- item .path_concept or "" ,
464+ key = lambda item : _escalation_path_sort_key (
465+ item ,
466+ permissions_by_principal = permissions_by_principal ,
470467 )
471468 )
472469
@@ -3119,6 +3116,11 @@ def _build_escalation_direct_control_record(
31193116 if part
31203117 )
31213118 next_review = current_foothold_next_review_hint ()
3119+ why_care = (
3120+ f"The current foothold already has { stronger_outcome } . This row is already direct "
3121+ "Azure control, not a separate pivot hunt. AzureFox is not narrowing one exact "
3122+ "downstream action beyond the control already shown here."
3123+ )
31223124
31233125 return ChainPathRecord (
31243126 chain_id = f"escalation-path::{ current_foothold .get ('principal_id' )} ::current-foothold-direct-control" ,
@@ -3139,11 +3141,7 @@ def _build_escalation_direct_control_record(
31393141 insertion_point = "Current foothold already holds high-impact RBAC on visible scope." ,
31403142 path_concept = "current-foothold-direct-control" ,
31413143 stronger_outcome = stronger_outcome ,
3142- why_care = (
3143- f"The current foothold already has { stronger_outcome } . This row is already direct "
3144- "Azure control, not a separate pivot hunt. AzureFox is not narrowing one exact "
3145- "downstream action beyond the control already shown here."
3146- ),
3144+ why_care = why_care ,
31473145 likely_impact = stronger_outcome ,
31483146 confidence_boundary = confidence_boundary ,
31493147 target_service = "azure-control" ,
@@ -3159,8 +3157,7 @@ def _build_escalation_direct_control_record(
31593157 related_ids = [str (value ) for value in current_foothold .get ("related_ids" ) or [] if value ],
31603158 )
31613159
3162-
3163- def _build_escalation_trust_record (
3160+ def _select_escalation_trust_record (
31643161 family_name : str ,
31653162 current_foothold : dict ,
31663163 trusts : list ,
@@ -3169,8 +3166,30 @@ def _build_escalation_trust_record(
31693166 current_foothold_id : str | None ,
31703167 current_foothold_permission : dict | None ,
31713168) -> ChainPathRecord | None :
3172- if not current_foothold_id :
3169+ records = _build_escalation_trust_records (
3170+ family_name ,
3171+ current_foothold ,
3172+ trusts ,
3173+ permissions_by_principal ,
3174+ current_foothold_id = current_foothold_id ,
3175+ current_foothold_permission = current_foothold_permission ,
3176+ )
3177+ if not records :
31733178 return None
3179+ return records [0 ]
3180+
3181+
3182+ def _build_escalation_trust_records (
3183+ family_name : str ,
3184+ current_foothold : dict ,
3185+ trusts : list ,
3186+ permissions_by_principal : dict [str , dict ],
3187+ * ,
3188+ current_foothold_id : str | None ,
3189+ current_foothold_permission : dict | None ,
3190+ ) -> list [ChainPathRecord ]:
3191+ if not current_foothold_id :
3192+ return []
31743193
31753194 candidates : list [tuple [tuple [int , int , int , str ], ChainPathRecord ]] = []
31763195 federated_trusts_by_application_id : dict [str , list [dict ]] = defaultdict (list )
@@ -3239,11 +3258,21 @@ def _build_escalation_trust_record(
32393258 )
32403259
32413260 if not candidates :
3242- return None
3261+ return []
32433262
32443263 candidates .sort (key = lambda item : item [0 ])
3245- return candidates [0 ][1 ]
3246-
3264+ selected : list [ChainPathRecord ] = []
3265+ seen_targets : set [tuple [str , tuple [str , ...]]] = set ()
3266+ for _ , record in candidates :
3267+ dedupe_key = (
3268+ str (record .path_concept or "" ),
3269+ tuple (str (value ) for value in record .target_ids ),
3270+ )
3271+ if dedupe_key in seen_targets :
3272+ continue
3273+ seen_targets .add (dedupe_key )
3274+ selected .append (record )
3275+ return selected
32473276
32483277def _build_escalation_single_trust_record (
32493278 * ,
@@ -3271,6 +3300,7 @@ def _build_escalation_single_trust_record(
32713300 usable_identity_result = str (trust_row .get ("usable_identity_result" ) or "" ).strip ()
32723301 defender_cut_point = str (trust_row .get ("defender_cut_point" ) or "" ).strip ()
32733302 trust_type = str (trust_row .get ("trust_type" ) or "trust-expansion" )
3303+ path_concept = _escalation_trust_path_concept (trust_type )
32743304
32753305 if not target_permission or not escalation_mechanism or not usable_identity_result :
32763306 return None
@@ -3287,7 +3317,7 @@ def _build_escalation_single_trust_record(
32873317 target_resolution = target_resolution ,
32883318 target_count = 1 ,
32893319 source_command = "role-trusts" ,
3290- path_concept = "trust-expansion" ,
3320+ path_concept = path_concept ,
32913321 )
32923322 )
32933323 confidence_boundary = (
@@ -3307,7 +3337,9 @@ def _build_escalation_single_trust_record(
33073337 why_care = f"{ why_care } { defender_cut_point } "
33083338
33093339 return ChainPathRecord (
3310- chain_id = f"escalation-path::{ current_foothold_id } ::trust-expansion::{ target_permission_id } " ,
3340+ chain_id = (
3341+ f"escalation-path::{ current_foothold_id } ::{ path_concept } ::{ target_permission_id } "
3342+ ),
33113343 asset_id = str (current_foothold_id ),
33123344 asset_name = str (
33133345 current_foothold .get ("starting_foothold" )
@@ -3323,7 +3355,7 @@ def _build_escalation_single_trust_record(
33233355 urgency = semantic .urgency ,
33243356 visible_path = _escalation_visible_path (trust_type ),
33253357 insertion_point = escalation_mechanism ,
3326- path_concept = "trust-expansion" ,
3358+ path_concept = path_concept ,
33273359 stronger_outcome = stronger_outcome ,
33283360 why_care = why_care ,
33293361 likely_impact = stronger_outcome ,
@@ -3354,23 +3386,63 @@ def _escalation_trust_candidate_sort_key(
33543386 record : ChainPathRecord ,
33553387 current_foothold_permission : dict | None ,
33563388 target_permission : dict | None ,
3357- ) -> tuple [int , int , int , int , int , str ]:
3358- path_preference_rank = {
3359- "service-principal-owner" : 0 ,
3360- "federated-credential" : 1 ,
3361- "app-owner" : 2 ,
3362- "app-to-service-principal" : 3 ,
3363- }.get (clue_type , 9 )
3389+ ) -> tuple [int , int , int , int , int , int , str ]:
33643390 return (
33653391 semantic_priority_sort_value (record .priority ),
33663392 _JOIN_QUALITY_ORDER .get (record .target_resolution , 9 ),
3393+ - _permission_upgrade_score (current_foothold_permission , target_permission ),
33673394 - len (_permission_new_scope_ids (current_foothold_permission , target_permission )),
33683395 - _permission_role_strength (target_permission ),
3369- path_preference_rank ,
3396+ _escalation_path_effort_rank ( record . path_concept , clue_type = clue_type ) ,
33703397 record .chain_id ,
33713398 )
33723399
33733400
3401+ def _escalation_path_sort_key (
3402+ record : ChainPathRecord ,
3403+ * ,
3404+ permissions_by_principal : dict [str , dict ],
3405+ ) -> tuple [int , int , int , int , int , str , str ]:
3406+ current_foothold_permission = permissions_by_principal .get (record .asset_id )
3407+ target_permission = _escalation_record_target_permission (
3408+ record ,
3409+ permissions_by_principal = permissions_by_principal ,
3410+ )
3411+ return (
3412+ semantic_priority_sort_value (record .priority ),
3413+ - _permission_upgrade_score (current_foothold_permission , target_permission ),
3414+ - len (_permission_new_scope_ids (current_foothold_permission , target_permission )),
3415+ _escalation_path_effort_rank (record .path_concept , clue_type = record .clue_type ),
3416+ _JOIN_QUALITY_ORDER .get (record .target_resolution , 9 ),
3417+ record .asset_name ,
3418+ record .chain_id ,
3419+ )
3420+
3421+
3422+ def _escalation_record_target_permission (
3423+ record : ChainPathRecord ,
3424+ * ,
3425+ permissions_by_principal : dict [str , dict ],
3426+ ) -> dict | None :
3427+ for target_id in record .target_ids :
3428+ target_permission = permissions_by_principal .get (str (target_id ))
3429+ if target_permission is not None :
3430+ return target_permission
3431+ return None
3432+
3433+
3434+ def _escalation_path_effort_rank (path_concept : str | None , * , clue_type : str ) -> int :
3435+ if path_concept == "current-foothold-direct-control" :
3436+ return 0
3437+ if path_concept == "app-permission-reach" :
3438+ return 1
3439+ return {
3440+ "federated-credential" : 2 ,
3441+ "service-principal-owner" : 3 ,
3442+ "app-owner" : 4 ,
3443+ }.get (clue_type , 9 )
3444+
3445+
33743446def _escalation_target_permission_for_sort (
33753447 * ,
33763448 trust_row : dict ,
@@ -3396,6 +3468,12 @@ def _escalation_visible_path(trust_type: str) -> str:
33963468 return "Current foothold -> trust edge -> higher-value identity"
33973469
33983470
3471+ def _escalation_trust_path_concept (trust_type : str ) -> str :
3472+ if trust_type == "app-to-service-principal" :
3473+ return "app-permission-reach"
3474+ return "trust-expansion"
3475+
3476+
33993477def _build_escalation_federated_takeover_record (
34003478 * ,
34013479 family_name : str ,
@@ -3542,6 +3620,14 @@ def _escalation_trust_note(
35423620 f"or used authentication material for service principal '{ target_name } '."
35433621 )
35443622
3623+ if trust_type == "app-to-service-principal" and target_name :
3624+ return (
3625+ f"The current foothold already has application-permission reach into service "
3626+ f"principal '{ target_name } '. { gain_text } AzureFox is not proving that the current "
3627+ f"foothold has already exercised one exact downstream action through "
3628+ f"'{ target_name } '."
3629+ )
3630+
35453631 return (
35463632 f"The current foothold can reach '{ target_permission_name } '. { gain_text } AzureFox is "
35473633 "not proving that the current foothold has already turned this trust edge into usable "
@@ -3607,9 +3693,18 @@ def _permission_adds_net_value(
36073693 return True
36083694 if _permission_new_scope_ids (current_permission , target_permission ):
36093695 return True
3610- return _permission_role_strength (target_permission ) > _permission_role_strength (
3611- current_permission
3612- )
3696+ return _permission_upgrade_score (current_permission , target_permission ) > 0
3697+
3698+
3699+ def _permission_upgrade_score (
3700+ current_permission : dict | None ,
3701+ target_permission : dict | None ,
3702+ ) -> int :
3703+ if not target_permission :
3704+ return 0
3705+ current_strength = _permission_role_strength (current_permission )
3706+ target_strength = _permission_role_strength (target_permission )
3707+ return max (0 , target_strength - current_strength )
36133708
36143709
36153710def _permission_new_scope_ids (
@@ -3637,7 +3732,6 @@ def _permission_new_scope_ids(
36373732def _permission_role_strength (permission_row : dict | None ) -> int :
36383733 ranks = {
36393734 "contributor" : 1 ,
3640- "user access administrator" : 2 ,
36413735 "owner" : 3 ,
36423736 }
36433737 roles = (permission_row or {}).get ("high_impact_roles" ) or []
@@ -3654,8 +3748,6 @@ def _permission_capability_text(permission_row: dict | None) -> str:
36543748 }
36553749 if "owner" in roles :
36563750 return "Owner-level Azure control, including role assignment"
3657- if "user access administrator" in roles :
3658- return "role-assignment control"
36593751 if "contributor" in roles :
36603752 return "write/change control"
36613753 return "meaningful Azure control"
@@ -3722,6 +3814,5 @@ def _scope_label(scope_id: str) -> str:
37223814 return f"resource '{ name } '"
37233815 return "visible scope"
37243816
3725-
37263817def _source_chain_id (family_name : str , asset_id : str , target_service : str ) -> str :
37273818 return f"{ family_name } ::{ asset_id } ::{ target_service } "
0 commit comments