From 601b63f4dc4110b71d5ce6c62b72e013970bbe9e Mon Sep 17 00:00:00 2001 From: Colby Farley Date: Sun, 12 Apr 2026 21:02:58 -0500 Subject: [PATCH 1/3] Add container workload coverage and tighten compute-control --- README.md | 6 +- src/azurefox/chains/compute_control.py | 67 +++ src/azurefox/cli.py | 10 + src/azurefox/collectors/commands.py | 55 ++ src/azurefox/collectors/provider.py | 561 +++++++++++++++++- src/azurefox/help.py | 70 +++ src/azurefox/models/commands.py | 16 + src/azurefox/models/common.py | 43 ++ src/azurefox/registry.py | 4 + src/azurefox/render/table.py | 250 +++++++- tests/fixtures/lab_tenant/container_apps.json | 56 ++ .../lab_tenant/container_instances.json | 65 ++ tests/test_cli_smoke.py | 71 ++- tests/test_collectors.py | 322 +++++++++- tests/test_compute_control.py | 247 +++++++- tests/test_help.py | 8 + tests/test_terminal_ux.py | 4 +- 17 files changed, 1787 insertions(+), 68 deletions(-) create mode 100644 tests/fixtures/lab_tenant/container_apps.json create mode 100644 tests/fixtures/lab_tenant/container_instances.json diff --git a/README.md b/README.md index c30a4a1..d911442 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # AzureFox

- AzureFox logo + AzureFox logo

Find attack paths, pivot opportunities, and movement across Azure before you drown in inventory. @@ -106,8 +106,8 @@ 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`, `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) | +| orchestration | [`chains`](https://github.com/TacoRocket/AzureFox/wiki/Chains), `credential-path`, `deployment-path`, `escalation-path`, `compute-control` | ## 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..6358ca0 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,36 @@ 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 +5642,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 +7376,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 +7462,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 +7508,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 +7663,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 +7705,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 +7837,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/test_cli_smoke.py b/tests/test_cli_smoke.py index 6954082..4797f38 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,34 @@ 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 @@ -723,3 +736,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..c8cf10e 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 @@ -12,6 +13,8 @@ collect_application_gateway, collect_arm_deployments, collect_auth_policies, + collect_container_apps, + collect_container_instances, collect_automation, collect_cross_tenant, collect_databases, @@ -496,6 +499,104 @@ 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": [ @@ -2007,6 +2108,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 +2301,19 @@ 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 +2329,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 +4672,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..8f906f8 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,108 @@ 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 = f"/subscriptions/sub/resourceGroups/rg-apps/providers/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 +242,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 +1441,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..0b7d622 100644 --- a/tests/test_terminal_ux.py +++ b/tests/test_terminal_ux.py @@ -873,14 +873,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: From d3e879263620cbf86203c45a1f2415ae6fba0abc Mon Sep 17 00:00:00 2001 From: Colby Farley Date: Sun, 12 Apr 2026 21:04:06 -0500 Subject: [PATCH 2/3] Fix publish guardrail lint issues --- src/azurefox/collectors/provider.py | 4 +++- tests/test_cli_smoke.py | 5 ++++- tests/test_collectors.py | 32 ++++++++++++++++++++++------- tests/test_compute_control.py | 5 ++++- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/azurefox/collectors/provider.py b/src/azurefox/collectors/provider.py index 6358ca0..3184981 100644 --- a/src/azurefox/collectors/provider.py +++ b/src/azurefox/collectors/provider.py @@ -3795,7 +3795,9 @@ def _collect_resource_type_summaries( 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)) + issues.append( + _issue_from_exception(f"{hydrate_issue_scope}[{resource_id}]", exc) + ) hydrated = resource rows.append(summary_fn(hydrated)) except Exception as exc: diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 4797f38..d4adc6b 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -490,7 +490,10 @@ def test_cli_smoke_chains_compute_control_table_output(tmp_path: Path) -> None: 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 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 diff --git a/tests/test_collectors.py b/tests/test_collectors.py index c8cf10e..0d5d569 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -13,9 +13,9 @@ collect_application_gateway, collect_arm_deployments, collect_auth_policies, + collect_automation, collect_container_apps, collect_container_instances, - collect_automation, collect_cross_tenant, collect_databases, collect_devops, @@ -511,7 +511,10 @@ def container_apps(self) -> dict: "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", + "environment_id": ( + "/subscriptions/sub/resourceGroups/rg/providers/" + "Microsoft.App/managedEnvironments/env-internal" + ), "workload_identity_type": "SystemAssigned", "summary": "internal identity-backed container app", }, @@ -524,7 +527,10 @@ def container_apps(self) -> dict: "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", + "environment_id": ( + "/subscriptions/sub/resourceGroups/rg/providers/" + "Microsoft.App/managedEnvironments/env-public" + ), "workload_identity_type": "SystemAssigned", "summary": "public identity-backed container app", }, @@ -537,7 +543,10 @@ def container_apps(self) -> dict: "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", + "environment_id": ( + "/subscriptions/sub/resourceGroups/rg/providers/" + "Microsoft.App/managedEnvironments/env-public" + ), "workload_identity_type": None, "summary": "public non-identity container app", }, @@ -554,7 +563,10 @@ def container_instances(self) -> dict: "public_ip_address": None, "fqdn": None, "exposed_ports": [], - "subnet_ids": ["/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet/subnets/apps"], + "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", @@ -571,7 +583,10 @@ def container_instances(self) -> dict: "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"], + "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", @@ -2313,7 +2328,10 @@ def test_collect_endpoints(fixture_provider, options) -> None: 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) + assert any( + item.endpoint == "aci-public-api.eastus.azurecontainer.io" + for item in output.endpoints + ) def test_collect_env_vars(fixture_provider, options) -> None: diff --git a/tests/test_compute_control.py b/tests/test_compute_control.py index 8f906f8..211f51a 100644 --- a/tests/test_compute_control.py +++ b/tests/test_compute_control.py @@ -112,7 +112,10 @@ def _base_loaded_app_service( identities: list[ManagedIdentity] | None = None, managed_identity_issues: list[CollectionIssue] | None = None, ) -> dict[str, object]: - asset_id = f"/subscriptions/sub/resourceGroups/rg-apps/providers/Microsoft.Web/sites/{asset_name}" + 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", From c03732366dcb8d9e0084ee9326f8bed2bbd9f2d6 Mon Sep 17 00:00:00 2001 From: Colby Farley Date: Sun, 12 Apr 2026 21:07:10 -0500 Subject: [PATCH 3/3] Refresh docs and goldens for container surfaces --- README.md | 11 +- tests/golden/endpoints.json | 44 +++ tests/golden/network-effective.json | 16 + tests/golden/tokens-credentials.json | 518 ++++++++++++++++----------- tests/golden/workloads.json | 99 +++++ tests/test_cli_smoke.py | 3 +- tests/test_collectors.py | 4 +- tests/test_terminal_ux.py | 14 +- 8 files changed, 494 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index d911442..8944839 100644 --- a/README.md +++ b/README.md @@ -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), `container-apps`, `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) | -| orchestration | [`chains`](https://github.com/TacoRocket/AzureFox/wiki/Chains), `credential-path`, `deployment-path`, `escalation-path`, `compute-control` | +| `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/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 d4adc6b..2e44f31 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -558,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: @@ -695,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(): diff --git a/tests/test_collectors.py b/tests/test_collectors.py index 0d5d569..82147e4 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -1601,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( diff --git a/tests/test_terminal_ux.py b/tests/test_terminal_ux.py index 0b7d622..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: