From 3c30fbcb34f5bdf7e48cc5d530cff919bf167cd1 Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Thu, 25 Jun 2026 15:38:51 +0000 Subject: [PATCH] Add azure-storage-account-investigation CodeBundle Investigate storage account RBAC, Resource Graph dependencies, transaction metrics, and StorageBlobLogs to support safe remediation of public blob access and shared key authentication. Co-authored-by: Cursor --- .../azure-storage-account-investigation.yaml | 22 ++ ...ure-storage-account-investigation-sli.yaml | 46 +++ ...ure-storage-account-investigation-slx.yaml | 31 ++ ...storage-account-investigation-taskset.yaml | 43 +++ .../.test/README.md | 17 ++ .../.test/Taskfile.yaml | 92 ++++++ .../.test/terraform/backend.tf | 5 + .../.test/terraform/main.tf | 92 ++++++ .../.test/terraform/outputs.tf | 24 ++ .../.test/terraform/terraform.tfvars | 4 + .../.test/terraform/variables.tf | 41 +++ .../README.md | 63 ++++ .../analyze-transaction-metrics.sh | 259 ++++++++++++++++ .../list-rbac-assignments.sh | 243 +++++++++++++++ .../query-access-logs.sh | 285 ++++++++++++++++++ .../query-dependencies.sh | 204 +++++++++++++ .../runbook.robot | 219 ++++++++++++++ .../sli-investigation-score.sh | 72 +++++ .../sli.robot | 152 ++++++++++ 19 files changed, 1914 insertions(+) create mode 100644 codebundles/azure-storage-account-investigation/.runwhen/generation-rules/azure-storage-account-investigation.yaml create mode 100644 codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-sli.yaml create mode 100644 codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-slx.yaml create mode 100644 codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-taskset.yaml create mode 100644 codebundles/azure-storage-account-investigation/.test/README.md create mode 100644 codebundles/azure-storage-account-investigation/.test/Taskfile.yaml create mode 100644 codebundles/azure-storage-account-investigation/.test/terraform/backend.tf create mode 100644 codebundles/azure-storage-account-investigation/.test/terraform/main.tf create mode 100644 codebundles/azure-storage-account-investigation/.test/terraform/outputs.tf create mode 100644 codebundles/azure-storage-account-investigation/.test/terraform/terraform.tfvars create mode 100644 codebundles/azure-storage-account-investigation/.test/terraform/variables.tf create mode 100644 codebundles/azure-storage-account-investigation/README.md create mode 100755 codebundles/azure-storage-account-investigation/analyze-transaction-metrics.sh create mode 100755 codebundles/azure-storage-account-investigation/list-rbac-assignments.sh create mode 100755 codebundles/azure-storage-account-investigation/query-access-logs.sh create mode 100755 codebundles/azure-storage-account-investigation/query-dependencies.sh create mode 100644 codebundles/azure-storage-account-investigation/runbook.robot create mode 100755 codebundles/azure-storage-account-investigation/sli-investigation-score.sh create mode 100644 codebundles/azure-storage-account-investigation/sli.robot diff --git a/codebundles/azure-storage-account-investigation/.runwhen/generation-rules/azure-storage-account-investigation.yaml b/codebundles/azure-storage-account-investigation/.runwhen/generation-rules/azure-storage-account-investigation.yaml new file mode 100644 index 00000000..a28ba711 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.runwhen/generation-rules/azure-storage-account-investigation.yaml @@ -0,0 +1,22 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: azure + generationRules: + - resourceTypes: + - azure_storage_accounts + matchRules: + - type: pattern + pattern: ".+" + properties: [name] + mode: substring + slxs: + - baseName: azure-storage-investigation + qualifiers: ["resource", "subscription_id", "resource_group"] + baseTemplateName: azure-storage-account-investigation + levelOfDetail: basic + outputItems: + - type: slx + - type: sli + - type: runbook + templateName: azure-storage-account-investigation-taskset.yaml diff --git a/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-sli.yaml b/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-sli.yaml new file mode 100644 index 00000000..ba3824ec --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-sli.yaml @@ -0,0 +1,46 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelIndicator +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + displayUnitsLong: Investigation Score + displayUnitsShort: score + locations: + - {{default_location}} + description: Measures investigation completeness for Azure Storage Account RBAC, metrics, and diagnostic log coverage. + 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-storage-account-investigation/sli.robot + intervalStrategy: intermezzo + intervalSeconds: 600 + configProvided: + - name: AZURE_SUBSCRIPTION_ID + value: "{{ subscription_id }}" + - name: AZURE_RESOURCE_GROUP + value: "{{ resource_group.name }}" + - name: AZURE_STORAGE_ACCOUNT_NAME + value: "{{ match_resource.resource.name }}" + secretsProvided: + {% if wb_version %} + {% include "azure-auth.yaml" ignore missing %} + {% else %} + - name: azure_credentials + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} + alertConfig: + tasks: + persona: eager-edgar + sessionTTL: 10m diff --git a/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-slx.yaml b/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-slx.yaml new file mode 100644 index 00000000..512d7fa1 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-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/storage/10086-icon-service-Storage-Accounts.svg + alias: {{ match_resource.resource.name }} Azure Storage Account Investigation + asMeasuredBy: Investigation completeness score covering RBAC, dependencies, metrics, and access logs. + configProvided: + - name: SLX_PLACEHOLDER + value: SLX_PLACEHOLDER + owners: + - {{ workspace.owner_email }} + statement: Investigate Azure Storage Account utilization, ownership, dependencies, and access patterns to support safe remediation of public blob access and shared key authentication. + 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: storage + - name: scope + value: resource + - name: access + value: read-only diff --git a/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-taskset.yaml b/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-taskset.yaml new file mode 100644 index 00000000..e82f470a --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.runwhen/templates/azure-storage-account-investigation-taskset.yaml @@ -0,0 +1,43 @@ +apiVersion: runwhen.com/v1 +kind: Runbook +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + location: {{default_location}} + description: Investigate Azure Storage Account RBAC, dependencies, transaction metrics, and access logs for safe remediation planning. + 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-storage-account-investigation/runbook.robot + configProvided: + - name: AZURE_SUBSCRIPTION_ID + value: "{{ subscription_id }}" + - name: AZURE_RESOURCE_GROUP + value: "{{ resource_group.name }}" + - name: AZURE_STORAGE_ACCOUNT_NAME + value: "{{ match_resource.resource.name }}" + - name: LOOKBACK_DAYS + value: "{{ custom.lookback_days | default('7') }}" + - name: ADDITIONAL_SUBSCRIPTION_IDS + value: "{{ custom.additional_subscription_ids | default('') }}" + - name: LOG_ANALYTICS_WORKSPACE_ID + value: "{{ custom.log_analytics_workspace_id | default('') }}" + secretsProvided: + {% if wb_version %} + {% include "azure-auth.yaml" ignore missing %} + {% else %} + - name: azure_credentials + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} diff --git a/codebundles/azure-storage-account-investigation/.test/README.md b/codebundles/azure-storage-account-investigation/.test/README.md new file mode 100644 index 00000000..ec57822c --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.test/README.md @@ -0,0 +1,17 @@ +# Test Infrastructure + +Terraform provisions a storage account with public blob access enabled, diagnostic settings forwarding StorageBlobLogs to Log Analytics, and sample RBAC role assignments for integration testing. + +## Prerequisites + +- Azure CLI authenticated with permissions to create storage accounts and Log Analytics workspaces +- Copy `terraform/tf.secret.example` to `terraform/tf.secret` with service principal credentials (see azure-acr-health bundle) + +## Usage + +```bash +task build-infra # terraform apply +task clean # terraform destroy +``` + +Outputs include `storage_account_name`, `resource_group_name`, and `log_analytics_workspace_id` for runbook configuration. diff --git a/codebundles/azure-storage-account-investigation/.test/Taskfile.yaml b/codebundles/azure-storage-account-investigation/.test/Taskfile.yaml new file mode 100644 index 00000000..6c1aa8ff --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.test/Taskfile.yaml @@ -0,0 +1,92 @@ +version: "3" + +tasks: + default: + desc: "Run complete test suite" + 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 + 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 configuration (workspaceInfo.yaml)" + cmds: + - | + if [ ! -f terraform/tf.secret ]; then + echo "Create terraform/tf.secret with Azure credentials before testing." + exit 1 + fi + source terraform/tf.secret + repo_url=$(git config --get remote.origin.url) + branch_name=$(git rev-parse --abbrev-ref HEAD) + codebundle=$(basename "$(dirname "$PWD")") + cat < workspaceInfo.yaml + workspaceName: "${RW_WORKSPACE:-my-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}" + codeCollections: + - repoURL: "${repo_url}" + branch: "${branch_name}" + codeBundles: ["${codebundle}"] + EOF + silent: true + + run-rwl-discovery: + desc: "Run RunWhen Local Discovery" + cmds: + - echo "RunWhen Local discovery requires docker and tf.secret; see README.md" + + build-terraform-infra: + desc: "Run terraform apply" + dir: terraform + cmds: + - | + if [ -f tf.secret ]; then source tf.secret; fi + terraform init + terraform apply -auto-approve + + check-and-cleanup-terraform: + desc: "Destroy Terraform resources if state exists" + dir: terraform + cmds: + - | + if [ -f "terraform.tfstate" ]; then + if [ -f tf.secret ]; then source tf.secret; fi + terraform destroy -auto-approve + fi + + clean-rwl-discovery: + desc: "Clean RunWhen Local output" + cmds: + - rm -rf output workspaceInfo.yaml diff --git a/codebundles/azure-storage-account-investigation/.test/terraform/backend.tf b/codebundles/azure-storage-account-investigation/.test/terraform/backend.tf new file mode 100644 index 00000000..3c533e6b --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.test/terraform/backend.tf @@ -0,0 +1,5 @@ +terraform { + backend "local" { + path = "terraform.tfstate" + } +} diff --git a/codebundles/azure-storage-account-investigation/.test/terraform/main.tf b/codebundles/azure-storage-account-investigation/.test/terraform/main.tf new file mode 100644 index 00000000..9c0ddcde --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.test/terraform/main.tf @@ -0,0 +1,92 @@ +terraform { + required_version = ">= 1.3.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "azurerm" { + features {} +} + +resource "random_string" "suffix" { + length = 8 + special = false + upper = false +} + +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "test" { + name = var.resource_group + location = var.location + tags = var.tags +} + +resource "azurerm_log_analytics_workspace" "logs" { + name = "${var.codebundle}-logs-${random_string.suffix.result}" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + sku = "PerGB2018" + retention_in_days = 30 + tags = var.tags +} + +resource "azurerm_storage_account" "investigation" { + name = "${var.codebundle}${random_string.suffix.result}" + resource_group_name = azurerm_resource_group.test.name + location = azurerm_resource_group.test.location + account_tier = "Standard" + account_replication_type = "LRS" + allow_nested_items_to_be_public = true + min_tls_version = "TLS1_2" + tags = var.tags +} + +resource "azurerm_monitor_diagnostic_setting" "blob_logs" { + name = "blob-logs-to-law" + target_resource_id = "${azurerm_storage_account.investigation.id}/blobServices/default" + log_analytics_workspace_id = azurerm_log_analytics_workspace.logs.id + + enabled_log { + category = "StorageRead" + } + enabled_log { + category = "StorageWrite" + } + enabled_log { + category = "StorageDelete" + } + + metric { + category = "Transaction" + enabled = true + } +} + +resource "azurerm_role_assignment" "sp_reader" { + count = var.sp_principal_id != "" ? 1 : 0 + scope = azurerm_storage_account.investigation.id + role_definition_name = "Storage Blob Data Reader" + principal_id = var.sp_principal_id +} + +resource "azurerm_role_assignment" "sp_contributor_rg" { + count = var.sp_principal_id != "" ? 1 : 0 + scope = azurerm_resource_group.test.id + role_definition_name = "Reader" + principal_id = var.sp_principal_id +} + +resource "azurerm_storage_container" "public_test" { + name = "public-test" + storage_account_name = azurerm_storage_account.investigation.name + container_access_type = "blob" +} diff --git a/codebundles/azure-storage-account-investigation/.test/terraform/outputs.tf b/codebundles/azure-storage-account-investigation/.test/terraform/outputs.tf new file mode 100644 index 00000000..261a48f5 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.test/terraform/outputs.tf @@ -0,0 +1,24 @@ +output "storage_account_name" { + value = azurerm_storage_account.investigation.name + description = "Test storage account name" +} + +output "resource_group_name" { + value = azurerm_resource_group.test.name + description = "Test resource group name" +} + +output "subscription_id" { + value = data.azurerm_client_config.current.subscription_id + description = "Subscription ID" +} + +output "log_analytics_workspace_id" { + value = azurerm_log_analytics_workspace.logs.id + description = "Log Analytics workspace resource ID" +} + +output "storage_account_id" { + value = azurerm_storage_account.investigation.id + description = "Storage account resource ID" +} diff --git a/codebundles/azure-storage-account-investigation/.test/terraform/terraform.tfvars b/codebundles/azure-storage-account-investigation/.test/terraform/terraform.tfvars new file mode 100644 index 00000000..6b386b59 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.test/terraform/terraform.tfvars @@ -0,0 +1,4 @@ +# Example values for local testing (no secrets) +codebundle = "storinv" +resource_group = "rg-storage-account-investigation-test" +location = "eastus" diff --git a/codebundles/azure-storage-account-investigation/.test/terraform/variables.tf b/codebundles/azure-storage-account-investigation/.test/terraform/variables.tf new file mode 100644 index 00000000..c12c7532 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/.test/terraform/variables.tf @@ -0,0 +1,41 @@ +variable "codebundle" { + type = string + description = "CodeBundle name prefix for test resources" + default = "storinv" +} + +variable "resource_group" { + type = string + description = "Resource group name for test infrastructure" + default = "rg-storage-account-investigation-test" +} + +variable "location" { + type = string + description = "Azure region" + default = "eastus" +} + +variable "subscription_id" { + type = string + description = "Azure subscription ID" +} + +variable "tenant_id" { + type = string + description = "Azure tenant ID" +} + +variable "sp_principal_id" { + type = string + description = "Service principal object ID for RBAC test assignments" + default = "" +} + +variable "tags" { + type = map(string) + default = { + purpose = "codebundle-test" + codebundle = "azure-storage-account-investigation" + } +} diff --git a/codebundles/azure-storage-account-investigation/README.md b/codebundles/azure-storage-account-investigation/README.md new file mode 100644 index 00000000..8f38b258 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/README.md @@ -0,0 +1,63 @@ +# Azure Storage Account Investigation + +Investigate Azure Storage Account utilization, ownership, dependencies, and access patterns so operators can safely assess configuration changes such as disabling public blob access or shared key authentication. Complements Cloud Custodian detection with the investigation data needed for safe remediation. + +## Overview + +This CodeBundle runs four read-only investigation tasks against a single storage account: + +- **RBAC analysis**: Lists all principals with RBAC access including inherited assignments; flags Owner/Contributor at resource scope and user data-plane roles. +- **Dependency mapping**: Queries Azure Resource Graph for resources referencing the account (Data Factory, Function Apps, private endpoints, diagnostic settings, and more). +- **Transaction metrics**: Analyzes blob transaction metrics by authentication type (AccountKey, SAS, OAuth, Anonymous) over a configurable lookback window. +- **Access logs**: Queries StorageBlobLogs in Log Analytics for caller IPs, identities, and authentication types; short-circuits when diagnostic settings are not enabled. + +Each task emits structured JSON with an `issues` array and a `risk_assessment` section including `safe_to_disable_public_access` and `safe_to_disable_shared_key` booleans. + +## Configuration + +### Required Variables + +- `AZURE_SUBSCRIPTION_ID`: Azure subscription ID containing the storage account +- `AZURE_RESOURCE_GROUP`: Resource group containing the storage account +- `AZURE_STORAGE_ACCOUNT_NAME`: Name of the storage account to investigate + +### Optional Variables + +- `LOOKBACK_DAYS`: Days of metrics and logs to analyze (default: `7`) +- `ADDITIONAL_SUBSCRIPTION_IDS`: Comma-separated subscription IDs for cross-subscription Resource Graph dependency queries (default: empty) +- `LOG_ANALYTICS_WORKSPACE_ID`: Log Analytics workspace resource ID for StorageBlobLogs queries; auto-discovered from diagnostic settings when omitted (default: empty) + +### Secrets + +- `azure_credentials`: Azure Service Principal credentials for `az` CLI authentication. JSON object with `clientId`, `clientSecret`, `tenantId`, and `subscriptionId` (or equivalent fields per the `azure-auth.yaml` workspace template). + +## Tasks Overview + +### List Storage Account RBAC Role Assignments + +Identifies all principals with RBAC access to the storage account, including inherited assignments from resource group and subscription scope. Issues severity 3 for Owner/Contributor at resource scope and severity 4 for user principals with data-plane roles. + +### Query Resource Graph for Storage Account Dependencies + +Maps dependent Azure resources via property references, private endpoint connections, and diagnostic settings targets. Issues severity 2 when more than five dependents are found, severity 3 for one to five, and severity 4 when none are found (with Resource Graph blind-spot notes). + +### Analyze Storage Account Transaction Metrics by Authentication Type + +Pulls Azure Monitor blob transaction metrics broken down by Authentication and ApiName dimensions, plus ingress, egress, and capacity metrics. Issues severity 2 for anonymous transactions, severity 3 when AccountKey exceeds 50% of traffic, and severity 4 for SAS usage. + +### Query Storage Account Access Logs + +Queries StorageBlobLogs for caller IPs, UPNs, service principal object IDs, and authentication types. Issues severity 1 when diagnostic settings are not enabled, severity 2 for anonymous auth from external IPs, severity 3 for anonymous internal IPs, and severity 4 for multiple distinct AccountKey callers. + +## Permissions Required + +- Reader plus `Microsoft.Authorization/roleAssignments/read` on the target subscription (Task 1) +- Reader at subscription scope for Resource Graph (Task 2) +- Monitoring Reader on the storage account (Task 3) +- Log Analytics Reader on the workspace (Task 4, when configured) + +## Related Bundles + +- **azure-storage-health** (azure-c7n-codecollection): Detects misconfigurations such as `allowBlobPublicAccess: true` +- **azure-storage-cost-optimization**: Subscription/RG-wide storage spend analysis +- **azure-acr-health**: Reference for Azure resource-scoped investigation patterns diff --git a/codebundles/azure-storage-account-investigation/analyze-transaction-metrics.sh b/codebundles/azure-storage-account-investigation/analyze-transaction-metrics.sh new file mode 100755 index 00000000..0ecc83d6 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/analyze-transaction-metrics.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# AZURE_SUBSCRIPTION_ID +# AZURE_RESOURCE_GROUP +# AZURE_STORAGE_ACCOUNT_NAME +# +# OPTIONAL: +# LOOKBACK_DAYS Days of metrics to analyze (default: 7) +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${AZURE_STORAGE_ACCOUNT_NAME:?Must set AZURE_STORAGE_ACCOUNT_NAME}" + +LOOKBACK_DAYS="${LOOKBACK_DAYS:-7}" +OUTPUT_FILE="transaction_metrics_output.json" +issues_json='[]' + +add_issue() { + local title="$1" severity="$2" expected="$3" actual="$4" details="$5" next_steps="$6" + local reproduce_hint="${7:-}" + issues_json=$(echo "$issues_json" | jq \ + --arg title "$title" \ + --arg expected "$expected" \ + --arg actual "$actual" \ + --arg details "$details" \ + --arg next_steps "$next_steps" \ + --arg reproduce_hint "$reproduce_hint" \ + --argjson severity "$severity" \ + '. += [{ + title: $title, + severity: $severity, + expected: $expected, + actual: $actual, + details: $details, + next_steps: $next_steps, + reproduce_hint: $reproduce_hint + }]') +} + +echo "Analyzing transaction metrics for ${AZURE_STORAGE_ACCOUNT_NAME} (last ${LOOKBACK_DAYS} days)" >&2 + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + +storage_info=$(az storage account show \ + --name "$AZURE_STORAGE_ACCOUNT_NAME" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "") + +if [[ -z "$storage_info" || "$storage_info" == "null" ]]; then + add_issue \ + "Cannot access storage account for metrics \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Storage account should be readable" \ + "az storage account show failed" \ + "Verify account and Reader permissions." \ + "Confirm storage account exists before analyzing metrics." \ + "az storage account show --name ${AZURE_STORAGE_ACCOUNT_NAME} --resource-group ${AZURE_RESOURCE_GROUP}" + jq -n --argjson issues "$issues_json" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "Account inaccessible"}, summary: {}}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +resource_id=$(echo "$storage_info" | jq -r '.id') +blob_resource_id="${resource_id}/blobServices/default" +portal_url="https://portal.azure.com/#@/resource${resource_id}/metrics" +end_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +start_time=$(date -u -d "${LOOKBACK_DAYS} days ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-"${LOOKBACK_DAYS}"d +"%Y-%m-%dT%H:%M:%SZ") + +fetch_metric() { + local metric_name="$1" + shift + local dim_args=("$@") + az monitor metrics list \ + --resource "$blob_resource_id" \ + --metric "$metric_name" \ + --aggregation Total Average Maximum \ + --interval PT1H \ + --start-time "$start_time" \ + --end-time "$end_time" \ + "${dim_args[@]}" \ + -o json 2>metrics.err || echo "" +} + +tx_metrics=$(fetch_metric "Transactions" --dimension Authentication ApiName) +if [[ -z "$tx_metrics" ]]; then + err_msg=$(cat metrics.err 2>/dev/null || echo "Unknown metrics error") + if echo "$err_msg" | grep -qiE 'AuthorizationFailed|403|Forbidden|network rules'; then + add_issue \ + "Transaction metrics blocked by network rules for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Metrics should be readable via Azure Monitor control plane" \ + "Metrics API returned authorization/network failure" \ + "${err_msg}" \ + "Storage firewall may block data-plane calls; control-plane metrics should still be attempted. Review network rules before remediation." \ + "az monitor metrics list --resource ${blob_resource_id} --metric Transactions" + else + add_issue \ + "Unable to retrieve transaction metrics for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Monitoring Reader should allow blob transaction metrics" \ + "az monitor metrics list failed or returned empty" \ + "${err_msg}" \ + "Grant Monitoring Reader on the storage account and retry." \ + "az monitor metrics list --resource ${blob_resource_id} --metric Transactions --dimension Authentication" + fi + jq -n --argjson issues "$issues_json" --arg portal_url "$portal_url" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "Metrics unavailable"}, summary: {}, portal_url: $portal_url}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +# Sum transactions by Authentication dimension +auth_totals=$(echo "$tx_metrics" | jq ' + [.value[0].timeseries[]? | .metadata[]? | select(.name.value == "Authentication") | .value] as $auths | + [.value[0].timeseries[]? | .data[]? | .total // 0] as $totals | + reduce range(0; ($auths | length)) as $i ({}; . + {($auths[$i]): ((.[ $auths[$i] ] // 0) + ($totals[$i] // 0))}) +' 2>/dev/null || echo '{}') + +if [[ "$auth_totals" == "{}" || "$auth_totals" == "null" ]]; then + auth_totals=$(echo "$tx_metrics" | jq ' + [.value[]?.timeseries[]? | + (.metadata[]? | select(.name.value == "Authentication") | .value) as $auth | + ([.data[]?.total // 0] | add // 0) as $sum | + {key: $auth, value: $sum} + ] | from_entries + ' 2>/dev/null || echo '{}') +fi + +anonymous_count=$(echo "$auth_totals" | jq '.Anonymous // .anonymous // 0') +account_key_count=$(echo "$auth_totals" | jq '.AccountKey // .accountkey // 0') +sas_count=$(echo "$auth_totals" | jq '.SAS // .sas // 0') +oauth_count=$(echo "$auth_totals" | jq '.OAuth // .oauth // 0') +total_tx=$(echo "$auth_totals" | jq '[.[] | tonumber? // 0] | add // 0') + +ingress_metrics=$(fetch_metric "Ingress") +egress_metrics=$(fetch_metric "Egress") +capacity_metrics=$(fetch_metric "BlobCapacity") + +ingress_total=$(echo "$ingress_metrics" | jq '[.value[]?.timeseries[]?.data[]?.total // 0] | add // 0') +egress_total=$(echo "$egress_metrics" | jq '[.value[]?.timeseries[]?.data[]?.total // 0] | add // 0') +capacity_latest=$(echo "$capacity_metrics" | jq '[.value[]?.timeseries[]?.data[]?.average // 0] | max // 0') + +echo "Transaction totals by auth: $(echo "$auth_totals" | jq -c .)" >&2 + +safe_public=true +safe_shared_key=true +rationale_parts=() + +if [[ $(echo "$anonymous_count >= 1" | bc -l 2>/dev/null || python3 -c "print(1 if float('${anonymous_count}') >= 1 else 0)") -eq 1 ]]; then + add_issue \ + "Anonymous blob transactions detected on \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 2 \ + "No anonymous (public) blob transactions before disabling public access" \ + "${anonymous_count} anonymous transaction(s) in last ${LOOKBACK_DAYS} day(s)" \ + "Anonymous auth indicates public blob/container access or anonymous endpoints are in use." \ + "Identify public containers and anonymous callers via access logs before setting allowBlobPublicAccess=false." \ + "az monitor metrics list --resource ${blob_resource_id} --metric Transactions --dimension Authentication" + safe_public=false + rationale_parts+=("Anonymous transactions detected") +fi + +if [[ "$total_tx" != "0" && "$total_tx" != "0.0" ]]; then + account_key_pct=$(python3 -c "t=float('${total_tx}'); k=float('${account_key_count}'); print(round(100*k/t,1) if t>0 else 0)") + if python3 -c "exit(0 if float('${account_key_pct}') > 50 else 1)"; then + add_issue \ + "AccountKey authentication exceeds 50% of transactions on \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Shared key should not dominate authentication mix before disablement" \ + "AccountKey represents ${account_key_pct}% of ${total_tx} total transactions" \ + "Auth breakdown: $(echo "$auth_totals" | jq -c .)" \ + "Migrate AccountKey callers to OAuth/managed identity before disabling allowSharedKeyAccess." \ + "az monitor metrics list --resource ${blob_resource_id} --metric Transactions --dimension Authentication" + safe_shared_key=false + rationale_parts+=("AccountKey >50% of transactions") + fi +else + account_key_pct=0 +fi + +if [[ $(echo "$sas_count >= 1" | bc -l 2>/dev/null || python3 -c "print(1 if float('${sas_count}') >= 1 else 0)") -eq 1 ]]; then + add_issue \ + "SAS token usage detected on \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "SAS usage should be inventoried before shared key disablement" \ + "${sas_count} SAS-authenticated transaction(s) in lookback window" \ + "SAS tokens often depend on shared key backing; disabling shared keys breaks SAS." \ + "Identify SAS issuers and migrate to user delegation SAS or OAuth where possible." \ + "az monitor metrics list --resource ${blob_resource_id} --metric Transactions --dimension Authentication" + safe_shared_key=false + rationale_parts+=("SAS usage detected") +fi + +if [[ "$total_tx" != "0" && "$total_tx" != "0.0" ]]; then + oauth_pct=$(python3 -c "t=float('${total_tx}'); o=float('${oauth_count}'); print(round(100*o/t,1) if t>0 else 0)") + if python3 -c "exit(0 if float('${oauth_pct}') >= 99.9 and float('${anonymous_count}') == 0 and float('${account_key_count}') == 0 and float('${sas_count}') == 0 else 1)"; then + echo "Healthy: 100% OAuth authentication in lookback window" >&2 + fi +else + oauth_pct=0 + add_issue \ + "No blob transactions recorded for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Metrics should confirm whether the account is actively used" \ + "Zero Transactions metric totals in last ${LOOKBACK_DAYS} day(s)" \ + "Absence of metrics may indicate idle account or insufficient monitoring permissions." \ + "Cross-check with access logs and Resource Graph before assuming safe remediation." \ + "az monitor metrics list --resource ${blob_resource_id} --metric Transactions" +fi + +rationale="Metrics analyzed over ${LOOKBACK_DAYS} day(s). Total transactions: ${total_tx}." +if [[ ${#rationale_parts[@]} -gt 0 ]]; then + rationale="${rationale} $(IFS='; '; echo "${rationale_parts[*]}")." +else + rationale="${rationale} No anonymous, high AccountKey, or SAS blockers detected." +fi + +jq -n \ + --argjson issues "$issues_json" \ + --argjson auth_totals "$auth_totals" \ + --argjson total_tx "$total_tx" \ + --argjson anonymous_count "$anonymous_count" \ + --argjson account_key_count "$account_key_count" \ + --argjson sas_count "$sas_count" \ + --argjson oauth_count "$oauth_count" \ + --argjson ingress_total "$ingress_total" \ + --argjson egress_total "$egress_total" \ + --argjson capacity_latest "$capacity_latest" \ + --arg portal_url "$portal_url" \ + --argjson safe_public "$safe_public" \ + --argjson safe_shared_key "$safe_shared_key" \ + --arg rationale "$rationale" \ + --argjson lookback_days "$LOOKBACK_DAYS" \ + '{ + issues: $issues, + risk_assessment: { + safe_to_disable_public_access: $safe_public, + safe_to_disable_shared_key: $safe_shared_key, + rationale: $rationale + }, + summary: { + lookback_days: $lookback_days, + transactions_by_authentication: $auth_totals, + total_transactions: $total_tx, + ingress_bytes: $ingress_total, + egress_bytes: $egress_total, + blob_capacity_bytes: $capacity_latest + }, + portal_url: $portal_url + }' > "$OUTPUT_FILE" + +cat "$OUTPUT_FILE" diff --git a/codebundles/azure-storage-account-investigation/list-rbac-assignments.sh b/codebundles/azure-storage-account-investigation/list-rbac-assignments.sh new file mode 100755 index 00000000..1c8aaa9c --- /dev/null +++ b/codebundles/azure-storage-account-investigation/list-rbac-assignments.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# AZURE_SUBSCRIPTION_ID +# AZURE_RESOURCE_GROUP +# AZURE_STORAGE_ACCOUNT_NAME +# +# Lists RBAC role assignments for a storage account including inherited scopes. +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${AZURE_STORAGE_ACCOUNT_NAME:?Must set AZURE_STORAGE_ACCOUNT_NAME}" + +OUTPUT_FILE="rbac_assignments_output.json" +issues_json='[]' + +add_issue() { + local title="$1" severity="$2" expected="$3" actual="$4" details="$5" next_steps="$6" + local reproduce_hint="${7:-}" + issues_json=$(echo "$issues_json" | jq \ + --arg title "$title" \ + --arg expected "$expected" \ + --arg actual "$actual" \ + --arg details "$details" \ + --arg next_steps "$next_steps" \ + --arg reproduce_hint "$reproduce_hint" \ + --argjson severity "$severity" \ + '. += [{ + title: $title, + severity: $severity, + expected: $expected, + actual: $actual, + details: $details, + next_steps: $next_steps, + reproduce_hint: $reproduce_hint + }]') +} + +safe_to_disable_public_access=true +safe_to_disable_shared_key=true +rationale_parts=() + +echo "Listing RBAC assignments for storage account: ${AZURE_STORAGE_ACCOUNT_NAME}" >&2 + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || { + add_issue \ + "Cannot set Azure subscription context for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Azure CLI should authenticate and set subscription context" \ + "az account set failed for subscription ${AZURE_SUBSCRIPTION_ID}" \ + "Verify azure_credentials secret and subscription ID." \ + "Confirm service principal has Reader access on subscription ${AZURE_SUBSCRIPTION_ID}." \ + "az account set --subscription ${AZURE_SUBSCRIPTION_ID}" + jq -n \ + --argjson issues "$issues_json" \ + --arg portal_url "" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "Unable to authenticate"}, summary: {assignment_count: 0}, portal_url: $portal_url}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +} + +storage_info=$(az storage account show \ + --name "$AZURE_STORAGE_ACCOUNT_NAME" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>storage_show.err || true) + +if [[ -z "$storage_info" || "$storage_info" == "null" ]]; then + err_msg=$(cat storage_show.err 2>/dev/null || echo "Unknown error") + add_issue \ + "Cannot access storage account \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Storage account should be readable with provided credentials" \ + "az storage account show failed: ${err_msg}" \ + "Account: ${AZURE_STORAGE_ACCOUNT_NAME}, Resource group: ${AZURE_RESOURCE_GROUP}" \ + "Verify account name, resource group, and Reader permissions on the storage account." \ + "az storage account show --name ${AZURE_STORAGE_ACCOUNT_NAME} --resource-group ${AZURE_RESOURCE_GROUP}" + jq -n \ + --argjson issues "$issues_json" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "Storage account not accessible"}, summary: {assignment_count: 0}}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +resource_id=$(echo "$storage_info" | jq -r '.id') +portal_url="https://portal.azure.com/#@/resource${resource_id}/users" +allow_blob_public_access=$(echo "$storage_info" | jq -r '.allowBlobPublicAccess // true') +shared_key_access=$(echo "$storage_info" | jq -r '.allowSharedKeyAccess // true') + +echo "Storage account resource ID: ${resource_id}" >&2 +echo "Portal IAM: ${portal_url}" >&2 + +rbac_assignments=$(az role assignment list \ + --scope "$resource_id" \ + --include-inherited \ + --all \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>rbac.err || echo "[]") + +if [[ ! "$rbac_assignments" =~ ^\[ ]]; then + err_msg=$(cat rbac.err 2>/dev/null || echo "Unknown RBAC error") + if echo "$err_msg" | grep -qiE 'AuthorizationFailed|403|Forbidden'; then + add_issue \ + "RBAC enumeration blocked for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Reader and Microsoft.Authorization/roleAssignments/read should allow RBAC listing" \ + "Role assignment list returned authorization failure" \ + "${err_msg}" \ + "Grant Reader plus roleAssignments/read on subscription or storage account scope before remediation." \ + "az role assignment list --scope ${resource_id} --include-inherited --all" + else + add_issue \ + "Failed to list RBAC assignments for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "RBAC assignments should be enumerable" \ + "az role assignment list failed" \ + "${err_msg}" \ + "Verify permissions and retry RBAC enumeration." \ + "az role assignment list --scope ${resource_id} --include-inherited --all" + fi + jq -n \ + --argjson issues "$issues_json" \ + --arg portal_url "$portal_url" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "RBAC data unavailable"}, summary: {assignment_count: 0}, portal_url: $portal_url}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +assignment_count=$(echo "$rbac_assignments" | jq 'length') +echo "Found ${assignment_count} RBAC assignment(s)" >&2 + +summary_by_type=$(echo "$rbac_assignments" | jq '[group_by(.principalType)[] | {principalType: .[0].principalType, count: length}]') +summary_by_role=$(echo "$rbac_assignments" | jq '[group_by(.roleDefinitionName)[] | {role: .[0].roleDefinitionName, count: length}] | sort_by(-.count)') + +data_plane_roles='["Storage Blob Data Contributor","Storage Blob Data Reader","Storage Blob Data Owner","Storage Queue Data Contributor","Storage Queue Data Reader","Storage Table Data Contributor","Storage Table Data Reader","Storage File Data SMB Share Contributor","Storage File Data SMB Share Reader"]' + +# Owner/Contributor at resource scope (not inherited) +resource_scope_privileged=$(echo "$rbac_assignments" | jq --arg rid "$resource_id" ' + [.[] | select(.scope == $rid and (.roleDefinitionName == "Owner" or .roleDefinitionName == "Contributor"))] +') +resource_scope_count=$(echo "$resource_scope_privileged" | jq 'length') +if [[ "$resource_scope_count" -gt 0 ]]; then + principals=$(echo "$resource_scope_privileged" | jq -r '[.[].principalName] | join(", ")') + add_issue \ + "Over-privileged RBAC at storage account scope for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Owner/Contributor should not be assigned directly at storage account resource scope" \ + "${resource_scope_count} Owner/Contributor assignment(s) at resource scope" \ + "Principals: ${principals}. Full list in summary.assignments." \ + "Replace Owner/Contributor with least-privilege storage data-plane or control-plane roles before disabling public access or shared keys." \ + "az role assignment list --scope ${resource_id} --include-inherited false" + safe_to_disable_public_access=false + safe_to_disable_shared_key=false + rationale_parts+=("Owner/Contributor assigned at storage account scope") +fi + +# User principals with data-plane roles +user_data_plane=$(echo "$rbac_assignments" | jq --argjson roles "$data_plane_roles" ' + [.[] | select(.principalType == "User" and (.roleDefinitionName as $r | $roles | index($r)))] +') +user_data_plane_count=$(echo "$user_data_plane" | jq 'length') +if [[ "$user_data_plane_count" -gt 0 ]]; then + users=$(echo "$user_data_plane" | jq -r '[.[].principalName] | unique | join(", ")') + add_issue \ + "User accounts with storage data-plane access on \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Automated workloads should use managed identities or service principals instead of user data-plane roles" \ + "${user_data_plane_count} user data-plane role assignment(s)" \ + "Users: ${users}. Contact these principals before disabling shared key or public blob access." \ + "Review user data-plane assignments and migrate to OAuth-based managed identity access where possible." \ + "az role assignment list --scope ${resource_id} --include-inherited --all" + safe_to_disable_shared_key=false + rationale_parts+=("User principals hold data-plane roles") +fi + +# Informational: all data-plane role holders +all_data_plane=$(echo "$rbac_assignments" | jq --argjson roles "$data_plane_roles" ' + [.[] | select(.roleDefinitionName as $r | $roles | index($r))] +') +data_plane_count=$(echo "$all_data_plane" | jq 'length') +if [[ "$data_plane_count" -gt 0 ]]; then + holder_summary=$(echo "$all_data_plane" | jq -r '[.[] | "\(.principalName) (\(.roleDefinitionName))"] | join("; ")') + add_issue \ + "Storage data-plane role holders for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Data-plane role holders should be documented before remediation" \ + "${data_plane_count} data-plane role assignment(s) identified" \ + "Holders: ${holder_summary}" \ + "Coordinate with listed principals before disabling public access or shared key authentication." \ + "az role assignment list --scope ${resource_id} --include-inherited --all" +fi + +if [[ "$allow_blob_public_access" == "true" ]]; then + safe_to_disable_public_access=false + rationale_parts+=("allowBlobPublicAccess is enabled") +fi + +if [[ "$shared_key_access" == "true" && "$user_data_plane_count" -gt 0 ]]; then + safe_to_disable_shared_key=false +fi + +rationale="RBAC review complete. ${assignment_count} assignment(s) enumerated." +if [[ ${#rationale_parts[@]} -gt 0 ]]; then + rationale="${rationale} Concerns: $(IFS='; '; echo "${rationale_parts[*]}")." +else + rationale="${rationale} No over-privileged resource-scope Owner/Contributor or user data-plane blockers detected." +fi + +echo "RBAC analysis complete. Issues: $(echo "$issues_json" | jq 'length')" >&2 + +jq -n \ + --argjson issues "$issues_json" \ + --argjson assignments "$rbac_assignments" \ + --argjson summary_by_type "$summary_by_type" \ + --argjson summary_by_role "$summary_by_role" \ + --arg portal_url "$portal_url" \ + --argjson safe_public "$([ "$safe_to_disable_public_access" = true ] && echo true || echo false)" \ + --argjson safe_shared_key "$([ "$safe_to_disable_shared_key" = true ] && echo true || echo false)" \ + --arg rationale "$rationale" \ + --argjson assignment_count "$assignment_count" \ + '{ + issues: $issues, + risk_assessment: { + safe_to_disable_public_access: $safe_public, + safe_to_disable_shared_key: $safe_shared_key, + rationale: $rationale + }, + summary: { + assignment_count: $assignment_count, + by_principal_type: $summary_by_type, + by_role: $summary_by_role, + assignments: $assignments + }, + portal_url: $portal_url + }' > "$OUTPUT_FILE" + +cat "$OUTPUT_FILE" diff --git a/codebundles/azure-storage-account-investigation/query-access-logs.sh b/codebundles/azure-storage-account-investigation/query-access-logs.sh new file mode 100755 index 00000000..53dc5d46 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/query-access-logs.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# AZURE_SUBSCRIPTION_ID +# AZURE_RESOURCE_GROUP +# AZURE_STORAGE_ACCOUNT_NAME +# +# OPTIONAL: +# LOOKBACK_DAYS Days of logs to analyze (default: 7) +# LOG_ANALYTICS_WORKSPACE_ID Workspace resource ID (auto-discovered when empty) +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${AZURE_STORAGE_ACCOUNT_NAME:?Must set AZURE_STORAGE_ACCOUNT_NAME}" + +LOOKBACK_DAYS="${LOOKBACK_DAYS:-7}" +LOG_ANALYTICS_WORKSPACE_ID="${LOG_ANALYTICS_WORKSPACE_ID:-}" +OUTPUT_FILE="access_logs_output.json" +issues_json='[]' + +add_issue() { + local title="$1" severity="$2" expected="$3" actual="$4" details="$5" next_steps="$6" + local reproduce_hint="${7:-}" + issues_json=$(echo "$issues_json" | jq \ + --arg title "$title" \ + --arg expected "$expected" \ + --arg actual "$actual" \ + --arg details "$details" \ + --arg next_steps "$next_steps" \ + --arg reproduce_hint "$reproduce_hint" \ + --argjson severity "$severity" \ + '. += [{ + title: $title, + severity: $severity, + expected: $expected, + actual: $actual, + details: $details, + next_steps: $next_steps, + reproduce_hint: $reproduce_hint + }]') +} + +is_private_ip() { + local ip="$1" + python3 - "$ip" <<'PY' +import ipaddress, sys +ip = sys.argv[1] +try: + addr = ipaddress.ip_address(ip) + print("1" if addr.is_private or addr.is_loopback or addr.is_link_local else "0") +except ValueError: + print("0") +PY +} + +echo "Querying StorageBlobLogs for ${AZURE_STORAGE_ACCOUNT_NAME} (last ${LOOKBACK_DAYS} days)" >&2 + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + +storage_info=$(az storage account show \ + --name "$AZURE_STORAGE_ACCOUNT_NAME" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "") + +if [[ -z "$storage_info" || "$storage_info" == "null" ]]; then + add_issue \ + "Cannot access storage account for log analysis \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Storage account should be readable" \ + "az storage account show failed" \ + "Verify account and permissions." \ + "Confirm storage account exists." \ + "az storage account show --name ${AZURE_STORAGE_ACCOUNT_NAME} --resource-group ${AZURE_RESOURCE_GROUP}" + jq -n --argjson issues "$issues_json" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "Account inaccessible"}, summary: {diagnostic_settings_enabled: false}}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +resource_id=$(echo "$storage_info" | jq -r '.id') +blob_resource_id="${resource_id}/blobServices/default" +portal_url="https://portal.azure.com/#@/resource${resource_id}/logs" +account_name_lower=$(echo "$AZURE_STORAGE_ACCOUNT_NAME" | tr '[:upper:]' '[:lower:]') +timespan="P${LOOKBACK_DAYS}D" + +diagnostic_settings=$(az monitor diagnostic-settings list --resource "$blob_resource_id" -o json 2>/dev/null || echo "[]") +diagnostic_enabled=false +workspace_id="$LOG_ANALYTICS_WORKSPACE_ID" + +if [[ -n "$diagnostic_settings" && "$diagnostic_settings" != "[]" ]]; then + ws_from_diag=$(echo "$diagnostic_settings" | jq -r '.[].workspaceId // empty' | head -1) + storage_logs_enabled=$(echo "$diagnostic_settings" | jq '[.[] | .logs[]? | select(.category == "StorageRead" or .category == "StorageWrite" or .category == "StorageDelete") | select(.enabled == true)] | length') + if [[ -n "$ws_from_diag" ]]; then + workspace_id="$ws_from_diag" + diagnostic_enabled=true + fi + if [[ "$storage_logs_enabled" -gt 0 ]]; then + diagnostic_enabled=true + fi +fi + +if [[ -z "$workspace_id" ]]; then + echo "Attempting workspace auto-discovery from resource group..." >&2 + workspace_id=$(az monitor log-analytics workspace list \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --query "[0].id" -o tsv 2>/dev/null || echo "") +fi + +if [[ "$diagnostic_enabled" != "true" ]]; then + add_issue \ + "StorageBlobLogs diagnostic settings not enabled for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 1 \ + "Storage blob logs should be forwarded to Log Analytics for caller identification" \ + "No diagnostic settings forwarding StorageRead/Write/Delete logs to Log Analytics" \ + "Enable diagnostic settings on blobServices/default with StorageRead, StorageWrite, and StorageDelete categories." \ + "Configure diagnostic settings: Portal > Storage Account > Monitoring > Diagnostic settings > Add > Send to Log Analytics workspace." \ + "az monitor diagnostic-settings list --resource ${blob_resource_id}" + jq -n \ + --argjson issues "$issues_json" \ + --arg portal_url "$portal_url" \ + '{ + issues: $issues, + risk_assessment: { + safe_to_disable_public_access: false, + safe_to_disable_shared_key: false, + rationale: "Access logs unavailable; enable StorageBlobLogs diagnostic settings before remediation" + }, + summary: { + diagnostic_settings_enabled: false, + workspace_id: null + }, + portal_url: $portal_url + }' > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +echo "Using Log Analytics workspace: ${workspace_id}" >&2 + +kql="StorageBlobLogs +| where TimeGenerated > ago(${LOOKBACK_DAYS}d) +| where AccountName =~ '${account_name_lower}' +| summarize RequestCount=count() by CallerIpAddress, AuthenticationType, Identity, UserPrincipalName, ObjectId, OperationName +| order by RequestCount desc +| take 100" + +log_results=$(timeout 120 az monitor log-analytics query \ + --workspace "$workspace_id" \ + --analytics-query "$kql" \ + --timespan "$timespan" \ + -o json 2>log_query.err || echo "[]") + +if echo "$log_results" | grep -qiE 'AuthorizationFailed|403|Forbidden'; then + err_msg=$(cat log_query.err 2>/dev/null || echo "Authorization failure") + add_issue \ + "Log Analytics query blocked for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Log Analytics Reader should allow StorageBlobLogs queries" \ + "Query returned authorization failure" \ + "${err_msg}" \ + "Grant Log Analytics Reader on workspace ${workspace_id}." \ + "az monitor log-analytics query --workspace ${workspace_id} --analytics-query \"StorageBlobLogs | take 1\"" + jq -n --argjson issues "$issues_json" --arg workspace_id "$workspace_id" --arg portal_url "$portal_url" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "Log query blocked"}, summary: {diagnostic_settings_enabled: true, workspace_id: $workspace_id}, portal_url: $portal_url}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +rows=$(echo "$log_results" | jq '. // []') +row_count=$(echo "$rows" | jq 'length') + +safe_public=true +safe_shared_key=true +rationale_parts=() + +anonymous_external=0 +anonymous_internal=0 +account_key_callers=0 + +if [[ "$row_count" -gt 0 ]]; then + while IFS= read -r row; do + auth_type=$(echo "$row" | jq -r '.AuthenticationType // .authenticationType // ""') + caller_ip=$(echo "$row" | jq -r '.CallerIpAddress // .callerIpAddress // ""') + count=$(echo "$row" | jq -r '.RequestCount // .requestCount // 0') + + if [[ "$auth_type" =~ [Aa]nonymous ]]; then + if [[ "$(is_private_ip "$caller_ip")" == "1" ]]; then + anonymous_internal=$((anonymous_internal + count)) + else + anonymous_external=$((anonymous_external + count)) + fi + fi + if [[ "$auth_type" =~ [Aa]ccountKey ]]; then + account_key_callers=$((account_key_callers + 1)) + fi + done < <(echo "$rows" | jq -c '.[]') +fi + +caller_summary=$(echo "$rows" | jq '[.[] | { + caller_ip: (.CallerIpAddress // .callerIpAddress), + auth: (.AuthenticationType // .authenticationType), + identity: (.Identity // .identity // .UserPrincipalName // .ObjectId // "unknown"), + operation: (.OperationName // .operationName), + requests: (.RequestCount // .requestCount) +}]') + +if [[ "$anonymous_external" -gt 0 ]]; then + add_issue \ + "Anonymous blob access from external IPs on \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 2 \ + "No anonymous access from public IP addresses before disabling public blob access" \ + "${anonymous_external} anonymous request(s) from external IPs in last ${LOOKBACK_DAYS} day(s)" \ + "External anonymous traffic indicates active public blob/container consumption." \ + "Identify public containers and external consumers; notify owners before disabling allowBlobPublicAccess." \ + "StorageBlobLogs | where AuthenticationType == \"Anonymous\" | summarize count() by CallerIpAddress" + safe_public=false + rationale_parts+=("Anonymous external IP traffic") +elif [[ "$anonymous_internal" -gt 0 ]]; then + add_issue \ + "Anonymous blob access from internal IPs on \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Anonymous access should be eliminated before public access disablement" \ + "${anonymous_internal} anonymous request(s) from private/internal IPs" \ + "Internal anonymous traffic may originate from VNet-integrated workloads or private endpoints." \ + "Trace internal anonymous callers via Identity and OperationName fields before remediation." \ + "StorageBlobLogs | where AuthenticationType == \"Anonymous\" | summarize count() by CallerIpAddress, Identity" + safe_public=false + rationale_parts+=("Anonymous internal IP traffic") +fi + +distinct_account_key=$(echo "$rows" | jq '[.[] | select((.AuthenticationType // .authenticationType // "") | test("AccountKey"; "i")) | (.CallerIpAddress // .callerIpAddress // "unknown")] | unique | length') +if [[ "$distinct_account_key" -gt 1 ]]; then + add_issue \ + "Multiple distinct AccountKey callers on \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "AccountKey callers should be inventoried before shared key disablement" \ + "${distinct_account_key} distinct caller IP(s) using AccountKey authentication" \ + "Multiple AccountKey sources increase remediation coordination effort." \ + "Contact owners of each caller IP/identity and migrate to OAuth or managed identity." \ + "StorageBlobLogs | where AuthenticationType == \"AccountKey\" | summarize count() by CallerIpAddress, Identity" + safe_shared_key=false + rationale_parts+=("Multiple AccountKey callers") +fi + +rationale="StorageBlobLogs analyzed over ${LOOKBACK_DAYS} day(s). ${row_count} aggregated caller row(s)." +if [[ ${#rationale_parts[@]} -gt 0 ]]; then + rationale="${rationale} $(IFS='; '; echo "${rationale_parts[*]}")." +else + rationale="${rationale} No anonymous or multi-AccountKey blockers detected in logs." +fi + +jq -n \ + --argjson issues "$issues_json" \ + --argjson caller_summary "$caller_summary" \ + --argjson row_count "$row_count" \ + --arg workspace_id "$workspace_id" \ + --arg portal_url "$portal_url" \ + --argjson safe_public "$safe_public" \ + --argjson safe_shared_key "$safe_shared_key" \ + --arg rationale "$rationale" \ + --argjson lookback_days "$LOOKBACK_DAYS" \ + '{ + issues: $issues, + risk_assessment: { + safe_to_disable_public_access: $safe_public, + safe_to_disable_shared_key: $safe_shared_key, + rationale: $rationale + }, + summary: { + diagnostic_settings_enabled: true, + workspace_id: $workspace_id, + lookback_days: $lookback_days, + aggregated_rows: $row_count, + callers: $caller_summary + }, + portal_url: $portal_url + }' > "$OUTPUT_FILE" + +cat "$OUTPUT_FILE" diff --git a/codebundles/azure-storage-account-investigation/query-dependencies.sh b/codebundles/azure-storage-account-investigation/query-dependencies.sh new file mode 100755 index 00000000..ffc9585a --- /dev/null +++ b/codebundles/azure-storage-account-investigation/query-dependencies.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# AZURE_SUBSCRIPTION_ID +# AZURE_RESOURCE_GROUP +# AZURE_STORAGE_ACCOUNT_NAME +# +# OPTIONAL: +# ADDITIONAL_SUBSCRIPTION_IDS Comma-separated subscription IDs for cross-sub queries +# ----------------------------------------------------------------------------- + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${AZURE_STORAGE_ACCOUNT_NAME:?Must set AZURE_STORAGE_ACCOUNT_NAME}" + +ADDITIONAL_SUBSCRIPTION_IDS="${ADDITIONAL_SUBSCRIPTION_IDS:-}" +OUTPUT_FILE="dependencies_output.json" +issues_json='[]' + +add_issue() { + local title="$1" severity="$2" expected="$3" actual="$4" details="$5" next_steps="$6" + local reproduce_hint="${7:-}" + issues_json=$(echo "$issues_json" | jq \ + --arg title "$title" \ + --arg expected "$expected" \ + --arg actual "$actual" \ + --arg details "$details" \ + --arg next_steps "$next_steps" \ + --arg reproduce_hint "$reproduce_hint" \ + --argjson severity "$severity" \ + '. += [{ + title: $title, + severity: $severity, + expected: $expected, + actual: $actual, + details: $details, + next_steps: $next_steps, + reproduce_hint: $reproduce_hint + }]') +} + +merge_graph_results() { + local combined='[]' + while IFS= read -r chunk; do + [[ -z "$chunk" || "$chunk" == "null" ]] && continue + combined=$(echo "$combined" | jq --argjson chunk "$chunk" '. + $chunk') + done + echo "$combined" | jq 'unique_by(.id)' +} + +echo "Querying Resource Graph dependencies for: ${AZURE_STORAGE_ACCOUNT_NAME}" >&2 + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + +storage_info=$(az storage account show \ + --name "$AZURE_STORAGE_ACCOUNT_NAME" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null || echo "") + +if [[ -z "$storage_info" || "$storage_info" == "null" ]]; then + add_issue \ + "Cannot resolve storage account for dependency mapping \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Storage account metadata required for dependency queries" \ + "az storage account show failed" \ + "Verify account name and permissions." \ + "Confirm storage account exists and credentials have Reader access." \ + "az storage account show --name ${AZURE_STORAGE_ACCOUNT_NAME} --resource-group ${AZURE_RESOURCE_GROUP}" + jq -n --argjson issues "$issues_json" \ + '{issues: $issues, risk_assessment: {safe_to_disable_public_access: false, safe_to_disable_shared_key: false, rationale: "Account not found"}, summary: {dependency_count: 0, dependencies: []}}' \ + > "$OUTPUT_FILE" + cat "$OUTPUT_FILE" + exit 0 +fi + +resource_id=$(echo "$storage_info" | jq -r '.id') +portal_url="https://portal.azure.com/#@/resource${resource_id}" +primary_blob=$(echo "$storage_info" | jq -r '.primaryEndpoints.blob // empty' | sed 's|https://||; s|/$||') +primary_dfs=$(echo "$storage_info" | jq -r '.primaryEndpoints.dfs // empty' | sed 's|https://||; s|/$||') +account_name_lower=$(echo "$AZURE_STORAGE_ACCOUNT_NAME" | tr '[:upper:]' '[:lower:]') + +subscriptions=("$AZURE_SUBSCRIPTION_ID") +if [[ -n "$ADDITIONAL_SUBSCRIPTION_IDS" ]]; then + IFS=',' read -ra extra_subs <<< "$ADDITIONAL_SUBSCRIPTION_IDS" + for sub in "${extra_subs[@]}"; do + sub=$(echo "$sub" | xargs) + [[ -n "$sub" ]] && subscriptions+=("$sub") + done +fi +sub_args=() +for sub in "${subscriptions[@]}"; do + sub_args+=(--subscriptions "$sub") +done + +echo "Searching subscriptions: ${subscriptions[*]}" >&2 + +# Query 1: property references (name + blob/dfs FQDN) +prop_query="Resources +| where id !~ '${resource_id}' +| where tostring(properties) contains '${account_name_lower}' + or (isnotempty('${primary_blob}') and tostring(properties) contains '${primary_blob}') + or (isnotempty('${primary_dfs}') and tostring(properties) contains '${primary_dfs}') +| project id, name, type, resourceGroup, subscriptionId, location" + +prop_result=$(az graph query -q "$prop_query" "${sub_args[@]}" -o json 2>/dev/null | jq '.data // []' || echo '[]') + +# Query 2: private endpoint connections targeting this storage account +pe_query="Resources +| where type == 'microsoft.network/privateendpoints' +| mv-expand plc = properties.privateLinkServiceConnections +| where tostring(plc.properties.privateLinkServiceId) =~ '${resource_id}' +| project id, name, type, resourceGroup, subscriptionId, location" + +pe_result=$(az graph query -q "$pe_query" "${sub_args[@]}" -o json 2>/dev/null | jq '.data // []' || echo '[]') + +# Query 3: diagnostic settings referencing this storage account as log destination +diag_query="Resources +| where type == 'microsoft.insights/diagnosticsettings' +| where tostring(properties) contains '${account_name_lower}' + or tostring(properties) contains '${resource_id}' +| project id, name, type, resourceGroup, subscriptionId, location" + +diag_result=$(az graph query -q "$diag_query" "${sub_args[@]}" -o json 2>/dev/null | jq '.data // []' || echo '[]') + +dependencies=$(merge_graph_results <<< "$(echo "$prop_result"; echo "$pe_result"; echo "$diag_result")") +dependency_count=$(echo "$dependencies" | jq 'length') + +echo "Found ${dependency_count} dependent resource(s) via Resource Graph" >&2 + +blind_spots="Resource Graph cannot see Key Vault secret references, Databricks mount paths, application code, Terraform state backends, or SAS tokens embedded outside Azure resource properties." + +dependency_list=$(echo "$dependencies" | jq '[.[] | { + id: .id, + name: .name, + type: .type, + resourceGroup: .resourceGroup, + subscriptionId: .subscriptionId, + portal_url: ("https://portal.azure.com/#@/resource" + .id) +}]') + +if [[ "$dependency_count" -gt 5 ]]; then + top_names=$(echo "$dependencies" | jq -r '[.[].name][0:8] | join(", ")') + add_issue \ + "High blast radius: ${dependency_count} dependents reference \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 2 \ + "Fewer than 6 direct Azure resource dependencies before disabling public access" \ + "${dependency_count} dependent resources found via Resource Graph" \ + "Sample dependents: ${top_names}. ${blind_spots}" \ + "Review each dependent resource before disabling public blob access or shared key auth. Expand search with ADDITIONAL_SUBSCRIPTION_IDS if workloads span subscriptions." \ + "az graph query -q \"Resources | where tostring(properties) contains '${account_name_lower}'\"" +elif [[ "$dependency_count" -ge 1 ]]; then + dep_summary=$(echo "$dependencies" | jq -r '[.[] | "\(.name) (\(.type))"] | join("; ")') + add_issue \ + "Moderate blast radius: ${dependency_count} dependent resource(s) for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 3 \ + "Understand all dependents before configuration changes" \ + "${dependency_count} dependent resource(s) found" \ + "Dependents: ${dep_summary}. ${blind_spots}" \ + "Validate each dependent still functions after disabling public access or shared keys." \ + "az graph query -q \"Resources | where tostring(properties) contains '${account_name_lower}'\"" +else + add_issue \ + "No Resource Graph dependencies found for \`${AZURE_STORAGE_ACCOUNT_NAME}\`" \ + 4 \ + "Dependency mapping should confirm whether hidden consumers exist" \ + "0 dependents returned by Resource Graph (property, private endpoint, diagnostic queries)" \ + "${blind_spots} Zero graph hits does not prove the account is unused." \ + "Supplement Resource Graph with access logs and transaction metrics before remediation." \ + "az graph query -q \"Resources | where tostring(properties) contains '${account_name_lower}'\"" +fi + +safe_public=$([ "$dependency_count" -le 5 ] && echo true || echo false) +safe_shared_key=$([ "$dependency_count" -le 5 ] && echo true || echo false) +rationale="Resource Graph mapped ${dependency_count} dependent resource(s). ${blind_spots}" + +jq -n \ + --argjson issues "$issues_json" \ + --argjson dependencies "$dependency_list" \ + --argjson dependency_count "$dependency_count" \ + --arg portal_url "$portal_url" \ + --argjson safe_public "$safe_public" \ + --argjson safe_shared_key "$safe_shared_key" \ + --arg rationale "$rationale" \ + --arg blind_spots "$blind_spots" \ + '{ + issues: $issues, + risk_assessment: { + safe_to_disable_public_access: $safe_public, + safe_to_disable_shared_key: $safe_shared_key, + rationale: $rationale + }, + summary: { + dependency_count: $dependency_count, + dependencies: $dependencies, + blind_spots: $blind_spots, + queries_run: ["property_references", "private_endpoints", "diagnostic_settings"] + }, + portal_url: $portal_url + }' > "$OUTPUT_FILE" + +cat "$OUTPUT_FILE" diff --git a/codebundles/azure-storage-account-investigation/runbook.robot b/codebundles/azure-storage-account-investigation/runbook.robot new file mode 100644 index 00000000..662db213 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/runbook.robot @@ -0,0 +1,219 @@ +*** Settings *** +Documentation Investigate Azure Storage Account utilization, ownership, dependencies, and access patterns to support safe remediation of public blob access and shared key authentication. +Metadata Author rw-codebundle-agent +Metadata Display Name Azure Storage Account Investigation +Metadata Supports Azure Storage Account Investigation Security RBAC Dependencies Metrics Logs +Force Tags Azure Storage Account Investigation Security access:read-only + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + + +*** Tasks *** +List Storage Account RBAC Role Assignments for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Identify principals with RBAC access to the storage account including inherited assignments and flag over-privileged or user-based data-plane access. + [Tags] Azure Storage RBAC Security access:read-only data:logs-config + ${result}= RW.CLI.Run Bash File + ... bash_file=list-rbac-assignments.sh + ... env=${env} + ... secret__azure_credentials=${azure_credentials} + ... timeout_seconds=180 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./list-rbac-assignments.sh + + RW.Core.Add Pre To Report ${result.stderr} + + TRY + ${payload}= Evaluate json.loads(r'''${result.stdout}''') json + ${issue_list}= Set Variable ${payload['issues']} + EXCEPT + Log Failed to parse JSON for RBAC 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=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Query Resource Graph for Storage Account Dependencies for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Map Azure resources referencing the storage account via Resource Graph to quantify blast radius before disabling public access. + [Tags] Azure Storage Dependencies ResourceGraph access:read-only data:logs-config + ${result}= RW.CLI.Run Bash File + ... bash_file=query-dependencies.sh + ... env=${env} + ... secret__azure_credentials=${azure_credentials} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./query-dependencies.sh + + RW.Core.Add Pre To Report ${result.stderr} + + TRY + ${payload}= Evaluate json.loads(r'''${result.stdout}''') json + ${issue_list}= Set Variable ${payload['issues']} + EXCEPT + Log Failed to parse JSON for dependencies 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=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Analyze Storage Account Transaction Metrics by Authentication Type for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Pull Azure Monitor blob transaction metrics by authentication type and assess risk of disabling public access or shared key authentication. + [Tags] Azure Storage Metrics Authentication access:read-only data:metrics + ${result}= RW.CLI.Run Bash File + ... bash_file=analyze-transaction-metrics.sh + ... env=${env} + ... secret__azure_credentials=${azure_credentials} + ... timeout_seconds=240 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./analyze-transaction-metrics.sh + + RW.Core.Add Pre To Report ${result.stderr} + + TRY + ${payload}= Evaluate json.loads(r'''${result.stdout}''') json + ${issue_list}= Set Variable ${payload['issues']} + EXCEPT + Log Failed to parse JSON for metrics 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=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + +Query Storage Account Access Logs for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Query StorageBlobLogs for caller IPs, identities, and authentication types; short-circuits when diagnostic settings are not enabled. + [Tags] Azure Storage Logs Access access:read-only data:logs-config + ${result}= RW.CLI.Run Bash File + ... bash_file=query-access-logs.sh + ... env=${env} + ... secret__azure_credentials=${azure_credentials} + ... timeout_seconds=300 + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./query-access-logs.sh + + RW.Core.Add Pre To Report ${result.stderr} + + TRY + ${payload}= Evaluate json.loads(r'''${result.stdout}''') json + ${issue_list}= Set Variable ${payload['issues']} + EXCEPT + Log Failed to parse JSON for access logs 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=${issue['expected']} + ... actual=${issue['actual']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + +*** Keywords *** +Suite Initialization + TRY + ${azure_credentials}= RW.Core.Import Secret + ... azure_credentials + ... type=string + ... description=Azure Service Principal credentials (clientId, clientSecret, tenantId, subscriptionId) + ... pattern=\w* + Set Suite Variable ${azure_credentials} ${azure_credentials} + EXCEPT + Log azure_credentials secret not found; relying on ambient az login context WARN + Set Suite Variable ${azure_credentials} ${EMPTY} + END + + ${AZURE_SUBSCRIPTION_ID}= RW.Core.Import User Variable AZURE_SUBSCRIPTION_ID + ... type=string + ... description=Azure subscription ID containing the storage account + ... pattern=\w* + ${AZURE_RESOURCE_GROUP}= RW.Core.Import User Variable AZURE_RESOURCE_GROUP + ... type=string + ... description=Resource group containing the storage account + ... pattern=\w* + ${AZURE_STORAGE_ACCOUNT_NAME}= RW.Core.Import User Variable AZURE_STORAGE_ACCOUNT_NAME + ... type=string + ... description=Name of the storage account to investigate + ... pattern=\w* + ${LOOKBACK_DAYS}= RW.Core.Import User Variable LOOKBACK_DAYS + ... type=string + ... description=Days of metrics and logs to analyze + ... pattern=\d+ + ... default=7 + ${ADDITIONAL_SUBSCRIPTION_IDS}= RW.Core.Import User Variable ADDITIONAL_SUBSCRIPTION_IDS + ... type=string + ... description=Comma-separated subscription IDs for cross-subscription Resource Graph queries + ... pattern=[\w,\-]* + ... default=${EMPTY} + ${LOG_ANALYTICS_WORKSPACE_ID}= RW.Core.Import User Variable LOG_ANALYTICS_WORKSPACE_ID + ... type=string + ... description=Log Analytics workspace ID for StorageBlobLogs queries + ... 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 ${AZURE_STORAGE_ACCOUNT_NAME} ${AZURE_STORAGE_ACCOUNT_NAME} + Set Suite Variable ${LOOKBACK_DAYS} ${LOOKBACK_DAYS} + Set Suite Variable ${ADDITIONAL_SUBSCRIPTION_IDS} ${ADDITIONAL_SUBSCRIPTION_IDS} + Set Suite Variable ${LOG_ANALYTICS_WORKSPACE_ID} ${LOG_ANALYTICS_WORKSPACE_ID} + + ${env_dict}= Create Dictionary + ... AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID} + ... AZURE_RESOURCE_GROUP=${AZURE_RESOURCE_GROUP} + ... AZURE_STORAGE_ACCOUNT_NAME=${AZURE_STORAGE_ACCOUNT_NAME} + ... LOOKBACK_DAYS=${LOOKBACK_DAYS} + ... ADDITIONAL_SUBSCRIPTION_IDS=${ADDITIONAL_SUBSCRIPTION_IDS} + ... LOG_ANALYTICS_WORKSPACE_ID=${LOG_ANALYTICS_WORKSPACE_ID} + Set Suite Variable ${env} ${env_dict} + + RW.CLI.Run Cli + ... cmd=az account set --subscription ${AZURE_SUBSCRIPTION_ID} + ... include_in_history=false diff --git a/codebundles/azure-storage-account-investigation/sli-investigation-score.sh b/codebundles/azure-storage-account-investigation/sli-investigation-score.sh new file mode 100755 index 00000000..ab58b480 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/sli-investigation-score.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# Lightweight SLI probe: returns JSON with dimension scores (0 or 1). + +: "${AZURE_SUBSCRIPTION_ID:?Must set AZURE_SUBSCRIPTION_ID}" +: "${AZURE_RESOURCE_GROUP:?Must set AZURE_RESOURCE_GROUP}" +: "${AZURE_STORAGE_ACCOUNT_NAME:?Must set AZURE_STORAGE_ACCOUNT_NAME}" + +OUTPUT_FILE="sli_probe_output.json" + +az account set --subscription "$AZURE_SUBSCRIPTION_ID" 2>/dev/null || true + +score_account=0 +score_rbac=0 +score_metrics=0 +score_logs=0 + +if storage_info=$(az storage account show \ + --name "$AZURE_STORAGE_ACCOUNT_NAME" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + -o json 2>/dev/null); then + score_account=1 + resource_id=$(echo "$storage_info" | jq -r '.id') + blob_resource_id="${resource_id}/blobServices/default" + + if rbac=$(az role assignment list --scope "$resource_id" --include-inherited --all -o json 2>/dev/null); then + [[ -n "$rbac" ]] && score_rbac=1 + fi + + end_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + start_time=$(date -u -d "1 day ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-1d +"%Y-%m-%dT%H:%M:%SZ") + if metrics=$(az monitor metrics list \ + --resource "$blob_resource_id" \ + --metric "Transactions" \ + --aggregation Total \ + --interval PT1H \ + --start-time "$start_time" \ + --end-time "$end_time" \ + -o json 2>/dev/null); then + [[ -n "$metrics" && "$metrics" != "null" ]] && score_metrics=1 + fi + + if diag=$(az monitor diagnostic-settings list --resource "$blob_resource_id" -o json 2>/dev/null); then + ws=$(echo "$diag" | jq -r '.[].workspaceId // empty' | head -1) + logs_on=$(echo "$diag" | jq '[.[] | .logs[]? | select(.enabled == true)] | length') + if [[ -n "$ws" || "$logs_on" -gt 0 ]]; then + score_logs=1 + fi + fi +fi + +health_score=$(python3 -c "print(round((${score_account}+${score_rbac}+${score_metrics}+${score_logs})/4, 2))") + +jq -n \ + --argjson score_account "$score_account" \ + --argjson score_rbac "$score_rbac" \ + --argjson score_metrics "$score_metrics" \ + --argjson score_logs "$score_logs" \ + --argjson health_score "$health_score" \ + '{ + dimensions: { + account_accessible: $score_account, + rbac_enumerated: $score_rbac, + metrics_available: $score_metrics, + logs_enabled: $score_logs + }, + health_score: $health_score + }' > "$OUTPUT_FILE" + +cat "$OUTPUT_FILE" diff --git a/codebundles/azure-storage-account-investigation/sli.robot b/codebundles/azure-storage-account-investigation/sli.robot new file mode 100644 index 00000000..4330cc66 --- /dev/null +++ b/codebundles/azure-storage-account-investigation/sli.robot @@ -0,0 +1,152 @@ +*** Settings *** +Documentation Measures investigation completeness for an Azure Storage Account by scoring account access, RBAC enumeration, metrics availability, and diagnostic log forwarding. +Metadata Author rw-codebundle-agent +Metadata Display Name Azure Storage Account Investigation SLI +Metadata Supports Azure Storage Account Investigation SLI +Suite Setup Suite Initialization + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + + +*** Tasks *** +Check Storage Account Accessibility for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Verifies the storage account is readable via Azure CLI. + [Tags] access:read-only data:config + ${result}= RW.CLI.Run Cli + ... cmd=az storage account show --name "${AZURE_STORAGE_ACCOUNT_NAME}" --resource-group "${AZURE_RESOURCE_GROUP}" --subscription "${AZURE_SUBSCRIPTION_ID}" -o json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + TRY + ${info}= Evaluate json.loads(r'''${result.stdout}''') json + ${score}= Set Variable ${1} + EXCEPT + ${score}= Set Variable ${0} + END + Set Suite Variable ${account_score} ${score} + RW.Core.Push Metric ${score} sub_name=account_accessible + +Check RBAC Enumeration for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Scores whether RBAC assignments can be listed for the storage account. + [Tags] access:read-only data:config + ${resource_id}= RW.CLI.Run Cli + ... cmd=az storage account show --name "${AZURE_STORAGE_ACCOUNT_NAME}" --resource-group "${AZURE_RESOURCE_GROUP}" --subscription "${AZURE_SUBSCRIPTION_ID}" --query id -o tsv + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + ${rbac}= RW.CLI.Run Cli + ... cmd=az role assignment list --scope "${resource_id.stdout.strip()}" --include-inherited --all -o json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + TRY + ${assignments}= Evaluate json.loads(r'''${rbac.stdout}''') json + ${score}= Evaluate 1 if len(@{assignments}) >= 0 and "${rbac.return_code}" == "0" else 0 + EXCEPT + ${score}= Set Variable ${0} + END + Set Suite Variable ${rbac_score} ${score} + RW.Core.Push Metric ${score} sub_name=rbac_enumerated + +Check Metrics Availability for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Scores whether blob transaction metrics are queryable. + [Tags] access:read-only data:metrics + ${resource_id}= RW.CLI.Run Cli + ... cmd=az storage account show --name "${AZURE_STORAGE_ACCOUNT_NAME}" --resource-group "${AZURE_RESOURCE_GROUP}" --subscription "${AZURE_SUBSCRIPTION_ID}" --query id -o tsv + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + ${blob_id}= Set Variable ${resource_id.stdout.strip()}/blobServices/default + ${metrics}= RW.CLI.Run Cli + ... cmd=az monitor metrics list --resource "${blob_id}" --metric Transactions --aggregation Total --interval PT1H --offset 1d -o json + ... env=${env} + ... timeout_seconds=90 + ... include_in_history=false + TRY + ${payload}= Evaluate json.loads(r'''${metrics.stdout}''') json + ${score}= Evaluate 1 if 'value' in $payload else 0 + EXCEPT + ${score}= Set Variable ${0} + END + Set Suite Variable ${metrics_score} ${score} + RW.Core.Push Metric ${score} sub_name=metrics_available + +Check Diagnostic Log Forwarding for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Scores whether blob diagnostic settings forward logs to Log Analytics. + [Tags] access:read-only data:logs-config + ${resource_id}= RW.CLI.Run Cli + ... cmd=az storage account show --name "${AZURE_STORAGE_ACCOUNT_NAME}" --resource-group "${AZURE_RESOURCE_GROUP}" --subscription "${AZURE_SUBSCRIPTION_ID}" --query id -o tsv + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + ${blob_id}= Set Variable ${resource_id.stdout.strip()}/blobServices/default + ${diag}= RW.CLI.Run Cli + ... cmd=az monitor diagnostic-settings list --resource "${blob_id}" -o json + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + TRY + ${settings}= Evaluate json.loads(r'''${diag.stdout}''') json + ${score}= Evaluate 1 if len(@{settings}) > 0 and any(s.get('workspaceId') for s in $settings) else 0 + EXCEPT + ${score}= Set Variable ${0} + END + Set Suite Variable ${logs_score} ${score} + RW.Core.Push Metric ${score} sub_name=logs_enabled + +Generate Investigation Completeness Score for `${AZURE_STORAGE_ACCOUNT_NAME}` + [Documentation] Averages dimension scores into a 0-1 investigation completeness metric. + [Tags] access:read-only data:config + ${health_score}= Evaluate (${account_score} + ${rbac_score} + ${metrics_score} + ${logs_score}) / 4 + ${health_score}= Convert To Number ${health_score} 2 + RW.Core.Add to Report Investigation completeness score: ${health_score} + RW.Core.Push Metric ${health_score} + + +*** Keywords *** +Suite Initialization + TRY + ${azure_credentials}= RW.Core.Import Secret + ... azure_credentials + ... type=string + ... description=Azure Service Principal credentials + ... pattern=\w* + Set Suite Variable ${azure_credentials} ${azure_credentials} + EXCEPT + Log azure_credentials secret not found; relying on ambient az login context WARN + Set Suite Variable ${azure_credentials} ${EMPTY} + END + + ${AZURE_SUBSCRIPTION_ID}= RW.Core.Import User Variable AZURE_SUBSCRIPTION_ID + ... type=string + ... description=Azure subscription ID containing the storage account + ... pattern=\w* + ${AZURE_RESOURCE_GROUP}= RW.Core.Import User Variable AZURE_RESOURCE_GROUP + ... type=string + ... description=Resource group containing the storage account + ... pattern=\w* + ${AZURE_STORAGE_ACCOUNT_NAME}= RW.Core.Import User Variable AZURE_STORAGE_ACCOUNT_NAME + ... type=string + ... description=Name of the storage account to investigate + ... pattern=\w* + + Set Suite Variable ${AZURE_SUBSCRIPTION_ID} ${AZURE_SUBSCRIPTION_ID} + Set Suite Variable ${AZURE_RESOURCE_GROUP} ${AZURE_RESOURCE_GROUP} + Set Suite Variable ${AZURE_STORAGE_ACCOUNT_NAME} ${AZURE_STORAGE_ACCOUNT_NAME} + Set Suite Variable ${account_score} ${0} + Set Suite Variable ${rbac_score} ${0} + Set Suite Variable ${metrics_score} ${0} + Set Suite Variable ${logs_score} ${0} + + ${env_dict}= Create Dictionary + ... AZURE_SUBSCRIPTION_ID=${AZURE_SUBSCRIPTION_ID} + ... AZURE_RESOURCE_GROUP=${AZURE_RESOURCE_GROUP} + ... AZURE_STORAGE_ACCOUNT_NAME=${AZURE_STORAGE_ACCOUNT_NAME} + Set Suite Variable ${env} ${env_dict} + + RW.CLI.Run Cli + ... cmd=az account set --subscription ${AZURE_SUBSCRIPTION_ID} + ... include_in_history=false