From a186c2ff55be49181db87ef70e008a1db833c1ee Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Mon, 6 Apr 2026 01:20:11 +0000 Subject: [PATCH] Add azure-subnet-egress-validation CodeBundle Validate subnet NSG egress rules, UDR default routes vs Azure Firewall, optional Network Watcher connectivity probes, and merged summary output. Ref: issue #73 design spec (azure-subnet-egress-validation). Made-with: Cursor --- .../azure-subnet-egress-validation.yaml | 21 ++ .../azure-subnet-egress-validation-slx.yaml | 24 ++ ...zure-subnet-egress-validation-taskset.yaml | 43 +++ .../.test/README.md | 16 + .../.test/Taskfile.yaml | 159 ++++++++++ .../.test/terraform/backend.tf | 3 + .../.test/terraform/main.tf | 47 +++ .../.test/terraform/provider.tf | 12 + .../.test/terraform/terraform.tfvars | 8 + .../.test/terraform/vars.tf | 16 + .../azure-subnet-egress-validation/README.md | 51 ++++ .../runbook.robot | 276 ++++++++++++++++++ .../subnet-discover-attachments.sh | 101 +++++++ .../subnet-effective-nsg-egress.sh | 92 ++++++ .../subnet-egress-probe.sh | 177 +++++++++++ .../subnet-egress-summary.sh | 67 +++++ .../subnet-route-firewall-check.sh | 127 ++++++++ 17 files changed, 1240 insertions(+) create mode 100644 codebundles/azure-subnet-egress-validation/.runwhen/generation-rules/azure-subnet-egress-validation.yaml create mode 100644 codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-slx.yaml create mode 100644 codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-taskset.yaml create mode 100644 codebundles/azure-subnet-egress-validation/.test/README.md create mode 100644 codebundles/azure-subnet-egress-validation/.test/Taskfile.yaml create mode 100644 codebundles/azure-subnet-egress-validation/.test/terraform/backend.tf create mode 100644 codebundles/azure-subnet-egress-validation/.test/terraform/main.tf create mode 100644 codebundles/azure-subnet-egress-validation/.test/terraform/provider.tf create mode 100644 codebundles/azure-subnet-egress-validation/.test/terraform/terraform.tfvars create mode 100644 codebundles/azure-subnet-egress-validation/.test/terraform/vars.tf create mode 100644 codebundles/azure-subnet-egress-validation/README.md create mode 100644 codebundles/azure-subnet-egress-validation/runbook.robot create mode 100755 codebundles/azure-subnet-egress-validation/subnet-discover-attachments.sh create mode 100755 codebundles/azure-subnet-egress-validation/subnet-effective-nsg-egress.sh create mode 100755 codebundles/azure-subnet-egress-validation/subnet-egress-probe.sh create mode 100755 codebundles/azure-subnet-egress-validation/subnet-egress-summary.sh create mode 100755 codebundles/azure-subnet-egress-validation/subnet-route-firewall-check.sh diff --git a/codebundles/azure-subnet-egress-validation/.runwhen/generation-rules/azure-subnet-egress-validation.yaml b/codebundles/azure-subnet-egress-validation/.runwhen/generation-rules/azure-subnet-egress-validation.yaml new file mode 100644 index 00000000..049e7382 --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.runwhen/generation-rules/azure-subnet-egress-validation.yaml @@ -0,0 +1,21 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: azure + generationRules: + - resourceTypes: + - azure_network_virtual_networks + matchRules: + - type: pattern + pattern: ".+" + properties: [name] + mode: substring + slxs: + - baseName: az-subnet-egress-validation + qualifiers: [resource, resource_group, subscription_id] + baseTemplateName: azure-subnet-egress-validation + levelOfDetail: basic + outputItems: + - type: slx + - type: runbook + templateName: azure-subnet-egress-validation-taskset.yaml diff --git a/codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-slx.yaml b/codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-slx.yaml new file mode 100644 index 00000000..1f400c5c --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-slx.yaml @@ -0,0 +1,24 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelX +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + imageURL: https://storage.googleapis.com/runwhen-nonprod-shared-images/icons/azure/networking/10064-icon-service-Virtual-Networks.svg + alias: "{{match_resource.resource.name}} Azure Subnet Egress Validation" + asMeasuredBy: NSG egress rules, UDR default routes vs Azure Firewall, and optional Network Watcher probes from a source VM. + owners: + - {{ workspace.owner_email }} + statement: Validates that subnet egress paths align with NSGs, route tables, and optional Azure Firewall policies. + additionalContext: + {% include "azure-hierarchy.yaml" ignore missing %} + qualified_name: "{{ match_resource.qualified_name }}" + tags: + {% include "azure-tags.yaml" ignore missing %} + - name: service + value: virtual-network + - name: access + value: read-only diff --git a/codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-taskset.yaml b/codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-taskset.yaml new file mode 100644 index 00000000..fabede69 --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.runwhen/templates/azure-subnet-egress-validation-taskset.yaml @@ -0,0 +1,43 @@ +apiVersion: runwhen.com/v1 +kind: Runbook +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + location: {{default_location}} + description: Validates subnet egress using NSG rules, route tables, optional Azure Firewall alignment, and optional Network Watcher probes. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/azure-subnet-egress-validation/runbook.robot + configProvided: + - name: AZURE_SUBSCRIPTION_ID + value: "{{ subscription_id }}" + - name: AZURE_RESOURCE_GROUP + value: "{{ resource_group.name }}" + - name: VNET_NAME + value: "{{ match_resource.resource.name }}" + - name: PROBE_TARGETS + value: "{{ custom.probe_targets | default('https://example.com:443') }}" + - name: PROBE_MODE + value: "{{ custom.probe_mode | default('network-watcher') }}" + - name: SOURCE_VM_RESOURCE_ID + value: "{{ custom.source_vm_resource_id | default('') }}" + secretsProvided: + {% if wb_version %} + {% include "azure-auth.yaml" ignore missing %} + {% else %} + - name: azure_credentials + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} diff --git a/codebundles/azure-subnet-egress-validation/.test/README.md b/codebundles/azure-subnet-egress-validation/.test/README.md new file mode 100644 index 00000000..414eec5e --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.test/README.md @@ -0,0 +1,16 @@ +# Test infrastructure — Azure Subnet Egress Validation + +This directory contains a Terraform stack and Taskfile tasks used to exercise the CodeBundle against a real Azure subscription. + +## Prerequisites + +- Azure CLI and Terraform installed +- `terraform/tf.secret` (not committed) with `ARM_SUBSCRIPTION_ID`, `AZ_TENANT_ID`, `AZ_CLIENT_ID`, `AZ_CLIENT_SECRET` exported for Terraform and `az` + +## Usage + +1. Copy `terraform/terraform.tfvars` values to match your subscription (resource group name, region, VNet name). +2. `cd terraform && terraform init && terraform apply` +3. From `.test`, run `task generate-rwl-config` (after sourcing `tf.secret`) to build `workspaceInfo.yaml` for RunWhen Local. + +Tag test resources with `lifecycle: deleteme` for easy identification. diff --git a/codebundles/azure-subnet-egress-validation/.test/Taskfile.yaml b/codebundles/azure-subnet-egress-validation/.test/Taskfile.yaml new file mode 100644 index 00000000..0e5a7e49 --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.test/Taskfile.yaml @@ -0,0 +1,159 @@ +version: "3" + +tasks: + default: + desc: "Run/refresh config" + cmds: + - task: check-unpushed-commits + - task: generate-rwl-config + - task: run-rwl-discovery + + clean: + desc: "Run cleanup tasks" + cmds: + - task: check-and-cleanup-terraform + - task: clean-rwl-discovery + + build-infra: + desc: "Build test infrastructure" + cmds: + - task: build-terraform-infra + + check-unpushed-commits: + desc: Check if outstanding commits or file updates need to be pushed before testing. + vars: + BASE_DIR: "../" + cmds: + - | + UNCOMMITTED_FILES=$(git diff --name-only HEAD | grep -E "^${BASE_DIR}(\.runwhen|[^/]+)" | grep -v "/\.test/" || true) + if [ -n "$UNCOMMITTED_FILES" ]; then + echo "Uncommitted changes found:"; echo "$UNCOMMITTED_FILES"; exit 1 + fi + - | + git fetch origin + UNPUSHED_FILES=$(git diff --name-only origin/$(git rev-parse --abbrev-ref HEAD) HEAD | grep -E "^${BASE_DIR}(\.runwhen|[^/]+)" | grep -v "/\.test/" || true) + if [ -n "$UNPUSHED_FILES" ]; then + echo "Unpushed commits found:"; echo "$UNPUSHED_FILES"; exit 1 + fi + silent: true + + generate-rwl-config: + desc: "Generate RunWhen Local configuration (workspaceInfo.yaml)" + env: + ARM_SUBSCRIPTION_ID: "{{.ARM_SUBSCRIPTION_ID}}" + AZ_TENANT_ID: "{{.AZ_TENANT_ID}}" + AZ_CLIENT_SECRET: "{{.AZ_CLIENT_SECRET}}" + AZ_CLIENT_ID: "{{.AZ_CLIENT_ID}}" + RW_WORKSPACE: '{{.RW_WORKSPACE | default "my-workspace"}}' + cmds: + - | + source terraform/tf.secret + repo_url=$(git config --get remote.origin.url) + branch_name=$(git rev-parse --abbrev-ref HEAD) + codebundle=$(basename "$(dirname "$PWD")") + subscription_name=$(az account show --subscription "${ARM_SUBSCRIPTION_ID}" --query name -o tsv) + pushd terraform > /dev/null + resource_group=$(terraform show -json terraform.tfstate 2>/dev/null | jq -r ' + .values.root_module.resources[]? + | select(.type == "azurerm_resource_group") | .values.name' | head -1) + popd > /dev/null + if [ -z "$resource_group" ]; then + echo "Error: Apply Terraform first or fix state so resource_group can be read." + exit 1 + fi + cat < workspaceInfo.yaml + workspaceName: "$RW_WORKSPACE" + workspaceOwnerEmail: authors@runwhen.com + defaultLocation: location-01-us-west1 + defaultLOD: detailed + cloudConfig: + azure: + subscriptionId: "$ARM_SUBSCRIPTION_ID" + tenantId: "$AZ_TENANT_ID" + clientId: "$AZ_CLIENT_ID" + clientSecret: "$AZ_CLIENT_SECRET" + resourceGroupLevelOfDetails: + $resource_group: detailed + codeCollections: + - repoURL: "$repo_url" + branch: "$branch_name" + codeBundles: ["$codebundle"] + custom: + subscription_name: $subscription_name + EOF + silent: true + + run-rwl-discovery: + desc: "Run RunWhen Local Discovery on test infrastructure" + cmds: + - | + source terraform/tf.secret + CONTAINER_NAME="RunWhenLocal" + docker rm -f "$CONTAINER_NAME" 2>/dev/null || true + rm -rf output && mkdir -p output && chmod 777 output + docker run --name $CONTAINER_NAME -p 8081:8081 -v "$(pwd)":/shared -d ghcr.io/runwhen-contrib/runwhen-local:latest + docker exec -w /workspace-builder $CONTAINER_NAME ./run.sh $1 --verbose + echo "Review generated config under output/workspaces/" + silent: true + + validate-generation-rules: + desc: "Validate YAML files in .runwhen/generation-rules" + cmds: + - | + for cmd in curl yq ajv; do + command -v $cmd >/dev/null || { echo "Missing $cmd"; exit 1; } + done + temp_dir=$(mktemp -d) + curl -s -o "$temp_dir/generation-rule-schema.json" \ + https://raw.githubusercontent.com/runwhen-contrib/runwhen-local/refs/heads/main/src/generation-rule-schema.json + for yaml_file in ../.runwhen/generation-rules/*.yaml; do + json_file="$temp_dir/$(basename "${yaml_file%.*}.json")" + yq -o=json "$yaml_file" > "$json_file" + ajv validate -s "$temp_dir/generation-rule-schema.json" -d "$json_file" \ + --spec=draft2020 --strict=false \ + && echo "$yaml_file OK" || echo "$yaml_file failed" + done + rm -rf "$temp_dir" + silent: true + + check-terraform-infra: + desc: "Check if Terraform has deployed resources" + cmds: + - | + source terraform/tf.secret + cd terraform + [ -f terraform.tfstate ] || { echo "No state"; exit 0; } + terraform state list | head -20 + silent: true + + build-terraform-infra: + desc: "Run terraform apply" + cmds: + - | + source terraform/tf.secret + cd terraform + terraform init + terraform apply -auto-approve + silent: true + + cleanup-terraform-infra: + desc: "Cleanup deployed Terraform infrastructure" + cmds: + - | + source terraform/tf.secret + cd terraform + [ -f terraform.tfstate ] && terraform destroy -auto-approve || true + silent: true + + check-and-cleanup-terraform: + desc: "Destroy Terraform resources if present" + cmds: + - task: cleanup-terraform-infra + + clean-rwl-discovery: + desc: "Remove RunWhen Local output" + cmds: + - | + rm -rf output + rm -f workspaceInfo.yaml + silent: true diff --git a/codebundles/azure-subnet-egress-validation/.test/terraform/backend.tf b/codebundles/azure-subnet-egress-validation/.test/terraform/backend.tf new file mode 100644 index 00000000..f966bbb9 --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.test/terraform/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "local" {} +} diff --git a/codebundles/azure-subnet-egress-validation/.test/terraform/main.tf b/codebundles/azure-subnet-egress-validation/.test/terraform/main.tf new file mode 100644 index 00000000..a123ca5a --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.test/terraform/main.tf @@ -0,0 +1,47 @@ +resource "azurerm_resource_group" "rg" { + name = var.resource_group + location = var.location + tags = var.tags +} + +resource "azurerm_virtual_network" "vnet" { + name = var.vnet_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + address_space = ["10.42.0.0/16"] + tags = var.tags +} + +resource "azurerm_subnet" "subnet_a" { + name = "subnet-a" + resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = azurerm_virtual_network.vnet.name + address_prefixes = ["10.42.1.0/24"] +} + +resource "azurerm_subnet" "subnet_b" { + name = "subnet-b" + resource_group_name = azurerm_resource_group.rg.name + virtual_network_name = azurerm_virtual_network.vnet.name + address_prefixes = ["10.42.2.0/24"] +} + +resource "azurerm_network_security_group" "nsg" { + name = "rw-subnet-egress-nsg" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tags = var.tags +} + +resource "azurerm_subnet_network_security_group_association" "subnet_a_nsg" { + subnet_id = azurerm_subnet.subnet_a.id + network_security_group_id = azurerm_network_security_group.nsg.id +} + +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "vnet_name" { + value = azurerm_virtual_network.vnet.name +} diff --git a/codebundles/azure-subnet-egress-validation/.test/terraform/provider.tf b/codebundles/azure-subnet-egress-validation/.test/terraform/provider.tf new file mode 100644 index 00000000..a6dc87bb --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.test/terraform/provider.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.18" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/codebundles/azure-subnet-egress-validation/.test/terraform/terraform.tfvars b/codebundles/azure-subnet-egress-validation/.test/terraform/terraform.tfvars new file mode 100644 index 00000000..5c966d8b --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.test/terraform/terraform.tfvars @@ -0,0 +1,8 @@ +resource_group = "azure-subnet-egress-test-rg" +location = "East US" +vnet_name = "rw-subnet-egress-test-vnet" +tags = { + "env" = "test" + "lifecycle" = "deleteme" + "product" = "runwhen" +} diff --git a/codebundles/azure-subnet-egress-validation/.test/terraform/vars.tf b/codebundles/azure-subnet-egress-validation/.test/terraform/vars.tf new file mode 100644 index 00000000..d1f34bfb --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/.test/terraform/vars.tf @@ -0,0 +1,16 @@ +variable "resource_group" { + type = string +} + +variable "location" { + type = string + default = "East US" +} + +variable "vnet_name" { + type = string +} + +variable "tags" { + type = map(string) +} diff --git a/codebundles/azure-subnet-egress-validation/README.md b/codebundles/azure-subnet-egress-validation/README.md new file mode 100644 index 00000000..7bbeed95 --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/README.md @@ -0,0 +1,51 @@ +# Azure Subnet Egress Path Validation + +This CodeBundle inspects Azure virtual network subnets to validate egress-related configuration: subnet-attached NSGs (outbound rules), route tables and default routes relative to Azure Firewall, optional Network Watcher connectivity probes from a designated VM, and a merged summary matrix. + +## Overview + +- **Discovery**: Lists subnets under a VNet and resolves attached NSG and route table resource IDs. +- **NSG egress**: Summarizes outbound NSG rules per subnet-attached NSG and flags subnets without an NSG. +- **Routes and firewall**: Evaluates UDR default routes (`0.0.0.0/0`) when an Azure Firewall exists in the same resource group and warns on Internet bypass patterns. +- **Probes**: Optional `az network watcher test-connectivity` runs from `SOURCE_VM_RESOURCE_ID` to each `PROBE_TARGETS` entry, or skips/bastion placeholder modes. +- **Summary**: Merges issue JSON from prior steps and emits a structured matrix (`subnet_summary_matrix.json`). + +## Configuration + +### Required variables + +- `AZURE_SUBSCRIPTION_ID`: Azure subscription ID containing the VNet. +- `AZURE_RESOURCE_GROUP`: Resource group that holds the virtual network. +- `VNET_NAME`: Name of the virtual network to analyze. +- `PROBE_TARGETS`: Comma-separated destinations for probes, for example `https://example.com:443`, `http://example.com:80`, or `example.com:443`. + +### Optional variables + +- `PROBE_MODE`: `network-watcher` (default), `bastion-agent` (manual guidance only), or `skip-probes` (rules-only). +- `SOURCE_VM_RESOURCE_ID`: Azure resource ID of a VM in the target subnet; required for `network-watcher` probes. + +### Secrets + +- `azure_credentials`: JSON or workspace mapping with `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`, and typically `AZURE_SUBSCRIPTION_ID`. Network Watcher connectivity tests may require additional RBAC (for example Network Contributor) beyond Reader. + +## Tasks overview + +### Discover Subnets and Attached NSGs in Scope for VNet + +Lists subnets and resolves NSG and route table IDs; raises issues if the VNet cannot be read or has no subnets. + +### Summarize Effective Egress Rules per Subnet for VNet + +Aggregates outbound NSG rules per subnet NSG and warns when a subnet has no NSG attached. + +### Validate Route Table and Firewall Next Hop for VNet + +Checks route tables for a default route and compares with Azure Firewall presence in the resource group. + +### Run Connectivity Probes for Egress Targets from VNet + +Runs Network Watcher tests per target, or skips/bastion modes per `PROBE_MODE`. + +### Report Egress Validation Summary for VNet + +Merges prior step issue files and prints a subscription/VNet matrix with probe results when available. diff --git a/codebundles/azure-subnet-egress-validation/runbook.robot b/codebundles/azure-subnet-egress-validation/runbook.robot new file mode 100644 index 00000000..f12bacfb --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/runbook.robot @@ -0,0 +1,276 @@ +*** Settings *** +Documentation Validates that subnet egress paths align with NSGs, route tables, and optional Azure Firewall, and runs optional Network Watcher connectivity probes. +Metadata Author rw-codebundle-agent +Metadata Display Name Azure Subnet Egress Path Validation +Metadata Supports Azure Subnet Network Egress NSG Route Firewall +Force Tags Azure Subnet Network Egress NSG Route Firewall + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + + +*** Tasks *** +Discover Subnets and Attached NSGs in Scope for VNet `${VNET_NAME}` + [Documentation] Lists subnets in the VNet scope and resolves subnet IDs, attached NSGs, and route tables for downstream tasks. + [Tags] Azure Subnet Network Discovery access:read-only data:logs-config + ${result}= RW.CLI.Run Bash File + ... bash_file=subnet-discover-attachments.sh + ... env=${env} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./subnet-discover-attachments.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat subnet_discover_issues.json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for subnet discovery issues, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Subnet discovery should succeed without API or configuration errors for VNet `${VNET_NAME}` + ... actual=Discovery reported a problem for VNet `${VNET_NAME}` in resource group `${AZURE_RESOURCE_GROUP}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Summarize Effective Egress Rules per Subnet for VNet `${VNET_NAME}` + [Documentation] Aggregates NSG outbound rules for each subnet-attached NSG and highlights deny/allow posture for egress. + [Tags] Azure Subnet NSG Egress access:read-only data:logs-config + ${result}= RW.CLI.Run Bash File + ... bash_file=subnet-effective-nsg-egress.sh + ... env=${env} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./subnet-effective-nsg-egress.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat subnet_effective_nsg_issues.json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for NSG egress issues, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=NSG egress rules should match intended allow/deny policy for subnets in `${VNET_NAME}` + ... actual=NSG egress analysis raised findings for subnets in `${VNET_NAME}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Validate Route Table and Firewall Next Hop for VNet `${VNET_NAME}` + [Documentation] Inspects UDRs for default routes and compares against Azure Firewall presence in the resource group to flag risky Internet bypass patterns. + [Tags] Azure Subnet Route Firewall access:read-only data:logs-config + ${result}= RW.CLI.Run Bash File + ... bash_file=subnet-route-firewall-check.sh + ... env=${env} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./subnet-route-firewall-check.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat subnet_route_issues.json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for route/firewall issues, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Default routes and next hops should enforce centralized egress when Azure Firewall is deployed for `${AZURE_RESOURCE_GROUP}` + ... actual=Route analysis found potential misalignment between UDRs and Azure Firewall for VNet `${VNET_NAME}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Run Connectivity Probes for Egress Targets from VNet `${VNET_NAME}` + [Documentation] Uses Network Watcher test-connectivity from a source VM (or skips/bastion placeholder) to probe PROBE_TARGETS and compare outcomes to policy expectations. + [Tags] Azure Subnet NetworkWatcher Probe access:read-only data:metrics + ${result}= RW.CLI.Run Bash File + ... bash_file=subnet-egress-probe.sh + ... env=${env} + ... timeout_seconds=300 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./subnet-egress-probe.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat subnet_probe_issues.json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for probe issues, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Egress probes should reach allowed destinations from the source VM for `${VNET_NAME}` when policy permits + ... actual=One or more egress probes failed or could not run for targets in `${PROBE_TARGETS}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Report Egress Validation Summary for VNet `${VNET_NAME}` + [Documentation] Produces a merged per-subnet matrix from discovery, routes, and probes with consolidated issue counts for operators. + [Tags] Azure Subnet Summary Report access:read-only data:logs-config + ${result}= RW.CLI.Run Bash File + ... bash_file=subnet-egress-summary.sh + ... env=${env} + ... timeout_seconds=120 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./subnet-egress-summary.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=cat subnet_summary_issues.json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for summary issues, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Prior validation steps should not leave unresolved merged findings for VNet `${VNET_NAME}` + ... actual=Merged validation output contains reported issues for subscription `${AZURE_SUBSCRIPTION_ID}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + +*** Keywords *** +Suite Initialization + ${AZURE_SUBSCRIPTION_ID}= RW.Core.Import User Variable AZURE_SUBSCRIPTION_ID + ... type=string + ... description=Azure subscription ID for the VNet. + ... pattern=[a-fA-F0-9-]+ + ${AZURE_RESOURCE_GROUP}= RW.Core.Import User Variable AZURE_RESOURCE_GROUP + ... type=string + ... description=Resource group containing the virtual network. + ... pattern=\w* + ${VNET_NAME}= RW.Core.Import User Variable VNET_NAME + ... type=string + ... description=Name of the virtual network to analyze. + ... pattern=[a-zA-Z0-9_.-]+ + ${PROBE_TARGETS}= RW.Core.Import User Variable PROBE_TARGETS + ... type=string + ... description=Comma-separated probe targets (https://host:port, http://host:port, or host:port). + ... pattern=\S+ + ${PROBE_MODE}= RW.Core.Import User Variable PROBE_MODE + ... type=string + ... description=Probe mode network-watcher, bastion-agent, or skip-probes. + ... pattern=\w+ + ... default=network-watcher + ${SOURCE_VM_RESOURCE_ID}= RW.Core.Import User Variable SOURCE_VM_RESOURCE_ID + ... type=string + ... description=Optional Azure Resource ID of a VM in the subnet for Network Watcher probes. + ... pattern=\S* + ... default= + + TRY + ${azure_credentials}= RW.Core.Import Secret + ... azure_credentials + ... type=string + ... description=JSON with AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID + ... pattern=\w* + Set Suite Variable ${AZURE_CREDENTIALS} ${azure_credentials} + EXCEPT + Log azure_credentials secret not found; relying on existing Azure CLI session if any. WARN + Set Suite Variable ${AZURE_CREDENTIALS} ${EMPTY} + END + + Set Suite Variable ${AZURE_SUBSCRIPTION_ID} ${AZURE_SUBSCRIPTION_ID} + Set Suite Variable ${AZURE_RESOURCE_GROUP} ${AZURE_RESOURCE_GROUP} + Set Suite Variable ${VNET_NAME} ${VNET_NAME} + Set Suite Variable ${PROBE_TARGETS} ${PROBE_TARGETS} + Set Suite Variable ${PROBE_MODE} ${PROBE_MODE} + Set Suite Variable ${SOURCE_VM_RESOURCE_ID} ${SOURCE_VM_RESOURCE_ID} + + ${env}= Create Dictionary + ... AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID} + ... AZURE_RESOURCE_GROUP=${AZURE_RESOURCE_GROUP} + ... VNET_NAME=${VNET_NAME} + ... PROBE_TARGETS=${PROBE_TARGETS} + ... PROBE_MODE=${PROBE_MODE} + ... SOURCE_VM_RESOURCE_ID=${SOURCE_VM_RESOURCE_ID} + Set Suite Variable ${env} ${env} + + ${az_account_result}= RW.CLI.Run Cli + ... cmd=az account set --subscription ${AZURE_SUBSCRIPTION_ID} + ... include_in_history=false + IF '${az_account_result.returncode}' != '0' + Log az account set failed; ensure Azure CLI is logged in or workspace secrets are valid. WARN + END diff --git a/codebundles/azure-subnet-egress-validation/subnet-discover-attachments.sh b/codebundles/azure-subnet-egress-validation/subnet-discover-attachments.sh new file mode 100755 index 00000000..a1c6d595 --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/subnet-discover-attachments.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Discovers subnets in the VNet and resolves attached NSGs and route tables. +# Writes JSON array of issues to subnet_discover_issues.json +set -euo pipefail +set -x + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${VNET_NAME:?Must set VNET_NAME}" + +OUTPUT_FILE="subnet_discover_issues.json" +DISCOVERY_JSON="subnet_discovery.json" +issues_json='[]' + +if ! az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot set Azure subscription \`$AZURE_SUBSCRIPTION_ID\`" \ + --arg details "az account set failed. Verify credentials and subscription access." \ + --arg severity "4" \ + --arg next_steps "Confirm the service principal can read the subscription and AZURE_SUBSCRIPTION_ID is correct." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "$issues_json" > "$OUTPUT_FILE" + echo "[]" > "$DISCOVERY_JSON" + exit 0 +fi + +if ! vnet_json=$(az network vnet show \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --name "$VNET_NAME" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Virtual network not found: \`$VNET_NAME\`" \ + --arg details "az network vnet show failed for resource group \`$AZURE_RESOURCE_GROUP\`." \ + --arg severity "4" \ + --arg next_steps "Verify VNET_NAME and AZURE_RESOURCE_GROUP. Ensure the VNet exists in the subscription." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "$issues_json" > "$OUTPUT_FILE" + echo "[]" > "$DISCOVERY_JSON" + exit 0 +fi + +subnet_names=$(az network vnet subnet list \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --query "[].name" -o tsv 2>/dev/null || true) + +if [ -z "${subnet_names//[$'\t\r\n']/}" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No subnets found in VNet \`$VNET_NAME\`" \ + --arg details "The virtual network has no subnets to analyze." \ + --arg severity "2" \ + --arg next_steps "Create subnets in the VNet or confirm the correct VNet name." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') +fi + +discovery='[]' +while IFS= read -r sn; do + [ -z "$sn" ] && continue + sub_json=$(az network vnet subnet show \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$sn" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "{}") + nsg_id=$(echo "$sub_json" | jq -r '.networkSecurityGroup.id // ""') + rt_id=$(echo "$sub_json" | jq -r '.routeTable.id // ""') + discovery=$(echo "$discovery" | jq \ + --arg name "$sn" \ + --arg nsg "$nsg_id" \ + --arg rt "$rt_id" \ + '. += [{ + "subnetName": $name, + "networkSecurityGroupId": $nsg, + "routeTableId": $rt + }]') +done <<< "$subnet_names" + +echo "$discovery" > "$DISCOVERY_JSON" + +echo "Subnet discovery for VNet \`$VNET_NAME\` (subscription $AZURE_SUBSCRIPTION_ID, RG $AZURE_RESOURCE_GROUP):" +echo "$discovery" | jq . + +echo "$issues_json" > "$OUTPUT_FILE" +echo "Discovery complete. Issues saved to $OUTPUT_FILE" diff --git a/codebundles/azure-subnet-egress-validation/subnet-effective-nsg-egress.sh b/codebundles/azure-subnet-egress-validation/subnet-effective-nsg-egress.sh new file mode 100755 index 00000000..35c74a7d --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/subnet-effective-nsg-egress.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Summarizes effective NSG outbound rules per subnet (subnet-attached NSG). +# Writes subnet_effective_nsg_issues.json and subnet_effective_nsg_summary.json +set -euo pipefail +set -x + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${VNET_NAME:?Must set VNET_NAME}" + +OUTPUT_FILE="subnet_effective_nsg_issues.json" +SUMMARY_JSON="subnet_effective_nsg_summary.json" +issues_json='[]' +summary='[]' + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + +subnet_names=$(az network vnet subnet list \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --query "[].name" -o tsv 2>/dev/null || true) + +while IFS= read -r sn; do + [ -z "$sn" ] && continue + sub_json=$(az network vnet subnet show \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$sn" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "{}") + + nsg_id=$(echo "$sub_json" | jq -r '.networkSecurityGroup.id // ""') + if [ -z "$nsg_id" ] || [ "$nsg_id" = "null" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Subnet \`$sn\` has no NSG attached" \ + --arg details "Without an NSG, Azure platform default rules apply; subnet-level traffic filtering is not explicit." \ + --arg severity "2" \ + --arg next_steps "Associate an NSG with the subnet or confirm intent to rely on NIC-level NSGs only." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + summary=$(echo "$summary" | jq \ + --arg subnet "$sn" \ + --argjson rules '[]' \ + --arg note "no subnet NSG" \ + '. += [{ + "subnetName": $subnet, + "outboundRules": $rules, + "note": $note + }]') + continue + fi + + nsg_name=$(basename "$nsg_id") + nsg_rg=$(echo "$nsg_id" | sed -n 's|.*/resourceGroups/\([^/]*\)/providers/.*|\1|p') + if [ -z "$nsg_rg" ]; then + nsg_rg="$AZURE_RESOURCE_GROUP" + fi + + rules_json=$(az network nsg rule list \ + --resource-group "$nsg_rg" \ + --nsg-name "$nsg_name" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "[]") + + outbound=$(echo "$rules_json" | jq '[.[] | select(.direction == "Outbound")]') + deny_count=$(echo "$outbound" | jq '[.[] | select(.access == "Deny")] | length') + + summary=$(echo "$summary" | jq \ + --arg subnet "$sn" \ + --arg nsg "$nsg_name" \ + --argjson outbound "$outbound" \ + --argjson deny_count "$deny_count" \ + '. += [{ + "subnetName": $subnet, + "nsgName": $nsg, + "outboundRuleCount": ($outbound | length), + "denyRuleCount": $deny_count, + "outboundRules": $outbound + }]') +done <<< "$subnet_names" + +echo "$summary" > "$SUMMARY_JSON" +echo "$issues_json" > "$OUTPUT_FILE" + +echo "Effective NSG egress summary:" +echo "$summary" | jq '[.[] | {subnetName, nsgName, outboundRuleCount, denyRuleCount}]' +echo "Issues written to $OUTPUT_FILE" diff --git a/codebundles/azure-subnet-egress-validation/subnet-egress-probe.sh b/codebundles/azure-subnet-egress-validation/subnet-egress-probe.sh new file mode 100755 index 00000000..127f160d --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/subnet-egress-probe.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Runs egress connectivity probes (Network Watcher test-connectivity, skip, or bastion-agent placeholder). +# Writes subnet_probe_issues.json and subnet_probe_results.json +set -euo pipefail +set -x + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${VNET_NAME:?Must set VNET_NAME}" +: "${PROBE_TARGETS:?Must set PROBE_TARGETS}" + +OUTPUT_FILE="subnet_probe_issues.json" +RESULTS_JSON="subnet_probe_results.json" +PROBE_MODE="${PROBE_MODE:-network-watcher}" +SOURCE_VM_RESOURCE_ID="${SOURCE_VM_RESOURCE_ID:-}" + +issues_json='[]' +results_json='[]' + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + +if [ "$PROBE_MODE" = "skip-probes" ]; then + echo "PROBE_MODE=skip-probes: connectivity probes skipped (rules-only validation)." + echo "$issues_json" > "$OUTPUT_FILE" + echo "$results_json" > "$RESULTS_JSON" + exit 0 +fi + +if [ "$PROBE_MODE" = "bastion-agent" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Bastion/agent probe mode requires manual execution" \ + --arg details "PROBE_MODE=bastion-agent is not automated here. Run curl/HTTPS from a jump host or test VM in the subnet and compare to policy." \ + --arg severity "2" \ + --arg next_steps "Use Azure Bastion or a probe VM in the subnet; set PROBE_MODE=network-watcher with SOURCE_VM_RESOURCE_ID for automated tests." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "$issues_json" > "$OUTPUT_FILE" + echo "$results_json" > "$RESULTS_JSON" + exit 0 +fi + +if [ -z "${SOURCE_VM_RESOURCE_ID//[$'\t\r\n']/}" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Network Watcher probes require SOURCE_VM_RESOURCE_ID" \ + --arg details "Set SOURCE_VM_RESOURCE_ID to the Azure Resource ID of a VM in the target subnet for az network watcher test-connectivity." \ + --arg severity "2" \ + --arg next_steps "Deploy or select a probe VM in the subnet and pass its resource ID. Alternatively set PROBE_MODE=skip-probes for rules-only runs." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "$issues_json" > "$OUTPUT_FILE" + echo "$results_json" > "$RESULTS_JSON" + exit 0 +fi + +IFS=',' read -ra TARGETS <<< "$PROBE_TARGETS" +if [ "${#TARGETS[@]}" -eq 0 ] || [ -z "${TARGETS[0]//[$'\t\r\n']/}" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "PROBE_TARGETS is empty" \ + --arg details "Provide a comma-separated list of destinations (e.g. https://example.com:443 or host:443)." \ + --arg severity "2" \ + --arg next_steps "Set PROBE_TARGETS to destinations to test from the source VM." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + echo "$issues_json" > "$OUTPUT_FILE" + echo "$results_json" > "$RESULTS_JSON" + exit 0 +fi + +vm_rg="$AZURE_RESOURCE_GROUP" +if [[ "$SOURCE_VM_RESOURCE_ID" == /subscriptions/* ]]; then + extracted=$(echo "$SOURCE_VM_RESOURCE_ID" | sed -n 's|.*/resourceGroups/\([^/]*\)/providers/.*|\1|p') + [ -n "$extracted" ] && vm_rg="$extracted" +fi + +for raw in "${TARGETS[@]}"; do + t=$(echo "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$t" ] && continue + + proto="Tcp" + host="" + port="443" + + if [[ "$t" =~ ^https://([^/:]+)(:([0-9]+))? ]]; then + host="${BASH_REMATCH[1]}" + port="${BASH_REMATCH[3]:-443}" + proto="Https" + elif [[ "$t" =~ ^http://([^/:]+)(:([0-9]+))? ]]; then + host="${BASH_REMATCH[1]}" + port="${BASH_REMATCH[3]:-80}" + proto="Http" + elif [[ "$t" =~ ^([^:]+):([0-9]+)$ ]]; then + host="${BASH_REMATCH[1]}" + port="${BASH_REMATCH[2]}" + proto="Tcp" + else + issues_json=$(echo "$issues_json" | jq \ + --arg title "Unparseable PROBE target: \`$t\`" \ + --arg details "Use https://host:port, http://host:port, or host:port." \ + --arg severity "2" \ + --arg next_steps "Fix PROBE_TARGETS format and re-run." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + continue + fi + + probe_out="" + if ! probe_out=$(az network watcher test-connectivity \ + --resource-group "$vm_rg" \ + --source-resource "$SOURCE_VM_RESOURCE_ID" \ + --dest-address "$host" \ + --dest-port "$port" \ + --protocol "$proto" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>&1); then + true + fi + + conn_status=$(echo "$probe_out" | jq -r '.connectionStatus // .properties.connectionStatus // "Unknown"' 2>/dev/null || echo "Unknown") + if [ "$conn_status" = "Unknown" ]; then + conn_status=$(echo "$probe_out" | jq -r '.. | .connectionStatus? // empty' | head -1) + [ -z "$conn_status" ] && conn_status="Failed" + fi + + snippet=$(echo "$probe_out" | head -c 4000 | jq -Rs .) + results_json=$(echo "$results_json" | jq \ + --arg target "$t" \ + --arg host "$host" \ + --arg port "$port" \ + --arg proto "$proto" \ + --arg status "$conn_status" \ + --argjson snippet "$snippet" \ + '. += [{ + "target": $target, + "destinationHost": $host, + "destinationPort": $port, + "protocol": $proto, + "connectionStatus": $status, + "rawSnippet": $snippet + }]') + + if [ "$conn_status" != "Reachable" ] && [ "$conn_status" != "Connected" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Egress probe failed for \`$t\` (status: $conn_status)" \ + --arg details "Network Watcher test-connectivity from source VM did not report success. Raw output (truncated): $(echo "$probe_out" | head -c 2000)" \ + --arg severity "3" \ + --arg next_steps "Verify NSG egress, UDR, firewall app rules, and that the destination allows the probe. Confirm Network Watcher is available in the region." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + fi +done + +echo "$results_json" > "$RESULTS_JSON" +echo "$issues_json" > "$OUTPUT_FILE" + +echo "Probe results:" +echo "$results_json" | jq . +echo "Issues written to $OUTPUT_FILE" diff --git a/codebundles/azure-subnet-egress-validation/subnet-egress-summary.sh b/codebundles/azure-subnet-egress-validation/subnet-egress-summary.sh new file mode 100755 index 00000000..54e72d9b --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/subnet-egress-summary.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Merges prior JSON outputs into a per-subnet validation matrix and optional consolidated issues list. +# Writes subnet_summary_issues.json and subnet_summary_matrix.json +set -euo pipefail +set -x + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${VNET_NAME:?Must set VNET_NAME}" + +OUTPUT_FILE="subnet_summary_issues.json" +MATRIX_JSON="subnet_summary_matrix.json" + +merge_issue_files() { + local files=() + for f in subnet_discover_issues.json subnet_effective_nsg_issues.json subnet_route_issues.json subnet_probe_issues.json; do + [ -f "$f" ] && files+=("$f") + done + if [ "${#files[@]}" -eq 0 ]; then + echo '[]' + return + fi + jq -s 'add' "${files[@]}" 2>/dev/null || echo '[]' +} + +merged_issues=$(merge_issue_files) +echo "$merged_issues" > "$OUTPUT_FILE" + +disc_json='[]' +rt_json='[]' +probe_json='[]' +if [ -f subnet_discovery.json ]; then + disc_json=$(cat subnet_discovery.json) +fi +if [ -f subnet_route_summary.json ]; then + rt_json=$(cat subnet_route_summary.json) +fi +if [ -f subnet_probe_results.json ]; then + probe_json=$(cat subnet_probe_results.json) +fi + +matrix=$(jq -n \ + --arg vnet "$VNET_NAME" \ + --arg rg "$AZURE_RESOURCE_GROUP" \ + --arg sub "$AZURE_SUBSCRIPTION_ID" \ + --argjson discovery "$disc_json" \ + --argjson routes "$rt_json" \ + --argjson probes "$probe_json" \ + --argjson issues "$merged_issues" \ + '{ + vnetName: $vnet, + resourceGroup: $rg, + subscriptionId: $sub, + discovery: $discovery, + routeSummary: $routes, + probeResults: $probes, + mergedIssueCount: ($issues | length) + }') + +echo "$matrix" > "$MATRIX_JSON" + +echo "Egress validation summary for VNet \`$VNET_NAME\`:" +echo "$matrix" | jq . + +issue_count=$(echo "$merged_issues" | jq 'length') +echo "Total merged issues from prior steps: $issue_count" +echo "Matrix written to $MATRIX_JSON; merged issues to $OUTPUT_FILE" diff --git a/codebundles/azure-subnet-egress-validation/subnet-route-firewall-check.sh b/codebundles/azure-subnet-egress-validation/subnet-route-firewall-check.sh new file mode 100755 index 00000000..a94bef74 --- /dev/null +++ b/codebundles/azure-subnet-egress-validation/subnet-route-firewall-check.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Validates UDRs for default route (0.0.0.0/0) and presence of Azure Firewall in the RG. +# Writes subnet_route_issues.json and subnet_route_summary.json +set -euo pipefail +set -x + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${VNET_NAME:?Must set VNET_NAME}" + +OUTPUT_FILE="subnet_route_issues.json" +SUMMARY_JSON="subnet_route_summary.json" +issues_json='[]' +summary='[]' + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + +firewall_ids=$(az network firewall list \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --query "[].id" -o tsv 2>/dev/null || true) +if [ -n "${firewall_ids//[$'\t\r\n']/}" ]; then + fw_count=$(echo "$firewall_ids" | wc -l) +else + fw_count=0 +fi + +subnet_names=$(az network vnet subnet list \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --query "[].name" -o tsv 2>/dev/null || true) + +while IFS= read -r sn; do + [ -z "$sn" ] && continue + sub_json=$(az network vnet subnet show \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --vnet-name "$VNET_NAME" \ + --name "$sn" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "{}") + + rt_id=$(echo "$sub_json" | jq -r '.routeTable.id // ""') + if [ -n "$rt_id" ] && [ "$rt_id" != "null" ]; then + has_rt_json=true + else + has_rt_json=false + fi + entry=$(jq -n \ + --arg subnet "$sn" \ + --arg rt "$rt_id" \ + --argjson hasRt "$has_rt_json" \ + '{subnetName: $subnet, routeTableId: $rt, hasRouteTable: $hasRt}') + + if [ -z "$rt_id" ] || [ "$rt_id" = "null" ]; then + if [ "${fw_count:-0}" -gt 0 ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Subnet \`$sn\` has no route table while Azure Firewall exists in \`$AZURE_RESOURCE_GROUP\`" \ + --arg details "Forced tunneling through Azure Firewall typically requires a UDR on the subnet with 0.0.0.0/0 -> VirtualAppliance (firewall private IP)." \ + --arg severity "3" \ + --arg next_steps "Attach a route table with default route pointing to the firewall private IP, or confirm hub/spoke design routes traffic elsewhere." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + fi + summary=$(echo "$summary" | jq --argjson e "$entry" '. += [$e]') + continue + fi + + rt_name=$(basename "$rt_id") + rt_rg=$(echo "$rt_id" | sed -n 's|.*/resourceGroups/\([^/]*\)/providers/.*|\1|p') + [ -z "$rt_rg" ] && rt_rg="$AZURE_RESOURCE_GROUP" + + routes_json=$(az network route-table route list \ + --resource-group "$rt_rg" \ + --route-table-name "$rt_name" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "[]") + + default_route=$(echo "$routes_json" | jq '[.[] | select((.addressPrefix // "") | test("^0\\.0\\.0\\.0/0$"))] | .[0] // empty') + next_hop=$(echo "$default_route" | jq -r '.nextHopType // empty') + next_hop_ip=$(echo "$default_route" | jq -r '.nextHopIpAddress // empty') + + entry=$(echo "$entry" | jq \ + --arg nh "$next_hop" \ + --arg ip "$next_hop_ip" \ + '. + {defaultRouteNextHopType: $nh, defaultRouteNextHopIp: $ip}') + summary=$(echo "$summary" | jq --argjson e "$entry" '. += [$e]') + + if [ -n "$default_route" ] && [ "$next_hop" = "Internet" ] && [ "${fw_count:-0}" -gt 0 ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Subnet \`$sn\` default route uses Internet next hop while Azure Firewall exists" \ + --arg details "Default route 0.0.0.0/0 points to Internet instead of VirtualAppliance/firewall. Egress may bypass Azure Firewall policies." \ + --arg severity "4" \ + --arg next_steps "Change the default route next hop to VirtualAppliance with the firewall private IP, or move the subnet behind a hub VNet with correct UDRs." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + fi + + if [ -z "$default_route" ] && [ "${fw_count:-0}" -gt 0 ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Subnet \`$sn\` route table has no 0.0.0.0/0 route while Azure Firewall exists" \ + --arg details "Without an explicit default route, traffic may follow system routes (often direct Internet from Azure) and not through the firewall." \ + --arg severity "3" \ + --arg next_steps "Add UDR 0.0.0.0/0 with next hop VirtualAppliance to the firewall IP if policy requires centralized egress." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') + fi +done <<< "$subnet_names" + +echo "$summary" > "$SUMMARY_JSON" +echo "$issues_json" > "$OUTPUT_FILE" + +echo "Route / firewall summary:" +echo "$summary" | jq . +echo "Issues written to $OUTPUT_FILE"