Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/media/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
155 changes: 154 additions & 1 deletion src/azurefox/chains/presentation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import re


def compute_control_when_label(urgency: str) -> str:
labels = {
Expand Down Expand Up @@ -60,7 +62,7 @@ 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"}:
if family not in {"credential-path", "deployment-path", "compute-control", "escalation-path"}:
return payload
paths = payload.get("paths")
if not isinstance(paths, list):
Expand All @@ -75,6 +77,47 @@ def normalize_chain_payload_for_output(command: str, payload: dict) -> dict:

def normalize_chain_path_row(family: str, row: dict) -> dict:
normalized_row = dict(row)
if family == "credential-path":
normalized_row["asset"] = str(row.get("asset") or row.get("asset_name") or "")
normalized_row["setting"] = str(row.get("setting") or row.get("setting_name") or "")
normalized_row["target"] = str(row.get("target") or row.get("target_service") or "")
normalized_row["visible_targets"] = str(
row.get("visible_targets") or credential_path_visible_targets(row)
)
normalized_row["confidence_boundary"] = str(
row.get("confidence_boundary") or credential_path_note(row)
)
return normalized_row

if family == "deployment-path":
normalized_row["source"] = str(
row.get("source") or stack_chain_source(row.get("asset_name"))
)
normalized_row["actionability"] = str(
row.get("actionability") or deployment_actionability_label(row)
)
normalized_row["insertion_point_display"] = str(
row.get("insertion_point_display")
or stack_chain_insertion_point(
row.get("insertion_point") or deployment_path_type_label(row)
)
)
normalized_row["likely_azure_impact"] = str(
row.get("likely_azure_impact")
or row.get("likely_impact")
or chain_target_context(row)
)
normalized_row["confidence_boundary"] = str(
row.get("confidence_boundary") or deployment_path_note(row)
)
normalized_row["whats_missing"] = str(
row.get("whats_missing") or normalized_row["confidence_boundary"]
)
normalized_row["note"] = str(
row.get("note") or row.get("why_care") or row.get("asset_kind") or ""
)
return normalized_row

if family == "escalation-path":
normalized_row["starting_foothold"] = str(
row.get("starting_foothold") or row.get("asset_name") or ""
Expand Down Expand Up @@ -114,3 +157,113 @@ def normalize_chain_path_row(family: str, row: dict) -> dict:
)
normalized_row["note"] = str(row.get("why_care") or row.get("note") or "")
return normalized_row


def credential_path_visible_targets(row: dict) -> str:
return chain_target_context(row)


def credential_path_note(row: dict) -> str:
resolution = str(row.get("target_resolution") or "")
target_service = str(row.get("target_service") or row.get("target") or "target")
confidence_boundary = str(row.get("confidence_boundary") or "").strip()

if confidence_boundary and resolution != "named target not visible":
return confidence_boundary
if resolution == "named match":
return "Named target matched visible inventory."
if resolution == "visibility blocked":
return f"{target_service} visibility is blocked; do not infer a target."
if resolution == "narrowed candidates":
return (
f"This app exposes a secret-shaped setting that may reach {target_service}; "
"exact target still unconfirmed."
)
if resolution == "tenant-wide candidates":
return f"This app likely reaches {target_service}, but the target set is still broad."
if resolution == "service hint only":
return f"AzureFox sees a likely {target_service} path, but no target inventory yet."
if resolution == "named target not visible":
return f"This app names a {target_service} target AzureFox cannot see in current inventory."
return str(row.get("summary") or "-")


def deployment_path_type_label(row: dict) -> str:
concept = str(row.get("path_concept") or "")
labels = {
"controllable-change-path": "controllable change path",
"execution-hub": "execution hub",
"secret-escalation-support": "secret-backed support",
}
return labels.get(concept, concept or "-")


def deployment_actionability_label(row: dict) -> str:
state = str(row.get("actionability_state") or "")
labels = {
"currently actionable": "currently actionable",
"conditionally actionable": "conditionally actionable",
"consequence-grounded but insertion point unproven": "grounded, insertion unproven",
"visibility-bounded": "visibility-bounded",
"support-only": "support-only",
}
return labels.get(state, state or "-")


def stack_chain_source(value: object) -> str:
text = str(value or "").strip()
if not text or len(text) <= 18 or "-" not in text:
return text
return text.replace("-", "-\n")


def stack_chain_insertion_point(value: object) -> str:
text = str(value or "").strip()
if not text:
return text
text = re.sub(r";\s+", ";\n", text)
text = re.sub(r",\s+", ",\n", text)
text = text.replace(" through ", "\nthrough ")
text = text.replace(" under ", "\nunder ")
text = text.replace(" at ", "\nat ")
return text


def chain_target_context(row: dict) -> str:
target_visibility_issue = row.get("target_visibility_issue")
if target_visibility_issue:
return str(target_visibility_issue)

target_names = row.get("target_names") or []
if target_names:
names = [str(value) for value in target_names[:3]]
if len(names) == 1:
return names[0]
return "\n".join(names)

target_count = row.get("target_count") or 0
if target_count:
return f"{target_count} visible target(s)"
return "none visible"


def deployment_path_note(row: dict) -> str:
resolution = str(row.get("target_resolution") or "")
target_service = str(row.get("target_service") or "target")
confidence_boundary = str(row.get("confidence_boundary") or "").strip()

if confidence_boundary:
return confidence_boundary
if resolution == "named match":
return "Named target matched visible inventory."
if resolution == "visibility blocked":
return f"{target_service} visibility is blocked; do not infer a target."
if resolution == "narrowed candidates":
return "Change-capable source narrows the next review set; exact target unconfirmed."
if resolution == "tenant-wide candidates":
return f"{target_service} family is visible, but narrowing is still broad."
if resolution == "service hint only":
return f"{target_service} path is suggested, but no target inventory is visible."
if resolution == "named target not visible":
return "The named target is not visible in current inventory."
return str(row.get("summary") or "-")
16 changes: 12 additions & 4 deletions src/azurefox/chains/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
GROUPED_COMMAND_NAME = "chains"
GROUPED_COMMAND_INPUT_MODES = ("live", "artifacts")
PREFERRED_ARTIFACT_ORDER = ("loot", "json")
SEMANTIC_LOOT_CHAIN_FAMILIES = (
"credential-path",
"deployment-path",
"escalation-path",
"compute-control",
)


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -376,14 +382,16 @@ class ChainFamilySpec:
allowed_claim=(
"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. Cannot claim token minting success or broaden the family to generic "
"credential, deployment, or trust stories without a clearer compute-side transform."
"identity. Cannot claim SSRF, runtime compromise, metadata abuse, token minting "
"success, or broader credential, deployment, or trust stories without a clearer "
"compute-side transform."
),
current_gap=(
"The live family is intentionally narrow in v1: direct token-opportunity rows only. "
"Broader trust expansion and secret-bearing config starts still sit outside this "
"family, and mixed-identity workloads still need explicit corroboration before "
"default admission."
"family, mixed-identity workloads still need explicit corroboration before default "
"admission, and AzureFox stays on the recon side of the boundary rather than trying "
"to verify web-app exploitation or other runtime execution paths."
),
best_current_examples=(
"tokens-credentials -> managed-identities -> permissions",
Expand Down
4 changes: 3 additions & 1 deletion src/azurefox/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,7 +1179,9 @@ class SectionHelpTopic:
"app-permission reach. Within admitted escalation rows, AzureFox ranks bigger "
"control gain first and uses path ease as a follow-on tiebreaker. "
"compute-control currently ships a narrow direct-token-opportunity v1 where the "
"compute foothold and stronger Azure control are both already visible."
"compute foothold and stronger Azure control are both already visible. It does not "
"prove SSRF, web-app exploitation, metadata abuse, or successful token minting just "
"because a workload is reachable or token-capable."
),
output_highlights=(
"family selectors",
Expand Down
24 changes: 21 additions & 3 deletions src/azurefox/output/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import typer

from azurefox.chains.presentation import normalize_chain_payload_for_output
from azurefox.chains.registry import SEMANTIC_LOOT_CHAIN_FAMILIES
from azurefox.config import GlobalOptions
from azurefox.models.common import OutputMode
from azurefox.render.table import render_table
Expand Down Expand Up @@ -130,7 +131,7 @@ def _build_loot_payload(command: str, payload: dict) -> dict:
loot_payload[key] = _build_loot_metadata(value)
continue
if key == primary_key and isinstance(value, list):
selected_rows, loot_scope = _select_loot_rows(command, value)
selected_rows, loot_scope = _select_loot_rows(command, payload, value)
loot_payload[key] = selected_rows
continue
if key in {"findings", "issues"} and not value:
Expand All @@ -152,8 +153,14 @@ def _build_loot_metadata(metadata: object) -> object:
}


def _select_loot_rows(command: str, rows: list[object]) -> tuple[list[object], dict | None]:
if command in SEMANTIC_LOOT_BAND_COMMANDS:
def _select_loot_rows(
command: str,
payload: dict,
rows: list[object],
) -> tuple[list[object], dict | None]:
use_semantic_high_band = _uses_semantic_high_band(command, payload)

if use_semantic_high_band:
high_priority_rows = [
row
for row in rows
Expand Down Expand Up @@ -182,6 +189,17 @@ def _select_loot_rows(command: str, rows: list[object]) -> tuple[list[object], d
return selected_rows, None


def _uses_semantic_high_band(command: str, payload: dict) -> bool:
if command in SEMANTIC_LOOT_BAND_COMMANDS:
return True

family = str(payload.get("family") or "").strip()
if command == "chains" and family in SEMANTIC_LOOT_CHAIN_FAMILIES:
return True

return False


def _write_json(command: str, payload: dict, outdir: Path) -> Path:
outdir.mkdir(parents=True, exist_ok=True)
path = outdir / f"{command}.json"
Expand Down
69 changes: 69 additions & 0 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,8 @@ def test_cli_smoke_chains_overview_table_output(tmp_path: Path) -> None:
assert "deployment-path" in result.stdout
assert "escalation-path" in result.stdout
assert "compute-control" in result.stdout
assert "Cannot claim SSRF" in result.stdout
assert "web-app exploitation" in result.stdout
assert "implemented" in result.stdout
assert "backing commands" in result.stdout

Expand Down Expand Up @@ -959,6 +961,73 @@ def test_cli_smoke_loot_artifact_written_end_to_end(tmp_path: Path) -> None:
}


def test_cli_smoke_chains_deployment_path_loot_artifact_uses_semantic_high_band(
tmp_path: Path,
) -> None:
fixture_dir = Path(__file__).resolve().parent / "fixtures" / "lab_tenant"

result = runner.invoke(
app,
["--outdir", str(tmp_path), "--output", "json", "chains", "deployment-path"],
env={"AZUREFOX_FIXTURE_DIR": str(fixture_dir)},
)

assert result.exit_code == 0
json_payload = json.loads(result.stdout)
loot_payload = json.loads((tmp_path / "loot" / "chains.json").read_text(encoding="utf-8"))

assert loot_payload["metadata"] == {
"schema_version": json_payload["metadata"]["schema_version"],
"command": "chains",
}
assert loot_payload["family"] == "deployment-path"
assert "generated_at" not in loot_payload["metadata"]
assert {row["priority"] for row in loot_payload["paths"]} == {"high"}
assert len(loot_payload["paths"]) == 2
assert len(loot_payload["paths"]) < len(json_payload["paths"])
assert {row["asset_name"] for row in loot_payload["paths"]} == {
"deploy-appservice-prod",
"aa-hybrid-prod",
}
assert loot_payload["loot_scope"] == {
"selection": "semantic-high-priority",
"priority_band": "high",
"source_count": len(json_payload["paths"]),
"returned_count": 2,
}


def test_cli_smoke_supported_chains_families_use_semantic_high_band_loot(tmp_path: Path) -> None:
fixture_dir = Path(__file__).resolve().parent / "fixtures" / "lab_tenant"

for family in ("credential-path", "deployment-path", "compute-control", "escalation-path"):
family_dir = tmp_path / family
result = runner.invoke(
app,
["--outdir", str(family_dir), "--output", "json", "chains", family],
env={"AZUREFOX_FIXTURE_DIR": str(fixture_dir)},
)

assert result.exit_code == 0
json_payload = json.loads(result.stdout)
loot_payload = json.loads((family_dir / "loot" / "chains.json").read_text(encoding="utf-8"))
high_priority_rows = [
row for row in json_payload["paths"] if str(row.get("priority") or "").lower() == "high"
]

assert loot_payload["family"] == family
assert high_priority_rows
assert loot_payload["paths"] == high_priority_rows[:10]
assert {row["priority"] for row in loot_payload["paths"]} == {"high"}
assert loot_payload["loot_scope"] == {
"selection": "semantic-high-priority",
"priority_band": "high",
"source_count": len(json_payload["paths"]),
"returned_count": len(high_priority_rows[:10]),
**({"limit": 10} if len(high_priority_rows) > 10 else {}),
}


def test_cli_smoke_devops_accepts_organization_after_command(tmp_path: Path) -> None:
fixture_dir = Path(__file__).resolve().parent / "fixtures" / "lab_tenant"

Expand Down
2 changes: 2 additions & 0 deletions tests/test_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ def test_help_command_chains_topic_sets_planned_runtime_expectations() -> None:
)
assert "escalation-path" in result.stdout
assert "compute-control currently ships a narrow direct-token-opportunity v1" in result.stdout
assert "does not prove SSRF" in result.stdout
assert "web-app exploitation" in result.stdout
assert "compute-control" in result.stdout
assert "credential-path" in result.stdout
assert "claim_boundary" in result.stdout
Expand Down
Loading
Loading