diff --git a/README.md b/README.md
index c30a4a1..8944839 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# AzureFox
-
+
Find attack paths, pivot opportunities, and movement across Azure before you drown in inventory.
@@ -97,6 +97,14 @@ azurefox permissions
## Currently Supported Azure Commands
+### Orchestration
+
+| Grouped Command | Live Families |
+| --- | --- |
+| [`chains`](https://github.com/TacoRocket/AzureFox/wiki/Chains) | [`credential-path`](https://github.com/TacoRocket/AzureFox/wiki/Chains-Credential-Path), [`deployment-path`](https://github.com/TacoRocket/AzureFox/wiki/Chains-Deployment-Path), [`escalation-path`](https://github.com/TacoRocket/AzureFox/wiki/Chains-Escalation-Path), [`compute-control`](https://github.com/TacoRocket/AzureFox/wiki/Chains-Compute-Control) |
+
+### Flat Commands
+
| Section | Commands |
| --- | --- |
| `core` | [`inventory`](https://github.com/TacoRocket/AzureFox/wiki/Inventory) |
@@ -106,8 +114,7 @@ azurefox permissions
| `resource` | [`automation`](https://github.com/TacoRocket/AzureFox/wiki/Automation), [`devops`](https://github.com/TacoRocket/AzureFox/wiki/Devops), [`acr`](https://github.com/TacoRocket/AzureFox/wiki/ACR), [`api-mgmt`](https://github.com/TacoRocket/AzureFox/wiki/API-Mgmt), [`databases`](https://github.com/TacoRocket/AzureFox/wiki/Databases), [`resource-trusts`](https://github.com/TacoRocket/AzureFox/wiki/Resource-Trusts) |
| `storage` | [`storage`](https://github.com/TacoRocket/AzureFox/wiki/Storage) |
| `network` | [`nics`](https://github.com/TacoRocket/AzureFox/wiki/Nics), [`dns`](https://github.com/TacoRocket/AzureFox/wiki/DNS), [`endpoints`](https://github.com/TacoRocket/AzureFox/wiki/Endpoints), [`network-effective`](https://github.com/TacoRocket/AzureFox/wiki/Network-Effective), [`network-ports`](https://github.com/TacoRocket/AzureFox/wiki/Network-Ports) |
-| `compute` | [`workloads`](https://github.com/TacoRocket/AzureFox/wiki/Workloads), [`app-services`](https://github.com/TacoRocket/AzureFox/wiki/App-Services), [`functions`](https://github.com/TacoRocket/AzureFox/wiki/Functions), [`aks`](https://github.com/TacoRocket/AzureFox/wiki/AKS), [`vms`](https://github.com/TacoRocket/AzureFox/wiki/VMs), [`vmss`](https://github.com/TacoRocket/AzureFox/wiki/VMSS), [`snapshots-disks`](https://github.com/TacoRocket/AzureFox/wiki/Snapshots-Disks) |
-| orchestration | [`chains`](https://github.com/TacoRocket/AzureFox/wiki/Chains) |
+| `compute` | [`workloads`](https://github.com/TacoRocket/AzureFox/wiki/Workloads), [`app-services`](https://github.com/TacoRocket/AzureFox/wiki/App-Services), [`functions`](https://github.com/TacoRocket/AzureFox/wiki/Functions), [`container-apps`](https://github.com/TacoRocket/AzureFox/wiki/Container-Apps), [`container-instances`](https://github.com/TacoRocket/AzureFox/wiki/Container-Instances), [`aks`](https://github.com/TacoRocket/AzureFox/wiki/AKS), [`vms`](https://github.com/TacoRocket/AzureFox/wiki/VMs), [`vmss`](https://github.com/TacoRocket/AzureFox/wiki/VMSS), [`snapshots-disks`](https://github.com/TacoRocket/AzureFox/wiki/Snapshots-Disks) |
## Need A Test Lab?
diff --git a/src/azurefox/chains/compute_control.py b/src/azurefox/chains/compute_control.py
index 1331fde..c45ba09 100644
--- a/src/azurefox/chains/compute_control.py
+++ b/src/azurefox/chains/compute_control.py
@@ -566,6 +566,11 @@ def _build_compute_control_record(
"tokens "
f"as {identity_name}; that identity already maps to {stronger_outcome}."
)
+ why_care = _compute_control_why_care(
+ why_care,
+ surface_row=surface_row,
+ workload_row=workload_row,
+ )
evidence_commands = ["tokens-credentials", "workloads"]
joined_surface_types = ["managed-identity-token", "workload"]
if mixed_identity_corroborated:
@@ -711,6 +716,11 @@ def _build_mixed_identity_candidate_record(
"identities. AzureFox cannot yet defend one chosen identity, but visible Azure control "
f"currently maps to {stronger_outcome}."
)
+ why_care = _compute_control_why_care(
+ why_care,
+ surface_row=surface_row,
+ workload_row=workload_row,
+ )
evidence_commands = ["tokens-credentials", "workloads"]
joined_surface_types = ["managed-identity-token", "workload"]
@@ -813,6 +823,63 @@ def _compute_control_next_review(workload_row: dict, *, identity_choice_basis: s
)
+def _compute_control_why_care(
+ base_text: str,
+ *,
+ surface_row: dict,
+ workload_row: dict,
+) -> str:
+ return f"{base_text} {_compute_control_required_foothold(surface_row, workload_row)}"
+
+
+def _compute_control_required_foothold(surface_row: dict, workload_row: dict) -> str:
+ access_path = str(surface_row.get("access_path") or "")
+ asset_kind = str(workload_row.get("asset_kind") or "workload")
+ public_signal = _has_public_compute_signal(workload_row)
+ public_compute_label = (
+ "this public-facing container group"
+ if asset_kind == "ContainerInstance"
+ else "this public-facing service"
+ )
+ internal_compute_label = (
+ "this container group" if asset_kind == "ContainerInstance" else "this workload"
+ )
+
+ if access_path == "workload-identity":
+ 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."
+ )
+ return (
+ "To turn this into downstream Azure access, an operator would need a service-side "
+ f"foothold that can run inside {internal_compute_label} and invoke its token request "
+ "path. "
+ "AzureFox does not yet show that start from the current foothold."
+ )
+
+ if access_path == "imds":
+ 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."
+ )
+ return (
+ f"To turn this into downstream Azure access, an operator would need host-level "
+ f"execution or admin access on this {asset_kind} so the Azure VM metadata token "
+ "path is reachable. AzureFox does not yet show that start from the current foothold."
+ )
+
+ return (
+ "To turn this into downstream Azure access, an operator would need a foothold that "
+ "can reach the workload-side token path. AzureFox does not yet show that start from "
+ "the current foothold."
+ )
+
+
def _permission_control_summary(permission_row: dict | None) -> str | None:
if not permission_row:
return None
diff --git a/src/azurefox/cli.py b/src/azurefox/cli.py
index 4c8a368..227120a 100644
--- a/src/azurefox/cli.py
+++ b/src/azurefox/cli.py
@@ -132,6 +132,16 @@ def functions(ctx: typer.Context) -> None:
_run_single(ctx, "functions")
+@app.command("container-apps")
+def container_apps(ctx: typer.Context) -> None:
+ _run_single(ctx, "container-apps")
+
+
+@app.command("container-instances")
+def container_instances(ctx: typer.Context) -> None:
+ _run_single(ctx, "container-instances")
+
+
@app.command("aks")
def aks(ctx: typer.Context) -> None:
_run_single(ctx, "aks")
diff --git a/src/azurefox/collectors/commands.py b/src/azurefox/collectors/commands.py
index 0f042df..8ec1ad6 100644
--- a/src/azurefox/collectors/commands.py
+++ b/src/azurefox/collectors/commands.py
@@ -28,6 +28,8 @@
ArmDeploymentsOutput,
AuthPoliciesOutput,
AutomationOutput,
+ ContainerAppsOutput,
+ ContainerInstancesOutput,
CrossTenantOutput,
DatabasesOutput,
DevopsOutput,
@@ -279,6 +281,41 @@ def collect_functions(provider: BaseProvider, options: GlobalOptions) -> Functio
)
+def collect_container_apps(
+ provider: BaseProvider,
+ options: GlobalOptions,
+) -> ContainerAppsOutput:
+ data = provider.container_apps()
+ container_apps = sorted(data.get("container_apps", []), key=_container_app_sort_key)
+ return ContainerAppsOutput.model_validate(
+ {
+ "metadata": _metadata(provider, "container-apps", options),
+ "findings": [],
+ **data,
+ "container_apps": container_apps,
+ }
+ )
+
+
+def collect_container_instances(
+ provider: BaseProvider,
+ options: GlobalOptions,
+) -> ContainerInstancesOutput:
+ data = provider.container_instances()
+ container_instances = sorted(
+ data.get("container_instances", []),
+ key=_container_instance_sort_key,
+ )
+ return ContainerInstancesOutput.model_validate(
+ {
+ "metadata": _metadata(provider, "container-instances", options),
+ "findings": [],
+ **data,
+ "container_instances": container_instances,
+ }
+ )
+
+
def collect_arm_deployments(provider: BaseProvider, options: GlobalOptions) -> ArmDeploymentsOutput:
data = provider.arm_deployments()
deployments = sorted(data.get("deployments", []), key=_arm_deployment_sort_key)
@@ -1048,6 +1085,24 @@ def _function_app_sort_key(item: dict) -> tuple[bool, bool, bool, tuple[int, int
)
+def _container_app_sort_key(item: dict) -> tuple[bool, bool, bool, str]:
+ return (
+ not bool(item.get("external_ingress_enabled")),
+ not _has_workload_identity(item),
+ not bool(item.get("default_hostname")),
+ item.get("name") or "",
+ )
+
+
+def _container_instance_sort_key(item: dict) -> tuple[bool, bool, bool, str]:
+ return (
+ not bool(item.get("public_ip_address") or item.get("fqdn")),
+ not _has_workload_identity(item),
+ not bool(item.get("fqdn")),
+ item.get("name") or "",
+ )
+
+
def _arm_deployment_sort_key(item: dict) -> tuple[int, int, int, int, bool, str, str]:
link_count = int(bool(item.get("template_link"))) + int(bool(item.get("parameters_link")))
provider_count = len(item.get("providers", []) or [])
diff --git a/src/azurefox/collectors/provider.py b/src/azurefox/collectors/provider.py
index 4915095..3184981 100644
--- a/src/azurefox/collectors/provider.py
+++ b/src/azurefox/collectors/provider.py
@@ -102,6 +102,12 @@ def api_mgmt(self) -> dict:
def functions(self) -> dict:
return {"function_apps": [], "issues": []}
+ def container_apps(self) -> dict:
+ return {"container_apps": [], "issues": []}
+
+ def container_instances(self) -> dict:
+ return {"container_instances": [], "issues": []}
+
@abstractmethod
def env_vars(self) -> dict:
raise NotImplementedError
@@ -111,12 +117,16 @@ def web_workloads(self) -> dict:
def tokens_credentials(self) -> dict:
workload_data = self.web_workloads()
+ container_instance_data = self.container_instances()
env_var_data = self.env_vars()
arm_data = self.arm_deployments()
vm_data = self.vms()
surfaces = [
*_token_credential_surfaces_from_web_workloads(workload_data.get("workloads", [])),
+ *_token_credential_surfaces_from_container_instances(
+ container_instance_data.get("container_instances", [])
+ ),
*_tokens_credentials_surfaces_from_env_vars(env_var_data.get("env_vars", [])),
*_token_credential_surfaces_from_arm_deployments(arm_data.get("deployments", [])),
*_token_credential_surfaces_from_vms(vm_data.get("vm_assets", [])),
@@ -127,6 +137,7 @@ def tokens_credentials(self) -> dict:
"surfaces": surfaces,
"issues": [
*workload_data.get("issues", []),
+ *container_instance_data.get("issues", []),
*env_var_data.get("issues", []),
*arm_data.get("issues", []),
*vm_data.get("issues", []),
@@ -135,11 +146,15 @@ def tokens_credentials(self) -> dict:
def endpoints(self) -> dict:
workload_data = self.web_workloads()
+ container_instance_data = self.container_instances()
vm_data = self.vms()
endpoints = [
*_endpoints_from_vms(vm_data.get("vm_assets", [])),
*_endpoints_from_web_workloads(workload_data.get("workloads", [])),
+ *_endpoints_from_container_instances(
+ container_instance_data.get("container_instances", [])
+ ),
]
endpoints.sort(
key=lambda item: (
@@ -151,15 +166,23 @@ def endpoints(self) -> dict:
return {
"endpoints": endpoints,
- "issues": [*workload_data.get("issues", []), *vm_data.get("issues", [])],
+ "issues": [
+ *workload_data.get("issues", []),
+ *container_instance_data.get("issues", []),
+ *vm_data.get("issues", []),
+ ],
}
def workloads(self) -> dict:
workload_data = self.web_workloads()
+ container_instance_data = self.container_instances()
vm_data = self.vms()
endpoints = [
*_endpoints_from_vms(vm_data.get("vm_assets", [])),
*_endpoints_from_web_workloads(workload_data.get("workloads", [])),
+ *_endpoints_from_container_instances(
+ container_instance_data.get("container_instances", [])
+ ),
]
endpoints_by_asset = _endpoints_by_asset(endpoints)
workloads = [
@@ -168,11 +191,19 @@ def workloads(self) -> dict:
workload_data.get("workloads", []),
endpoints_by_asset,
),
+ *_workload_rows_from_container_instances(
+ container_instance_data.get("container_instances", []),
+ endpoints_by_asset,
+ ),
]
workloads.sort(key=_workload_sort_key)
return {
"workloads": workloads,
- "issues": [*workload_data.get("issues", []), *vm_data.get("issues", [])],
+ "issues": [
+ *workload_data.get("issues", []),
+ *container_instance_data.get("issues", []),
+ *vm_data.get("issues", []),
+ ],
}
def network_effective(self) -> dict:
@@ -276,6 +307,12 @@ def _read(self, name: str) -> dict:
raise AzureFoxError(ErrorKind.UNKNOWN, f"Fixture file not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
+ def _read_optional(self, name: str, *, empty_key: str) -> dict:
+ path = self.fixture_dir / f"{name}.json"
+ if not path.exists():
+ return {empty_key: [], "issues": []}
+ return json.loads(path.read_text(encoding="utf-8"))
+
def whoami(self) -> dict:
return self._read("whoami")
@@ -396,8 +433,29 @@ def keyvault_secret_access(
def env_vars(self) -> dict:
return self._read("env_vars")
+ def container_apps(self) -> dict:
+ return self._read_optional("container_apps", empty_key="container_apps")
+
+ def container_instances(self) -> dict:
+ return self._read_optional("container_instances", empty_key="container_instances")
+
def web_workloads(self) -> dict:
- return self._read("web_workloads")
+ data = self._read("web_workloads")
+ container_app_data = self.container_apps()
+ workloads = [
+ *data.get("workloads", []),
+ *[
+ _container_app_workload_summary(item)
+ for item in container_app_data.get("container_apps", [])
+ ],
+ ]
+ workloads.sort(
+ key=lambda item: ((item.get("asset_name") or ""), item.get("asset_id") or "")
+ )
+ return {
+ "workloads": workloads,
+ "issues": [*data.get("issues", []), *container_app_data.get("issues", [])],
+ }
def storage(self) -> dict:
return self._read("storage")
@@ -1689,6 +1747,28 @@ def functions(self) -> dict:
)
return {"function_apps": function_apps, "issues": issues}
+ def container_apps(self) -> dict:
+ container_apps, issues = _collect_resource_type_summaries(
+ resources_client=self.clients.resource.resources,
+ resource_type="Microsoft.App/containerApps",
+ api_version="2024-03-01",
+ summary_fn=_container_app_summary,
+ list_issue_scope="container_apps.resources",
+ hydrate_issue_scope="container_apps.hydrate",
+ )
+ return {"container_apps": container_apps, "issues": issues}
+
+ def container_instances(self) -> dict:
+ container_instances, issues = _collect_resource_type_summaries(
+ resources_client=self.clients.resource.resources,
+ resource_type="Microsoft.ContainerInstance/containerGroups",
+ api_version="2023-05-01",
+ summary_fn=_container_instance_summary,
+ list_issue_scope="container_instances.resources",
+ hydrate_issue_scope="container_instances.hydrate",
+ )
+ return {"container_instances": container_instances, "issues": issues}
+
def web_workloads(self) -> dict:
issues: list[dict] = []
workloads: list[dict] = []
@@ -1703,6 +1783,13 @@ def web_workloads(self) -> dict:
except Exception as exc:
issues.append(_issue_from_exception("web_workloads.web_apps", exc))
+ container_app_data = self.container_apps()
+ workloads.extend(
+ _container_app_workload_summary(item)
+ for item in container_app_data.get("container_apps", [])
+ )
+ issues.extend(container_app_data.get("issues", []))
+
workloads.sort(
key=lambda item: ((item.get("asset_name") or ""), item.get("asset_id") or "")
)
@@ -3687,6 +3774,38 @@ def _issue_from_exception(area: str, exc: Exception) -> dict:
}
+def _collect_resource_type_summaries(
+ *,
+ resources_client: object,
+ resource_type: str,
+ api_version: str,
+ summary_fn,
+ list_issue_scope: str,
+ hydrate_issue_scope: str,
+) -> tuple[list[dict], list[dict]]:
+ rows: list[dict] = []
+ issues: list[dict] = []
+
+ try:
+ resources = resources_client.list(filter=f"resourceType eq '{resource_type}'")
+ for resource in resources:
+ resource_id = _string_value(getattr(resource, "id", None))
+ hydrated = resource
+ if resource_id:
+ try:
+ hydrated = resources_client.get_by_id(resource_id, api_version)
+ except Exception as exc:
+ issues.append(
+ _issue_from_exception(f"{hydrate_issue_scope}[{resource_id}]", exc)
+ )
+ hydrated = resource
+ rows.append(summary_fn(hydrated))
+ except Exception as exc:
+ issues.append(_issue_from_exception(list_issue_scope, exc))
+
+ return rows, issues
+
+
def _call_automation_operation(
client: object,
attrs: tuple[str, ...],
@@ -5525,6 +5644,240 @@ def _function_app_summary(
}
+def _container_app_summary(resource: object) -> dict:
+ resource_id = _string_value(getattr(resource, "id", None)) or ""
+ name = _string_value(getattr(resource, "name", None)) or "unknown"
+ identity = getattr(resource, "identity", None)
+ properties = getattr(resource, "properties", None)
+ configuration = _property_value(properties, "configuration")
+ ingress = _property_value(configuration, "ingress")
+
+ workload_identity_ids = sorted(
+ str(identity_id)
+ for identity_id in (
+ _property_value(identity, "userAssignedIdentities", "user_assigned_identities") or {}
+ ).keys()
+ )
+ workload_identity_type = _string_value(_property_value(identity, "type"))
+ workload_principal_id = _string_value(_property_value(identity, "principalId", "principal_id"))
+ workload_client_id = _string_value(_property_value(identity, "clientId", "client_id"))
+ default_hostname = _string_value(_property_value(ingress, "fqdn"))
+ external_ingress_enabled = _bool_or_none(_property_value(ingress, "external"))
+ ingress_target_port = _int_value(_property_value(ingress, "targetPort", "target_port"))
+ ingress_transport = _string_value(_property_value(ingress, "transport"))
+ revision_mode = _string_value(
+ _property_value(configuration, "activeRevisionsMode", "active_revisions_mode")
+ )
+ latest_revision_name = _string_value(
+ _property_value(properties, "latestRevisionName", "latest_revision_name")
+ )
+ latest_ready_revision_name = _string_value(
+ _property_value(properties, "latestReadyRevisionName", "latest_ready_revision_name")
+ )
+ environment_id = _string_value(
+ _property_value(properties, "managedEnvironmentId", "managed_environment_id")
+ )
+
+ ingress_parts: list[str] = []
+ if external_ingress_enabled is True:
+ ingress_parts.append("external ingress enabled")
+ elif external_ingress_enabled is False:
+ ingress_parts.append("internal ingress only")
+ if ingress_target_port is not None:
+ ingress_parts.append(f"target port {ingress_target_port}")
+ if ingress_transport:
+ ingress_parts.append(f"transport {ingress_transport}")
+
+ revision_parts: list[str] = []
+ if revision_mode:
+ revision_parts.append(f"revision mode {revision_mode}")
+ if latest_ready_revision_name:
+ revision_parts.append(f"latest ready revision {latest_ready_revision_name}")
+ elif latest_revision_name:
+ revision_parts.append(f"latest revision {latest_revision_name}")
+
+ endpoint_phrase = (
+ f"publishes hostname '{default_hostname}'"
+ if default_hostname
+ else "has no visible hostname from the current read path"
+ )
+ identity_phrase = (
+ f"uses managed identity ({workload_identity_type})"
+ if workload_identity_type
+ else "has no managed identity visible from the current read path"
+ )
+ posture_parts = ingress_parts + revision_parts
+ posture_phrase = (
+ f" Visible posture: {', '.join(posture_parts)}."
+ if posture_parts
+ else ""
+ )
+
+ return {
+ "id": resource_id or f"/unknown/{name}",
+ "name": name,
+ "resource_group": _resource_group_from_id(resource_id),
+ "location": _string_value(getattr(resource, "location", None)),
+ "environment_id": environment_id,
+ "default_hostname": default_hostname,
+ "external_ingress_enabled": external_ingress_enabled,
+ "ingress_target_port": ingress_target_port,
+ "ingress_transport": ingress_transport,
+ "revision_mode": revision_mode,
+ "latest_revision_name": latest_revision_name,
+ "latest_ready_revision_name": latest_ready_revision_name,
+ "workload_identity_type": workload_identity_type,
+ "workload_principal_id": workload_principal_id,
+ "workload_client_id": workload_client_id,
+ "workload_identity_ids": workload_identity_ids,
+ "summary": (
+ f"Container App '{name}' {endpoint_phrase} and {identity_phrase}.{posture_phrase}"
+ ),
+ "related_ids": _dedupe_strings(
+ [
+ resource_id,
+ environment_id,
+ workload_principal_id,
+ *workload_identity_ids,
+ ]
+ ),
+ }
+
+
+def _container_app_workload_summary(item: dict) -> dict:
+ return {
+ "asset_id": item.get("id") or f"/unknown/{item.get('name') or 'container-app'}",
+ "asset_name": item.get("name") or "unknown",
+ "asset_kind": "ContainerApp",
+ "resource_group": item.get("resource_group"),
+ "location": item.get("location"),
+ "default_hostname": item.get("default_hostname"),
+ "external_ingress_enabled": item.get("external_ingress_enabled"),
+ "workload_identity_type": item.get("workload_identity_type"),
+ "workload_principal_id": item.get("workload_principal_id"),
+ "workload_client_id": item.get("workload_client_id"),
+ "workload_identity_ids": list(item.get("workload_identity_ids") or []),
+ }
+
+
+def _container_instance_summary(resource: object) -> dict:
+ resource_id = _string_value(getattr(resource, "id", None)) or ""
+ name = _string_value(getattr(resource, "name", None)) or "unknown"
+ identity = getattr(resource, "identity", None)
+ properties = getattr(resource, "properties", None)
+ ip_address = _property_value(properties, "ipAddress", "ip_address")
+ containers = _property_value(properties, "containers") or []
+
+ workload_identity_ids = sorted(
+ str(identity_id)
+ for identity_id in (
+ _property_value(identity, "userAssignedIdentities", "user_assigned_identities") or {}
+ ).keys()
+ )
+ workload_identity_type = _string_value(_property_value(identity, "type"))
+ workload_principal_id = _string_value(_property_value(identity, "principalId", "principal_id"))
+ workload_client_id = _string_value(_property_value(identity, "clientId", "client_id"))
+
+ fqdn = _string_value(_property_value(ip_address, "fqdn"))
+ public_ip_address = _string_value(_property_value(ip_address, "ip"))
+ exposed_ports = sorted(
+ {
+ port
+ for port in (
+ _int_value(_property_value(item, "port"))
+ for item in (_property_value(ip_address, "ports") or [])
+ )
+ if port is not None
+ }
+ )
+ subnet_ids = _dedupe_strings(
+ [
+ _string_value(_property_value(item, "id"))
+ for item in (_property_value(properties, "subnetIds", "subnet_ids") or [])
+ ]
+ )
+ container_images = _dedupe_strings(
+ [
+ _string_value(_property_value(_property_value(item, "properties"), "image"))
+ for item in containers
+ ]
+ )
+ restart_policy = _string_value(
+ _property_value(properties, "restartPolicy", "restart_policy")
+ )
+ os_type = _string_value(_property_value(properties, "osType", "os_type"))
+ provisioning_state = _string_value(
+ _property_value(properties, "provisioningState", "provisioning_state")
+ )
+
+ endpoint_parts: list[str] = []
+ if fqdn:
+ endpoint_parts.append(f"publishes FQDN '{fqdn}'")
+ if public_ip_address:
+ endpoint_parts.append(f"uses public IP {public_ip_address}")
+ if not endpoint_parts:
+ endpoint_parts.append("has no public endpoint visible from the current read path")
+
+ posture_parts: list[str] = []
+ if os_type:
+ posture_parts.append(f"os {os_type}")
+ if restart_policy:
+ posture_parts.append(f"restart {restart_policy}")
+ if exposed_ports:
+ posture_parts.append(
+ "ports " + ", ".join(str(port) for port in exposed_ports[:5])
+ + ("..." if len(exposed_ports) > 5 else "")
+ )
+ if subnet_ids:
+ posture_parts.append(f"subnets {len(subnet_ids)}")
+ if containers:
+ posture_parts.append(f"containers {len(containers)}")
+
+ identity_phrase = (
+ f"uses managed identity ({workload_identity_type})"
+ if workload_identity_type
+ else "has no managed identity visible from the current read path"
+ )
+ posture_phrase = (
+ f" Visible posture: {', '.join(posture_parts)}."
+ if posture_parts
+ else ""
+ )
+
+ return {
+ "id": resource_id or f"/unknown/{name}",
+ "name": name,
+ "resource_group": _resource_group_from_id(resource_id),
+ "location": _string_value(getattr(resource, "location", None)),
+ "os_type": os_type,
+ "restart_policy": restart_policy,
+ "provisioning_state": provisioning_state,
+ "public_ip_address": public_ip_address,
+ "fqdn": fqdn,
+ "exposed_ports": exposed_ports,
+ "subnet_ids": subnet_ids,
+ "container_count": len(containers),
+ "container_images": container_images,
+ "workload_identity_type": workload_identity_type,
+ "workload_principal_id": workload_principal_id,
+ "workload_client_id": workload_client_id,
+ "workload_identity_ids": workload_identity_ids,
+ "summary": (
+ f"Container group '{name}' {' and '.join(endpoint_parts)} and {identity_phrase}."
+ f"{posture_phrase}"
+ ),
+ "related_ids": _dedupe_strings(
+ [
+ resource_id,
+ public_ip_address,
+ workload_principal_id,
+ *workload_identity_ids,
+ *subnet_ids,
+ ]
+ ),
+ }
+
+
def _env_var_summary(
app: object,
*,
@@ -7025,6 +7378,57 @@ def _token_credential_surfaces_from_web_workloads(workloads: list[dict]) -> list
return surfaces
+def _token_credential_surfaces_from_container_instances(
+ container_instances: list[dict],
+) -> list[dict]:
+ surfaces: list[dict] = []
+
+ for item in container_instances:
+ asset_id = item.get("id")
+ asset_name = item.get("name") or asset_id or "unknown"
+ related_ids = [
+ *([asset_id] if asset_id else []),
+ *[str(identity_id) for identity_id in item.get("workload_identity_ids", [])],
+ ]
+ if item.get("workload_principal_id"):
+ related_ids.append(str(item.get("workload_principal_id")))
+
+ if not item.get("workload_identity_type"):
+ continue
+
+ identity_signal = str(item.get("workload_identity_type"))
+ user_assigned_count = len(item.get("workload_identity_ids", []))
+ if user_assigned_count:
+ identity_signal = f"{identity_signal}; user-assigned={user_assigned_count}"
+
+ next_review_hint = tokens_credential_next_review_hint(
+ surface_type="managed-identity-token",
+ access_path="workload-identity",
+ operator_signal=identity_signal,
+ )
+ surfaces.append(
+ {
+ "asset_id": asset_id or f"/unknown/{asset_name}",
+ "asset_name": asset_name,
+ "asset_kind": "ContainerInstance",
+ "resource_group": item.get("resource_group"),
+ "location": item.get("location"),
+ "surface_type": "managed-identity-token",
+ "access_path": "workload-identity",
+ "priority": "medium",
+ "operator_signal": identity_signal,
+ "summary": (
+ f"ContainerInstance '{asset_name}' can request tokens through attached "
+ f"managed identity ({item.get('workload_identity_type')}). "
+ f"{next_review_hint}"
+ ),
+ "related_ids": _dedupe_strings(related_ids),
+ }
+ )
+
+ return surfaces
+
+
def _endpoints_from_vms(vm_assets: list[dict]) -> list[dict]:
endpoints: list[dict] = []
@@ -7060,17 +7464,23 @@ def _endpoints_from_web_workloads(workloads: list[dict]) -> list[dict]:
endpoints: list[dict] = []
for item in workloads:
+ asset_kind = item.get("asset_kind") or "WebWorkload"
+ if asset_kind == "ContainerApp" and item.get("external_ingress_enabled") is not True:
+ continue
default_hostname = str(item.get("default_hostname") or "")
if not default_hostname:
continue
asset_id = item.get("asset_id")
asset_name = item.get("asset_name") or asset_id or "unknown"
- asset_kind = item.get("asset_kind") or "WebWorkload"
ingress_path = (
"azure-functions-default-hostname"
if asset_kind == "FunctionApp"
- else "azurewebsites-default-hostname"
+ else (
+ "azure-container-apps-default-hostname"
+ if asset_kind == "ContainerApp"
+ else "azurewebsites-default-hostname"
+ )
)
endpoints.append(
@@ -7100,6 +7510,62 @@ def _endpoints_from_web_workloads(workloads: list[dict]) -> list[dict]:
return endpoints
+def _endpoints_from_container_instances(container_instances: list[dict]) -> list[dict]:
+ endpoints: list[dict] = []
+
+ for item in container_instances:
+ asset_id = item.get("id")
+ asset_name = item.get("name") or asset_id or "unknown"
+ related_ids = _dedupe_strings(
+ [
+ asset_id,
+ item.get("workload_principal_id"),
+ *item.get("workload_identity_ids", []),
+ *item.get("subnet_ids", []),
+ ]
+ )
+
+ if item.get("public_ip_address"):
+ endpoints.append(
+ {
+ "endpoint": str(item.get("public_ip_address")),
+ "endpoint_type": "ip",
+ "source_asset_id": asset_id or f"/unknown/{asset_name}",
+ "source_asset_name": asset_name,
+ "source_asset_kind": "ContainerInstance",
+ "exposure_family": "public-ip",
+ "ingress_path": "azure-container-instances-public-ip",
+ "summary": (
+ f"ContainerInstance '{asset_name}' exposes public IP "
+ f"{item.get('public_ip_address')}. Review the visible ingress path, "
+ "ports, and runtime posture together."
+ ),
+ "related_ids": related_ids,
+ }
+ )
+
+ if item.get("fqdn"):
+ endpoints.append(
+ {
+ "endpoint": str(item.get("fqdn")),
+ "endpoint_type": "hostname",
+ "source_asset_id": asset_id or f"/unknown/{asset_name}",
+ "source_asset_name": asset_name,
+ "source_asset_kind": "ContainerInstance",
+ "exposure_family": "managed-container-fqdn",
+ "ingress_path": "azure-container-instances-fqdn",
+ "summary": (
+ f"ContainerInstance '{asset_name}' publishes hostname "
+ f"'{item.get('fqdn')}'. Validate whether that ingress path is intended "
+ "and how it is constrained."
+ ),
+ "related_ids": related_ids,
+ }
+ )
+
+ return endpoints
+
+
def _endpoints_by_asset(endpoints: list[dict]) -> dict[str, list[dict]]:
endpoints_by_asset: dict[str, list[dict]] = {}
for endpoint in endpoints:
@@ -7199,6 +7665,10 @@ def _workload_rows_from_web_workloads(
network_signals: list[str] = []
if item.get("default_hostname"):
network_signals.append("default-hostname")
+ if item.get("external_ingress_enabled") is True:
+ network_signals.append("external-ingress")
+ elif item.get("external_ingress_enabled") is False:
+ network_signals.append("internal-only")
if identity_ids:
network_signals.append(f"user-assigned={len(identity_ids)}")
@@ -7237,6 +7707,80 @@ def _workload_rows_from_web_workloads(
return workloads
+def _workload_rows_from_container_instances(
+ container_instances: list[dict],
+ endpoints_by_asset: dict[str, list[dict]],
+) -> list[dict]:
+ workloads: list[dict] = []
+
+ for item in container_instances:
+ asset_id = item.get("id")
+ asset_name = item.get("name") or asset_id or "unknown"
+ normalized_asset_id = str(asset_id or f"/unknown/{asset_name}")
+ identity_ids = _dedupe_strings(item.get("workload_identity_ids", []))
+ identity_type = item.get("workload_identity_type")
+ asset_endpoints = endpoints_by_asset.get(
+ _arm_id_join_key(asset_id or f"/unknown/{asset_name}") or "",
+ [],
+ )
+ endpoints = _dedupe_strings([endpoint.get("endpoint") for endpoint in asset_endpoints])
+ ingress_paths = _dedupe_strings(
+ [endpoint.get("ingress_path") for endpoint in asset_endpoints]
+ )
+ exposure_families = _dedupe_strings(
+ [endpoint.get("exposure_family") for endpoint in asset_endpoints]
+ )
+
+ network_signals: list[str] = []
+ if item.get("public_ip_address"):
+ network_signals.append("public-ip")
+ if item.get("fqdn"):
+ network_signals.append("fqdn")
+ if item.get("subnet_ids"):
+ network_signals.append(f"subnets={len(item.get('subnet_ids', []))}")
+ if item.get("exposed_ports"):
+ network_signals.append(f"ports={len(item.get('exposed_ports', []))}")
+ if item.get("container_count") is not None:
+ network_signals.append(f"containers={item.get('container_count')}")
+ if identity_ids:
+ network_signals.append(f"user-assigned={len(identity_ids)}")
+
+ workloads.append(
+ {
+ "asset_id": normalized_asset_id,
+ "asset_name": asset_name,
+ "asset_kind": "ContainerInstance",
+ "resource_group": item.get("resource_group"),
+ "location": item.get("location"),
+ "identity_type": identity_type,
+ "identity_principal_id": item.get("workload_principal_id"),
+ "identity_client_id": item.get("workload_client_id"),
+ "identity_ids": identity_ids,
+ "endpoints": endpoints,
+ "ingress_paths": ingress_paths,
+ "exposure_families": exposure_families,
+ "summary": _workload_summary_text(
+ asset_kind="ContainerInstance",
+ asset_name=asset_name,
+ endpoints=endpoints,
+ exposure_families=exposure_families,
+ identity_type=identity_type,
+ network_signals=network_signals,
+ ),
+ "related_ids": _dedupe_strings(
+ [
+ asset_id,
+ item.get("workload_principal_id"),
+ *identity_ids,
+ *item.get("subnet_ids", []),
+ ]
+ ),
+ }
+ )
+
+ return workloads
+
+
def _vm_identity_type(identity_ids: list[str]) -> str | None:
has_system = any(
str(identity_id).endswith("/identities/system") for identity_id in identity_ids
@@ -7295,7 +7839,14 @@ def _workload_summary_text(
def _workload_sort_key(item: dict) -> tuple[bool, bool, int, str]:
- kind_order = {"VM": 0, "AppService": 1, "FunctionApp": 2, "VMSS": 3}
+ kind_order = {
+ "VM": 0,
+ "AppService": 1,
+ "FunctionApp": 2,
+ "ContainerApp": 3,
+ "ContainerInstance": 4,
+ "VMSS": 5,
+ }
return (
not bool(item.get("endpoints")),
not bool(item.get("identity_type")),
diff --git a/src/azurefox/help.py b/src/azurefox/help.py
index a0b0fab..d25768f 100644
--- a/src/azurefox/help.py
+++ b/src/azurefox/help.py
@@ -353,6 +353,76 @@ class SectionHelpTopic:
),
example="azurefox functions --output table",
),
+ "container-apps": CommandHelpTopic(
+ name="container-apps",
+ section="compute",
+ summary=(
+ "Review Azure Container Apps for exposure, revision, environment, and managed "
+ "identity context."
+ ),
+ offensive_question=(
+ "Which Container Apps are externally reachable, and which of them carry the identity "
+ "context worth deeper operator follow-up?"
+ ),
+ cloudfox_frame=(
+ "Azure-native Container Apps review that stays at management-plane posture: ingress, "
+ "hostname, revision mode, environment anchor, and managed identity context before "
+ "deeper workload or exploitation analysis."
+ ),
+ output_highlights=(
+ "default_hostname",
+ "external_ingress_enabled",
+ "ingress_target_port",
+ "revision_mode",
+ "environment_id",
+ "workload_identity_type",
+ ),
+ attack_leads=(
+ AttackLead("Discovery", "Cloud Service Discovery"),
+ AttackLead("Discovery", "Network Service Discovery"),
+ AttackLead("Initial Access", "Exploit Public-Facing Application"),
+ AttackLead(
+ "Credential Access",
+ "Use Alternate Authentication Material: Application Access Token",
+ ),
+ ),
+ example="azurefox container-apps --output table",
+ ),
+ "container-instances": CommandHelpTopic(
+ name="container-instances",
+ section="compute",
+ summary=(
+ "Review Azure Container Instances for public endpoint, runtime, restart, and managed "
+ "identity context."
+ ),
+ offensive_question=(
+ "Which container groups are publicly reachable, and which of them carry the identity "
+ "or runtime posture worth deeper operator follow-up?"
+ ),
+ cloudfox_frame=(
+ "Azure-native Container Instances review that stays at management-plane posture: "
+ "public IP or FQDN, exposed ports, restart and OS context, container count, and "
+ "managed identity context before deeper runtime or exploitation analysis."
+ ),
+ output_highlights=(
+ "public_ip_address",
+ "fqdn",
+ "exposed_ports",
+ "restart_policy",
+ "os_type",
+ "workload_identity_type",
+ ),
+ attack_leads=(
+ AttackLead("Discovery", "Cloud Service Discovery"),
+ AttackLead("Discovery", "Network Service Discovery"),
+ AttackLead("Initial Access", "Exploit Public-Facing Application"),
+ AttackLead(
+ "Credential Access",
+ "Use Alternate Authentication Material: Application Access Token",
+ ),
+ ),
+ example="azurefox container-instances --output table",
+ ),
"aks": CommandHelpTopic(
name="aks",
section="compute",
diff --git a/src/azurefox/models/commands.py b/src/azurefox/models/commands.py
index 0e5c29a..65e3028 100644
--- a/src/azurefox/models/commands.py
+++ b/src/azurefox/models/commands.py
@@ -16,6 +16,8 @@
AutomationAccountAsset,
CollectionIssue,
CommandMetadata,
+ ContainerAppAsset,
+ ContainerInstanceAsset,
CrossTenantPathSummary,
DatabaseServerAsset,
DevopsPipelineAsset,
@@ -137,6 +139,20 @@ class FunctionsOutput(BaseModel):
issues: list[CollectionIssue] = Field(default_factory=list)
+class ContainerAppsOutput(BaseModel):
+ metadata: CommandMetadata
+ container_apps: list[ContainerAppAsset] = Field(default_factory=list)
+ findings: list[Finding] = Field(default_factory=list)
+ issues: list[CollectionIssue] = Field(default_factory=list)
+
+
+class ContainerInstancesOutput(BaseModel):
+ metadata: CommandMetadata
+ container_instances: list[ContainerInstanceAsset] = Field(default_factory=list)
+ findings: list[Finding] = Field(default_factory=list)
+ issues: list[CollectionIssue] = Field(default_factory=list)
+
+
class RbacOutput(BaseModel):
metadata: CommandMetadata
principals: list[Principal] = Field(default_factory=list)
diff --git a/src/azurefox/models/common.py b/src/azurefox/models/common.py
index 9b21f3c..846c392 100644
--- a/src/azurefox/models/common.py
+++ b/src/azurefox/models/common.py
@@ -505,6 +505,49 @@ class FunctionAppAsset(BaseModel):
related_ids: list[str] = Field(default_factory=list)
+class ContainerAppAsset(BaseModel):
+ id: str
+ name: str
+ resource_group: str | None = None
+ location: str | None = None
+ environment_id: str | None = None
+ default_hostname: str | None = None
+ external_ingress_enabled: bool | None = None
+ ingress_target_port: int | None = None
+ ingress_transport: str | None = None
+ revision_mode: str | None = None
+ latest_revision_name: str | None = None
+ latest_ready_revision_name: str | None = None
+ workload_identity_type: str | None = None
+ workload_principal_id: str | None = None
+ workload_client_id: str | None = None
+ workload_identity_ids: list[str] = Field(default_factory=list)
+ summary: str
+ related_ids: list[str] = Field(default_factory=list)
+
+
+class ContainerInstanceAsset(BaseModel):
+ id: str
+ name: str
+ resource_group: str | None = None
+ location: str | None = None
+ os_type: str | None = None
+ restart_policy: str | None = None
+ provisioning_state: str | None = None
+ public_ip_address: str | None = None
+ fqdn: str | None = None
+ exposed_ports: list[int] = Field(default_factory=list)
+ subnet_ids: list[str] = Field(default_factory=list)
+ container_count: int | None = None
+ container_images: list[str] = Field(default_factory=list)
+ workload_identity_type: str | None = None
+ workload_principal_id: str | None = None
+ workload_client_id: str | None = None
+ workload_identity_ids: list[str] = Field(default_factory=list)
+ summary: str
+ related_ids: list[str] = Field(default_factory=list)
+
+
class AksClusterAsset(BaseModel):
id: str
name: str
diff --git a/src/azurefox/registry.py b/src/azurefox/registry.py
index e6ea143..7424230 100644
--- a/src/azurefox/registry.py
+++ b/src/azurefox/registry.py
@@ -12,6 +12,8 @@
collect_arm_deployments,
collect_auth_policies,
collect_automation,
+ collect_container_apps,
+ collect_container_instances,
collect_cross_tenant,
collect_databases,
collect_devops,
@@ -82,6 +84,8 @@ class CommandSpec:
CommandSpec("workloads", "compute", collect_workloads),
CommandSpec("app-services", "compute", collect_app_services),
CommandSpec("functions", "compute", collect_functions),
+ CommandSpec("container-apps", "compute", collect_container_apps),
+ CommandSpec("container-instances", "compute", collect_container_instances),
CommandSpec("aks", "compute", collect_aks),
CommandSpec("api-mgmt", "resource", collect_api_mgmt),
CommandSpec("acr", "resource", collect_acr),
diff --git a/src/azurefox/render/table.py b/src/azurefox/render/table.py
index 52f46d0..cedfd94 100644
--- a/src/azurefox/render/table.py
+++ b/src/azurefox/render/table.py
@@ -80,7 +80,9 @@ def render_table(command: str, payload: dict) -> str:
_render_scope_boundary_notes(console, command, payload)
- takeaway = _takeaway_for_command(command, payload)
+ takeaway = ""
+ if command != "chains":
+ takeaway = _takeaway_for_command(command, payload)
if takeaway:
console.print("")
console.print(f"Takeaway: {takeaway}")
@@ -317,7 +319,7 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis
"name": item.get("name"),
"default_hostname": item.get("default_hostname"),
"runtime_stack": item.get("runtime_stack") or "-",
- "identity": _app_service_identity_context(item),
+ "identity": _resource_identity_context(item),
"exposure": _app_service_exposure_context(item),
"posture": _app_service_posture_context(item),
"why_it_matters": item.get("summary"),
@@ -342,7 +344,7 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis
{
"name": item.get("name"),
"login_server": item.get("login_server"),
- "identity": _app_service_identity_context(item),
+ "identity": _resource_identity_context(item),
"auth": _acr_auth_context(item),
"exposure": _acr_exposure_context(item),
"depth": _acr_depth_context(item),
@@ -370,7 +372,7 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis
"name": item.get("name"),
"engine": item.get("engine"),
"endpoint": item.get("fully_qualified_domain_name"),
- "identity": _app_service_identity_context(item),
+ "identity": _resource_identity_context(item),
"inventory": _database_inventory_context(item),
"exposure": _database_exposure_context(item),
"posture": _database_posture_context(item),
@@ -464,7 +466,7 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis
{
"name": item.get("name"),
"gateway": item.get("gateway_hostnames", []),
- "identity": _app_service_identity_context(item),
+ "identity": _resource_identity_context(item),
"inventory": _api_mgmt_inventory_context(item),
"exposure": _api_mgmt_exposure_context(item),
"posture": _api_mgmt_posture_context(item),
@@ -490,7 +492,7 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis
"name": item.get("name"),
"default_hostname": item.get("default_hostname"),
"runtime": _function_runtime_context(item),
- "identity": _app_service_identity_context(item),
+ "identity": _resource_identity_context(item),
"deployment": _function_deployment_context(item),
"posture": _function_posture_context(item),
"why_it_matters": item.get("summary"),
@@ -499,6 +501,56 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis
],
)
+ if command == "container-apps":
+ return (
+ [
+ ("name", "container app"),
+ ("environment", "environment"),
+ ("hostname", "hostname"),
+ ("ingress", "ingress"),
+ ("identity", "identity"),
+ ("revisions", "revisions"),
+ ("why_it_matters", "why it matters"),
+ ],
+ [
+ {
+ "name": item.get("name"),
+ "environment": _container_app_environment_context(item),
+ "hostname": item.get("default_hostname") or "-",
+ "ingress": _container_app_ingress_context(item),
+ "identity": _resource_identity_context(item),
+ "revisions": _container_app_revision_context(item),
+ "why_it_matters": item.get("summary"),
+ }
+ for item in payload.get("container_apps", [])
+ ],
+ )
+
+ if command == "container-instances":
+ return (
+ [
+ ("name", "container group"),
+ ("endpoint", "endpoint"),
+ ("network", "network"),
+ ("identity", "identity"),
+ ("runtime", "runtime"),
+ ("images", "images"),
+ ("why_it_matters", "why it matters"),
+ ],
+ [
+ {
+ "name": item.get("name"),
+ "endpoint": _container_instance_endpoint_context(item),
+ "network": _container_instance_network_context(item),
+ "identity": _resource_identity_context(item),
+ "runtime": _container_instance_runtime_context(item),
+ "images": _container_instance_images_context(item),
+ "why_it_matters": item.get("summary"),
+ }
+ for item in payload.get("container_instances", [])
+ ],
+ )
+
if command == "arm-deployments":
return (
[
@@ -741,27 +793,26 @@ def _table_spec(command: str, payload: dict) -> tuple[list[tuple[str, str]], lis
return (
[
("priority", "priority"),
- ("urgency", "urgency"),
- ("asset_name", "compute"),
- ("path_concept", "path type"),
- ("insertion_point", "insertion point"),
- ("stronger_outcome", "visible azure control"),
- ("confidence_boundary", "confidence boundary"),
- ("next_review", "next review"),
+ ("when", "when"),
+ ("workload_reach", "reach from here"),
+ ("asset_name", "compute foothold"),
+ ("insertion_point", "token path"),
+ ("identity", "identity"),
+ ("stronger_outcome", "Azure access"),
+ ("proof_status", "proof status"),
("why_care", "note"),
],
[
{
"priority": item.get("priority"),
- "urgency": item.get("urgency") or "-",
+ "when": _compute_control_when(item),
+ "workload_reach": _compute_control_workload_reach(item),
"asset_name": item.get("asset_name"),
- "path_concept": _compute_control_path_type(item),
- "insertion_point": item.get("insertion_point"),
+ "insertion_point": _compute_control_token_path(item),
+ "identity": _compute_control_identity(item),
"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"),
+ "proof_status": _compute_control_proof_status(item),
"why_care": item.get("why_care"),
}
for item in payload.get("paths", [])
@@ -1735,6 +1786,32 @@ def _takeaway_for_command(command: str, payload: dict) -> str:
f"{keyvault_backed} include Key Vault-backed settings."
)
+ if command == "container-apps":
+ container_apps = payload.get("container_apps", [])
+ external = sum(item.get("external_ingress_enabled") is True for item in container_apps)
+ identities = sum(bool(item.get("workload_identity_type")) for item in container_apps)
+ hostnames = sum(bool(item.get("default_hostname")) for item in container_apps)
+ return (
+ f"{len(container_apps)} Container Apps visible; {external} expose external ingress, "
+ f"{hostnames} publish visible hostnames, and {identities} carry managed identity "
+ "context."
+ )
+ if command == "container-instances":
+ container_instances = payload.get("container_instances", [])
+ public_endpoints = sum(
+ bool(item.get("public_ip_address") or item.get("fqdn"))
+ for item in container_instances
+ )
+ identities = sum(
+ bool(item.get("workload_identity_type")) for item in container_instances
+ )
+ subnets = sum(bool(item.get("subnet_ids")) for item in container_instances)
+ return (
+ f"{len(container_instances)} Container Instances visible; {public_endpoints} publish "
+ f"public endpoint cues, {identities} carry managed identity context, and {subnets} "
+ "show subnet placement."
+ )
+
if command == "arm-deployments":
deployments = payload.get("deployments", [])
findings = payload.get("findings", [])
@@ -2100,6 +2177,56 @@ 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")
@@ -2140,7 +2267,7 @@ def _chains_note(item: dict, *, family: str = "") -> str:
return item.get("summary") or "-"
-def _app_service_identity_context(item: dict) -> str:
+def _resource_identity_context(item: dict) -> str:
parts: list[str] = []
if item.get("workload_identity_type"):
parts.append(str(item.get("workload_identity_type")))
@@ -2668,6 +2795,89 @@ def _function_posture_context(item: dict) -> str:
return "; ".join(parts)
+def _container_app_environment_context(item: dict) -> str:
+ environment_id = str(item.get("environment_id") or "")
+ if not environment_id:
+ return "-"
+ return environment_id.rstrip("/").split("/")[-1]
+
+
+def _container_app_ingress_context(item: dict) -> str:
+ parts: list[str] = []
+ if item.get("external_ingress_enabled") is True:
+ parts.append("external")
+ elif item.get("external_ingress_enabled") is False:
+ parts.append("internal")
+ if item.get("ingress_target_port") is not None:
+ parts.append(f"port {item.get('ingress_target_port')}")
+ if item.get("ingress_transport"):
+ parts.append(str(item.get("ingress_transport")))
+ if not parts:
+ return "not visible"
+ return "; ".join(parts)
+
+
+def _container_app_revision_context(item: dict) -> str:
+ parts: list[str] = []
+ if item.get("revision_mode"):
+ parts.append(str(item.get("revision_mode")))
+ if item.get("latest_ready_revision_name"):
+ parts.append(f"ready {item.get('latest_ready_revision_name')}")
+ elif item.get("latest_revision_name"):
+ parts.append(f"latest {item.get('latest_revision_name')}")
+ if not parts:
+ return "-"
+ return "; ".join(parts)
+
+
+def _container_instance_endpoint_context(item: dict) -> str:
+ parts: list[str] = []
+ if item.get("fqdn"):
+ parts.append(str(item.get("fqdn")))
+ if item.get("public_ip_address"):
+ parts.append(str(item.get("public_ip_address")))
+ if not parts:
+ return "-"
+ return "; ".join(parts)
+
+
+def _container_instance_network_context(item: dict) -> str:
+ parts: list[str] = []
+ ports = [str(port) for port in item.get("exposed_ports", []) if port is not None]
+ if ports:
+ ports_text = ", ".join(ports[:5]) + ("..." if len(ports) > 5 else "")
+ parts.append(f"ports {ports_text}")
+ if item.get("subnet_ids"):
+ parts.append(f"subnets={len(item.get('subnet_ids', []))}")
+ if not parts:
+ return "-"
+ return "; ".join(parts)
+
+
+def _container_instance_runtime_context(item: dict) -> str:
+ parts: list[str] = []
+ if item.get("os_type"):
+ parts.append(f"os={item.get('os_type')}")
+ if item.get("restart_policy"):
+ parts.append(f"restart={item.get('restart_policy')}")
+ if item.get("container_count") is not None:
+ parts.append(f"containers={item.get('container_count')}")
+ if item.get("provisioning_state"):
+ parts.append(f"state={item.get('provisioning_state')}")
+ if not parts:
+ return "-"
+ return "; ".join(parts)
+
+
+def _container_instance_images_context(item: dict) -> str:
+ images = [str(value) for value in item.get("container_images", []) if value]
+ if not images:
+ return "-"
+ if len(images) == 1:
+ return images[0]
+ return f"{images[0]} (+{len(images) - 1} more)"
+
+
def _api_mgmt_inventory_context(item: dict) -> str:
parts: list[str] = []
if item.get("api_count") is not None:
diff --git a/tests/fixtures/lab_tenant/container_apps.json b/tests/fixtures/lab_tenant/container_apps.json
new file mode 100644
index 0000000..8d82e36
--- /dev/null
+++ b/tests/fixtures/lab_tenant/container_apps.json
@@ -0,0 +1,56 @@
+{
+ "container_apps": [
+ {
+ "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "name": "aca-orders",
+ "resource_group": "rg-containers",
+ "location": "eastus",
+ "environment_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/managedEnvironments/aca-env-prod",
+ "default_hostname": "aca-orders.wittyfield.eastus.azurecontainerapps.io",
+ "external_ingress_enabled": true,
+ "ingress_target_port": 8080,
+ "ingress_transport": "auto",
+ "revision_mode": "Single",
+ "latest_revision_name": "aca-orders--x1",
+ "latest_ready_revision_name": "aca-orders--x1",
+ "workload_identity_type": "SystemAssigned",
+ "workload_principal_id": "abab1111-1111-1111-1111-111111111111",
+ "workload_client_id": "cdcd1111-1111-1111-1111-111111111111",
+ "workload_identity_ids": [],
+ "summary": "Container App 'aca-orders' publishes hostname 'aca-orders.wittyfield.eastus.azurecontainerapps.io' and uses managed identity (SystemAssigned). Visible posture: external ingress enabled, target port 8080, transport auto, revision mode Single, latest ready revision aca-orders--x1.",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/managedEnvironments/aca-env-prod",
+ "abab1111-1111-1111-1111-111111111111"
+ ]
+ },
+ {
+ "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs",
+ "name": "aca-internal-jobs",
+ "resource_group": "rg-containers",
+ "location": "eastus",
+ "environment_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/managedEnvironments/aca-env-internal",
+ "default_hostname": null,
+ "external_ingress_enabled": false,
+ "ingress_target_port": 8080,
+ "ingress_transport": "http",
+ "revision_mode": "Multiple",
+ "latest_revision_name": "aca-internal-jobs--x3",
+ "latest_ready_revision_name": "aca-internal-jobs--x2",
+ "workload_identity_type": "SystemAssigned, UserAssigned",
+ "workload_principal_id": "abab2222-2222-2222-2222-222222222222",
+ "workload_client_id": "cdcd2222-2222-2222-2222-222222222222",
+ "workload_identity_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-container-jobs"
+ ],
+ "summary": "Container App 'aca-internal-jobs' has no visible hostname from the current read path and uses managed identity (SystemAssigned, UserAssigned). Visible posture: internal ingress only, target port 8080, transport http, revision mode Multiple, latest ready revision aca-internal-jobs--x2.",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs",
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/managedEnvironments/aca-env-internal",
+ "abab2222-2222-2222-2222-222222222222",
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-container-jobs"
+ ]
+ }
+ ],
+ "issues": []
+}
diff --git a/tests/fixtures/lab_tenant/container_instances.json b/tests/fixtures/lab_tenant/container_instances.json
new file mode 100644
index 0000000..a615446
--- /dev/null
+++ b/tests/fixtures/lab_tenant/container_instances.json
@@ -0,0 +1,65 @@
+{
+ "container_instances": [
+ {
+ "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "name": "aci-public-api",
+ "resource_group": "rg-apps",
+ "location": "eastus",
+ "os_type": "Linux",
+ "restart_policy": "Always",
+ "provisioning_state": "Succeeded",
+ "public_ip_address": "52.160.10.30",
+ "fqdn": "aci-public-api.eastus.azurecontainer.io",
+ "exposed_ports": [80, 443],
+ "subnet_ids": [],
+ "container_count": 2,
+ "container_images": [
+ "mcr.microsoft.com/azuredocs/aci-helloworld:latest",
+ "ghcr.io/harrierops/metrics-sidecar:1.0"
+ ],
+ "workload_identity_type": "SystemAssigned",
+ "workload_principal_id": "acac1111-1111-1111-1111-111111111111",
+ "workload_client_id": "acacaaaa-1111-1111-1111-111111111111",
+ "workload_identity_ids": [],
+ "summary": "Container group 'aci-public-api' publishes FQDN 'aci-public-api.eastus.azurecontainer.io' and uses public IP 52.160.10.30 and uses managed identity (SystemAssigned). Visible posture: os Linux, restart Always, ports 80, 443, containers 2.",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "52.160.10.30",
+ "acac1111-1111-1111-1111-111111111111"
+ ]
+ },
+ {
+ "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker",
+ "name": "aci-internal-worker",
+ "resource_group": "rg-jobs",
+ "location": "eastus",
+ "os_type": "Linux",
+ "restart_policy": "OnFailure",
+ "provisioning_state": "Succeeded",
+ "public_ip_address": null,
+ "fqdn": null,
+ "exposed_ports": [],
+ "subnet_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet-shared/subnets/jobs"
+ ],
+ "container_count": 1,
+ "container_images": [
+ "ghcr.io/harrierops/internal-worker:2.0"
+ ],
+ "workload_identity_type": "SystemAssigned, UserAssigned",
+ "workload_principal_id": "acac2222-2222-2222-2222-222222222222",
+ "workload_client_id": "acacbbbb-2222-2222-2222-222222222222",
+ "workload_identity_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-aci-jobs"
+ ],
+ "summary": "Container group 'aci-internal-worker' has no public endpoint visible from the current read path and uses managed identity (SystemAssigned, UserAssigned). Visible posture: os Linux, restart OnFailure, subnets 1, containers 1.",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker",
+ "acac2222-2222-2222-2222-222222222222",
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-aci-jobs",
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet-shared/subnets/jobs"
+ ]
+ }
+ ],
+ "issues": []
+}
diff --git a/tests/golden/endpoints.json b/tests/golden/endpoints.json
index 5c0d3ce..96d7e9d 100644
--- a/tests/golden/endpoints.json
+++ b/tests/golden/endpoints.json
@@ -1,5 +1,19 @@
{
"endpoints": [
+ {
+ "endpoint": "52.160.10.30",
+ "endpoint_type": "ip",
+ "exposure_family": "public-ip",
+ "ingress_path": "azure-container-instances-public-ip",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "acac1111-1111-1111-1111-111111111111"
+ ],
+ "source_asset_id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "source_asset_kind": "ContainerInstance",
+ "source_asset_name": "aci-public-api",
+ "summary": "ContainerInstance 'aci-public-api' exposes public IP 52.160.10.30. Review the visible ingress path, ports, and runtime posture together."
+ },
{
"endpoint": "52.160.10.20",
"endpoint_type": "ip",
@@ -15,6 +29,34 @@
"source_asset_name": "vm-web-01",
"summary": "VM 'vm-web-01' exposes public IP 52.160.10.20. Review direct ingress path alongside NIC and NSG context."
},
+ {
+ "endpoint": "aca-orders.wittyfield.eastus.azurecontainerapps.io",
+ "endpoint_type": "hostname",
+ "exposure_family": "managed-web-hostname",
+ "ingress_path": "azure-container-apps-default-hostname",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "abab1111-1111-1111-1111-111111111111"
+ ],
+ "source_asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "source_asset_kind": "ContainerApp",
+ "source_asset_name": "aca-orders",
+ "summary": "ContainerApp 'aca-orders' publishes Azure-managed hostname 'aca-orders.wittyfield.eastus.azurecontainerapps.io'. Validate whether that ingress path is intended and how it is constrained."
+ },
+ {
+ "endpoint": "aci-public-api.eastus.azurecontainer.io",
+ "endpoint_type": "hostname",
+ "exposure_family": "managed-container-fqdn",
+ "ingress_path": "azure-container-instances-fqdn",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "acac1111-1111-1111-1111-111111111111"
+ ],
+ "source_asset_id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "source_asset_kind": "ContainerInstance",
+ "source_asset_name": "aci-public-api",
+ "summary": "ContainerInstance 'aci-public-api' publishes hostname 'aci-public-api.eastus.azurecontainer.io'. Validate whether that ingress path is intended and how it is constrained."
+ },
{
"endpoint": "app-empty-mi.azurewebsites.net",
"endpoint_type": "hostname",
@@ -62,7 +104,9 @@
"findings": [],
"issues": [],
"metadata": {
+ "auth_mode": null,
"command": "endpoints",
+ "devops_organization": null,
"generated_at": "",
"schema_version": "1.3.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
diff --git a/tests/golden/network-effective.json b/tests/golden/network-effective.json
index 4cecf1e..1529b5a 100644
--- a/tests/golden/network-effective.json
+++ b/tests/golden/network-effective.json
@@ -26,11 +26,27 @@
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Network/networkSecurityGroups/nsg-vnet-app"
],
"summary": "Asset 'vm-web-01' endpoint 52.160.10.20 has internet-facing allow evidence on TCP/22 and narrower allow evidence on TCP/443, TCP/8080. Treat this as visible Azure network triage signal, not proof of full effective reachability."
+ },
+ {
+ "asset_id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "asset_name": "aci-public-api",
+ "constrained_ports": [],
+ "effective_exposure": "low",
+ "endpoint": "52.160.10.30",
+ "endpoint_type": "ip",
+ "internet_exposed_ports": [],
+ "observed_paths": [],
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "acac1111-1111-1111-1111-111111111111"
+ ],
+ "summary": "Asset 'aci-public-api' endpoint 52.160.10.30 is visible as a public IP path, but no inbound-rule evidence was surfaced from the current read path. Treat this as a low-confidence triage clue rather than proof of exposure."
}
],
"findings": [],
"issues": [],
"metadata": {
+ "auth_mode": null,
"command": "network-effective",
"devops_organization": null,
"generated_at": "",
diff --git a/tests/golden/tokens-credentials.json b/tests/golden/tokens-credentials.json
index 030f6f0..f3f7680 100644
--- a/tests/golden/tokens-credentials.json
+++ b/tests/golden/tokens-credentials.json
@@ -1,325 +1,435 @@
{
- "metadata": {
- "schema_version": "1.3.0",
- "command": "tokens-credentials",
- "generated_at": "",
- "tenant_id": "11111111-1111-1111-1111-111111111111",
- "subscription_id": "22222222-2222-2222-2222-222222222222",
- "token_source": null
- },
- "surfaces": [
+ "findings": [
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
- "asset_name": "app-public-api",
- "asset_kind": "AppService",
- "resource_group": "rg-apps",
- "location": "eastus",
- "surface_type": "plain-text-secret",
- "access_path": "app-setting",
- "priority": "high",
- "operator_signal": "setting=DB_PASSWORD",
- "summary": "AppService 'app-public-api' exposes credential-like setting 'DB_PASSWORD' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "description": "AppService 'app-public-api' exposes credential-like setting 'DB_PASSWORD' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "id": "tokens-credentials-plain-text-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api-app-setting-setting-db-password",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
"aaaa1111-1111-1111-1111-111111111111"
- ]
+ ],
+ "severity": "high",
+ "title": "Credential-like value is exposed in plain-text app settings"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
- "asset_name": "func-orders",
- "asset_kind": "FunctionApp",
- "resource_group": "rg-apps",
- "location": "eastus",
- "surface_type": "plain-text-secret",
- "access_path": "app-setting",
- "priority": "high",
- "operator_signal": "setting=AzureWebJobsStorage",
- "summary": "FunctionApp 'func-orders' exposes credential-like setting 'AzureWebJobsStorage' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "description": "FunctionApp 'func-orders' exposes credential-like setting 'AzureWebJobsStorage' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "id": "tokens-credentials-plain-text-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders-app-setting-setting-azurewebjobsstorage",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-orders",
"cccc2222-2222-2222-2222-222222222222"
- ]
+ ],
+ "severity": "high",
+ "title": "Credential-like value is exposed in plain-text app settings"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01",
- "asset_name": "vm-web-01",
- "asset_kind": "VM",
- "resource_group": "rg-workload",
- "location": "eastus",
- "surface_type": "managed-identity-token",
- "access_path": "imds",
- "priority": "high",
- "operator_signal": "public-ip=52.160.10.20; identities=1",
- "summary": "VM 'vm-web-01' is publicly reachable and exposes a token minting path through IMDS for its attached managed identity. Check endpoints for the ingress path, then managed-identities and permissions for Azure control.",
+ "description": "VM 'vm-web-01' is publicly reachable and exposes a token minting path through IMDS for its attached managed identity. Check endpoints for the ingress path, then managed-identities and permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01-imds-public-ip-52-160-10-20-identities-1",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-app"
- ]
+ ],
+ "severity": "high",
+ "title": "Publicly reachable workload can mint tokens with managed identity"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-empty-mi",
- "asset_name": "app-empty-mi",
- "asset_kind": "AppService",
- "resource_group": "rg-apps",
- "location": "eastus",
- "surface_type": "managed-identity-token",
- "access_path": "workload-identity",
- "priority": "medium",
- "operator_signal": "SystemAssigned",
- "summary": "AppService 'app-empty-mi' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "description": "ContainerApp 'aca-internal-jobs' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs-workload-identity-systemassigned-userassigned-user-assigned-1",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs",
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-container-jobs",
+ "abab2222-2222-2222-2222-222222222222"
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
+ },
+ {
+ "description": "ContainerApp 'aca-orders' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders-workload-identity-systemassigned",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "abab1111-1111-1111-1111-111111111111"
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
+ },
+ {
+ "description": "ContainerInstance 'aci-internal-worker' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker-workload-identity-systemassigned-userassigned-user-assigned-1",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker",
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-aci-jobs",
+ "acac2222-2222-2222-2222-222222222222"
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
+ },
+ {
+ "description": "ContainerInstance 'aci-public-api' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api-workload-identity-systemassigned",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "acac1111-1111-1111-1111-111111111111"
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
+ },
+ {
+ "description": "AppService 'app-empty-mi' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-empty-mi-workload-identity-systemassigned",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-empty-mi",
"eeee3333-3333-3333-3333-333333333333"
- ]
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
- "asset_name": "app-public-api",
- "asset_kind": "AppService",
- "resource_group": "rg-apps",
- "location": "eastus",
- "surface_type": "managed-identity-token",
- "access_path": "workload-identity",
- "priority": "medium",
- "operator_signal": "SystemAssigned",
- "summary": "AppService 'app-public-api' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "description": "AppService 'app-public-api' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api-workload-identity-systemassigned",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
"aaaa1111-1111-1111-1111-111111111111"
- ]
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
- "asset_name": "func-orders",
- "asset_kind": "FunctionApp",
- "resource_group": "rg-apps",
- "location": "eastus",
- "surface_type": "keyvault-reference",
- "access_path": "app-setting",
- "priority": "medium",
- "operator_signal": "target=kvlabopen01.vault.azure.net/secrets/payment-api-key; identity=SystemAssigned",
- "summary": "FunctionApp 'func-orders' uses setting 'PAYMENT_API_KEY' to reach Key Vault-backed secret material (kvlabopen01.vault.azure.net/secrets/payment-api-key) via SystemAssigned. Check keyvault for the referenced secret boundary, then managed-identities for the backing workload identity.",
+ "description": "FunctionApp 'func-orders' uses setting 'PAYMENT_API_KEY' to reach Key Vault-backed secret material (kvlabopen01.vault.azure.net/secrets/payment-api-key) via SystemAssigned. Check keyvault for the referenced secret boundary, then managed-identities for the backing workload identity.",
+ "id": "tokens-credentials-keyvault-ref-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders-app-setting-target-kvlabopen01-vault-azure-net-secrets-payment-api-key-identity-systemassigned",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-orders",
"cccc2222-2222-2222-2222-222222222222"
- ]
+ ],
+ "severity": "low",
+ "title": "Workload setting depends on Key Vault-backed secret retrieval"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
- "asset_name": "func-orders",
- "asset_kind": "FunctionApp",
- "resource_group": "rg-apps",
- "location": "eastus",
- "surface_type": "managed-identity-token",
- "access_path": "workload-identity",
- "priority": "medium",
- "operator_signal": "SystemAssigned, UserAssigned; user-assigned=1",
- "summary": "FunctionApp 'func-orders' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "description": "FunctionApp 'func-orders' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders-workload-identity-systemassigned-userassigned-user-assigned-1",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-orders",
"cccc2222-2222-2222-2222-222222222222"
- ]
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets",
- "asset_name": "kv-secrets",
- "asset_kind": "ArmDeployment",
- "resource_group": "rg-secrets",
- "location": null,
- "surface_type": "deployment-output",
- "access_path": "deployment-history",
- "priority": "medium",
- "operator_signal": "outputs=1; providers=1",
- "summary": "Deployment 'kv-secrets' recorded 1 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "description": "Deployment 'kv-secrets' recorded 1 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "id": "tokens-credentials-deployment-output-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets-deployment-history-outputs-1-providers-1",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets"
- ]
+ ],
+ "severity": "medium",
+ "title": "Deployment history records output values"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation",
- "asset_name": "sub-foundation",
- "asset_kind": "ArmDeployment",
- "resource_group": null,
- "location": null,
- "surface_type": "deployment-output",
- "access_path": "deployment-history",
- "priority": "medium",
- "operator_signal": "outputs=2; providers=2",
- "summary": "Deployment 'sub-foundation' recorded 2 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "description": "Deployment 'sub-foundation' recorded 2 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "id": "tokens-credentials-deployment-output-/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation-deployment-history-outputs-2-providers-2",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation"
- ]
+ ],
+ "severity": "medium",
+ "title": "Deployment history records output values"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01",
- "asset_name": "vmss-edge-01",
- "asset_kind": "VMSS",
- "resource_group": "rg-workload",
- "location": "eastus",
- "surface_type": "managed-identity-token",
- "access_path": "imds",
- "priority": "medium",
- "operator_signal": "public-ip=none; identities=1",
- "summary": "VMSS 'vmss-edge-01' exposes a token minting path through IMDS for its attached managed identity. Check managed-identities for the identity path, then permissions for Azure control.",
+ "description": "VMSS 'vmss-edge-01' exposes a token minting path through IMDS for its attached managed identity. Check managed-identities for the identity path, then permissions for Azure control.",
+ "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01-imds-public-ip-none-identities-1",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01/identities/system"
- ]
+ ],
+ "severity": "medium",
+ "title": "Workload can mint tokens with managed identity"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets",
- "asset_name": "kv-secrets",
- "asset_kind": "ArmDeployment",
- "resource_group": "rg-secrets",
- "location": null,
- "surface_type": "linked-deployment-content",
- "access_path": "deployment-history",
- "priority": "low",
- "operator_signal": "parameters=example.blob.core.windows.net/parameters/kv-secrets.parameters.json",
- "summary": "Deployment 'kv-secrets' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "description": "Deployment 'kv-secrets' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "id": "tokens-credentials-linked-content-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets-deployment-history-parameters-example-blob-core-windows-net-parameters-kv-secrets-parameters-json",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets"
- ]
+ ],
+ "severity": "low",
+ "title": "Deployment history references remote template or parameter content"
},
{
- "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation",
- "asset_name": "sub-foundation",
- "asset_kind": "ArmDeployment",
- "resource_group": null,
- "location": null,
- "surface_type": "linked-deployment-content",
- "access_path": "deployment-history",
- "priority": "low",
- "operator_signal": "template=example.blob.core.windows.net/templates/sub-foundation.json",
- "summary": "Deployment 'sub-foundation' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "description": "Deployment 'sub-foundation' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "id": "tokens-credentials-linked-content-/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation-deployment-history-template-example-blob-core-windows-net-templates-sub-foundation-json",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation"
- ]
+ ],
+ "severity": "low",
+ "title": "Deployment history references remote template or parameter content"
}
],
- "findings": [
+ "issues": [],
+ "metadata": {
+ "auth_mode": null,
+ "command": "tokens-credentials",
+ "devops_organization": null,
+ "generated_at": "",
+ "schema_version": "1.3.0",
+ "subscription_id": "22222222-2222-2222-2222-222222222222",
+ "tenant_id": "11111111-1111-1111-1111-111111111111",
+ "token_source": null
+ },
+ "surfaces": [
{
- "id": "tokens-credentials-plain-text-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api-app-setting-setting-db-password",
- "severity": "high",
- "title": "Credential-like value is exposed in plain-text app settings",
- "description": "AppService 'app-public-api' exposes credential-like setting 'DB_PASSWORD' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "access_path": "app-setting",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
+ "asset_kind": "AppService",
+ "asset_name": "app-public-api",
+ "location": "eastus",
+ "operator_signal": "setting=DB_PASSWORD",
+ "priority": "high",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
"aaaa1111-1111-1111-1111-111111111111"
- ]
+ ],
+ "resource_group": "rg-apps",
+ "summary": "AppService 'app-public-api' exposes credential-like setting 'DB_PASSWORD' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "surface_type": "plain-text-secret"
},
{
- "id": "tokens-credentials-plain-text-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders-app-setting-setting-azurewebjobsstorage",
- "severity": "high",
- "title": "Credential-like value is exposed in plain-text app settings",
- "description": "FunctionApp 'func-orders' exposes credential-like setting 'AzureWebJobsStorage' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "access_path": "app-setting",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
+ "asset_kind": "FunctionApp",
+ "asset_name": "func-orders",
+ "location": "eastus",
+ "operator_signal": "setting=AzureWebJobsStorage",
+ "priority": "high",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-orders",
"cccc2222-2222-2222-2222-222222222222"
- ]
+ ],
+ "resource_group": "rg-apps",
+ "summary": "FunctionApp 'func-orders' exposes credential-like setting 'AzureWebJobsStorage' as plain-text management-plane app configuration. Check env-vars for the exact setting context behind this credential clue.",
+ "surface_type": "plain-text-secret"
},
{
- "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01-imds-public-ip-52-160-10-20-identities-1",
- "severity": "high",
- "title": "Publicly reachable workload can mint tokens with managed identity",
- "description": "VM 'vm-web-01' is publicly reachable and exposes a token minting path through IMDS for its attached managed identity. Check endpoints for the ingress path, then managed-identities and permissions for Azure control.",
+ "access_path": "imds",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01",
+ "asset_kind": "VM",
+ "asset_name": "vm-web-01",
+ "location": "eastus",
+ "operator_signal": "public-ip=52.160.10.20; identities=1",
+ "priority": "high",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-app"
- ]
+ ],
+ "resource_group": "rg-workload",
+ "summary": "VM 'vm-web-01' is publicly reachable and exposes a token minting path through IMDS for its attached managed identity. Check endpoints for the ingress path, then managed-identities and permissions for Azure control.",
+ "surface_type": "managed-identity-token"
},
{
- "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-empty-mi-workload-identity-systemassigned",
- "severity": "medium",
- "title": "Workload can mint tokens with managed identity",
- "description": "AppService 'app-empty-mi' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "access_path": "workload-identity",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs",
+ "asset_kind": "ContainerApp",
+ "asset_name": "aca-internal-jobs",
+ "location": "eastus",
+ "operator_signal": "SystemAssigned, UserAssigned; user-assigned=1",
+ "priority": "medium",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs",
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-container-jobs",
+ "abab2222-2222-2222-2222-222222222222"
+ ],
+ "resource_group": "rg-containers",
+ "summary": "ContainerApp 'aca-internal-jobs' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
+ },
+ {
+ "access_path": "workload-identity",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "asset_kind": "ContainerApp",
+ "asset_name": "aca-orders",
+ "location": "eastus",
+ "operator_signal": "SystemAssigned",
+ "priority": "medium",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "abab1111-1111-1111-1111-111111111111"
+ ],
+ "resource_group": "rg-containers",
+ "summary": "ContainerApp 'aca-orders' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
+ },
+ {
+ "access_path": "workload-identity",
+ "asset_id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker",
+ "asset_kind": "ContainerInstance",
+ "asset_name": "aci-internal-worker",
+ "location": "eastus",
+ "operator_signal": "SystemAssigned, UserAssigned; user-assigned=1",
+ "priority": "medium",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker",
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-aci-jobs",
+ "acac2222-2222-2222-2222-222222222222"
+ ],
+ "resource_group": "rg-jobs",
+ "summary": "ContainerInstance 'aci-internal-worker' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
+ },
+ {
+ "access_path": "workload-identity",
+ "asset_id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "asset_kind": "ContainerInstance",
+ "asset_name": "aci-public-api",
+ "location": "eastus",
+ "operator_signal": "SystemAssigned",
+ "priority": "medium",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "acac1111-1111-1111-1111-111111111111"
+ ],
+ "resource_group": "rg-apps",
+ "summary": "ContainerInstance 'aci-public-api' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
+ },
+ {
+ "access_path": "workload-identity",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-empty-mi",
+ "asset_kind": "AppService",
+ "asset_name": "app-empty-mi",
+ "location": "eastus",
+ "operator_signal": "SystemAssigned",
+ "priority": "medium",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-empty-mi",
"eeee3333-3333-3333-3333-333333333333"
- ]
+ ],
+ "resource_group": "rg-apps",
+ "summary": "AppService 'app-empty-mi' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
},
{
- "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api-workload-identity-systemassigned",
- "severity": "medium",
- "title": "Workload can mint tokens with managed identity",
- "description": "AppService 'app-public-api' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "access_path": "workload-identity",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
+ "asset_kind": "AppService",
+ "asset_name": "app-public-api",
+ "location": "eastus",
+ "operator_signal": "SystemAssigned",
+ "priority": "medium",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api",
"aaaa1111-1111-1111-1111-111111111111"
- ]
+ ],
+ "resource_group": "rg-apps",
+ "summary": "AppService 'app-public-api' can request tokens through attached managed identity (SystemAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
},
{
- "id": "tokens-credentials-keyvault-ref-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders-app-setting-target-kvlabopen01-vault-azure-net-secrets-payment-api-key-identity-systemassigned",
- "severity": "low",
- "title": "Workload setting depends on Key Vault-backed secret retrieval",
- "description": "FunctionApp 'func-orders' uses setting 'PAYMENT_API_KEY' to reach Key Vault-backed secret material (kvlabopen01.vault.azure.net/secrets/payment-api-key) via SystemAssigned. Check keyvault for the referenced secret boundary, then managed-identities for the backing workload identity.",
+ "access_path": "app-setting",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
+ "asset_kind": "FunctionApp",
+ "asset_name": "func-orders",
+ "location": "eastus",
+ "operator_signal": "target=kvlabopen01.vault.azure.net/secrets/payment-api-key; identity=SystemAssigned",
+ "priority": "medium",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-orders",
"cccc2222-2222-2222-2222-222222222222"
- ]
+ ],
+ "resource_group": "rg-apps",
+ "summary": "FunctionApp 'func-orders' uses setting 'PAYMENT_API_KEY' to reach Key Vault-backed secret material (kvlabopen01.vault.azure.net/secrets/payment-api-key) via SystemAssigned. Check keyvault for the referenced secret boundary, then managed-identities for the backing workload identity.",
+ "surface_type": "keyvault-reference"
},
{
- "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders-workload-identity-systemassigned-userassigned-user-assigned-1",
- "severity": "medium",
- "title": "Workload can mint tokens with managed identity",
- "description": "FunctionApp 'func-orders' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "access_path": "workload-identity",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
+ "asset_kind": "FunctionApp",
+ "asset_name": "func-orders",
+ "location": "eastus",
+ "operator_signal": "SystemAssigned, UserAssigned; user-assigned=1",
+ "priority": "medium",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-orders",
"cccc2222-2222-2222-2222-222222222222"
- ]
+ ],
+ "resource_group": "rg-apps",
+ "summary": "FunctionApp 'func-orders' can request tokens through attached managed identity (SystemAssigned, UserAssigned). Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
},
{
- "id": "tokens-credentials-deployment-output-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets-deployment-history-outputs-1-providers-1",
- "severity": "medium",
- "title": "Deployment history records output values",
- "description": "Deployment 'kv-secrets' recorded 1 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "access_path": "deployment-history",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets",
+ "asset_kind": "ArmDeployment",
+ "asset_name": "kv-secrets",
+ "location": null,
+ "operator_signal": "outputs=1; providers=1",
+ "priority": "medium",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets"
- ]
+ ],
+ "resource_group": "rg-secrets",
+ "summary": "Deployment 'kv-secrets' recorded 1 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "surface_type": "deployment-output"
},
{
- "id": "tokens-credentials-deployment-output-/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation-deployment-history-outputs-2-providers-2",
- "severity": "medium",
- "title": "Deployment history records output values",
- "description": "Deployment 'sub-foundation' recorded 2 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "access_path": "deployment-history",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation",
+ "asset_kind": "ArmDeployment",
+ "asset_name": "sub-foundation",
+ "location": null,
+ "operator_signal": "outputs=2; providers=2",
+ "priority": "medium",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation"
- ]
+ ],
+ "resource_group": null,
+ "summary": "Deployment 'sub-foundation' recorded 2 output values in deployment history. Check arm-deployments for the exact output context behind this credential clue.",
+ "surface_type": "deployment-output"
},
{
- "id": "tokens-credentials-managed-identity-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01-imds-public-ip-none-identities-1",
- "severity": "medium",
- "title": "Workload can mint tokens with managed identity",
- "description": "VMSS 'vmss-edge-01' exposes a token minting path through IMDS for its attached managed identity. Check managed-identities for the identity path, then permissions for Azure control.",
+ "access_path": "imds",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01",
+ "asset_kind": "VMSS",
+ "asset_name": "vmss-edge-01",
+ "location": "eastus",
+ "operator_signal": "public-ip=none; identities=1",
+ "priority": "medium",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01",
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01/identities/system"
- ]
+ ],
+ "resource_group": "rg-workload",
+ "summary": "VMSS 'vmss-edge-01' exposes a token minting path through IMDS for its attached managed identity. Check managed-identities for the identity path, then permissions for Azure control.",
+ "surface_type": "managed-identity-token"
},
{
- "id": "tokens-credentials-linked-content-/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets-deployment-history-parameters-example-blob-core-windows-net-parameters-kv-secrets-parameters-json",
- "severity": "low",
- "title": "Deployment history references remote template or parameter content",
- "description": "Deployment 'kv-secrets' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "access_path": "deployment-history",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets",
+ "asset_kind": "ArmDeployment",
+ "asset_name": "kv-secrets",
+ "location": null,
+ "operator_signal": "parameters=example.blob.core.windows.net/parameters/kv-secrets.parameters.json",
+ "priority": "low",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-secrets/providers/Microsoft.Resources/deployments/kv-secrets"
- ]
+ ],
+ "resource_group": "rg-secrets",
+ "summary": "Deployment 'kv-secrets' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "surface_type": "linked-deployment-content"
},
{
- "id": "tokens-credentials-linked-content-/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation-deployment-history-template-example-blob-core-windows-net-templates-sub-foundation-json",
- "severity": "low",
- "title": "Deployment history references remote template or parameter content",
- "description": "Deployment 'sub-foundation' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "access_path": "deployment-history",
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation",
+ "asset_kind": "ArmDeployment",
+ "asset_name": "sub-foundation",
+ "location": null,
+ "operator_signal": "template=example.blob.core.windows.net/templates/sub-foundation.json",
+ "priority": "low",
"related_ids": [
"/subscriptions/22222222-2222-2222-2222-222222222222/providers/Microsoft.Resources/deployments/sub-foundation"
- ]
+ ],
+ "resource_group": null,
+ "summary": "Deployment 'sub-foundation' references remote template or parameter content that may expose reusable configuration or credential context. Check arm-deployments for the linked template or parameter path behind this credential clue.",
+ "surface_type": "linked-deployment-content"
}
- ],
- "issues": []
+ ]
}
diff --git a/tests/golden/workloads.json b/tests/golden/workloads.json
index fd64bfc..72cf97e 100644
--- a/tests/golden/workloads.json
+++ b/tests/golden/workloads.json
@@ -2,6 +2,7 @@
"findings": [],
"issues": [],
"metadata": {
+ "auth_mode": null,
"command": "workloads",
"devops_organization": null,
"generated_at": "",
@@ -117,6 +118,104 @@
"resource_group": "rg-apps",
"summary": "FunctionApp 'func-orders' publishes visible endpoint hostname 'func-orders.azurewebsites.net' and carries managed identity context (SystemAssigned, UserAssigned). Visible signals: default-hostname, user-assigned=1. Use this as a quick workload census pivot before deeper service-specific review."
},
+ {
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "asset_kind": "ContainerApp",
+ "asset_name": "aca-orders",
+ "endpoints": [
+ "aca-orders.wittyfield.eastus.azurecontainerapps.io"
+ ],
+ "exposure_families": [
+ "managed-web-hostname"
+ ],
+ "identity_client_id": "cdcd1111-1111-1111-1111-111111111111",
+ "identity_ids": [],
+ "identity_principal_id": "abab1111-1111-1111-1111-111111111111",
+ "identity_type": "SystemAssigned",
+ "ingress_paths": [
+ "azure-container-apps-default-hostname"
+ ],
+ "location": "eastus",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-orders",
+ "abab1111-1111-1111-1111-111111111111"
+ ],
+ "resource_group": "rg-containers",
+ "summary": "ContainerApp 'aca-orders' publishes visible endpoint hostname 'aca-orders.wittyfield.eastus.azurecontainerapps.io' and carries managed identity context (SystemAssigned). Visible signals: default-hostname, external-ingress. Use this as a quick workload census pivot before deeper service-specific review."
+ },
+ {
+ "asset_id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "asset_kind": "ContainerInstance",
+ "asset_name": "aci-public-api",
+ "endpoints": [
+ "52.160.10.30",
+ "aci-public-api.eastus.azurecontainer.io"
+ ],
+ "exposure_families": [
+ "public-ip",
+ "managed-container-fqdn"
+ ],
+ "identity_client_id": "acacaaaa-1111-1111-1111-111111111111",
+ "identity_ids": [],
+ "identity_principal_id": "acac1111-1111-1111-1111-111111111111",
+ "identity_type": "SystemAssigned",
+ "ingress_paths": [
+ "azure-container-instances-public-ip",
+ "azure-container-instances-fqdn"
+ ],
+ "location": "eastus",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-apps/providers/Microsoft.ContainerInstance/containerGroups/aci-public-api",
+ "acac1111-1111-1111-1111-111111111111"
+ ],
+ "resource_group": "rg-apps",
+ "summary": "ContainerInstance 'aci-public-api' publishes 2 visible endpoint paths (52.160.10.30, aci-public-api.eastus.azurecontainer.io) and carries managed identity context (SystemAssigned). Visible signals: public-ip, fqdn, ports=2, containers=2. Use this as a quick workload census pivot before deeper service-specific review."
+ },
+ {
+ "asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs",
+ "asset_kind": "ContainerApp",
+ "asset_name": "aca-internal-jobs",
+ "endpoints": [],
+ "exposure_families": [],
+ "identity_client_id": "cdcd2222-2222-2222-2222-222222222222",
+ "identity_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-container-jobs"
+ ],
+ "identity_principal_id": "abab2222-2222-2222-2222-222222222222",
+ "identity_type": "SystemAssigned, UserAssigned",
+ "ingress_paths": [],
+ "location": "eastus",
+ "related_ids": [
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-containers/providers/Microsoft.App/containerApps/aca-internal-jobs",
+ "abab2222-2222-2222-2222-222222222222",
+ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-container-jobs"
+ ],
+ "resource_group": "rg-containers",
+ "summary": "ContainerApp 'aca-internal-jobs' has no visible endpoint path from the current read path and carries managed identity context (SystemAssigned, UserAssigned). Visible signals: internal-only, user-assigned=1. Use this as a quick workload census pivot before deeper service-specific review."
+ },
+ {
+ "asset_id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker",
+ "asset_kind": "ContainerInstance",
+ "asset_name": "aci-internal-worker",
+ "endpoints": [],
+ "exposure_families": [],
+ "identity_client_id": "acacbbbb-2222-2222-2222-222222222222",
+ "identity_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-aci-jobs"
+ ],
+ "identity_principal_id": "acac2222-2222-2222-2222-222222222222",
+ "identity_type": "SystemAssigned, UserAssigned",
+ "ingress_paths": [],
+ "location": "eastus",
+ "related_ids": [
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-jobs/providers/Microsoft.ContainerInstance/containerGroups/aci-internal-worker",
+ "acac2222-2222-2222-2222-222222222222",
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-aci-jobs",
+ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet-shared/subnets/jobs"
+ ],
+ "resource_group": "rg-jobs",
+ "summary": "ContainerInstance 'aci-internal-worker' has no visible endpoint path from the current read path and carries managed identity context (SystemAssigned, UserAssigned). Visible signals: subnets=1, containers=1, user-assigned=1. Use this as a quick workload census pivot before deeper service-specific review."
+ },
{
"asset_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-edge-01",
"asset_kind": "VMSS",
diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py
index 6954082..2e44f31 100644
--- a/tests/test_cli_smoke.py
+++ b/tests/test_cli_smoke.py
@@ -34,6 +34,8 @@ def test_cli_smoke_all_commands(tmp_path: Path) -> None:
"aks",
"api-mgmt",
"functions",
+ "container-apps",
+ "container-instances",
"arm-deployments",
"endpoints",
"network-effective",
@@ -167,7 +169,7 @@ def test_cli_smoke_chains_credential_path_table_output(tmp_path: Path) -> None:
assert "confirmed to reach it." in normalized_output
assert "Claim boundary:" not in result.stdout
assert "Current gap:" not in result.stdout
- assert "Takeaway: 3 visible credential paths" in result.stdout
+ assert "Takeaway:" not in result.stdout
def test_cli_smoke_chains_deployment_path_table_output(tmp_path: Path) -> None:
@@ -195,7 +197,7 @@ def test_cli_smoke_chains_deployment_path_table_output(tmp_path: Path) -> None:
assert "support-only" in result.stdout
assert "Redeploy-App" in result.stdout
assert "Lab-Maintenance" in result.stdout
- assert "Takeaway: 6 visible deployment paths" in result.stdout
+ assert "Takeaway:" not in result.stdout
def test_cli_smoke_chains_deployment_path_json(tmp_path: Path) -> None:
@@ -473,23 +475,37 @@ def test_cli_smoke_chains_compute_control_table_output(tmp_path: Path) -> None:
assert result.exit_code == 0
assert "azurefox chains" in result.stdout
- assert "compute" in result.stdout
- assert "path type" in result.stdout
- assert "insertion point" in result.stdout
- assert "visible azure control" in result.stdout
- assert "confidence boundary" in result.stdout
+ assert "reach from here" in result.stdout
+ assert "compute foothold" in result.stdout
+ assert "token path" in result.stdout
+ assert "identity" in result.stdout
+ assert "azure access" in result.stdout.lower()
+ assert "proof status" in result.stdout
assert "app-empty-mi" in result.stdout
assert "func-orders" in result.stdout
assert "vm-web-01" in result.stdout
assert "vmss-edge-01" in result.stdout
- assert "direct token opportunity" in result.stdout
- assert "public imds token path" in result.stdout.lower()
+ assert "service token request" in result.stdout.lower()
+ 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 "does not yet show that start from the current foothold" in normalized_output
+ assert "server-side execution" 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
assert "mixed identities" in normalized_output
- assert "current foothold" in normalized_output
+ assert "best current match" in normalized_output
+ assert "act now" in normalized_output
+ assert "review soon" in normalized_output
assert "Claim boundary:" in result.stdout
assert "Current gap:" in result.stdout
- assert "Takeaway: 4 visible compute-control paths" in result.stdout
+ assert "Takeaway:" not in result.stdout
assert "narrowed candidates" not in normalized_output
@@ -542,7 +558,6 @@ def test_cli_smoke_chains_overview_table_output(tmp_path: Path) -> None:
assert "compute-control" in result.stdout
assert "implemented" in result.stdout
assert "backing commands" in result.stdout
- assert "Takeaway: 4 chain families listed; 4 implemented." in result.stdout
def test_cli_smoke_chains_help_matches_overview_json(tmp_path: Path) -> None:
@@ -679,7 +694,7 @@ def test_cli_smoke_csv_row_mapping_for_inventory_style_commands(tmp_path: Path)
"acr": (2, "acr-public-legacy"),
"databases": (4, "sql-public-legacy"),
"dns": (3, "corp.example.com"),
- "network-effective": (1, "vm-web-01"),
+ "network-effective": (2, "vm-web-01"),
}
for command, (expected_rows, expected_first_name) in expectations.items():
@@ -723,3 +738,39 @@ def test_cli_smoke_rejects_removed_all_checks_command(tmp_path: Path) -> None:
assert result.exit_code == 2
assert "No such command 'all-checks'" in result.stderr
+
+
+def test_cli_smoke_container_apps_table_output(tmp_path: Path) -> None:
+ fixture_dir = Path(__file__).resolve().parent / "fixtures" / "lab_tenant"
+
+ result = runner.invoke(
+ app,
+ ["--outdir", str(tmp_path), "container-apps"],
+ env={"AZUREFOX_FIXTURE_DIR": str(fixture_dir)},
+ )
+
+ assert result.exit_code == 0
+ assert "azurefox container-apps" in result.stdout
+ assert "aca-orders" in result.stdout
+ assert "aca-internal-jobs" in result.stdout
+ assert "environment" in result.stdout.lower()
+ assert "ingress" in result.stdout.lower()
+ assert "identity" in result.stdout.lower()
+
+
+def test_cli_smoke_container_instances_table_output(tmp_path: Path) -> None:
+ fixture_dir = Path(__file__).resolve().parent / "fixtures" / "lab_tenant"
+
+ result = runner.invoke(
+ app,
+ ["--outdir", str(tmp_path), "container-instances"],
+ env={"AZUREFOX_FIXTURE_DIR": str(fixture_dir)},
+ )
+
+ assert result.exit_code == 0
+ assert "azurefox container-instances" in result.stdout
+ assert "aci-public-api" in result.stdout
+ assert "aci-internal-worker" in result.stdout
+ assert "network" in result.stdout.lower()
+ assert "runtime" in result.stdout.lower()
+ assert "images" in result.stdout.lower()
diff --git a/tests/test_collectors.py b/tests/test_collectors.py
index ef6a575..82147e4 100644
--- a/tests/test_collectors.py
+++ b/tests/test_collectors.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
from pathlib import Path
from types import SimpleNamespace
@@ -13,6 +14,8 @@
collect_arm_deployments,
collect_auth_policies,
collect_automation,
+ collect_container_apps,
+ collect_container_instances,
collect_cross_tenant,
collect_databases,
collect_devops,
@@ -496,6 +499,119 @@ def functions(self) -> dict:
"issues": [],
}
+ def container_apps(self) -> dict:
+ return {
+ "container_apps": [
+ {
+ "id": "ca-1",
+ "name": "zzz-internal-id",
+ "default_hostname": None,
+ "external_ingress_enabled": False,
+ "ingress_target_port": 8080,
+ "ingress_transport": "http",
+ "revision_mode": "Single",
+ "latest_ready_revision_name": "zzz-internal-id--001",
+ "environment_id": (
+ "/subscriptions/sub/resourceGroups/rg/providers/"
+ "Microsoft.App/managedEnvironments/env-internal"
+ ),
+ "workload_identity_type": "SystemAssigned",
+ "summary": "internal identity-backed container app",
+ },
+ {
+ "id": "ca-2",
+ "name": "mmm-public-id",
+ "default_hostname": "mmm-public-id.example",
+ "external_ingress_enabled": True,
+ "ingress_target_port": 443,
+ "ingress_transport": "auto",
+ "revision_mode": "Single",
+ "latest_ready_revision_name": "mmm-public-id--001",
+ "environment_id": (
+ "/subscriptions/sub/resourceGroups/rg/providers/"
+ "Microsoft.App/managedEnvironments/env-public"
+ ),
+ "workload_identity_type": "SystemAssigned",
+ "summary": "public identity-backed container app",
+ },
+ {
+ "id": "ca-3",
+ "name": "aaa-public-no-id",
+ "default_hostname": "aaa-public-no-id.example",
+ "external_ingress_enabled": True,
+ "ingress_target_port": 80,
+ "ingress_transport": "http",
+ "revision_mode": "Multiple",
+ "latest_ready_revision_name": "aaa-public-no-id--002",
+ "environment_id": (
+ "/subscriptions/sub/resourceGroups/rg/providers/"
+ "Microsoft.App/managedEnvironments/env-public"
+ ),
+ "workload_identity_type": None,
+ "summary": "public non-identity container app",
+ },
+ ],
+ "issues": [],
+ }
+
+ def container_instances(self) -> dict:
+ return {
+ "container_instances": [
+ {
+ "id": "ci-1",
+ "name": "zzz-private-id",
+ "public_ip_address": None,
+ "fqdn": None,
+ "exposed_ports": [],
+ "subnet_ids": [
+ "/subscriptions/sub/resourceGroups/rg/providers/"
+ "Microsoft.Network/virtualNetworks/vnet/subnets/apps"
+ ],
+ "container_count": 1,
+ "container_images": ["ghcr.io/example/jobs:latest"],
+ "restart_policy": "OnFailure",
+ "os_type": "Linux",
+ "provisioning_state": "Succeeded",
+ "workload_identity_type": "SystemAssigned",
+ "summary": "private identity-backed container group",
+ },
+ {
+ "id": "ci-2",
+ "name": "mmm-public-id",
+ "public_ip_address": "52.160.10.40",
+ "fqdn": "mmm-public-id.eastus.azurecontainer.io",
+ "exposed_ports": [80, 443],
+ "subnet_ids": [],
+ "container_count": 2,
+ "container_images": [
+ "mcr.microsoft.com/app/main:1.0",
+ "mcr.microsoft.com/app/sidecar:1.0",
+ ],
+ "restart_policy": "Always",
+ "os_type": "Linux",
+ "provisioning_state": "Succeeded",
+ "workload_identity_type": "SystemAssigned",
+ "summary": "public identity-backed container group",
+ },
+ {
+ "id": "ci-3",
+ "name": "aaa-public-no-id",
+ "public_ip_address": "52.160.10.41",
+ "fqdn": None,
+ "exposed_ports": [8080],
+ "subnet_ids": [],
+ "container_count": 1,
+ "container_images": ["mcr.microsoft.com/app/public:2.0"],
+ "restart_policy": "Never",
+ "os_type": "Linux",
+ "provisioning_state": "Succeeded",
+ "workload_identity_type": None,
+ "summary": "public non-identity container group",
+ },
+ ],
+ "issues": [],
+ }
+
def arm_deployments(self) -> dict:
return {
"deployments": [
@@ -1485,12 +1601,14 @@ def test_collect_application_gateway_keeps_command_level_issue_explicit(
def test_collect_network_effective(fixture_provider, options) -> None:
output = collect_network_effective(fixture_provider, options)
- assert len(output.effective_exposures) == 1
+ assert len(output.effective_exposures) == 2
assert len(output.findings) == 0
assert output.effective_exposures[0].asset_name == "vm-web-01"
assert output.effective_exposures[0].effective_exposure == "high"
assert output.effective_exposures[0].internet_exposed_ports == ["TCP/22"]
assert output.effective_exposures[0].constrained_ports == ["TCP/443", "TCP/8080"]
+ assert output.effective_exposures[1].asset_name == "aci-public-api"
+ assert output.effective_exposures[1].effective_exposure == "low"
def test_collect_network_effective_reuses_one_endpoint_snapshot_and_keeps_issues(
@@ -2007,6 +2125,178 @@ def test_collect_functions_keeps_partial_visibility_explicit(fixture_dir: Path,
assert output.issues[0].context["collector"] == "functions[rg-apps/func-orders].app_settings"
+def test_collect_container_apps(fixture_provider, options) -> None:
+ output = collect_container_apps(fixture_provider, options)
+ assert len(output.container_apps) == 2
+ assert len(output.findings) == 0
+ assert output.container_apps[0].name == "aca-orders"
+ assert output.container_apps[0].external_ingress_enabled is True
+ assert output.container_apps[0].workload_identity_type == "SystemAssigned"
+ assert output.container_apps[1].name == "aca-internal-jobs"
+ assert output.container_apps[1].external_ingress_enabled is False
+
+
+def test_collect_container_apps_sorts_external_then_identity_then_hostname(options) -> None:
+ output = collect_container_apps(DriftOrderingFixtureProvider(Path(".")), options)
+
+ assert [item.name for item in output.container_apps] == [
+ "mmm-public-id",
+ "aaa-public-no-id",
+ "zzz-internal-id",
+ ]
+
+
+def test_collect_container_instances(fixture_provider, options) -> None:
+ output = collect_container_instances(fixture_provider, options)
+ assert len(output.container_instances) == 2
+ assert len(output.findings) == 0
+ assert output.container_instances[0].name == "aci-public-api"
+ assert output.container_instances[0].public_ip_address == "52.160.10.30"
+ assert output.container_instances[0].workload_identity_type == "SystemAssigned"
+ assert output.container_instances[1].name == "aci-internal-worker"
+ assert output.container_instances[1].public_ip_address is None
+
+
+def test_collect_container_instances_sorts_public_then_identity_then_fqdn(options) -> None:
+ output = collect_container_instances(DriftOrderingFixtureProvider(Path(".")), options)
+
+ assert [item.name for item in output.container_instances] == [
+ "mmm-public-id",
+ "aaa-public-no-id",
+ "zzz-private-id",
+ ]
+
+
+def test_fixture_provider_workload_commands_tolerate_missing_optional_container_files(
+ tmp_path: Path,
+ options,
+) -> None:
+ for name, payload in {
+ "web_workloads": {"workloads": [], "issues": []},
+ "vms": {"vm_assets": [], "issues": []},
+ "env_vars": {"env_vars": [], "issues": []},
+ "arm_deployments": {"deployments": [], "issues": []},
+ }.items():
+ (tmp_path / f"{name}.json").write_text(json.dumps(payload), encoding="utf-8")
+
+ provider = FixtureProvider(tmp_path)
+
+ workloads_output = collect_workloads(provider, options)
+ tokens_output = collect_tokens_credentials(provider, options)
+
+ assert workloads_output.workloads == []
+ assert workloads_output.issues == []
+ assert tokens_output.surfaces == []
+ assert tokens_output.issues == []
+
+
+def test_collect_container_apps_reports_hydration_failures_as_issues(options) -> None:
+ class FakeResourcesClient:
+ def list(self, *, filter: str):
+ assert filter == "resourceType eq 'Microsoft.App/containerApps'"
+ return [
+ SimpleNamespace(
+ id=(
+ "/subscriptions/sub/resourceGroups/rg-apps/providers/"
+ "Microsoft.App/containerApps/aca-orders"
+ ),
+ name="aca-orders",
+ location="eastus",
+ identity=SimpleNamespace(type="SystemAssigned", principal_id="principal-aca"),
+ properties=SimpleNamespace(
+ managed_environment_id=(
+ "/subscriptions/sub/resourceGroups/rg-apps/providers/"
+ "Microsoft.App/managedEnvironments/aca-env"
+ ),
+ configuration=SimpleNamespace(
+ ingress=SimpleNamespace(
+ external=True,
+ fqdn="aca-orders.eastus.azurecontainerapps.io",
+ target_port=443,
+ ),
+ active_revisions_mode="Single",
+ ),
+ latest_ready_revision_name="aca-orders--rev1",
+ ),
+ )
+ ]
+
+ def get_by_id(self, resource_id: str, api_version: str):
+ assert resource_id.endswith("/aca-orders")
+ assert api_version == "2024-03-01"
+ raise RuntimeError("hydrate failed")
+
+ provider = AzureProvider.__new__(AzureProvider)
+ provider.session = SimpleNamespace(
+ tenant_id="tenant-id",
+ token_source="fixture",
+ auth_mode="fixture",
+ )
+ provider.clients = SimpleNamespace(
+ subscription_id="subscription-id",
+ resource=SimpleNamespace(resources=FakeResourcesClient()),
+ )
+
+ output = collect_container_apps(provider, options)
+
+ assert [item.name for item in output.container_apps] == ["aca-orders"]
+ assert output.issues[0].context["collector"].startswith("container_apps.hydrate[")
+ assert "hydrate failed" in output.issues[0].message
+
+
+def test_collect_container_instances_reports_hydration_failures_as_issues(options) -> None:
+ class FakeResourcesClient:
+ def list(self, *, filter: str):
+ assert filter == "resourceType eq 'Microsoft.ContainerInstance/containerGroups'"
+ return [
+ SimpleNamespace(
+ id=(
+ "/subscriptions/sub/resourceGroups/rg-apps/providers/"
+ "Microsoft.ContainerInstance/containerGroups/aci-public-api"
+ ),
+ name="aci-public-api",
+ location="eastus",
+ identity=SimpleNamespace(type="SystemAssigned", principal_id="principal-aci"),
+ properties=SimpleNamespace(
+ ip_address=SimpleNamespace(
+ fqdn="aci-public-api.eastus.azurecontainer.io",
+ ip="52.160.10.30",
+ ports=[SimpleNamespace(port=80)],
+ ),
+ containers=[
+ SimpleNamespace(
+ properties=SimpleNamespace(image="mcr.microsoft.com/app:1.0")
+ )
+ ],
+ os_type="Linux",
+ restart_policy="Always",
+ ),
+ )
+ ]
+
+ def get_by_id(self, resource_id: str, api_version: str):
+ assert resource_id.endswith("/aci-public-api")
+ assert api_version == "2023-05-01"
+ raise RuntimeError("hydrate failed")
+
+ provider = AzureProvider.__new__(AzureProvider)
+ provider.session = SimpleNamespace(
+ tenant_id="tenant-id",
+ token_source="fixture",
+ auth_mode="fixture",
+ )
+ provider.clients = SimpleNamespace(
+ subscription_id="subscription-id",
+ resource=SimpleNamespace(resources=FakeResourcesClient()),
+ )
+
+ output = collect_container_instances(provider, options)
+
+ assert [item.name for item in output.container_instances] == ["aci-public-api"]
+ assert output.issues[0].context["collector"].startswith("container_instances.hydrate[")
+ assert "hydrate failed" in output.issues[0].message
+
+
def test_collect_arm_deployments(fixture_provider, options) -> None:
output = collect_arm_deployments(fixture_provider, options)
assert len(output.deployments) == 3
@@ -2028,11 +2318,22 @@ def test_collect_arm_deployments_sorts_failures_and_linked_rows_first(options) -
def test_collect_endpoints(fixture_provider, options) -> None:
output = collect_endpoints(fixture_provider, options)
- assert len(output.endpoints) == 4
+ assert len(output.endpoints) == 7
assert len(output.findings) == 0
- assert output.endpoints[0].endpoint == "52.160.10.20"
- assert output.endpoints[0].ingress_path == "direct-vm-ip"
+ assert any(
+ item.endpoint == "52.160.10.20" and item.ingress_path == "direct-vm-ip"
+ for item in output.endpoints
+ )
assert any(item.endpoint == "app-public-api.azurewebsites.net" for item in output.endpoints)
+ assert any(
+ item.endpoint == "aca-orders.wittyfield.eastus.azurecontainerapps.io"
+ for item in output.endpoints
+ )
+ assert any(item.endpoint == "52.160.10.30" for item in output.endpoints)
+ assert any(
+ item.endpoint == "aci-public-api.eastus.azurecontainer.io"
+ for item in output.endpoints
+ )
def test_collect_env_vars(fixture_provider, options) -> None:
@@ -2048,13 +2349,17 @@ def test_collect_env_vars(fixture_provider, options) -> None:
def test_collect_tokens_credentials(fixture_provider, options) -> None:
output = collect_tokens_credentials(fixture_provider, options)
- assert len(output.surfaces) == 12
- assert len(output.findings) == 12
+ assert len(output.surfaces) == 16
+ assert len(output.findings) == 16
assert len({finding.id for finding in output.findings}) == len(output.findings)
assert output.surfaces[0].surface_type == "plain-text-secret"
assert output.surfaces[1].operator_signal == "setting=AzureWebJobsStorage"
assert "Check env-vars" in output.surfaces[0].summary
assert any(item.asset_name == "app-empty-mi" for item in output.surfaces)
+ assert any(item.asset_name == "aca-orders" for item in output.surfaces)
+ assert any(item.asset_name == "aca-internal-jobs" for item in output.surfaces)
+ assert any(item.asset_name == "aci-public-api" for item in output.surfaces)
+ assert any(item.asset_name == "aci-internal-worker" for item in output.surfaces)
assert any(item.asset_name == "vmss-edge-01" for item in output.surfaces)
assert any(
item.surface_type == "managed-identity-token" and item.access_path == "imds"
@@ -4387,11 +4692,36 @@ def test_vmss_summary_emits_partial_issue_when_nic_configs_are_missing() -> None
def test_collect_workloads(fixture_provider, options) -> None:
output = collect_workloads(fixture_provider, options)
- assert len(output.workloads) == 6
+ assert len(output.workloads) == 10
assert len(output.findings) == 0
assert output.workloads[0].asset_name == "vm-web-01"
assert output.workloads[0].identity_type == "UserAssigned"
assert output.workloads[0].endpoints == ["52.160.10.20"]
+ assert any(
+ item.asset_name == "aca-orders"
+ and item.asset_kind == "ContainerApp"
+ and item.endpoints == ["aca-orders.wittyfield.eastus.azurecontainerapps.io"]
+ for item in output.workloads
+ )
+ assert any(
+ item.asset_name == "aca-internal-jobs"
+ and item.asset_kind == "ContainerApp"
+ and item.endpoints == []
+ for item in output.workloads
+ )
+ assert any(
+ item.asset_name == "aci-public-api"
+ and item.asset_kind == "ContainerInstance"
+ and "52.160.10.30" in item.endpoints
+ and "aci-public-api.eastus.azurecontainer.io" in item.endpoints
+ for item in output.workloads
+ )
+ assert any(
+ item.asset_name == "aci-internal-worker"
+ and item.asset_kind == "ContainerInstance"
+ and item.endpoints == []
+ for item in output.workloads
+ )
assert output.workloads[-2].asset_name == "vmss-edge-01"
assert output.workloads[-1].asset_name == "vmss-batch-01"
assert output.workloads[-1].endpoints == []
diff --git a/tests/test_compute_control.py b/tests/test_compute_control.py
index 15cc827..211f51a 100644
--- a/tests/test_compute_control.py
+++ b/tests/test_compute_control.py
@@ -30,10 +30,17 @@ def _metadata(command: str) -> CommandMetadata:
return CommandMetadata(command=command)
-def _base_loaded_app_service(
+def _base_loaded_workload(
*,
asset_name: str,
+ asset_kind: str,
+ asset_id: str,
principal_id: str,
+ token_summary: str,
+ workload_summary: str,
+ endpoints: list[str],
+ ingress_paths: list[str],
+ exposure_families: list[str],
permission: PermissionSummary | None,
identities: list[ManagedIdentity] | None = None,
managed_identity_issues: list[CollectionIssue] | None = None,
@@ -43,20 +50,17 @@ def _base_loaded_app_service(
metadata=_metadata("tokens-credentials"),
surfaces=[
TokenCredentialSurfaceSummary(
- asset_id=f"/subscriptions/sub/resourceGroups/rg-apps/providers/Microsoft.Web/sites/{asset_name}",
+ asset_id=asset_id,
asset_name=asset_name,
- asset_kind="AppService",
+ asset_kind=asset_kind,
resource_group="rg-apps",
location="eastus",
surface_type="managed-identity-token",
access_path="workload-identity",
priority="medium",
operator_signal="SystemAssigned",
- summary="App Service can request tokens through its attached identity.",
- related_ids=[
- f"/subscriptions/sub/resourceGroups/rg-apps/providers/Microsoft.Web/sites/{asset_name}",
- principal_id,
- ],
+ summary=token_summary,
+ related_ids=[asset_id, principal_id],
)
],
issues=[],
@@ -65,23 +69,18 @@ def _base_loaded_app_service(
metadata=_metadata("workloads"),
workloads=[
WorkloadSummary(
- asset_id=f"/subscriptions/sub/resourceGroups/rg-apps/providers/Microsoft.Web/sites/{asset_name}",
+ asset_id=asset_id,
asset_name=asset_name,
- asset_kind="AppService",
+ asset_kind=asset_kind,
resource_group="rg-apps",
location="eastus",
identity_type="SystemAssigned",
identity_principal_id=principal_id,
- endpoints=[f"{asset_name}.azurewebsites.net"],
- ingress_paths=["azurewebsites-default-hostname"],
- exposure_families=["managed-web-hostname"],
- summary=(
- "App Service exposes a reachable hostname and carries a system identity."
- ),
- related_ids=[
- f"/subscriptions/sub/resourceGroups/rg-apps/providers/Microsoft.Web/sites/{asset_name}",
- principal_id,
- ],
+ endpoints=endpoints,
+ ingress_paths=ingress_paths,
+ exposure_families=exposure_families,
+ summary=workload_summary,
+ related_ids=[asset_id, principal_id],
)
],
issues=[],
@@ -105,6 +104,111 @@ def _base_loaded_app_service(
}
+def _base_loaded_app_service(
+ *,
+ asset_name: str,
+ principal_id: str,
+ permission: PermissionSummary | None,
+ identities: list[ManagedIdentity] | None = None,
+ managed_identity_issues: list[CollectionIssue] | None = None,
+) -> dict[str, object]:
+ asset_id = (
+ "/subscriptions/sub/resourceGroups/rg-apps/providers/"
+ f"Microsoft.Web/sites/{asset_name}"
+ )
+ return _base_loaded_workload(
+ asset_name=asset_name,
+ asset_kind="AppService",
+ asset_id=asset_id,
+ principal_id=principal_id,
+ token_summary="App Service can request tokens through its attached identity.",
+ workload_summary="App Service exposes a reachable hostname and carries a system identity.",
+ endpoints=[f"{asset_name}.azurewebsites.net"],
+ ingress_paths=["azurewebsites-default-hostname"],
+ exposure_families=["managed-web-hostname"],
+ permission=permission,
+ identities=identities,
+ managed_identity_issues=managed_identity_issues,
+ )
+
+
+def _base_loaded_container_app(
+ *,
+ asset_name: str,
+ principal_id: str,
+ permission: PermissionSummary | None,
+ external: bool,
+ identities: list[ManagedIdentity] | None = None,
+ managed_identity_issues: list[CollectionIssue] | None = None,
+) -> dict[str, object]:
+ asset_id = (
+ "/subscriptions/sub/resourceGroups/rg-apps/providers/"
+ f"Microsoft.App/containerApps/{asset_name}"
+ )
+ hostname = f"{asset_name}.eastus.azurecontainerapps.io"
+ return _base_loaded_workload(
+ asset_name=asset_name,
+ asset_kind="ContainerApp",
+ asset_id=asset_id,
+ principal_id=principal_id,
+ token_summary="Container App can request tokens through its attached identity.",
+ workload_summary=(
+ "Container App exposes ingress and carries a system identity."
+ if external
+ else "Container App is internal-only and carries a system identity."
+ ),
+ endpoints=[hostname] if external else [],
+ ingress_paths=["azure-container-apps-default-hostname"] if external else [],
+ exposure_families=["managed-web-hostname"] if external else [],
+ permission=permission,
+ identities=identities,
+ managed_identity_issues=managed_identity_issues,
+ )
+
+
+def _base_loaded_container_instance(
+ *,
+ asset_name: str,
+ principal_id: str,
+ permission: PermissionSummary | None,
+ public: bool,
+ identities: list[ManagedIdentity] | None = None,
+ managed_identity_issues: list[CollectionIssue] | None = None,
+) -> dict[str, object]:
+ asset_id = (
+ "/subscriptions/sub/resourceGroups/rg-apps/providers/"
+ f"Microsoft.ContainerInstance/containerGroups/{asset_name}"
+ )
+ endpoints = []
+ exposure_families = []
+ ingress_paths = []
+ if public:
+ endpoints = [f"{asset_name}.eastus.azurecontainer.io", "52.160.10.30"]
+ exposure_families = ["managed-container-fqdn", "public-ip"]
+ ingress_paths = [
+ "azure-container-instances-fqdn",
+ "azure-container-instances-public-ip",
+ ]
+ return _base_loaded_workload(
+ asset_name=asset_name,
+ asset_kind="ContainerInstance",
+ asset_id=asset_id,
+ principal_id=principal_id,
+ token_summary="Container group can request tokens through its attached identity.",
+ workload_summary=(
+ "Container group exposes a public endpoint and carries a system identity."
+ if public
+ else "Container group is internal-only and carries a system identity."
+ ),
+ endpoints=endpoints,
+ ingress_paths=ingress_paths,
+ exposure_families=exposure_families,
+ permission=permission,
+ identities=identities,
+ managed_identity_issues=managed_identity_issues,
+ )
+
+
def test_compute_control_admits_system_assigned_workload_via_workload_principal() -> None:
loaded = _base_loaded_app_service(
asset_name="app-public-api",
@@ -141,6 +245,84 @@ def test_compute_control_admits_system_assigned_workload_via_workload_principal(
assert "inferred from workload metadata" in (row.confidence_boundary or "")
+def test_compute_control_admits_container_app_via_workload_principal() -> None:
+ loaded = _base_loaded_container_app(
+ asset_name="aca-orders",
+ principal_id="abab1111-1111-1111-1111-111111111111",
+ permission=PermissionSummary(
+ principal_id="abab1111-1111-1111-1111-111111111111",
+ display_name="aca-orders-system",
+ principal_type="ServicePrincipal",
+ priority="high",
+ high_impact_roles=["Contributor"],
+ all_role_names=["Contributor"],
+ role_assignment_count=1,
+ scope_count=1,
+ scope_ids=["/subscriptions/sub/resourceGroups/rg-apps"],
+ privileged=True,
+ ),
+ external=True,
+ )
+
+ paths, issues = collect_compute_control_records("compute-control", loaded)
+
+ assert not issues
+ assert_rows_include(paths, field="asset_name", expected=["aca-orders"])
+ row = row_by_field(paths, field="asset_name", expected="aca-orders")
+ assert row.asset_kind == "ContainerApp"
+ assert row.insertion_point == "reachable service token request path"
+ assert row.priority == "high"
+ assert row.urgency == "pivot-now"
+ assert row.target_names == ["aca-orders system identity"]
+ assert row.evidence_commands == ["tokens-credentials", "workloads", "permissions"]
+ assert row.joined_surface_types == [
+ "managed-identity-token",
+ "workload",
+ "workload-principal",
+ "permissions",
+ ]
+ assert "server-side execution in this public-facing service" in (row.why_care or "")
+
+
+def test_compute_control_admits_container_instance_via_workload_principal() -> None:
+ loaded = _base_loaded_container_instance(
+ asset_name="aci-public-api",
+ principal_id="acac1111-1111-1111-1111-111111111111",
+ permission=PermissionSummary(
+ principal_id="acac1111-1111-1111-1111-111111111111",
+ display_name="aci-public-api-system",
+ principal_type="ServicePrincipal",
+ priority="high",
+ high_impact_roles=["Contributor"],
+ all_role_names=["Contributor"],
+ role_assignment_count=1,
+ scope_count=1,
+ scope_ids=["/subscriptions/sub/resourceGroups/rg-apps"],
+ privileged=True,
+ ),
+ public=True,
+ )
+
+ paths, issues = collect_compute_control_records("compute-control", loaded)
+
+ assert not issues
+ assert_rows_include(paths, field="asset_name", expected=["aci-public-api"])
+ row = row_by_field(paths, field="asset_name", expected="aci-public-api")
+ assert row.asset_kind == "ContainerInstance"
+ assert row.insertion_point == "reachable service token request path"
+ assert row.priority == "high"
+ assert row.urgency == "pivot-now"
+ assert row.target_names == ["aci-public-api system identity"]
+ assert row.evidence_commands == ["tokens-credentials", "workloads", "permissions"]
+ assert row.joined_surface_types == [
+ "managed-identity-token",
+ "workload",
+ "workload-principal",
+ "permissions",
+ ]
+ assert "server-side execution in this public-facing container group" in (row.why_care or "")
+
+
def test_compute_control_prefers_explicit_system_identity_anchor_when_present() -> None:
loaded = _base_loaded_app_service(
asset_name="app-public-api",
@@ -1262,6 +1444,34 @@ def test_compute_control_suppresses_system_assigned_workload_without_stronger_co
assert_rows_exclude(paths, field="asset_name", expected=["app-empty-mi"])
+def test_compute_control_suppresses_container_app_without_stronger_control() -> None:
+ loaded = _base_loaded_container_app(
+ asset_name="aca-internal-jobs",
+ principal_id="abab2222-2222-2222-2222-222222222222",
+ permission=None,
+ external=False,
+ )
+
+ paths, issues = collect_compute_control_records("compute-control", loaded)
+
+ assert not issues
+ assert_rows_exclude(paths, field="asset_name", expected=["aca-internal-jobs"])
+
+
+def test_compute_control_suppresses_container_instance_without_stronger_control() -> None:
+ loaded = _base_loaded_container_instance(
+ asset_name="aci-internal-worker",
+ principal_id="acac2222-2222-2222-2222-222222222222",
+ permission=None,
+ public=False,
+ )
+
+ paths, issues = collect_compute_control_records("compute-control", loaded)
+
+ assert not issues
+ assert_rows_exclude(paths, field="asset_name", expected=["aci-internal-worker"])
+
+
def test_compute_control_preserves_partial_visibility_issues_when_row_still_admits() -> None:
loaded = _base_loaded_app_service(
asset_name="app-empty-mi",
diff --git a/tests/test_help.py b/tests/test_help.py
index 8eefb15..5e01c5d 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -199,6 +199,14 @@ def test_help_command_command_topic() -> None:
HELP_TOPICS_WORKLOADS = (
("app-services", ("runtime stack", "workload_identity_type", "public_network_access")),
("functions", ("Functions runtime", "azure_webjobs_storage_value_type", "run_from_package")),
+ (
+ "container-apps",
+ ("external_ingress_enabled", "revision_mode", "workload_identity_type"),
+ ),
+ (
+ "container-instances",
+ ("public_ip_address", "restart_policy", "workload_identity_type"),
+ ),
(
"aks",
(
diff --git a/tests/test_terminal_ux.py b/tests/test_terminal_ux.py
index db25dcd..4e2cba5 100644
--- a/tests/test_terminal_ux.py
+++ b/tests/test_terminal_ux.py
@@ -573,8 +573,8 @@ def test_workloads_table_mode_surfaces_joined_workload_context(tmp_path: Path) -
assert "UserAssigned" in result.stdout
assert "vm-web-01" in result.stdout
assert (
- "Takeaway: 6 workloads visible; 4 with visible endpoint paths, 5 with identity context, "
- "across 3 compute and 3 web assets." in result.stdout
+ "Takeaway: 10 workloads visible; 6 with visible endpoint paths, 9 with identity context, "
+ "across 3 compute and 7 web assets." in result.stdout
)
@@ -643,9 +643,10 @@ def test_endpoints_table_mode_surfaces_reachability_context(tmp_path: Path) -> N
)
assert "family" in result.stdout
assert "direct-vm-ip" in result.stdout
- assert "app-public-api.azurewebsites.net" in result.stdout
+ assert "app-public-api" in result.stdout
assert (
- "Takeaway: 4 reachable surfaces visible; 1 public-ip, 3 managed-web-hostname."
+ "Takeaway: 7 reachable surfaces visible; 2 public-ip, 4 managed-web-hostname, "
+ "1 managed-container-fqdn."
in result.stdout
)
@@ -668,7 +669,8 @@ def test_network_effective_table_mode_surfaces_prioritized_reachability(
assert "TCP/22" in result.stdout
assert "TCP/443" in result.stdout
assert (
- "Takeaway: 1 public-IP exposure summaries visible; 1 high, 0 medium, 0 low" in result.stdout
+ "Takeaway: 2 public-IP exposure summaries visible; 1 high, 0 medium, 1 low"
+ in result.stdout
)
@@ -723,7 +725,7 @@ def test_tokens_credentials_table_mode_surfaces_findings_and_takeaway(tmp_path:
assert "plain-text-secret" in result.stdout
assert "deployment-history" in result.stdout
assert "Check env-vars" in result.stdout
- assert "Takeaway: 12 token or credential surfaces across 7 assets;" in result.stdout
+ assert "Takeaway: 16 token or credential surfaces across 11 assets;" in result.stdout
def test_managed_identities_table_mode_surfaces_next_review(tmp_path: Path) -> None:
@@ -873,14 +875,14 @@ def test_escalation_chains_table_mode_renders_defended_current_foothold_story(
assert "azurefox-lab-sp" in normalized_output
assert "(current" in normalized_output
assert "foothold)" in normalized_output
- assert "current foothold direct control" in normalized_output
+ assert "current foothold direct" in normalized_output
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"
in normalized_output
)
- assert "Takeaway: 1 visible escalation paths; 1 high, 1 pivot-now" in result.stdout
+ assert "Takeaway:" not in result.stdout
def test_auth_policies_partial_read_surfaces_collection_issue() -> None: