#!/usr/bin/env bash ######################################################################################### #### azure-blob-manager.sh — Manage Azure Blob Storage containers, lifecycle, and #### #### access auditing via az CLI. Upload, sync, tier, and audit blob storage #### #### Requires: bash 4+, az CLI, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./azure-blob-manager.sh --list #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Colors (pre-initialized) ───────────────────────────────────────── RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "${COLOR:-auto}" == "never" ]]; then return fi if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } elapsed() { local end_time end_time=$(date +%s) echo "$(( end_time - START_TIME ))s" } # ── Severity counters (for audit mode) ─────────────────────────────── TOTAL_CRIT=0 TOTAL_WARN=0 TOTAL_INFO=0 TOTAL_OK=0 flag_crit() { ((TOTAL_CRIT++)) || true; } flag_warn() { ((TOTAL_WARN++)) || true; } flag_info() { ((TOTAL_INFO++)) || true; } flag_ok() { ((TOTAL_OK++)) || true; } # ── Defaults ────────────────────────────────────────────────────────── RUN_MODE="" STORAGE_ACCOUNT="" CONTAINER_NAME="" RESOURCE_GROUP="" OUTPUT_FORMAT="${ABM_FORMAT:-text}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" MAX_AGE="${ABM_MAX_AGE:-90}" TIER_TARGET="" SOURCE_PATH="" SUBSCRIPTION="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── Dependency and credential checks ──────────────────────────────── check_deps() { command -v az &>/dev/null || die "az CLI is required (install: https://aka.ms/InstallAzureCLIDeb)" command -v jq &>/dev/null || die "jq is required" } check_credentials() { local acct acct=$(az account show --output json 2>&1) || die "Azure credentials not configured — run 'az login'" local sub_name sub_name=$(echo "$acct" | jq -r '.name') log "Subscription: ${sub_name}" if [[ -n "$SUBSCRIPTION" ]]; then az account set --subscription "$SUBSCRIPTION" 2>/dev/null \ || die "Cannot switch to subscription: ${SUBSCRIPTION}" fi } # ── Azure CLI wrapper ──────────────────────────────────────────────── az_cmd() { local args=("$@") [[ -n "$SUBSCRIPTION" ]] && args+=(--subscription "$SUBSCRIPTION") verbose "az ${args[*]}" az "${args[@]}" } # ══════════════════════════════════════════════════════════════════════ # LIST # ══════════════════════════════════════════════════════════════════════ do_list() { if [[ -n "$STORAGE_ACCOUNT" && -n "$CONTAINER_NAME" ]]; then list_blobs elif [[ -n "$STORAGE_ACCOUNT" ]]; then list_containers else list_accounts fi } list_accounts() { section_header "Storage Accounts" local accounts local args=(storage account list --output json) [[ -n "$RESOURCE_GROUP" ]] && args+=(--resource-group "$RESOURCE_GROUP") accounts=$(az_cmd "${args[@]}" 2>/dev/null) local count count=$(echo "$accounts" | jq 'length') printf " %-28s %-16s %-12s %-12s %s\n" \ "ACCOUNT" "RESOURCE_GROUP" "KIND" "REPLICATION" "LOCATION" printf " %s\n" "$(printf '%.0s─' {1..90})" echo "$accounts" | jq -c '.[]' | while IFS= read -r acct; do local name rg kind repl location name=$(echo "$acct" | jq -r '.name') rg=$(echo "$acct" | jq -r '.resourceGroup') kind=$(echo "$acct" | jq -r '.kind') repl=$(echo "$acct" | jq -r '.sku.name') location=$(echo "$acct" | jq -r '.location') printf " %-28s %-16s %-12s %-12s %s\n" \ "${name:0:27}" "${rg:0:15}" "${kind:0:11}" "${repl:0:11}" "$location" done echo "" field "Total accounts:" "$count" } list_containers() { section_header "Containers in ${STORAGE_ACCOUNT}" local containers containers=$(az_cmd storage container list \ --account-name "$STORAGE_ACCOUNT" --auth-mode login \ --output json 2>/dev/null) || die "Failed to list containers — check permissions" local count count=$(echo "$containers" | jq 'length') printf " %-32s %-16s %-12s %s\n" \ "CONTAINER" "PUBLIC_ACCESS" "LEASE_STATE" "LAST_MODIFIED" printf " %s\n" "$(printf '%.0s─' {1..80})" echo "$containers" | jq -c '.[]' | while IFS= read -r ctr; do local name public_access lease_state last_mod name=$(echo "$ctr" | jq -r '.name') public_access=$(echo "$ctr" | jq -r '.properties.publicAccess // "none"') lease_state=$(echo "$ctr" | jq -r '.properties.leaseState // "available"') last_mod=$(echo "$ctr" | jq -r '.properties.lastModified // ""' | cut -dT -f1) printf " %-32s %-16s %-12s %s\n" \ "${name:0:31}" "$public_access" "$lease_state" "$last_mod" done echo "" field "Total containers:" "$count" } list_blobs() { section_header "Blobs in ${STORAGE_ACCOUNT}/${CONTAINER_NAME}" local blobs blobs=$(az_cmd storage blob list \ --account-name "$STORAGE_ACCOUNT" --container-name "$CONTAINER_NAME" \ --auth-mode login --output json 2>/dev/null) \ || die "Failed to list blobs — check permissions" local count count=$(echo "$blobs" | jq 'length') printf " %-40s %-12s %-8s %s\n" \ "NAME" "SIZE" "TIER" "LAST_MODIFIED" printf " %s\n" "$(printf '%.0s─' {1..80})" echo "$blobs" | jq -c '.[]' | while IFS= read -r blob; do local name size tier last_mod size_str name=$(echo "$blob" | jq -r '.name') size=$(echo "$blob" | jq -r '.properties.contentLength // 0') tier=$(echo "$blob" | jq -r '.properties.blobTier // "N/A"') last_mod=$(echo "$blob" | jq -r '.properties.lastModified // ""' | cut -dT -f1) if (( size > 1073741824 )); then size_str="$(( size / 1073741824 )) GB" elif (( size > 1048576 )); then size_str="$(( size / 1048576 )) MB" elif (( size > 1024 )); then size_str="$(( size / 1024 )) KB" else size_str="${size} B" fi printf " %-40s %-12s %-8s %s\n" \ "${name:0:39}" "$size_str" "$tier" "$last_mod" done echo "" field "Total blobs:" "$count" } # ══════════════════════════════════════════════════════════════════════ # AUDIT # ══════════════════════════════════════════════════════════════════════ do_audit() { section_header "Storage Security Audit" local accounts local args=(storage account list --output json) [[ -n "$RESOURCE_GROUP" ]] && args+=(--resource-group "$RESOURCE_GROUP") accounts=$(az_cmd "${args[@]}" 2>/dev/null) printf " %-28s %-16s %-14s %-14s %s\n" \ "ACCOUNT" "HTTPS_ONLY" "PUBLIC_BLOB" "NETWORK_RULES" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..95})" echo "$accounts" | jq -c '.[]' | while IFS= read -r acct; do local name https_only public_access net_default name=$(echo "$acct" | jq -r '.name') https_only=$(echo "$acct" | jq -r '.enableHttpsTrafficOnly // true') public_access=$(echo "$acct" | jq -r '.allowBlobPublicAccess // false') net_default=$(echo "$acct" | jq -r '.networkRuleSet.defaultAction // "Allow"') local severity="OK" color="$GREEN" if [[ "$public_access" == "true" ]]; then severity="CRITICAL"; color="$RED"; flag_crit elif [[ "$https_only" != "true" ]]; then severity="WARN"; color="$YELLOW"; flag_warn elif [[ "$net_default" == "Allow" ]]; then severity="WARN"; color="$YELLOW"; flag_warn else flag_ok fi printf " %-28s %-16s %-14s %-14s %b%s%b\n" \ "${name:0:27}" "$https_only" "$public_access" "$net_default" \ "$color" "$severity" "$RESET" done echo "" # Check individual containers for public access log "Checking container-level public access..." echo "" echo "$accounts" | jq -r '.[].name' | while IFS= read -r acct_name; do local containers containers=$(az_cmd storage container list \ --account-name "$acct_name" --auth-mode login \ --output json 2>/dev/null) || continue echo "$containers" | jq -c '.[]' | while IFS= read -r ctr; do local ctr_name public_access ctr_name=$(echo "$ctr" | jq -r '.name') public_access=$(echo "$ctr" | jq -r '.properties.publicAccess // "none"') if [[ "$public_access" != "none" && "$public_access" != "null" ]]; then printf " %-28s %-28s %-14s %b%s%b\n" \ "${acct_name:0:27}" "${ctr_name:0:27}" "$public_access" \ "$RED" "CRITICAL" "$RESET" flag_crit fi done done print_summary } # ══════════════════════════════════════════════════════════════════════ # SYNC # ══════════════════════════════════════════════════════════════════════ do_sync() { [[ -z "$STORAGE_ACCOUNT" ]] && die "--sync requires --account" [[ -z "$CONTAINER_NAME" ]] && die "--sync requires --container" [[ -z "$SOURCE_PATH" ]] && die "--sync requires --source PATH" [[ -d "$SOURCE_PATH" ]] || die "Source path does not exist: ${SOURCE_PATH}" section_header "Syncing to ${STORAGE_ACCOUNT}/${CONTAINER_NAME}" field "Source:" "$SOURCE_PATH" echo "" if az_cmd storage blob upload-batch \ --account-name "$STORAGE_ACCOUNT" \ --destination "$CONTAINER_NAME" \ --source "$SOURCE_PATH" \ --auth-mode login \ --overwrite 2>/dev/null; then echo -e " ${GREEN}✓${RESET} Sync complete" else die "Sync failed" fi } # ══════════════════════════════════════════════════════════════════════ # TIER # ══════════════════════════════════════════════════════════════════════ do_tier() { [[ -z "$STORAGE_ACCOUNT" ]] && die "--tier requires --account" [[ -z "$CONTAINER_NAME" ]] && die "--tier requires --container" [[ -z "$TIER_TARGET" ]] && die "--tier requires --set-tier TIER" section_header "Changing Blob Tier" field "Account:" "$STORAGE_ACCOUNT" field "Container:" "$CONTAINER_NAME" field "Target tier:" "$TIER_TARGET" echo "" local blobs blobs=$(az_cmd storage blob list \ --account-name "$STORAGE_ACCOUNT" --container-name "$CONTAINER_NAME" \ --auth-mode login --output json 2>/dev/null) \ || die "Failed to list blobs" local changed=0 errors=0 echo "$blobs" | jq -r '.[].name' | while IFS= read -r blob_name; do if az_cmd storage blob set-tier \ --account-name "$STORAGE_ACCOUNT" --container-name "$CONTAINER_NAME" \ --name "$blob_name" --tier "$TIER_TARGET" \ --auth-mode login 2>/dev/null; then echo -e " ${GREEN}✓${RESET} ${blob_name} → ${TIER_TARGET}" ((changed++)) || true else echo -e " ${RED}✗${RESET} ${blob_name} — failed" ((errors++)) || true fi done echo "" field "Changed:" "$changed" [[ "$errors" -gt 0 ]] && field_color "Errors:" "${RED}${errors}${RESET}" } # ══════════════════════════════════════════════════════════════════════ # LIFECYCLE # ══════════════════════════════════════════════════════════════════════ do_lifecycle() { [[ -z "$STORAGE_ACCOUNT" ]] && die "--lifecycle requires --account" [[ -z "$RESOURCE_GROUP" ]] && die "--lifecycle requires --resource-group" section_header "Lifecycle Management Policy" field "Account:" "$STORAGE_ACCOUNT" echo "" local policy policy=$(az_cmd storage account management-policy show \ --account-name "$STORAGE_ACCOUNT" --resource-group "$RESOURCE_GROUP" \ --output json 2>/dev/null) if [[ -z "$policy" || "$policy" == "null" ]]; then log "No lifecycle policy configured" else echo "$policy" | jq '.policy.rules[] | {name: .name, type: .type, definition: .definition}' fi } # ══════════════════════════════════════════════════════════════════════ # STATS # ══════════════════════════════════════════════════════════════════════ do_stats() { section_header "Storage Statistics" local accounts local args=(storage account list --output json) [[ -n "$RESOURCE_GROUP" ]] && args+=(--resource-group "$RESOURCE_GROUP") accounts=$(az_cmd "${args[@]}" 2>/dev/null) local total_accounts total_accounts=$(echo "$accounts" | jq 'length') printf " %-28s %-16s %-12s %s\n" \ "ACCOUNT" "LOCATION" "KIND" "REPLICATION" printf " %s\n" "$(printf '%.0s─' {1..75})" echo "$accounts" | jq -c '.[]' | while IFS= read -r acct; do local name location kind repl name=$(echo "$acct" | jq -r '.name') location=$(echo "$acct" | jq -r '.location') kind=$(echo "$acct" | jq -r '.kind') repl=$(echo "$acct" | jq -r '.sku.name') printf " %-28s %-16s %-12s %s\n" \ "${name:0:27}" "$location" "${kind:0:11}" "$repl" done echo "" field "Total accounts:" "$total_accounts" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { echo "" echo " ══════════════════════════════════════════" echo " Storage Audit Summary" echo " ══════════════════════════════════════════" printf " %-20s %b%d%b\n" "CRITICAL:" "$RED" "$TOTAL_CRIT" "$RESET" printf " %-20s %b%d%b\n" "WARN:" "$YELLOW" "$TOTAL_WARN" "$RESET" printf " %-20s %b%d%b\n" "INFO:" "$CYAN" "$TOTAL_INFO" "$RESET" printf " %-20s %b%d%b\n" "OK:" "$GREEN" "$TOTAL_OK" "$RESET" echo " ──────────────────────────────────────────" echo "" if [[ "$TOTAL_CRIT" -gt 0 ]]; then echo -e " ${RED}${BOLD}Action required:${RESET} ${TOTAL_CRIT} critical finding(s)" echo "" echo " Top recommendations:" echo " • Disable public blob access on all storage accounts" echo " • Set container access level to private" echo " • Enable HTTPS-only traffic" echo " • Configure network rules to restrict access" echo "" elif [[ "$TOTAL_WARN" -gt 0 ]]; then echo -e " ${YELLOW}Review recommended:${RESET} ${TOTAL_WARN} warning(s)" echo "" else echo -e " ${GREEN}All checks passed${RESET}" echo "" fi } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat <