From 3a82b420ecde5d18ac40e06e2745372ca651887d Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Mon, 6 Apr 2026 01:20:01 +0000 Subject: [PATCH] Add azure-network-security-activity-audit CodeBundle Implements Activity Log audit for NSG and Azure Firewall mutations with caller classification against CI/CD allowlists, optional maintenance window flagging, and summary reporting. Includes generation rules for NSG and Azure Firewall resource types, SLX/taskset templates, and optional Terraform test NSG. Made-with: Cursor --- ...azure-network-security-activity-audit.yaml | 22 ++ ...e-network-security-activity-audit-slx.yaml | 33 +++ ...twork-security-activity-audit-taskset.yaml | 47 ++++ .../.test/README.md | 14 + .../.test/Taskfile.yaml | 67 +++++ .../.test/terraform/.gitignore | 5 + .../.test/terraform/backend.tf | 3 + .../.test/terraform/main.tf | 34 +++ .../.test/terraform/provider.tf | 14 + .../.test/terraform/terraform.tfvars | 7 + .../.test/terraform/vars.tf | 20 ++ .../README.md | 55 ++++ .../activity-classify-callers.sh | 81 ++++++ .../activity-flag-manual-changes.sh | 101 +++++++ .../activity-log-firewall-writes.sh | 69 +++++ .../activity-log-nsg-writes.sh | 70 +++++ .../activity-summary-report.sh | 69 +++++ .../azure_activity_log_common.sh | 32 +++ .../runbook.robot | 253 ++++++++++++++++++ 19 files changed, 996 insertions(+) create mode 100644 codebundles/azure-network-security-activity-audit/.runwhen/generation-rules/azure-network-security-activity-audit.yaml create mode 100644 codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-slx.yaml create mode 100644 codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-taskset.yaml create mode 100644 codebundles/azure-network-security-activity-audit/.test/README.md create mode 100644 codebundles/azure-network-security-activity-audit/.test/Taskfile.yaml create mode 100644 codebundles/azure-network-security-activity-audit/.test/terraform/.gitignore create mode 100644 codebundles/azure-network-security-activity-audit/.test/terraform/backend.tf create mode 100644 codebundles/azure-network-security-activity-audit/.test/terraform/main.tf create mode 100644 codebundles/azure-network-security-activity-audit/.test/terraform/provider.tf create mode 100644 codebundles/azure-network-security-activity-audit/.test/terraform/terraform.tfvars create mode 100644 codebundles/azure-network-security-activity-audit/.test/terraform/vars.tf create mode 100644 codebundles/azure-network-security-activity-audit/README.md create mode 100755 codebundles/azure-network-security-activity-audit/activity-classify-callers.sh create mode 100755 codebundles/azure-network-security-activity-audit/activity-flag-manual-changes.sh create mode 100755 codebundles/azure-network-security-activity-audit/activity-log-firewall-writes.sh create mode 100755 codebundles/azure-network-security-activity-audit/activity-log-nsg-writes.sh create mode 100755 codebundles/azure-network-security-activity-audit/activity-summary-report.sh create mode 100755 codebundles/azure-network-security-activity-audit/azure_activity_log_common.sh create mode 100644 codebundles/azure-network-security-activity-audit/runbook.robot diff --git a/codebundles/azure-network-security-activity-audit/.runwhen/generation-rules/azure-network-security-activity-audit.yaml b/codebundles/azure-network-security-activity-audit/.runwhen/generation-rules/azure-network-security-activity-audit.yaml new file mode 100644 index 00000000..1e0d1aaa --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.runwhen/generation-rules/azure-network-security-activity-audit.yaml @@ -0,0 +1,22 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: azure + generationRules: + - resourceTypes: + - microsoft_network_network_security_groups + - microsoft_network_azure_firewalls + matchRules: + - type: pattern + pattern: ".+" + properties: ["name"] + mode: substring + slxs: + - baseName: azure-netsec-activity + levelOfDetail: basic + qualifiers: ["subscription_id", "resource_group"] + baseTemplateName: azure-network-security-activity-audit + outputItems: + - type: slx + - type: runbook + templateName: azure-network-security-activity-audit-taskset.yaml diff --git a/codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-slx.yaml b/codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-slx.yaml new file mode 100644 index 00000000..2e0c8730 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-slx.yaml @@ -0,0 +1,33 @@ +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/network/10062-icon-service-Virtual-Network.svg + alias: "{{match_resource.resource.name}} Azure NSG and Firewall Activity Audit" + asMeasuredBy: Activity Log mutations on network security and firewall resources versus allowlists. + configProvided: + - name: AZURE_SUBSCRIPTION_ID + value: "{{subscription_id}}" + - name: AZURE_RESOURCE_GROUP + value: "{{resource_group.name}}" + owners: + - {{workspace.owner_email}} + statement: Network security changes should be traceable to approved automation or known identities. + 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 + - name: scope + value: subscription + - name: access + value: read-only diff --git a/codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-taskset.yaml b/codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-taskset.yaml new file mode 100644 index 00000000..f640aec8 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.runwhen/templates/azure-network-security-activity-audit-taskset.yaml @@ -0,0 +1,47 @@ +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: Audits Activity Log for NSG and Azure Firewall mutations and classifies callers for governance. + 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-network-security-activity-audit/runbook.robot + configProvided: + - name: AZURE_SUBSCRIPTION_ID + value: "{{subscription_id}}" + - name: AZURE_RESOURCE_GROUP + value: "{{resource_group.name}}" + - name: ACTIVITY_LOOKBACK_HOURS + value: "168" + - name: CICD_APP_IDS + value: "" + - name: CICD_OBJECT_IDS + value: "" + - name: MAINTENANCE_START_HOUR_UTC + value: "" + - name: MAINTENANCE_END_HOUR_UTC + value: "" + - name: AZURE_TENANT_ID + value: "" + 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-network-security-activity-audit/.test/README.md b/codebundles/azure-network-security-activity-audit/.test/README.md new file mode 100644 index 00000000..bb45fb40 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/README.md @@ -0,0 +1,14 @@ +# Test infrastructure (Azure) + +Optional Terraform in `terraform/` can provision an NSG and resource group for manual validation of this CodeBundle against a real subscription. + +Create `terraform/tf.secret` (not committed) with: + +```bash +export ARM_SUBSCRIPTION_ID="..." +export AZ_TENANT_ID="..." +export AZ_CLIENT_ID="..." +export AZ_CLIENT_SECRET="..." +``` + +Then run `task build-terraform-infra` from this directory after configuring credentials. diff --git a/codebundles/azure-network-security-activity-audit/.test/Taskfile.yaml b/codebundles/azure-network-security-activity-audit/.test/Taskfile.yaml new file mode 100644 index 00000000..efbe9999 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/Taskfile.yaml @@ -0,0 +1,67 @@ +version: "3" + +tasks: + default: + desc: "Run config generation steps (requires tf.secret for full flow)" + cmds: + - task: validate-generation-rules + + build-infra: + desc: "Build Terraform test resources" + cmds: + - task: build-terraform-infra + + validate-generation-rules: + desc: "Validate YAML files in .runwhen/generation-rules" + cmds: + - | + for cmd in curl yq ajv; do + if ! command -v $cmd &> /dev/null; then + echo "Error: $cmd is required but not installed." + exit 1 + fi + 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 + echo "Validating $yaml_file" + 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 is valid." || echo "$yaml_file is invalid." + done + rm -rf "$temp_dir" + silent: true + + build-terraform-infra: + desc: "Run terraform apply" + dir: terraform + cmds: + - | + if [ ! -f tf.secret ]; then + echo "Create terraform/tf.secret with Azure credentials (see README.md)." + exit 1 + fi + source tf.secret + export TF_VAR_subscription_id=$ARM_SUBSCRIPTION_ID + export TF_VAR_tenant_id=$AZ_TENANT_ID + terraform init + terraform apply -auto-approve || exit 1 + silent: true + + check-and-cleanup-terraform: + desc: "Destroy Terraform resources if state exists" + dir: terraform + cmds: + - | + if [ ! -f terraform.tfstate ]; then + echo "No state." + exit 0 + fi + source tf.secret + export TF_VAR_subscription_id=$ARM_SUBSCRIPTION_ID + export TF_VAR_tenant_id=$AZ_TENANT_ID + terraform destroy -auto-approve + silent: true diff --git a/codebundles/azure-network-security-activity-audit/.test/terraform/.gitignore b/codebundles/azure-network-security-activity-audit/.test/terraform/.gitignore new file mode 100644 index 00000000..19e7822f --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/terraform/.gitignore @@ -0,0 +1,5 @@ +tf.secret +.terraform/ +terraform.tfstate +terraform.tfstate.backup +*.tfplan diff --git a/codebundles/azure-network-security-activity-audit/.test/terraform/backend.tf b/codebundles/azure-network-security-activity-audit/.test/terraform/backend.tf new file mode 100644 index 00000000..f966bbb9 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/terraform/backend.tf @@ -0,0 +1,3 @@ +terraform { + backend "local" {} +} diff --git a/codebundles/azure-network-security-activity-audit/.test/terraform/main.tf b/codebundles/azure-network-security-activity-audit/.test/terraform/main.tf new file mode 100644 index 00000000..187c206e --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/terraform/main.tf @@ -0,0 +1,34 @@ +resource "azurerm_resource_group" "rg" { + name = var.resource_group + location = var.location + tags = var.tags +} + +resource "azurerm_network_security_group" "test" { + name = "nsg-activity-audit-test" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tags = var.tags +} + +resource "azurerm_network_security_rule" "allow_ssh" { + name = "AllowSSH" + priority = 100 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "22" + source_address_prefix = "*" + destination_address_prefix = "*" + network_security_group_name = azurerm_network_security_group.test.name + resource_group_name = azurerm_resource_group.rg.name +} + +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "nsg_name" { + value = azurerm_network_security_group.test.name +} diff --git a/codebundles/azure-network-security-activity-audit/.test/terraform/provider.tf b/codebundles/azure-network-security-activity-audit/.test/terraform/provider.tf new file mode 100644 index 00000000..4beb29b1 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/terraform/provider.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.18" + } + } +} + +provider "azurerm" { + features {} + subscription_id = var.subscription_id + tenant_id = var.tenant_id +} diff --git a/codebundles/azure-network-security-activity-audit/.test/terraform/terraform.tfvars b/codebundles/azure-network-security-activity-audit/.test/terraform/terraform.tfvars new file mode 100644 index 00000000..13807445 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/terraform/terraform.tfvars @@ -0,0 +1,7 @@ +resource_group = "azure-netsec-activity-audit-test" +location = "East US" +tags = { + "env" = "test" + "lifecycle" = "deleteme" + "product" = "runwhen" +} diff --git a/codebundles/azure-network-security-activity-audit/.test/terraform/vars.tf b/codebundles/azure-network-security-activity-audit/.test/terraform/vars.tf new file mode 100644 index 00000000..29ba963a --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/.test/terraform/vars.tf @@ -0,0 +1,20 @@ +variable "resource_group" { + type = string +} + +variable "location" { + type = string + default = "East US" +} + +variable "subscription_id" { + type = string +} + +variable "tenant_id" { + type = string +} + +variable "tags" { + type = map(string) +} diff --git a/codebundles/azure-network-security-activity-audit/README.md b/codebundles/azure-network-security-activity-audit/README.md new file mode 100644 index 00000000..5a83d44d --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/README.md @@ -0,0 +1,55 @@ +# Azure NSG and Firewall Change Activity Audit + +This CodeBundle queries the Azure Activity Log for create, update, and delete operations on Network Security Groups (including rules), Azure Firewall, and firewall policy resources. It classifies callers against optional allowlists of CI/CD application IDs and managed identity object IDs, flags manual or out-of-band changes, and summarizes activity for governance and incident review. + +## Overview + +- **NSG mutations**: Write, delete, and action operations on `Microsoft.Network` NSG resources in the configured lookback window, including failed operations and high-volume warnings (Activity Log queries are capped per CLI request). +- **Firewall and policy mutations**: Similar coverage for Azure Firewall, firewall policies, rule collection groups, and related operations. +- **Caller classification**: Compares Activity Log claims (`appid`, object identifier, `caller`) to `CICD_APP_IDS` and `CICD_OBJECT_IDS` when set. +- **Governance flags**: Optional non-allowlisted identity issues and optional UTC maintenance window violations. +- **Summary**: Aggregates counts by operation and caller with a subscription Activity Log portal link. + +Activity Log retention is typically 90 days at subscription scope; `az monitor activity-log list` returns at most `--max-events` records (default in scripts: 500) per query for the Microsoft.Network namespace filter. + +## Configuration + +### Required Variables + +- `AZURE_SUBSCRIPTION_ID`: Subscription to audit. + +### Optional Variables + +- `AZURE_RESOURCE_GROUP`: Limit queries to this resource group; leave empty for subscription scope. +- `ACTIVITY_LOOKBACK_HOURS`: Hours of history to analyze (default: `168`). +- `CICD_APP_IDS`: Comma-separated Azure AD application (client) IDs approved for automation. +- `CICD_OBJECT_IDS`: Comma-separated object IDs for managed identities or service principals. +- `MAINTENANCE_START_HOUR_UTC`: Optional maintenance window start hour `0`–`23` UTC (use with `MAINTENANCE_END_HOUR_UTC`). +- `MAINTENANCE_END_HOUR_UTC`: Optional end hour; window is `[start, end)` when start is less than end, otherwise wraps midnight. +- `AZURE_TENANT_ID`: Optional tenant ID included in the summary JSON for portal context. + +### Secrets + +- `azure_credentials`: Service principal JSON or environment fields (`AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_CLIENT_SECRET`). Reader on the subscription is sufficient for Activity Log read. + +## Tasks Overview + +### Query Activity Log for NSG Mutations + +Lists NSG-related write/delete/action events, records raw JSON to `nsg_writes_raw.json`, and emits issues for failed operations or unusually high volume relative to the query cap. + +### Query Activity Log for Azure Firewall and Policy Mutations + +Same pattern for Azure Firewall and firewall policy resources; output in `firewall_writes_raw.json` and `firewall_issues.json`. + +### Classify Callers Against Allowlist + +Merges NSG and firewall events and tags each as automated or manual/unknown. If allowlists are empty, emits an informational issue to configure `CICD_APP_IDS` / `CICD_OBJECT_IDS`. + +### Flag Manual or Out-of-Band Changes + +Raises higher-severity issues for identities not on the allowlist (when allowlists are configured) and for mutations outside the optional UTC maintenance window. + +### Summarize Change Timeline and Top Actors + +Produces `summary_report.json` with counts by operation and caller plus an Activity Log blade URL for the subscription. diff --git a/codebundles/azure-network-security-activity-audit/activity-classify-callers.sh b/codebundles/azure-network-security-activity-audit/activity-classify-callers.sh new file mode 100755 index 00000000..11213736 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/activity-classify-callers.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Classifies mutation events against CICD_APP_IDS and CICD_OBJECT_IDS (comma lists). +# Reads nsg_writes_raw.json and firewall_writes_raw.json from the bundle working directory. +# Writes classify_issues.json and classified_events.json +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +OUTPUT_ISSUES="classify_issues.json" +issues_json='[]' + +merge_raw() { + local nsg="[]" + local fw="[]" + [[ -f nsg_writes_raw.json ]] && nsg=$(cat nsg_writes_raw.json) + [[ -f firewall_writes_raw.json ]] && fw=$(cat firewall_writes_raw.json) + echo "$nsg" "$fw" | jq -s 'add' +} + +merged=$(merge_raw) +total=$(echo "$merged" | jq 'length') + +if [[ -z "${CICD_APP_IDS:-}" && -z "${CICD_OBJECT_IDS:-}" ]]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Caller allowlist not configured" \ + --arg details "Set CICD_APP_IDS and/or CICD_OBJECT_IDS to classify automation versus manual changes. Events in window: ${total}" \ + --argjson severity 1 \ + --arg next_steps "Populate allowlists with known pipeline app IDs and managed identity object IDs" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + echo "[]" > "classified_events.json" + echo "Classification skipped (no allowlists); merged events: ${total}" + exit 0 +fi + +classified=$(echo "$merged" | jq -c \ + --arg apps "${CICD_APP_IDS:-}" \ + --arg oids "${CICD_OBJECT_IDS:-}" \ + ' + def split_list($s): ($s | split(",") | map(gsub("^\\s+";"") | gsub("\\s+$";"")) | map(select(length > 0))); + def appid($c): ($c["appid"] // $c["http://schemas.microsoft.com/identity/claims/applicationid"] // "") | tostring; + def oid($c): ($c["http://schemas.microsoft.com/identity/claims/objectidentifier"] // $c["oid"] // "") | tostring; + (split_list($apps)) as $appList | + (split_list($oids)) as $oidList | + [.[] | . as $e | ($e.claims // {}) as $c | + (appid($c)) as $ap | + (oid($c)) as $ob | + ($e.caller // "") as $caller | + ( + if ($ap != "" and ($appList | index($ap) != null)) then "automated" + elif ($ob != "" and ($oidList | index($ob) != null)) then "automated" + elif ($caller != "" and (($appList | index($caller) != null) or ($oidList | index($caller) != null))) then "automated" + else "manual_or_unknown" + end + ) as $tag | + $e + {classification: $tag, resolvedAppId: $ap, resolvedObjectId: $ob} + ] +') + +echo "$classified" | jq . > "classified_events.json" + +manual_count=$(echo "$classified" | jq '[.[] | select(.classification == "manual_or_unknown")] | length') +if [[ "$manual_count" -gt 0 ]]; then + sample=$(echo "$classified" | jq '[.[] | select(.classification == "manual_or_unknown")] | .[0:10]') + issues_json=$(echo "$issues_json" | jq \ + --arg title "Mutations classified as manual or unknown caller" \ + --arg details "$(echo "$sample" | jq -c .)" \ + --argjson severity 3 \ + --arg next_steps "Review events; add missing app or object IDs to allowlists if legitimate automation" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +fi + +echo "$issues_json" > "$OUTPUT_ISSUES" +echo "Classified ${total} events; manual_or_unknown: ${manual_count}" +exit 0 diff --git a/codebundles/azure-network-security-activity-audit/activity-flag-manual-changes.sh b/codebundles/azure-network-security-activity-audit/activity-flag-manual-changes.sh new file mode 100755 index 00000000..bfb1b67c --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/activity-flag-manual-changes.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Raises issues for non-allowlisted mutations and (optionally) changes outside +# MAINTENANCE_START_HOUR_UTC..MAINTENANCE_END_HOUR_UTC (UTC). Window is [start,end) +# when start < end; otherwise wraps past midnight. +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +OUTPUT_ISSUES="flag_issues.json" +issues_json='[]' + +merge_raw() { + local nsg="[]" + local fw="[]" + [[ -f nsg_writes_raw.json ]] && nsg=$(cat nsg_writes_raw.json) + [[ -f firewall_writes_raw.json ]] && fw=$(cat firewall_writes_raw.json) + echo "$nsg" "$fw" | jq -s 'add' +} + +classify_merged() { + local merged_json="$1" + echo "$merged_json" | jq -c \ + --arg apps "${CICD_APP_IDS:-}" \ + --arg oids "${CICD_OBJECT_IDS:-}" \ + ' + def split_list($s): ($s | split(",") | map(gsub("^\\s+";"") | gsub("\\s+$";"")) | map(select(length > 0))); + def appid($c): ($c["appid"] // $c["http://schemas.microsoft.com/identity/claims/applicationid"] // "") | tostring; + def oid($c): ($c["http://schemas.microsoft.com/identity/claims/objectidentifier"] // $c["oid"] // "") | tostring; + (split_list($apps)) as $appList | + (split_list($oids)) as $oidList | + [.[] | . as $e | ($e.claims // {}) as $c | + (appid($c)) as $ap | + (oid($c)) as $ob | + ($e.caller // "") as $caller | + ( + if ($ap != "" and ($appList | index($ap) != null)) then "automated" + elif ($ob != "" and ($oidList | index($ob) != null)) then "automated" + elif ($caller != "" and (($appList | index($caller) != null) or ($oidList | index($caller) != null))) then "automated" + else "manual_or_unknown" + end + ) as $tag | + $e + {classification: $tag} + ] + ' +} + +merged=$(merge_raw) + +if [[ -f classified_events.json ]] && [[ $(jq 'length' classified_events.json 2>/dev/null || echo 0) -gt 0 ]]; then + classified=$(cat classified_events.json) +else + classified=$(classify_merged "$merged") +fi + +if [[ -n "${CICD_APP_IDS:-}" || -n "${CICD_OBJECT_IDS:-}" ]]; then + bad_count=$(echo "$classified" | jq '[.[] | select(.classification == "manual_or_unknown")] | length') + if [[ "$bad_count" -gt 0 ]]; then + sample=$(echo "$classified" | jq '[.[] | select(.classification == "manual_or_unknown")] | .[0:8]') + issues_json=$(echo "$issues_json" | jq \ + --arg title "Non-allowlisted identity performed network security mutations" \ + --arg details "$(echo "$sample" | jq -c .)" \ + --argjson severity 4 \ + --arg next_steps "Investigate caller; revoke access if unauthorized or register the identity in CICD_APP_IDS / CICD_OBJECT_IDS" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + fi +fi + +if [[ -n "${MAINTENANCE_START_HOUR_UTC:-}" && -n "${MAINTENANCE_END_HOUR_UTC:-}" ]]; then + ms="${MAINTENANCE_START_HOUR_UTC}" + me="${MAINTENANCE_END_HOUR_UTC}" + outside=$(echo "$merged" | jq \ + --argjson ms "$ms" \ + --argjson me "$me" \ + ' + [.[] | select(.eventTimestamp != null) | + (.eventTimestamp | if test("T") then (split("T")[1] | split(":")[0] | tonumber) else 12 end) as $h | + (if $ms < $me then (($h >= $ms) and ($h < $me)) + else (($h >= $ms) or ($h < $me)) end) as $inside | + select($inside | not) + ]') + oc=$(echo "$outside" | jq 'length') + if [[ "$oc" -gt 0 ]]; then + samp=$(echo "$outside" | jq '.[0:8]') + issues_json=$(echo "$issues_json" | jq \ + --arg title "Network security mutations outside configured maintenance window (UTC)" \ + --arg details "$(echo "$samp" | jq -c .)" \ + --argjson severity 3 \ + --arg next_steps "Align changes with the maintenance window or adjust MAINTENANCE_START_HOUR_UTC / MAINTENANCE_END_HOUR_UTC" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + fi +fi + +echo "$issues_json" > "$OUTPUT_ISSUES" +echo "Flag script completed" +exit 0 diff --git a/codebundles/azure-network-security-activity-audit/activity-log-firewall-writes.sh b/codebundles/azure-network-security-activity-audit/activity-log-firewall-writes.sh new file mode 100755 index 00000000..c144ade3 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/activity-log-firewall-writes.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Lists write/delete/action operations on Azure Firewall and Firewall Policy resources. +# Writes: firewall_writes_raw.json, firewall_issues.json +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/azure_activity_log_common.sh" + +OUTPUT_ISSUES="firewall_issues.json" +OUTPUT_RAW="firewall_writes_raw.json" +issues_json='[]' + +az account set --subscription "${AZURE_SUBSCRIPTION_ID}" || { + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot set Azure subscription context" \ + --arg details "az account set failed; verify credentials" \ + --argjson severity 4 \ + --arg next_steps "Confirm AZURE_SUBSCRIPTION_ID and azure_credentials" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + echo "[]" > "$OUTPUT_RAW" + exit 0 +} + +if ! raw_json=$(activity_fetch_network_events); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Activity log query failed for Azure Firewall audit" \ + --arg details "az monitor activity-log list returned an error" \ + --argjson severity 4 \ + --arg next_steps "Verify Reader access and retry" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + echo "[]" > "$OUTPUT_RAW" + exit 0 +fi + +filtered=$(echo "$raw_json" | jq '[.[] | select((.operationName.value // "") | test("azureFirewalls|firewallPolicies|ruleCollectionGroups|ruleCollections")) | select((.operationName.value // "") | test("/(write|delete|action)$"))]') +echo "$filtered" > "$OUTPUT_RAW" + +failed_count=$(echo "$filtered" | jq '[.[] | select((.status.value // "") != "Succeeded" and (.status.value // "") != "")] | length') +if [[ "$failed_count" -gt 0 ]]; then + failed_sample=$(echo "$filtered" | jq '[.[] | select((.status.value // "") != "Succeeded" and (.status.value // "") != "")] | .[0:5]') + issues_json=$(echo "$issues_json" | jq \ + --arg title "Azure Firewall or policy activity reported non-success status" \ + --arg details "$(echo "$failed_sample" | jq -c .)" \ + --argjson severity 2 \ + --arg next_steps "Review failed operations in Activity Log for the firewall or policy resource" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +fi + +mut_count=$(echo "$filtered" | jq 'length') +if [[ "$mut_count" -gt 50 ]]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "High volume of firewall or policy mutations (possible truncation)" \ + --arg details "Observed ${mut_count} events; Microsoft.Network activity log query is capped at 500 events." \ + --argjson severity 3 \ + --arg next_steps "Narrow time range or scope, or use Log Analytics for exhaustive history" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +fi + +echo "$issues_json" > "$OUTPUT_ISSUES" +echo "Firewall/policy mutation events in window: ${mut_count}" +exit 0 diff --git a/codebundles/azure-network-security-activity-audit/activity-log-nsg-writes.sh b/codebundles/azure-network-security-activity-audit/activity-log-nsg-writes.sh new file mode 100755 index 00000000..557d7a45 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/activity-log-nsg-writes.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Lists write/delete/action operations on NSGs and NSG rules in the lookback window. +# Writes: nsg_writes_raw.json (filtered events), nsg_issues.json (issue array for Robot). +# Env: AZURE_SUBSCRIPTION_ID, optional AZURE_RESOURCE_GROUP, ACTIVITY_LOOKBACK_HOURS +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +source "${SCRIPT_DIR}/azure_activity_log_common.sh" + +OUTPUT_ISSUES="nsg_issues.json" +OUTPUT_RAW="nsg_writes_raw.json" +issues_json='[]' + +az account set --subscription "${AZURE_SUBSCRIPTION_ID}" || { + issues_json=$(echo "$issues_json" | jq \ + --arg title "Cannot set Azure subscription context" \ + --arg details "az account set failed; verify azure_credentials and AZURE_SUBSCRIPTION_ID" \ + --argjson severity 4 \ + --arg next_steps "Confirm the service principal has Reader on the subscription and IDs are correct" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + echo "[]" > "$OUTPUT_RAW" + exit 0 +} + +if ! raw_json=$(activity_fetch_network_events); then + issues_json=$(echo "$issues_json" | jq \ + --arg title "Activity log query failed for NSG audit" \ + --arg details "az monitor activity-log list returned an error; see stderr above" \ + --argjson severity 4 \ + --arg next_steps "Verify Reader role includes Activity Log access and retry" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + echo "$issues_json" > "$OUTPUT_ISSUES" + echo "[]" > "$OUTPUT_RAW" + exit 0 +fi + +filtered=$(echo "$raw_json" | jq '[.[] | select((.operationName.value // "") | test("networkSecurityGroups")) | select((.operationName.value // "") | test("/(write|delete|action)$"))]') +echo "$filtered" > "$OUTPUT_RAW" + +failed_count=$(echo "$filtered" | jq '[.[] | select((.status.value // "") != "Succeeded" and (.status.value // "") != "")] | length') +if [[ "$failed_count" -gt 0 ]]; then + failed_sample=$(echo "$filtered" | jq '[.[] | select((.status.value // "") != "Succeeded" and (.status.value // "") != "")] | .[0:5]') + issues_json=$(echo "$issues_json" | jq \ + --arg title "NSG-related activity log operations reported non-success status" \ + --arg details "$(echo "$failed_sample" | jq -c .)" \ + --argjson severity 2 \ + --arg next_steps "Review failed operations in the Activity Log and remediate RBAC or quota issues" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +fi + +mut_count=$(echo "$filtered" | jq 'length') +if [[ "$mut_count" -gt 50 ]]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "High volume of NSG mutations in lookback window (possible truncation)" \ + --arg details "Observed ${mut_count} NSG write/delete/action events; CLI returns at most 500 Microsoft.Network events per query. Narrow scope or shorten ACTIVITY_LOOKBACK_HOURS." \ + --argjson severity 3 \ + --arg next_steps "Reduce lookback, scope to a resource group, or export logs to Log Analytics for full history" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +fi + +echo "$issues_json" > "$OUTPUT_ISSUES" +echo "NSG mutation events (writes/deletes/actions) in window: ${mut_count} (raw cap 500 Microsoft.Network events per query)" +exit 0 diff --git a/codebundles/azure-network-security-activity-audit/activity-summary-report.sh b/codebundles/azure-network-security-activity-audit/activity-summary-report.sh new file mode 100755 index 00000000..7d5b49c1 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/activity-summary-report.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# Summarizes counts by operation and caller; portal deep link for Activity Log. +# Writes summary_report.json (payload) and summary_issues.json (informational issues). +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +OUTPUT_SUMMARY="summary_report.json" +OUTPUT_ISSUES="summary_issues.json" + +merge_raw() { + local nsg="[]" + local fw="[]" + [[ -f nsg_writes_raw.json ]] && nsg=$(cat nsg_writes_raw.json) + [[ -f firewall_writes_raw.json ]] && fw=$(cat firewall_writes_raw.json) + echo "$nsg" "$fw" | jq -s 'add' +} + +merged=$(merge_raw) +tenant="${AZURE_TENANT_ID:-}" +if [[ -z "$tenant" ]] && command -v az >/dev/null 2>&1; then + tenant=$(az account show --query tenantId -o tsv 2>/dev/null || echo "") +fi + +portal="https://portal.azure.com/#blade/Microsoft_Azure_Monitoring/AzureActivityLogBlade/subscriptionId/${AZURE_SUBSCRIPTION_ID}" + +summary=$(echo "$merged" | jq -n \ + --argjson ev "$merged" \ + --arg sub "${AZURE_SUBSCRIPTION_ID}" \ + --arg portal "$portal" \ + --arg tenant "$tenant" \ + '{ + subscriptionId: $sub, + tenantId: $tenant, + totalEvents: ($ev | length), + byOperation: ($ev | group_by(.operationName.value // "unknown") | map({operation: (.[0].operationName.value // "unknown"), count: length})), + byCaller: ($ev | group_by(.caller // "unknown") | map({caller: (.[0].caller // "unknown"), count: length}) | sort_by(-.count)), + activityLogPortalUrl: $portal + }') + +echo "$summary" | jq . > "$OUTPUT_SUMMARY" + +issues_json='[]' +total=$(echo "$merged" | jq 'length') +issues_json=$(echo "$issues_json" | jq \ + --arg title "Activity audit summary for subscription" \ + --arg details "$(echo "$summary" | jq -c .)" \ + --argjson severity 1 \ + --arg next_steps "Use the activityLogPortalUrl in the report to open the subscription Activity Log; tune allowlists and lookback as needed" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') + +if [[ "$total" -eq 0 ]]; then + issues_json=$(echo "$issues_json" | jq \ + --arg title "No NSG or firewall mutation events in captured window" \ + --arg details "Merged NSG + firewall filtered events is zero (or raw files missing). Confirm scope, lookback, and that Microsoft.Network activity exists." \ + --argjson severity 2 \ + --arg next_steps "Verify AZURE_RESOURCE_GROUP if set, increase ACTIVITY_LOOKBACK_HOURS, or check for CLI max-events limits" \ + '. += [{"title": $title, "details": $details, "severity": $severity, "next_steps": $next_steps}]') +fi + +echo "$issues_json" > "$OUTPUT_ISSUES" +cat "$OUTPUT_SUMMARY" +exit 0 diff --git a/codebundles/azure-network-security-activity-audit/azure_activity_log_common.sh b/codebundles/azure-network-security-activity-audit/azure_activity_log_common.sh new file mode 100755 index 00000000..cc703051 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/azure_activity_log_common.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Shared helpers for Azure Network activity audit scripts (sourced, not run directly). +# shellcheck shell=bash + +activity_compute_times() { + local hours="${ACTIVITY_LOOKBACK_HOURS:-168}" + export ACTIVITY_START_TIME + export ACTIVITY_END_TIME + ACTIVITY_START_TIME=$(date -u -d "${hours} hours ago" '+%Y-%m-%dT%H:%M:%SZ') + ACTIVITY_END_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +} + +activity_az_base_args() { + # shellcheck disable=SC2207 + ACTIVITY_AZ_ARGS=(--subscription "${AZURE_SUBSCRIPTION_ID}" --namespace Microsoft.Network + --start-time "${ACTIVITY_START_TIME}" --end-time "${ACTIVITY_END_TIME}" --max-events 500 -o json) + if [[ -n "${AZURE_RESOURCE_GROUP:-}" ]]; then + ACTIVITY_AZ_ARGS+=(--resource-group "${AZURE_RESOURCE_GROUP}") + fi +} + +activity_fetch_network_events() { + activity_compute_times + activity_az_base_args + if ! az monitor activity-log list "${ACTIVITY_AZ_ARGS[@]}" 2>activity_err.log; then + cat activity_err.log >&2 + echo "[]" + return 1 + fi + rm -f activity_err.log + return 0 +} diff --git a/codebundles/azure-network-security-activity-audit/runbook.robot b/codebundles/azure-network-security-activity-audit/runbook.robot new file mode 100644 index 00000000..49b86905 --- /dev/null +++ b/codebundles/azure-network-security-activity-audit/runbook.robot @@ -0,0 +1,253 @@ +*** Settings *** +Documentation Audits Azure Activity Log for NSG and Azure Firewall mutations and classifies callers against CI/CD allowlists for governance review. +Metadata Author rw-codebundle-agent +Metadata Display Name Azure NSG and Firewall Change Activity Audit +Metadata Supports Azure NSG Firewall Activity Log Governance + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Force Tags Azure NSG Firewall ActivityLog Governance + +Suite Setup Suite Initialization + + +*** Tasks *** +Query Activity Log for NSG Mutations in Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Lists write, delete, and action operations on network security groups and rules in the lookback window and flags query failures or high volume. + [Tags] Azure NSG ActivityLog access:read-only data:logs-bulk + ${result}= RW.CLI.Run Bash File + ... bash_file=activity-log-nsg-writes.sh + ... env=${env} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=ACTIVITY_LOOKBACK_HOURS=${ACTIVITY_LOOKBACK_HOURS} ./activity-log-nsg-writes.sh + + ${issues}= RW.CLI.Run Cli cmd=cat nsg_issues.json + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for NSG 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-related activity log operations should succeed and remain within expected change volume + ... actual=Activity log findings for NSG mutations in subscription `${AZURE_SUBSCRIPTION_ID}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report NSG activity audit:\n${result.stdout} + +Query Activity Log for Azure Firewall and Policy Mutations in Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Lists write operations on Azure Firewall, firewall policies, and related rule collections in the lookback window. + [Tags] Azure Firewall ActivityLog access:read-only data:logs-bulk + ${result}= RW.CLI.Run Bash File + ... bash_file=activity-log-firewall-writes.sh + ... env=${env} + ... timeout_seconds=240 + ... include_in_history=false + ... cmd_override=./activity-log-firewall-writes.sh + + ${issues}= RW.CLI.Run Cli cmd=cat firewall_issues.json + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for firewall 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=Firewall and policy activity log operations should succeed under normal conditions + ... actual=Activity log findings for firewall or policy mutations in subscription `${AZURE_SUBSCRIPTION_ID}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Firewall activity audit:\n${result.stdout} + +Classify Callers Against Allowlist for Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Tags mutation events as automated versus manual or unknown using CICD_APP_IDS and CICD_OBJECT_IDS when configured. + [Tags] Azure Classification access:read-only data:logs-bulk + ${result}= RW.CLI.Run Bash File + ... bash_file=activity-classify-callers.sh + ... env=${env} + ... timeout_seconds=180 + ... include_in_history=false + ... cmd_override=./activity-classify-callers.sh + + ${issues}= RW.CLI.Run Cli cmd=cat classify_issues.json + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for classify 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=Automation identities should match configured allowlists when classifying callers + ... actual=Classification results for network security mutations in subscription `${AZURE_SUBSCRIPTION_ID}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Caller classification:\n${result.stdout} + +Flag Manual or Out-of-Band Changes for Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Raises issues for non-allowlisted identities and for changes outside optional UTC maintenance hours. + [Tags] Azure Governance access:read-only data:logs-bulk + ${result}= RW.CLI.Run Bash File + ... bash_file=activity-flag-manual-changes.sh + ... env=${env} + ... timeout_seconds=180 + ... include_in_history=false + ... cmd_override=./activity-flag-manual-changes.sh + + ${issues}= RW.CLI.Run Cli cmd=cat flag_issues.json + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for flag 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=Changes should come from allowlisted automation or occur inside the maintenance window when configured + ... actual=Governance findings for subscription `${AZURE_SUBSCRIPTION_ID}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Manual change flags:\n${result.stdout} + +Summarize Change Timeline and Top Actors for Subscription `${AZURE_SUBSCRIPTION_ID}` + [Documentation] Aggregates counts by operation and caller and provides an Activity Log portal link for the subscription. + [Tags] Azure Summary access:read-only data:logs-bulk + ${result}= RW.CLI.Run Bash File + ... bash_file=activity-summary-report.sh + ... env=${env} + ... timeout_seconds=180 + ... include_in_history=false + ... cmd_override=./activity-summary-report.sh + + ${issues}= RW.CLI.Run Cli cmd=cat summary_issues.json + + 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 have a clear summary of mutation activity for governance review + ... actual=Summary output for subscription `${AZURE_SUBSCRIPTION_ID}` + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Summary report:\n${result.stdout} + + +*** 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; Reader on subscription for Activity Log + ... pattern=\w* + ${AZURE_SUBSCRIPTION_ID}= RW.Core.Import User Variable AZURE_SUBSCRIPTION_ID + ... type=string + ... description=Azure subscription ID to audit. + ... pattern=\w* + ${AZURE_RESOURCE_GROUP}= RW.Core.Import User Variable AZURE_RESOURCE_GROUP + ... type=string + ... description=Optional resource group scope; leave empty for entire subscription. + ... pattern=\w* + ... default=${EMPTY} + ${ACTIVITY_LOOKBACK_HOURS}= RW.Core.Import User Variable ACTIVITY_LOOKBACK_HOURS + ... type=string + ... description=Hours of Activity Log history to query. + ... pattern=\w* + ... default=168 + ${CICD_APP_IDS}= RW.Core.Import User Variable CICD_APP_IDS + ... type=string + ... description=Comma-separated Azure AD application (client) IDs approved for automation. + ... pattern=\w* + ... default=${EMPTY} + ${CICD_OBJECT_IDS}= RW.Core.Import User Variable CICD_OBJECT_IDS + ... type=string + ... description=Comma-separated object IDs for managed identities or service principals. + ... pattern=\w* + ... default=${EMPTY} + ${MAINTENANCE_START_HOUR_UTC}= RW.Core.Import User Variable MAINTENANCE_START_HOUR_UTC + ... type=string + ... description=Optional maintenance window start hour (0-23 UTC) for flagging out-of-window changes. + ... pattern=\w* + ... default=${EMPTY} + ${MAINTENANCE_END_HOUR_UTC}= RW.Core.Import User Variable MAINTENANCE_END_HOUR_UTC + ... type=string + ... description=Optional maintenance window end hour (0-23 UTC), exclusive when start less than end. + ... pattern=\w* + ... default=${EMPTY} + ${AZURE_TENANT_ID}= RW.Core.Import User Variable AZURE_TENANT_ID + ... type=string + ... description=Optional tenant ID for portal context in summary output. + ... pattern=\w* + ... default=${EMPTY} + + Set Suite Variable ${AZURE_SUBSCRIPTION_ID} ${AZURE_SUBSCRIPTION_ID} + Set Suite Variable ${AZURE_RESOURCE_GROUP} ${AZURE_RESOURCE_GROUP} + Set Suite Variable ${ACTIVITY_LOOKBACK_HOURS} ${ACTIVITY_LOOKBACK_HOURS} + Set Suite Variable ${CICD_APP_IDS} ${CICD_APP_IDS} + Set Suite Variable ${CICD_OBJECT_IDS} ${CICD_OBJECT_IDS} + Set Suite Variable ${MAINTENANCE_START_HOUR_UTC} ${MAINTENANCE_START_HOUR_UTC} + Set Suite Variable ${MAINTENANCE_END_HOUR_UTC} ${MAINTENANCE_END_HOUR_UTC} + Set Suite Variable ${AZURE_TENANT_ID} ${AZURE_TENANT_ID} + + Set Suite Variable + ... ${env} + ... {"AZURE_SUBSCRIPTION_ID":"${AZURE_SUBSCRIPTION_ID}", "AZURE_RESOURCE_GROUP":"${AZURE_RESOURCE_GROUP}", "ACTIVITY_LOOKBACK_HOURS":"${ACTIVITY_LOOKBACK_HOURS}", "CICD_APP_IDS":"${CICD_APP_IDS}", "CICD_OBJECT_IDS":"${CICD_OBJECT_IDS}", "MAINTENANCE_START_HOUR_UTC":"${MAINTENANCE_START_HOUR_UTC}", "MAINTENANCE_END_HOUR_UTC":"${MAINTENANCE_END_HOUR_UTC}", "AZURE_TENANT_ID":"${AZURE_TENANT_ID}"} + + RW.CLI.Run Cli + ... cmd=az account set --subscription ${AZURE_SUBSCRIPTION_ID} + ... include_in_history=false