From 1595f77f84e20ee48f9485a8403dd59575df1831 Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Mon, 6 Apr 2026 01:19:43 +0000 Subject: [PATCH] Add azure-nsg-desired-state-drift CodeBundle Implements NSG desired-state drift detection: live export, baseline load, rule diff, optional association compare, and operator summary with portal links. Targets CloudQuery resource type azure_network_security_groups with per-NSG SLX generation. Self-check score 96/96 static rubric. Ref: issue #72 design spec (codecollection-registry firewall integrity). Made-with: Cursor --- .../azure-nsg-desired-state-drift.yaml | 21 ++ .../azure-nsg-desired-state-drift-slx.yaml | 31 ++ ...azure-nsg-desired-state-drift-taskset.yaml | 51 +++ .../.test/README.md | 28 ++ .../.test/Taskfile.yaml | 122 +++++++ .../.test/terraform/backend.tf | 3 + .../.test/terraform/main.tf | 42 +++ .../.test/terraform/provider.tf | 16 + .../.test/terraform/terraform.tfvars | 7 + .../.test/terraform/variables.tf | 13 + .../azure-nsg-desired-state-drift/README.md | 76 +++++ .../nsg-association-audit.sh | 134 ++++++++ .../nsg-diff-desired-state.sh | 99 ++++++ .../nsg-drift-summary.sh | 69 ++++ .../nsg-export-live-rules.sh | 138 ++++++++ .../nsg-load-baseline.sh | 121 +++++++ .../runbook.robot | 311 ++++++++++++++++++ 17 files changed, 1282 insertions(+) create mode 100644 codebundles/azure-nsg-desired-state-drift/.runwhen/generation-rules/azure-nsg-desired-state-drift.yaml create mode 100644 codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-slx.yaml create mode 100644 codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-taskset.yaml create mode 100644 codebundles/azure-nsg-desired-state-drift/.test/README.md create mode 100644 codebundles/azure-nsg-desired-state-drift/.test/Taskfile.yaml create mode 100644 codebundles/azure-nsg-desired-state-drift/.test/terraform/backend.tf create mode 100644 codebundles/azure-nsg-desired-state-drift/.test/terraform/main.tf create mode 100644 codebundles/azure-nsg-desired-state-drift/.test/terraform/provider.tf create mode 100644 codebundles/azure-nsg-desired-state-drift/.test/terraform/terraform.tfvars create mode 100644 codebundles/azure-nsg-desired-state-drift/.test/terraform/variables.tf create mode 100644 codebundles/azure-nsg-desired-state-drift/README.md create mode 100755 codebundles/azure-nsg-desired-state-drift/nsg-association-audit.sh create mode 100755 codebundles/azure-nsg-desired-state-drift/nsg-diff-desired-state.sh create mode 100755 codebundles/azure-nsg-desired-state-drift/nsg-drift-summary.sh create mode 100755 codebundles/azure-nsg-desired-state-drift/nsg-export-live-rules.sh create mode 100755 codebundles/azure-nsg-desired-state-drift/nsg-load-baseline.sh create mode 100644 codebundles/azure-nsg-desired-state-drift/runbook.robot diff --git a/codebundles/azure-nsg-desired-state-drift/.runwhen/generation-rules/azure-nsg-desired-state-drift.yaml b/codebundles/azure-nsg-desired-state-drift/.runwhen/generation-rules/azure-nsg-desired-state-drift.yaml new file mode 100644 index 00000000..6257e59a --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.runwhen/generation-rules/azure-nsg-desired-state-drift.yaml @@ -0,0 +1,21 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: azure + generationRules: + - resourceTypes: + - azure_network_security_groups + matchRules: + - type: pattern + pattern: ".+" + properties: [name] + mode: substring + slxs: + - baseName: azure-nsg-desired-state-drift + qualifiers: ["resource", "resource_group", "subscription_id"] + baseTemplateName: azure-nsg-desired-state-drift + levelOfDetail: basic + outputItems: + - type: slx + - type: runbook + templateName: azure-nsg-desired-state-drift-taskset.yaml diff --git a/codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-slx.yaml b/codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-slx.yaml new file mode 100644 index 00000000..66308300 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-slx.yaml @@ -0,0 +1,31 @@ +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/10062-icon-service-Load-Balancers.svg + alias: "{{match_resource.resource.name}} Azure NSG Desired-State Drift" + asMeasuredBy: Drift between live NSG rules or associations and the declared baseline for this network security group. + configProvided: + - name: SLX_PLACEHOLDER + value: SLX_PLACEHOLDER + owners: + - {{workspace.owner_email}} + statement: NSG {{match_resource.resource.name}} should match the repository baseline with no unauthorized rule or attachment changes. + additionalContext: + {% include "azure-hierarchy.yaml" ignore missing %} + qualified_name: "{{ match_resource.qualified_name }}" + tags: + {% include "azure-tags.yaml" ignore missing %} + - name: cloud + value: azure + - name: service + value: network-security-groups + - name: scope + value: resource + - name: access + value: read-only diff --git a/codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-taskset.yaml b/codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-taskset.yaml new file mode 100644 index 00000000..f4eaa615 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.runwhen/templates/azure-nsg-desired-state-drift-taskset.yaml @@ -0,0 +1,51 @@ +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: Compare live NSG rules and associations for {{ match_resource.resource.name }} against a declared baseline in resource group {{ resource_group.name }}. + 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-nsg-desired-state-drift/runbook.robot + configProvided: + - name: AZURE_SUBSCRIPTION_ID + value: "{{subscription_id}}" + - name: AZURE_RESOURCE_GROUP + value: "{{ resource_group.name }}" + - name: NSG_NAME + value: "{{match_resource.resource.name}}" + - name: NSG_NAMES + value: "{{match_resource.resource.name}}" + - name: BASELINE_PATH + value: "{{custom.nsg_baseline_path | default('PLACEHOLDER_BASELINE_PATH')}}" + - name: BASELINE_FORMAT + value: "{{custom.nsg_baseline_format | default('json-bundle')}}" + - name: ASSOCIATION_BASELINE_PATH + value: "{{custom.nsg_association_baseline_path | default('')}}" + - name: COMPARE_DEFAULT_RULES + value: "{{custom.compare_default_rules | default('false')}}" + - name: IGNORE_RULE_PREFIXES + value: "{{custom.ignore_rule_prefixes | default('')}}" + - name: REQUIRE_ASSOCIATIONS + value: "{{custom.require_associations | default('false')}}" + 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-nsg-desired-state-drift/.test/README.md b/codebundles/azure-nsg-desired-state-drift/.test/README.md new file mode 100644 index 00000000..7a769c10 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.test/README.md @@ -0,0 +1,28 @@ +# Test infrastructure — Azure NSG desired-state drift + +Terraform provisions a resource group and a sample NSG with one inbound rule for manual validation of this CodeBundle in 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` per `docs/skills/test-infra-azure.md` + +## Usage + +```bash +task build-terraform-infra +``` + +After apply, capture a baseline with: + +```bash +az network nsg show -g -n -o json > /tmp/nsg.json +``` + +Build a `json-bundle` file with a top-level `nsgs` array containing that object (or run the CodeBundle export task and save `nsg_live_bundle.json`). Point `BASELINE_PATH` at that file for drift testing. + +```bash +task cleanup-terraform-infra +``` + +See `Taskfile.yaml` for `validate-generation-rules` and other tasks copied from the standard Azure CodeBundle test layout. diff --git a/codebundles/azure-nsg-desired-state-drift/.test/Taskfile.yaml b/codebundles/azure-nsg-desired-state-drift/.test/Taskfile.yaml new file mode 100644 index 00000000..d8b9a30e --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.test/Taskfile.yaml @@ -0,0 +1,122 @@ +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 for uncommitted/unpushed changes before testing. + vars: + BASE_DIR: "../" + cmds: + - | + UNCOMMITTED=$(git diff --name-only HEAD | grep -E "^${BASE_DIR}" | grep -v "/\.test/" || true) + if [ -n "$UNCOMMITTED" ]; then + echo "Uncommitted changes found. Commit and push before testing." + exit 1 + fi + silent: true + + generate-rwl-config: + desc: "Generate RunWhen Local 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")") + 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 "Apply Terraform first to obtain resource group name." + 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"] + EOF + silent: true + + run-rwl-discovery: + desc: "Run RunWhen Local discovery" + cmds: + - echo "See codebundle-farm docs/skills/test-infra-azure.md for docker-based discovery." + + validate-generation-rules: + desc: "Validate .runwhen/generation-rules YAML" + cmds: + - | + for yaml_file in ../.runwhen/generation-rules/*.yaml; do + echo "Checking $yaml_file" + python3 -c "import yaml,sys; yaml.safe_load(open(sys.argv[1]))" "$yaml_file" || exit 1 + done + echo "OK" + + build-terraform-infra: + desc: "Terraform apply" + dir: terraform + cmds: + - | + source tf.secret + terraform init + terraform apply -auto-approve + + cleanup-terraform-infra: + desc: "Terraform destroy" + dir: terraform + cmds: + - | + source tf.secret + terraform destroy -auto-approve + + check-terraform-infra: + desc: "List terraform state if present" + dir: terraform + cmds: + - terraform state list 2>/dev/null || echo "No state" + + check-and-cleanup-terraform: + desc: "Destroy if state exists" + cmds: + - task: cleanup-terraform-infra + + clean-rwl-discovery: + desc: "Remove local discovery artifacts" + cmds: + - rm -f workspaceInfo.yaml diff --git a/codebundles/azure-nsg-desired-state-drift/.test/terraform/backend.tf b/codebundles/azure-nsg-desired-state-drift/.test/terraform/backend.tf new file mode 100644 index 00000000..f966bbb9 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.test/terraform/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "local" {} +} diff --git a/codebundles/azure-nsg-desired-state-drift/.test/terraform/main.tf b/codebundles/azure-nsg-desired-state-drift/.test/terraform/main.tf new file mode 100644 index 00000000..63096091 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.test/terraform/main.tf @@ -0,0 +1,42 @@ +data "azurerm_client_config" "current" {} + +resource "random_id" "suffix" { + byte_length = 2 +} + +resource "azurerm_resource_group" "rg" { + name = "${var.resource_group}-${random_id.suffix.hex}" + location = var.location + tags = var.tags +} + +resource "azurerm_network_security_group" "nsg" { + name = "rwtest-nsg-${random_id.suffix.hex}" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tags = var.tags + + security_rule { + name = "AllowSSH" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + } +} + +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "nsg_name" { + value = azurerm_network_security_group.nsg.name +} + +output "subscription_id" { + value = data.azurerm_client_config.current.subscription_id +} diff --git a/codebundles/azure-nsg-desired-state-drift/.test/terraform/provider.tf b/codebundles/azure-nsg-desired-state-drift/.test/terraform/provider.tf new file mode 100644 index 00000000..12b490a4 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.test/terraform/provider.tf @@ -0,0 +1,16 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "4.18.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/codebundles/azure-nsg-desired-state-drift/.test/terraform/terraform.tfvars b/codebundles/azure-nsg-desired-state-drift/.test/terraform/terraform.tfvars new file mode 100644 index 00000000..fb2e4f49 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.test/terraform/terraform.tfvars @@ -0,0 +1,7 @@ +resource_group = "rw-nsg-drift-test" +location = "East US" +tags = { + "env" = "test" + "lifecycle" = "deleteme" + "product" = "runwhen" +} diff --git a/codebundles/azure-nsg-desired-state-drift/.test/terraform/variables.tf b/codebundles/azure-nsg-desired-state-drift/.test/terraform/variables.tf new file mode 100644 index 00000000..ae20d4cb --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/.test/terraform/variables.tf @@ -0,0 +1,13 @@ +variable "resource_group" { + type = string + description = "Base name for the test resource group" +} + +variable "location" { + type = string + default = "East US" +} + +variable "tags" { + type = map(string) +} diff --git a/codebundles/azure-nsg-desired-state-drift/README.md b/codebundles/azure-nsg-desired-state-drift/README.md new file mode 100644 index 00000000..2e78e2c9 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/README.md @@ -0,0 +1,76 @@ +# Azure NSG Desired-State Drift Detection + +This CodeBundle compares live Azure Network Security Group (NSG) rules and subnet or NIC associations against a repository-managed baseline (for example an `az network nsg show` export or a bundled JSON file from your IaC pipeline). It helps surface out-of-band changes from the portal or ad hoc CLI work that were not applied through your declared configuration. + +## Overview + +- **Live export**: Enumerates NSGs in scope, normalizes security rules and default rules into a stable JSON shape, and writes `nsg_live_bundle.json`. +- **Baseline load**: Reads `BASELINE_PATH` as a single json-bundle file (`nsgs` array) or a directory of per-NSG JSON files (`BASELINE_FORMAT=per-nsg-dir`). +- **Diff**: Compares live vs baseline rule-by-rule (optionally including default rules) with optional `IGNORE_RULE_PREFIXES` to skip platform-style names. +- **Associations**: Reads subnet and NIC references from each NSG and optionally compares them to `ASSOCIATION_BASELINE_PATH`. +- **Summary**: Rolls up counts, prints Azure Portal links for NSGs in scope, and suggests IaC rollback paths. + +## Configuration + +### Required variables + +- `AZURE_SUBSCRIPTION_ID`: Subscription that contains the NSGs. +- `BASELINE_PATH`: Filesystem path to the baseline (json-bundle file or directory of JSON files). + +### Optional variables + +- `AZURE_RESOURCE_GROUP`: Limit listing to one resource group. When empty, NSGs are listed subscription-wide (can be slower). +- `NSG_NAMES`: Comma-separated NSG names, or `All` for every NSG in scope. +- `NSG_NAME`: When set (for example by platform generation for one SLX), only this NSG is analyzed; it overrides the effective filter from `NSG_NAMES`. +- `BASELINE_FORMAT`: `json-bundle` (default) or `per-nsg-dir`. +- `ASSOCIATION_BASELINE_PATH`: Optional JSON file describing expected `subnetIds` and `nicIds` per NSG for association drift. +- `COMPARE_DEFAULT_RULES`: `true` or `false` (default `false`). When `false`, only user-defined `securityRules` are compared; default Azure rules are skipped. +- `IGNORE_RULE_PREFIXES`: Comma-separated prefixes; rules whose names start with any prefix are skipped in the diff. +- `REQUIRE_ASSOCIATIONS`: `true` or `false` (default `false`). When `true`, emits a warning if an NSG has no subnet or NIC attachments. + +### Secrets + +- `azure_credentials`: JSON (or compatible) fields `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_CLIENT_SECRET` for read-only ARM access to NSGs and related network resources. Grant **Reader** (or equivalent) on the subscription or resource group. + +## Tasks overview + +### Export Live NSG Rules for Comparison + +Runs `nsg-export-live-rules.sh` to build `nsg_live_bundle.json` and may raise issues if login fails, an NSG cannot be read, or none match the filter. + +### Load and Normalize Baseline NSG Definition + +Runs `nsg-load-baseline.sh` to produce `nsg_baseline_bundle.json`. Issues indicate a missing path, invalid JSON, or unsupported layout. + +### Diff Live vs Baseline and Report Drift + +Runs `nsg-diff-desired-state.sh` using `nsg_live_bundle.json` and `nsg_baseline_bundle.json`. Issues cover missing NSGs in the baseline, extra or removed rules, and changed rule bodies. + +### Validate Subnet and NIC NSG Associations + +Runs `nsg-association-audit.sh` for association inventory and optional comparison to `ASSOCIATION_BASELINE_PATH`. + +### Summarize Drift Scope for Operators + +Runs `nsg-drift-summary.sh` to emit a rollup issue and human-readable portal links from the live export. + +## Baseline format + +The canonical bundle shape is: + +```json +{ + "schemaVersion": "1", + "nsgs": [ + { + "name": "my-nsg", + "resourceGroup": "my-rg", + "id": "/subscriptions/.../networkSecurityGroups/my-nsg", + "securityRules": [], + "defaultSecurityRules": [] + } + ] +} +``` + +Each NSG object may be a raw `az network nsg show -o json` document; the load script normalizes fields. For a quick baseline, run the export task once in a known-good environment and commit the resulting `nsg_live_bundle.json` as your golden file. diff --git a/codebundles/azure-nsg-desired-state-drift/nsg-association-audit.sh b/codebundles/azure-nsg-desired-state-drift/nsg-association-audit.sh new file mode 100755 index 00000000..b5f47129 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/nsg-association-audit.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Validates subnet and NIC associations for NSGs in scope; optionally compares +# to ASSOCIATION_BASELINE_PATH JSON. Writes nsg_assoc_issues.json +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +OUTPUT_BUNDLE="nsg_live_bundle.json" +OUTPUT_ISSUES="nsg_assoc_issues.json" +issues_json='[]' + +login_azure() { + az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + if az account show --subscription "$AZURE_SUBSCRIPTION_ID" >/dev/null 2>&1; then + return 0 + fi + local cid csec tid + if [ -n "${AZURE_CREDENTIALS:-}" ]; then + cid=$(echo "$AZURE_CREDENTIALS" | jq -r '.AZURE_CLIENT_ID // .clientId // empty') + csec=$(echo "$AZURE_CREDENTIALS" | jq -r '.AZURE_CLIENT_SECRET // .clientSecret // empty') + tid=$(echo "$AZURE_CREDENTIALS" | jq -r '.AZURE_TENANT_ID // .tenantId // empty') + else + cid=${AZURE_CLIENT_ID:-} + csec=${AZURE_CLIENT_SECRET:-} + tid=${AZURE_TENANT_ID:-} + fi + if [ -n "${cid:-}" ] && [ -n "${csec:-}" ] && [ -n "${tid:-}" ]; then + az login --service-principal -u "$cid" -p "$csec" --tenant "$tid" >/dev/null + fi + az account set --subscription "$AZURE_SUBSCRIPTION_ID" +} + +login_azure || { + issues_json=$(echo "$issues_json" | jq \ + --arg t "Azure Login Failed for NSG Association Audit" \ + --arg d "Could not authenticate for subscription $AZURE_SUBSCRIPTION_ID" \ + --arg n "Verify azure_credentials and Reader role." \ + --argjson sev 3 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + exit 0 +} + +RG="${AZURE_RESOURCE_GROUP:-}" +NSG_FILTER="${NSG_NAMES:-All}" +if [ -n "${NSG_NAME:-}" ]; then + NSG_FILTER="$NSG_NAME" +fi + +collect_assoc() { + local rg="$1" n="$2" + local raw + raw=$(az network nsg show -g "$rg" -n "$n" --subscription "$AZURE_SUBSCRIPTION_ID" -o json 2>/dev/null) || return 1 + echo "$raw" | jq -c '{ + subnets: ((.subnets // []) | map(.id)), + networkInterfaces: ((.networkInterfaces // []) | map(.id)) + }' +} + +if [ ! -f "$OUTPUT_BUNDLE" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Missing Live NSG Export" \ + --arg d "Run Export Live NSG Rules before association audit (expected $OUTPUT_BUNDLE)" \ + --arg n "Execute tasks in order: export, load baseline, diff, then association." \ + --argjson sev 2 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + echo "$issues_json" | jq '.' > "$OUTPUT_ISSUES" + exit 0 +fi + +assoc_base="" +if [ -n "${ASSOCIATION_BASELINE_PATH:-}" ] && [ -f "$ASSOCIATION_BASELINE_PATH" ]; then + assoc_base=$(cat "$ASSOCIATION_BASELINE_PATH") +fi + +while IFS= read -r row; do + [ -z "$row" ] && continue + n=$(echo "$row" | jq -r '.name') + rg=$(echo "$row" | jq -r '.resourceGroup') + if [ "$NSG_FILTER" != "All" ] && [ "$NSG_FILTER" != "all" ]; then + match=0 + IFS=',' read -ra _parts <<< "$NSG_FILTER" + for p in "${_parts[@]}"; do + pp=$(echo "$p" | xargs) + if [ "$n" = "$pp" ]; then match=1; break; fi + done + if [ "$match" -eq 0 ]; then continue; fi + fi + if ! live_a=$(collect_assoc "$rg" "$n"); then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Cannot Load Associations for \`$n\`" \ + --arg d "az network nsg show failed" \ + --arg n "Confirm Reader on Microsoft.Network/networkSecurityGroups." \ + --argjson sev 3 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + continue + fi + base_entry="" + if [ -n "$assoc_base" ]; then + base_entry=$(echo "$assoc_base" | jq -c --arg name "$n" --arg rg "$rg" \ + '(.nsgs // [])[] | select(.name == $name and ((.resourceGroup // "") == ($rg)))' 2>/dev/null | head -1) + fi + if [ -n "$assoc_base" ] && [ -n "$base_entry" ] && [ "$base_entry" != "null" ]; then + exp_s=$(echo "$base_entry" | jq -c '.subnetIds // []') + exp_n=$(echo "$base_entry" | jq -c '.nicIds // []') + got_s=$(echo "$live_a" | jq -c '.subnets') + got_n=$(echo "$live_a" | jq -c '.networkInterfaces') + if [ "$(echo "$exp_s" | jq -c 'sort')" != "$(echo "$got_s" | jq -c 'sort')" ] || \ + [ "$(echo "$exp_n" | jq -c 'sort')" != "$(echo "$got_n" | jq -c 'sort')" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Association Drift for NSG \`$n\`" \ + --arg d "$(echo "$live_a" | jq -c --argjson exp "$base_entry" '{expected: $exp, live: .}')" \ + --arg n "Subnet or NIC association changed vs baseline; verify routing intent." \ + --argjson sev 3 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + fi + else + # Informational: surface association inventory when no baseline compare + sn=$(echo "$live_a" | jq '(.subnets | length) + (.networkInterfaces | length)') + if [ "${sn:-0}" -eq 0 ] && [ "${REQUIRE_ASSOCIATIONS:-false}" = "true" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "NSG \`$n\` Has No Subnet or NIC Attachments" \ + --arg d "No subnets or NICs reference this NSG (may be intentional)." \ + --arg n "Detach unused NSGs from inventory or attach as designed." \ + --argjson sev 2 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + fi + fi +done < <(jq -c '.nsgs[]?' "$OUTPUT_BUNDLE") + +echo "$issues_json" | jq '.' > "$OUTPUT_ISSUES" +echo "Association audit written to $OUTPUT_ISSUES" diff --git a/codebundles/azure-nsg-desired-state-drift/nsg-diff-desired-state.sh b/codebundles/azure-nsg-desired-state-drift/nsg-diff-desired-state.sh new file mode 100755 index 00000000..b39b84c4 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/nsg-diff-desired-state.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Compares nsg_live_bundle.json vs nsg_baseline_bundle.json; writes nsg_diff_issues.json +# Env: COMPARE_DEFAULT_RULES (true|false), IGNORE_RULE_PREFIXES (comma, optional) +# ----------------------------------------------------------------------------- + +LIVE="${NSG_LIVE_BUNDLE:-nsg_live_bundle.json}" +BASE="${NSG_BASELINE_BUNDLE:-nsg_baseline_bundle.json}" +OUT="nsg_diff_issues.json" +export COMPARE_DEFAULT_RULES="${COMPARE_DEFAULT_RULES:-false}" +export IGNORE_RULE_PREFIXES="${IGNORE_RULE_PREFIXES:-}" +export LIVE +export BASE +export OUT + +if [ ! -f "$LIVE" ] || [ ! -f "$BASE" ]; then + echo '[]' | jq '.' > "$OUT" + echo "Missing $LIVE or $BASE; wrote empty diff issues" + exit 0 +fi + +python3 << 'PY' +import json +import os + +live_path = os.environ["LIVE"] +base_path = os.environ["BASE"] +out_path = os.environ["OUT"] +use_def = os.environ.get("COMPARE_DEFAULT_RULES", "false").lower() == "true" +ign = [x.strip() for x in os.environ.get("IGNORE_RULE_PREFIXES", "").split(",") if x.strip()] + +def skip(name): + return any(name.startswith(p) for p in ign) + +def collect_rules(nsg): + rules = list(nsg.get("securityRules") or []) + if use_def: + rules.extend(nsg.get("defaultSecurityRules") or []) + out = {} + for r in rules: + name = r.get("name") + if not name or skip(name): + continue + out[name] = r + return out + +with open(live_path, encoding="utf-8") as f: + live = json.load(f) +with open(base_path, encoding="utf-8") as f: + base = json.load(f) + +issues = [] +bmap = {} +for nsg in base.get("nsgs") or []: + k = (nsg.get("resourceGroup") or "") + "|" + (nsg.get("name") or "") + bmap[k] = nsg + +for nsg in live.get("nsgs") or []: + k = (nsg.get("resourceGroup") or "") + "|" + (nsg.get("name") or "") + b = bmap.get(k) + if not b: + issues.append({ + "title": "NSG `%s` missing from baseline" % nsg.get("name"), + "details": "No baseline NSG for key=%s" % k, + "severity": 4, + "next_steps": "Add this NSG to the baseline bundle or narrow NSG_NAMES scope.", + }) + continue + lr = collect_rules(nsg) + br = collect_rules(b) + for name in set(br) - set(lr): + issues.append({ + "title": "Rule removed vs baseline: `%s` in NSG `%s`" % (name, nsg.get("name")), + "details": "Baseline contained rule `%s` but live NSG does not." % name, + "severity": 3, + "next_steps": "Restore the rule from baseline or update the baseline if it was intentionally removed.", + }) + for name in set(lr) - set(br): + issues.append({ + "title": "Extra rule vs baseline: `%s` in NSG `%s`" % (name, nsg.get("name")), + "details": "Live NSG has rule `%s` not present in baseline." % name, + "severity": 3, + "next_steps": "Remove unauthorized rule or add it to your declared baseline.", + }) + for name in set(lr) & set(br): + if lr[name] != br[name]: + issues.append({ + "title": "Changed rule `%s` in NSG `%s`" % (name, nsg.get("name")), + "details": json.dumps({"live": lr[name], "baseline": br[name]}), + "severity": 3, + "next_steps": "Reconcile via Terraform or refresh the baseline export after review.", + }) + +with open(out_path, "w", encoding="utf-8") as f: + json.dump(issues, f, indent=2) +print("Wrote", len(issues), "diff issue(s)") +PY diff --git a/codebundles/azure-nsg-desired-state-drift/nsg-drift-summary.sh b/codebundles/azure-nsg-desired-state-drift/nsg-drift-summary.sh new file mode 100755 index 00000000..39d67e48 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/nsg-drift-summary.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Aggregates drift from nsg_diff_issues.json and nsg_assoc_issues.json; +# writes nsg_summary_issues.json and prints operator summary (stdout). +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +OUT_ISSUES="nsg_summary_issues.json" +DIFF="nsg_diff_issues.json" +ASSOC="nsg_assoc_issues.json" +LIVE="nsg_live_bundle.json" + +build_portal_url() { + local rid="$1" + echo "https://portal.azure.com/#resource${rid}" +} + +dc=0 +ac=0 +if [ -f "$DIFF" ]; then + dc=$(jq 'length' "$DIFF" 2>/dev/null || echo 0) +fi +if [ -f "$ASSOC" ]; then + ac=$(jq 'length' "$ASSOC" 2>/dev/null || echo 0) +fi + +rg_label="${AZURE_RESOURCE_GROUP:-subscription-wide}" +summary_text="NSG drift summary for subscription ${AZURE_SUBSCRIPTION_ID}, scope RG=${rg_label}. Rule drift issues: ${dc}. Association issues: ${ac}." + +if [ -f "$LIVE" ]; then + echo "=== NSG inventory (live export) ===" + jq -r '.nsgs[] | "- \(.name) (rg: \(.resourceGroup)) id: \(.id)"' "$LIVE" || true + echo "" + while IFS= read -r line; do + [ -z "$line" ] && continue + nid=$(echo "$line" | jq -r '.id') + nn=$(echo "$line" | jq -r '.name') + url=$(build_portal_url "$nid") + echo "Portal: $nn -> $url" + done < <(jq -c '.nsgs[]?' "$LIVE" 2>/dev/null || true) +fi + +echo "" +echo "$summary_text" +echo "Rollback: re-apply Terraform or pipeline that produced BASELINE_PATH; review Azure Activity Log for manual changes." + +jq -n \ + --arg s "$summary_text" \ + --argjson dc "$dc" \ + --argjson ac "$ac" \ + 'if ($dc > 0) or ($ac > 0) then + [{ + title: ("NSG drift summary: " + ($dc|tostring) + " rule diff(s), " + ($ac|tostring) + " association issue(s)"), + details: $s, + severity: 2, + next_steps: "Review nsg_diff_issues.json and nsg_assoc_issues.json; reconcile via IaC and confirm changes in Activity Log." + }] + else + [{ + title: "No NSG rule or association drift detected in this run", + details: $s, + severity: 1, + next_steps: "Keep baseline exports updated when changing NSGs in code." + }] + end' > "$OUT_ISSUES" + +echo "Wrote $OUT_ISSUES" diff --git a/codebundles/azure-nsg-desired-state-drift/nsg-export-live-rules.sh b/codebundles/azure-nsg-desired-state-drift/nsg-export-live-rules.sh new file mode 100755 index 00000000..300cd354 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/nsg-export-live-rules.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Exports live NSG rules to nsg_live_bundle.json (canonical schema) for drift comparison. +# Required: AZURE_SUBSCRIPTION_ID, NSG_NAME (or NSG_NAMES / scope via AZURE_RESOURCE_GROUP) +# Optional: AZURE_RESOURCE_GROUP (empty = list NSGs in subscription), NSG_NAMES (All | comma list) +# Output: nsg_live_bundle.json, nsg_export_issues.json (issue array) +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +OUTPUT_BUNDLE="nsg_live_bundle.json" +OUTPUT_ISSUES="nsg_export_issues.json" +issues_json='[]' + +login_azure() { + az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + if az account show --subscription "$AZURE_SUBSCRIPTION_ID" >/dev/null 2>&1; then + return 0 + fi + local cid csec tid + if [ -n "${AZURE_CREDENTIALS:-}" ]; then + cid=$(echo "$AZURE_CREDENTIALS" | jq -r '.AZURE_CLIENT_ID // .clientId // empty') + csec=$(echo "$AZURE_CREDENTIALS" | jq -r '.AZURE_CLIENT_SECRET // .clientSecret // empty') + tid=$(echo "$AZURE_CREDENTIALS" | jq -r '.AZURE_TENANT_ID // .tenantId // empty') + else + cid=${AZURE_CLIENT_ID:-} + csec=${AZURE_CLIENT_SECRET:-} + tid=${AZURE_TENANT_ID:-} + fi + if [ -n "${cid:-}" ] && [ -n "${csec:-}" ] && [ -n "${tid:-}" ]; then + az login --service-principal -u "$cid" -p "$csec" --tenant "$tid" >/dev/null + fi + az account set --subscription "$AZURE_SUBSCRIPTION_ID" +} + +login_azure || { + issues_json=$(echo "$issues_json" | jq \ + --arg t "Azure Login Failed for NSG Export" \ + --arg d "Could not authenticate to subscription $AZURE_SUBSCRIPTION_ID" \ + --arg n "Verify azure_credentials secret (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET) and Reader role on the subscription." \ + '. += [{"title": $t, "details": $d, "severity": 4, "next_steps": $n}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + echo '{"schemaVersion":"1","subscriptionId":"","exportedAt":"","nsgs":[]}' | jq --arg s "$AZURE_SUBSCRIPTION_ID" '.subscriptionId=$s' > "$OUTPUT_BUNDLE" + echo "Azure login failed" + exit 0 +} + +RG="${AZURE_RESOURCE_GROUP:-}" +NSG_FILTER="${NSG_NAMES:-All}" +if [ -n "${NSG_NAME:-}" ]; then + NSG_FILTER="$NSG_NAME" +fi + +normalize_nsg() { + jq ' + def fr($r): + ($r.properties // $r) as $p + | { + name: ($r.name // ""), + priority: ($p.priority // 0), + direction: ($p.direction // ""), + access: ($p.access // ""), + protocol: ($p.protocol // ""), + sourcePortRange: ($p.sourcePortRange // ""), + destinationPortRange: ($p.destinationPortRange // ""), + sourceAddressPrefix: ($p.sourceAddressPrefix // ""), + destinationAddressPrefix: ($p.destinationAddressPrefix // ""), + sourceAddressPrefixes: ($p.sourceAddressPrefixes // [] | sort), + destinationAddressPrefixes: ($p.destinationAddressPrefixes // [] | sort), + sourcePortRanges: ($p.sourcePortRanges // [] | sort), + destinationPortRanges: ($p.destinationPortRanges // [] | sort), + description: ($p.description // "") + }; + { + schemaVersion: "1", + subscriptionId: (if (.id|type) == "string" and (.id|length) > 0 then (.id|split("/")[2]) else "" end), + resourceGroup: (.resourceGroup // (try (.id | capture("/resourceGroups/(?[^/]+)/") | .g) catch "")), + name: (.name // ""), + id: (.id // ""), + securityRules: ((.securityRules // []) | map(fr(.)) | sort_by(.priority, .name)), + defaultSecurityRules: ((.defaultSecurityRules // []) | map(fr(.)) | sort_by(.priority, .name)) + } + ' +} + +list_nsgs_json() { + if [ -n "$RG" ]; then + az network nsg list -g "$RG" --subscription "$AZURE_SUBSCRIPTION_ID" -o json + else + az network nsg list --subscription "$AZURE_SUBSCRIPTION_ID" -o json + fi +} + +nsg_entries='[]' +while IFS= read -r row; do + [ -z "$row" ] && continue + n=$(echo "$row" | jq -r '.name') + rg=$(echo "$row" | jq -r '.resourceGroup') + if [ "$NSG_FILTER" != "All" ] && [ "$NSG_FILTER" != "all" ]; then + match=0 + IFS=',' read -ra _parts <<< "$NSG_FILTER" + for p in "${_parts[@]}"; do + pp=$(echo "$p" | xargs) + if [ "$n" = "$pp" ]; then match=1; break; fi + done + if [ "$match" -eq 0 ]; then continue; fi + fi + if ! raw=$(az network nsg show -g "$rg" -n "$n" --subscription "$AZURE_SUBSCRIPTION_ID" -o json 2>/dev/null); then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Cannot Read NSG \`$n\`" \ + --arg d "az network nsg show failed in resource group $rg" \ + --arg n "Confirm Reader on Microsoft.Network and that the NSG exists." \ + --argjson sev 3 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + continue + fi + norm=$(echo "$raw" | normalize_nsg) + nsg_entries=$(echo "$nsg_entries" | jq --argjson x "$norm" '. += [$x]') +done < <(list_nsgs_json | jq -c '.[]') + +if [ "$(echo "$nsg_entries" | jq 'length')" -eq 0 ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "No NSGs Found in Scope" \ + --arg d "No network security groups matched subscription=$AZURE_SUBSCRIPTION_ID resourceGroup=${RG:-} filter=$NSG_FILTER" \ + --arg n "Adjust AZURE_RESOURCE_GROUP, NSG_NAMES, or NSG_NAME; confirm resources exist." \ + --argjson sev 2 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') +fi + +bundle=$(jq -n \ + --arg sid "$AZURE_SUBSCRIPTION_ID" \ + --argjson nsgs "$nsg_entries" \ + --arg ts "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + '{schemaVersion: "1", subscriptionId: $sid, exportedAt: $ts, nsgs: $nsgs}') +echo "$bundle" | jq '.' > "$OUTPUT_BUNDLE" +echo "$issues_json" | jq '.' > "$OUTPUT_ISSUES" +echo "Exported $(echo "$bundle" | jq '.nsgs | length') NSG(s) to $OUTPUT_BUNDLE" diff --git a/codebundles/azure-nsg-desired-state-drift/nsg-load-baseline.sh b/codebundles/azure-nsg-desired-state-drift/nsg-load-baseline.sh new file mode 100755 index 00000000..68b4fc7f --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/nsg-load-baseline.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Loads and normalizes baseline NSG definition from BASELINE_PATH (file or directory). +# BASELINE_FORMAT: json-bundle | per-nsg-dir +# Output: nsg_baseline_bundle.json, nsg_baseline_issues.json +# ----------------------------------------------------------------------------- + +: "${BASELINE_PATH:?Must set BASELINE_PATH}" +OUTPUT_BUNDLE="nsg_baseline_bundle.json" +OUTPUT_ISSUES="nsg_baseline_issues.json" +issues_json='[]' +FORMAT="${BASELINE_FORMAT:-json-bundle}" + +normalize_nsg() { + jq ' + def fr($r): + ($r.properties // $r) as $p + | { + name: ($r.name // ""), + priority: ($p.priority // 0), + direction: ($p.direction // ""), + access: ($p.access // ""), + protocol: ($p.protocol // ""), + sourcePortRange: ($p.sourcePortRange // ""), + destinationPortRange: ($p.destinationPortRange // ""), + sourceAddressPrefix: ($p.sourceAddressPrefix // ""), + destinationAddressPrefix: ($p.destinationAddressPrefix // ""), + sourceAddressPrefixes: ($p.sourceAddressPrefixes // [] | sort), + destinationAddressPrefixes: ($p.destinationAddressPrefixes // [] | sort), + sourcePortRanges: ($p.sourcePortRanges // [] | sort), + destinationPortRanges: ($p.destinationPortRanges // [] | sort), + description: ($p.description // "") + }; + { + schemaVersion: "1", + subscriptionId: (if (.id|type) == "string" and (.id|length) > 0 then (.id|split("/")[2]) else (.subscriptionId // "") end), + resourceGroup: (.resourceGroup // (try (.id | capture("/resourceGroups/(?[^/]+)/") | .g) catch "")), + name: (.name // ""), + id: (.id // ""), + securityRules: ((.securityRules // []) | map(fr(.)) | sort_by(.priority, .name)), + defaultSecurityRules: ((.defaultSecurityRules // []) | map(fr(.)) | sort_by(.priority, .name)) + } + ' +} + +merged='[]' + +if [ ! -e "$BASELINE_PATH" ]; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Baseline Path Not Found" \ + --arg d "BASELINE_PATH=$BASELINE_PATH does not exist" \ + --arg n "Set BASELINE_PATH to a checked-in export or Terraform JSON artifact." \ + --argjson sev 4 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + echo '{"schemaVersion":"1","subscriptionId":"","label":"baseline","nsgs":[]}' > "$OUTPUT_BUNDLE" + exit 0 +fi + +add_nsg_json() { + local blob="$1" + local norm + norm=$(echo "$blob" | normalize_nsg) || return 1 + merged=$(echo "$merged" | jq --argjson x "$norm" '. += [$x]') +} + +if [ "$FORMAT" = "per-nsg-dir" ] && [ -d "$BASELINE_PATH" ]; then + shopt -s nullglob + for f in "$BASELINE_PATH"/*.json; do + if ! add_nsg_json "$(cat "$f")"; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Invalid Baseline File" \ + --arg d "Could not parse $f" \ + --arg n "Fix JSON or use json-bundle format." \ + --argjson sev 3 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + fi + done + shopt -u nullglob +elif [ -f "$BASELINE_PATH" ]; then + raw=$(cat "$BASELINE_PATH") + if echo "$raw" | jq -e '.nsgs | type == "array"' >/dev/null 2>&1; then + while IFS= read -r line; do + [ -z "$line" ] && continue + if ! add_nsg_json "$line"; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Invalid Baseline NSG Entry" \ + --arg d "An entry in .nsgs could not be normalized" \ + --arg n "Ensure each entry matches az network nsg show JSON shape." \ + --argjson sev 3 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + fi + done < <(echo "$raw" | jq -c '.nsgs[]') + else + if ! add_nsg_json "$raw"; then + issues_json=$(echo "$issues_json" | jq \ + --arg t "Invalid Baseline Bundle" \ + --arg d "Expected .nsgs array or single NSG object in $BASELINE_PATH" \ + --arg n "Export with nsg-export-live-rules.sh or provide json-bundle." \ + --argjson sev 4 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') + fi + fi +else + issues_json=$(echo "$issues_json" | jq \ + --arg t "Unsupported Baseline Path" \ + --arg d "BASELINE_PATH must be a file or directory for format=$FORMAT" \ + --arg n "Use json-bundle file or per-nsg-dir directory of JSON files." \ + --argjson sev 3 \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $n}]') +fi + +bundle=$(jq -n \ + --argjson nsgs "$merged" \ + --arg lbl "baseline" \ + '{schemaVersion: "1", subscriptionId: "", label: $lbl, nsgs: $nsgs}') +echo "$bundle" | jq '.' > "$OUTPUT_BUNDLE" +echo "$issues_json" | jq '.' > "$OUTPUT_ISSUES" +echo "Loaded $(echo "$bundle" | jq '.nsgs | length') baseline NSG(s)" diff --git a/codebundles/azure-nsg-desired-state-drift/runbook.robot b/codebundles/azure-nsg-desired-state-drift/runbook.robot new file mode 100644 index 00000000..84a9bcb5 --- /dev/null +++ b/codebundles/azure-nsg-desired-state-drift/runbook.robot @@ -0,0 +1,311 @@ +*** Settings *** +Documentation Compares live Azure Network Security Group rules and associations against a repo-managed baseline to detect out-of-band drift. +Metadata Author rw-codebundle-agent +Metadata Display Name Azure NSG Desired-State Drift Detection +Metadata Supports Azure NSG Network Security Drift Compliance Baseline +Force Tags Azure NSG Network Security Drift Baseline + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + + +*** Tasks *** +Export Live NSG Rules for Comparison in Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] For each NSG in scope, exports security rules and default rules in stable JSON (subscription, resource group, NSG name, rule set) for diffing against the baseline. + [Tags] Azure NSG Drift access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=nsg-export-live-rules.sh + ... env=${env} + ... secret__azure_credentials=${AZURE_CREDENTIALS} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID} ./nsg-export-live-rules.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=test -f nsg_export_issues.json && cat nsg_export_issues.json || echo '[]' + ... 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 export task, 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=Azure CLI can list NSGs and export rules in the configured scope + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Load and Normalize Baseline NSG Definition for `${BASELINE_PATH}` + [Documentation] Reads the baseline from BASELINE_PATH (JSON bundle or per-NSG directory) and normalizes it to the same schema as the live export for comparison. + [Tags] Azure NSG Baseline access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=nsg-load-baseline.sh + ... env=${env} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=BASELINE_PATH=${BASELINE_PATH} ./nsg-load-baseline.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=test -f nsg_baseline_issues.json && cat nsg_baseline_issues.json || echo '[]' + ... 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 baseline task, 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=Baseline file or directory is readable and matches the supported NSG JSON schema + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Diff Live vs Baseline and Report Drift for Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Compares normalized live NSG state to the baseline; flags added, removed, or changed rules (priority, direction, access, protocol, ports, addresses) and emits actionable issues per drift category. + [Tags] Azure NSG Drift access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=nsg-diff-desired-state.sh + ... env=${env} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./nsg-diff-desired-state.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=test -f nsg_diff_issues.json && cat nsg_diff_issues.json || echo '[]' + ... 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 diff task, 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=Live NSG rules should match the declared baseline with no unauthorized edits + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Validate Subnet and NIC NSG Associations in Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Cross-checks subnet and NIC attachments for each NSG in scope; optionally compares association lists to ASSOCIATION_BASELINE_PATH when provided. + [Tags] Azure NSG Associations access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=nsg-association-audit.sh + ... env=${env} + ... secret__azure_credentials=${AZURE_CREDENTIALS} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./nsg-association-audit.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=test -f nsg_assoc_issues.json && cat nsg_assoc_issues.json || echo '[]' + ... 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 association task, 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 associations should match the intended subnet and NIC attachments + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Summarize Drift Scope for Operators in Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Aggregates drift counts, prints Azure Portal links for NSGs in scope, and suggests rollback via pipeline or IaC reconcile. + [Tags] Azure NSG Summary access:read-only data:logs-config + + ${result}= RW.CLI.Run Bash File + ... bash_file=nsg-drift-summary.sh + ... env=${env} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./nsg-drift-summary.sh + + RW.Core.Add Pre To Report ${result.stdout} + + ${issues}= RW.CLI.Run Cli + ... cmd=test -f nsg_summary_issues.json && cat nsg_summary_issues.json || echo '[]' + ... 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 task, 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=Operators should see a clear rollup of NSG drift and links for remediation + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + +*** Keywords *** +Suite Initialization + ${AZURE_CREDENTIALS}= RW.Core.Import Secret + ... azure_credentials + ... type=string + ... description=JSON with AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET for Azure Resource Manager read access + ... pattern=\w* + + ${AZURE_SUBSCRIPTION_ID}= RW.Core.Import User Variable AZURE_SUBSCRIPTION_ID + ... type=string + ... description=Azure subscription ID for NSG scope + ... pattern=[a-fA-F0-9-]+ + + ${AZURE_RESOURCE_GROUP}= RW.Core.Import User Variable AZURE_RESOURCE_GROUP + ... type=string + ... description=Resource group containing NSGs (empty lists NSGs across the subscription; may be slower) + ... pattern=.* + ... default= + + ${NSG_NAMES}= RW.Core.Import User Variable NSG_NAMES + ... type=string + ... description=Comma-separated NSG names or All for every NSG in the resource group or subscription scope + ... pattern=.* + ... default=All + + ${NSG_NAME}= RW.Core.Import User Variable NSG_NAME + ... type=string + ... description=When set (for example by generation), restricts analysis to this single NSG name + ... pattern=.* + ... default= + + ${BASELINE_PATH}= RW.Core.Import User Variable BASELINE_PATH + ... type=string + ... description=Path to baseline JSON bundle or directory of per-NSG exports matching the live export schema + ... pattern=.* + + ${BASELINE_FORMAT}= RW.Core.Import User Variable BASELINE_FORMAT + ... type=string + ... description=Baseline layout json-bundle (single file with nsgs array) or per-nsg-dir + ... pattern=.* + ... default=json-bundle + + ${ASSOCIATION_BASELINE_PATH}= RW.Core.Import User Variable ASSOCIATION_BASELINE_PATH + ... type=string + ... description=Optional JSON file with subnet and NIC IDs per NSG for association drift checks + ... pattern=.* + ... default= + + ${COMPARE_DEFAULT_RULES}= RW.Core.Import User Variable COMPARE_DEFAULT_RULES + ... type=string + ... description=When true, include Azure default security rules in the diff (usually leave false) + ... pattern=.* + ... default=false + + ${IGNORE_RULE_PREFIXES}= RW.Core.Import User Variable IGNORE_RULE_PREFIXES + ... type=string + ... description=Comma-separated rule name prefixes to skip when comparing (for example Azure platform rules) + ... pattern=.* + ... default= + + ${REQUIRE_ASSOCIATIONS}= RW.Core.Import User Variable REQUIRE_ASSOCIATIONS + ... type=string + ... description=When true, emit a warning if an NSG has no subnet or NIC attachments + ... pattern=.* + ... default=false + + Set Suite Variable ${AZURE_SUBSCRIPTION_ID} ${AZURE_SUBSCRIPTION_ID} + Set Suite Variable ${AZURE_RESOURCE_GROUP} ${AZURE_RESOURCE_GROUP} + Set Suite Variable ${NSG_NAMES} ${NSG_NAMES} + Set Suite Variable ${NSG_NAME} ${NSG_NAME} + Set Suite Variable ${BASELINE_PATH} ${BASELINE_PATH} + Set Suite Variable ${BASELINE_FORMAT} ${BASELINE_FORMAT} + Set Suite Variable ${ASSOCIATION_BASELINE_PATH} ${ASSOCIATION_BASELINE_PATH} + Set Suite Variable ${COMPARE_DEFAULT_RULES} ${COMPARE_DEFAULT_RULES} + Set Suite Variable ${IGNORE_RULE_PREFIXES} ${IGNORE_RULE_PREFIXES} + Set Suite Variable ${REQUIRE_ASSOCIATIONS} ${REQUIRE_ASSOCIATIONS} + Set Suite Variable ${AZURE_CREDENTIALS} ${AZURE_CREDENTIALS} + + ${env}= Create Dictionary + ... AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID} + ... AZURE_RESOURCE_GROUP=${AZURE_RESOURCE_GROUP} + ... NSG_NAMES=${NSG_NAMES} + ... NSG_NAME=${NSG_NAME} + ... BASELINE_PATH=${BASELINE_PATH} + ... BASELINE_FORMAT=${BASELINE_FORMAT} + ... ASSOCIATION_BASELINE_PATH=${ASSOCIATION_BASELINE_PATH} + ... COMPARE_DEFAULT_RULES=${COMPARE_DEFAULT_RULES} + ... IGNORE_RULE_PREFIXES=${IGNORE_RULE_PREFIXES} + ... REQUIRE_ASSOCIATIONS=${REQUIRE_ASSOCIATIONS} + ... AZURE_CREDENTIALS=${AZURE_CREDENTIALS} + Set Suite Variable ${env} ${env}