SAMPLE DELIVERABLE · illustrative data only. Organisation names, IP addresses, subscription/tenant IDs and all other identifiers are fictional placeholders (Contoso & RFC 5737 documentation ranges) and refer to no real client or infrastructure.
Fig. 02 · Azure security assessment · Contoso PayGo thinsky.com

Read-only audit · Contoso PayGo · subscription a1b2c3d4

Azure security posture, measured against CIS, MCSB & NIST.

Secure Score 16.8 / 36 — 46.67 %. Twenty findings across networking, identity, detection, backup and governance. One Critical, nine High, five Medium, three Low, two Informational. The environment is functional; the detection and access-path layers are not.

Audit date
2026-05-14
Captured at
2026-05-14T20:13Z
Subscription
Contoso PayGo
Tenant
e7f8a9b0-2222-4b33-8d44-1e55f6607b88
Audit identity
Service principal · Reader
Frameworks
CIS Azure v2.1.0 · NIST 800-53 r5 · MCSB

This is a read-only audit with no remediation authority. Findings include code-level remediation guidance, but Dana (the subscription Owner) executes the changes. Effort, sequencing and scheduling are defined in § 8 Recommended sequencing.

§ 1 · Scope

What was in the audit — and what wasn't.

Tenante7f8a9b0-2222-4b33-8d44-1e55f6607b88
SubscriptionContoso PayGo (a1b2c3d4-1111-4a22-9c33-0d44e55f6a77)
Resource groups in scopeProduction-RG (eastus) + NetworkWatcherRG + azurebackuprg_eastus_1 (Microsoft-managed)
Compliance frameworksCIS Azure Foundations v2.1.0 · NIST 800-53 r5 · Microsoft Cloud Security Benchmark
Engagement typeRead-only audit, no remediation authority
Auditing identityService principal app id 0c1d2e3f-…, scope Reader at subscription
Auditing identity limitsNo Microsoft.Network/networkInterfaces/effectiveNetworkSecurityGroup/action, no Microsoft.Compute/virtualMachines/assessPatches/action, no Directory.Read.All on Microsoft Graph
Capture timestamp2026-05-14 20:13 UTC
In-scope resource types18 (full breakdown in § 3)
Out of scopeGuest OS configuration, application-level config inside VMs, Entra ID directory data (SP lacks scopes)

§ 2 · Executive summary

Functional, but the detection and access-path layers are not.

16.8 / 36
Secure Score (Defender for Cloud) — 46.67 %. 48 of 143 assessments are Unhealthy. The environment is functional but operates almost entirely on the Defender for Cloud free tier, exposes management-plane ports (RDP and SQL 1433) directly to the internet from individual admin home IPs, has no diagnostic settings or Activity Log routing on any resource, and has zero detection alerts. A new deployment of Azure Bastion with Shareable Link enabled materially widens the access path — sessions can be handed out by URL, sidestepping Conditional Access on the receiving end.

Severity dashboard

Critical1SQL 1433 reachable from internet
High9RDP, Bastion, Defender, logging, alerts, backup, DNS, DCs, VPN auth
Medium5Flow logs, Key Vault, locks, Owner sprawl, managed identity
Low3DDoS Standard, broken connector, disabled schedule
Info2Entra read denied, platform-managed encryption

Top 8 risks — ranked by exploitability × impact

#FindingSeverityWhat the attacker gains
1SQL Server 1433 reachable directly from the public internetCriticalDirect TDS access to every database on DBSvr-001 from any host that compromises the admin source IP — read/write at whatever privilege the captured credential carries, no further controls in front of the database engine.
2Every VM has a public IP with RDP open from an admin home IPHighBrute-force / credential-replay surface on each of six VMs concentrated behind a single residential IP; a single home-network compromise yields interactive Windows logon attempts against the entire fleet.
3Azure Bastion Shareable Link is ENABLEDHighAnyone with an Azure session can mint a URL granting access to a target VM without that recipient needing AAD / CA evaluation.
4Defender for Cloud — every workload plan is FREEHighNo threat protection on VMs, SQL, Storage, Key Vault, ARM, DNS. No vulnerability assessment. No JIT. No behavioural detection.
5No diagnostic settings anywhere — Activity Log not exportedHighForensic timeline maxes out at 90 days of platform-default retention; nothing immutable. A breach discovered at day 91 has no evidence trail.
6Zero Activity Log alertsHighNo detection on role-assignment changes, NSG rule deletion, Key Vault destruction, vault deletion, etc.
7Recovery Services Vault has no immutability and no multi-user authorisationHighRansomware playbook: encrypt VMs + delete backups (subject only to the 14-day soft-delete window, which the same RBAC can shorten).
8Domain controllers and SQL server have public IPs (CIS 6.6 violation)HighDCs and the database tier should never be internet-reachable, even with NSG narrowing. The blast radius of a control-plane misconfig is catastrophic.
Operational
corollary

The VPN gateway exists (Azure-ContosoNet, VpnGw2AZ SKU) but its P2S client authentication is unconfigured (zero root certs, no RADIUS, no Entra ID tenant set). The site-to-site tunnel ContosoNet is provisioned but shows 0 bytes ingress / 0 bytes egress. Remote workers can only reach Azure via the public-IP RDP path. This is why the public IPs cannot be removed today — and the only legitimate way to remove them in future is to configure P2S Entra ID authentication first.

§ 3 · Subscription inventory at audit time

Eighteen resource types — generated via Resource Graph.

Resources | summarize count() by type

Resource typeCountNotes
microsoft.compute/disks10OS + data disks
microsoft.network/publicipaddresses98 attached to internet-reachable resources
microsoft.compute/virtualmachines6All running, all domain-joined
microsoft.network/networkinterfaces6One per VM
microsoft.network/networksecuritygroups5NIC-scoped, no subnet-scoped NSG
microsoft.compute/restorepointcollections4Managed by Azure Backup
microsoft.compute/virtualmachines/extensions3SqlIaasExtension, AzureBackupWindowsWorkload, AADLogin
microsoft.sqlvirtualmachine/sqlvirtualmachines1DBSvr-001 SQL VM extension
microsoft.network/connections1S2S ContosoNet (idle — 0 bytes through it)
microsoft.network/localnetworkgateways1On-prem gateway definition (198.51.100.60, 192.168.32.0/24)
microsoft.network/virtualnetworkgateways1Azure-ContosoNet (VpnGw2AZ)
microsoft.devtestlab/schedules1Auto-shutdown for ADM-001az — disabled
microsoft.web/connections1Logic Apps "azurevm" connector — status Error
microsoft.compute/availabilitysets1
microsoft.recoveryservices/vaults1Production-Vault (was 2; vault-dbfiles deleted today)
microsoft.network/networkwatchers1NetworkWatcher_eastus
microsoft.network/bastionhosts1Contoso_Bastion Standard SKU — NEW vs prior audit
microsoft.network/virtualnetworks1vNet-001 (10.0.2.0/24)
microsoft.storage/storageaccounts0contosostorageacct1 was deleted today between audit runs

§ 4 · Network topology

The path to the internet, diagrammed.

                                    INTERNET
                                       │
       ┌───────────────────────────────┼───────────────────────────────────┐
       │                               │                                   │
       ▼                               ▼                                   ▼
┌──────────────────┐         ┌───────────────┐               ┌──────────────────────┐
│ 8 VM public IPs  │         │ Contoso_Bastion   │               │ Azure-ContosoNet (VPN)   │
│ (Standard, Static│         │ Standard SKU  │               │ VpnGw2AZ Gen2 a/a    │
│  Regional)       │         │ 2 scale units │               │ 2 public IPs         │
│                  │         │ ShareableLink │               │  • S2S idle (ContosoNet) │
│ ports per VM:    │         │   : ENABLED   │               │    to 198.51.100.60  │
│   3389 from      │         │ IpConnect:    │               │  • P2S protocols     │
│   198.51.100.10   │         │   ENABLED     │               │    configured but    │
│   (admin home)   │         │ Tunneling:    │               │    auth UNCONFIGURED │
│                  │         │   ENABLED     │               │    (0 root certs, no │
│ DCs + LAN also   │         │               │               │    RADIUS, no AAD)   │
│ open 53/TCP+UDP  │         │ subnet:       │               │                      │
│ to 0.0.0.0/0     │         │  AzureBastion-│               │ Used today:          │
│                  │         │  Subnet       │               │   NO (0 bytes)       │
│ DBSvr also open  │         │               │               │                      │
│ 1433 to one IP   │         │               │               │                      │
└────────┬─────────┘         └──────┬────────┘               └──────────┬───────────┘
         │                          │                                   │
         └──────────────────────────┴───────────────────────────────────┘
                                       ▼
              ┌────────────────────────────────────────────────────┐
              │ vNet-001 (10.0.2.0/24)                             │
              │   no peerings, no route tables (default routing)   │
              │   no DDoS Standard, no Flow Logs                   │
              │                                                    │
              │  10.0.2.4  DCSvr-001   DomainControllers-NSG       │
              │  10.0.2.5  DCSvr-002   DomainControllers-NSG       │
              │  10.0.2.6  LANSvr-001  Servers-NSG                 │
              │  10.0.2.7  DBSvr-001   DBServers-NSG               │
              │  10.0.2.8  ACC-001az   ACC-001az-nsg               │
              │  10.0.2.9  ADM-001az   ADM-001az-nsg               │
              │                                                    │
              │  Default rule AllowVNetInBound permits ALL traffic │
              │  between any two VMs on any port (no microsegment) │
              └────────────────────────────────────────────────────┘

Inbound NSG rules — complete list

NSG (NIC-scoped)PriDirActionProtoSourceDstDst portRule
ACC-001az-nsg300InboundAllowTCP198.51.100.10*3389PermitRDP
ADM-001az-nsg100InboundAllowTCP198.51.100.10*3389PermitRDP
DBServers-NSG100InboundAllowTCP198.51.100.10, 198.51.100.11*3389PermitMyRDP
DBServers-NSG110InboundAllowTCP198.51.100.10*1433PermitMySQL
DomainControllers-NSG100InboundAllowTCP198.51.100.10*3389PermitMyRDP
DomainControllers-NSG1010InboundAllowTCP**53PermitDNS-TCP
DomainControllers-NSG1020InboundAllowUDP**53PermitDNS-UDP
Servers-NSG100InboundAllowTCP198.51.100.10*3389PermitMyRDP
Servers-NSG1010InboundAllowTCP**53PermitDNS-TCP
Servers-NSG1020InboundAllowUDP**53PermitDNS-UDP

Outbound NSG rules: none defined. All traffic out is governed by the Azure default AllowInternetOutBound rule — every VM can talk to any internet destination on any port without restriction. There is no egress filtering, no Azure Firewall, no NAT Gateway, no Application Gateway.

Public IPs — what they're attached to

NameIPResourceWhy
AzureVPN-PIP1203.0.113.21Azure-ContosoNet (VPN)Required for VPN GW
AzureVPN-PIP2203.0.113.22Azure-ContosoNet (VPN active-active)Required for VPN GW
vNet-001-IPv4203.0.113.41Contoso_BastionRequired for Bastion
ACC-001az-ip203.0.113.31ACC-001az NICRemovable once Bastion adoption is complete
ADM-001az-ip203.0.113.32ADM-001az NICRemovable once Bastion adoption is complete
DBSvr-001-ip203.0.113.33DBSvr-001 NICShould be removed immediately — DB tier
DCSvr-001-ip203.0.113.11DCSvr-001 NICShould be removed — DC tier
DCSvr-002-ip203.0.113.12DCSvr-002 NICShould be removed — DC tier
LANSvr-001-ip203.0.113.34LANSvr-001 NICShould be removed

The presence of Bastion means every direct VM public IP is now redundant — admin access can route through Contoso_Bastion over its SSL session. Removing them eliminates six brute-force surfaces (plus the SQL 1433 surface) without breaking access.

§ 5 · Findings

Twenty findings — each one reproducible from scripts/_security_audit.json.

F-01Critical CVSS v3.1 9.4 · AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

SQL Server TCP/1433 reachable from the public internet.

Resource
DBServers-NSG rule PermitMySQL · NIC dbsvr-001887 (VM DBSvr-001, PIP 203.0.113.33)
Control
CIS Azure Foundations v2.1.0 § 6.4 · MCSB NS-1 · NIST 800-53 SC-7

Evidence

$ python scripts/azure_security_audit.py | jq '.network.nsgs[] | select(.name=="DBServers-NSG").rules'
[{ "name":"PermitMySQL","priority":110,"direction":"Inbound","access":"Allow",
   "protocol":"TCP","srcAddr":"198.51.100.10","dstPort":"1433" }]

Compromise

A compromise of the single source IP 198.51.100.10 (an admin's home internet IP) grants direct TCP access to TDS 1433 on the SQL Server. From there the attacker can: (a) attempt SQL / Windows login brute-force against DBSvr-001 without any rate-limit or WAF in the path; (b) fingerprint and target unpatched SQL Server CVEs (the engine is reachable from arbitrary internet hosts the moment the source IP is compromised or re-issued); (c) on any successful auth, read, modify, drop, or exfiltrate any database object the captured credential is authorised against — the audit cannot tell you which databases contain what, so the upper bound is "every database hosted on this instance"; (d) pivot from xp_cmdshell or similar features if enabled to gain code execution on the VM. Residential IPs are commonly re-issued by ISPs and frequently sit behind compromised consumer routers.

Remediation · Azure CLI

# Step 1 — confirm nobody currently relies on the public 1433 path
# (Activity Log + flow logs would show this; absent both, assume no)

# Step 2 — remove the rule
az network nsg rule delete \
  --resource-group Production-RG \
  --nsg-name DBServers-NSG \
  --name PermitMySQL

# Step 3 — confirm access path remains: ACC-001az (10.0.2.8) → DBSvr-001 (10.0.2.7) on 1433
# is permitted by the default AllowVnetInBound rule. CRM continues to work.

Rollback: az network nsg rule create --resource-group Production-RG --nsg-name DBServers-NSG --name PermitMySQL --priority 110 --direction Inbound --access Allow --protocol Tcp --source-address-prefixes 198.51.100.10 --destination-port-ranges 1433

Validation

az network nsg rule list --resource-group Production-RG --nsg-name DBServers-NSG \
  --query "[?name=='PermitMySQL']" -o table
# Expected output: empty

Blast radius of fix · Medium · reversible in seconds. Verify with Dana no reporting tool connects to the SQL VM from outside the VNet on the public IP.

F-02High CVSS v3.1 8.1 · AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

Public RDP (TCP/3389) on every VM from a single residential admin IP.

Resources
All 6 NSGs allow inbound TCP/3389 from 198.51.100.10 (one also from 198.51.100.11). PIPs: ACC-001az-ip, ADM-001az-ip, DBSvr-001-ip, DCSvr-001-ip, DCSvr-002-ip, LANSvr-001-ip.
Control
CIS Azure Foundations v2.1.0 § 6.1 · MCSB NS-2 · NIST 800-53 SC-7

Evidence

$ jq '.network.nsgs[].rules[] | select(.dstPort=="3389")' < scripts/_security_audit.json
# Every NSG returns at least one allow rule

Compromise

A single residential IP 198.51.100.10 (admin home) is the lateral-movement keystone for the entire production fleet. Common home-router compromises (CVE-2024-3273 D-Link, MikroTik VPNFilter, ISP-CPE 0-days) yield man-in-the-middle on outbound traffic and would allow brute-force or pass-the-hash from a position whose source IP the NSG already trusts. Even without compromise, a residential ISP can re-issue this IP after a modem reboot to a different subscriber. A successful RDP login on any single VM in this subnet then provides an in-VNet position from which the default AllowVnetInBound Azure rule permits unrestricted east-west movement to every other VM, including the SQL VM and both domain controllers (see § 4 — there is no microsegmentation).

Remediation · Azure CLI

# PREREQUISITE: Bastion is provisioned (Contoso_Bastion exists) — verify access via Bastion
# to ACC-001az and ADM-001az BEFORE removing the public-IP path.

# Step 1 — Verify the bastion path works for at least one administrator
# (manual verification — Dana RDPs through https://portal.azure.com → Bastion)

# Step 2 — Remove the per-NSG RDP rules
for nsg in ACC-001az-nsg ADM-001az-nsg DBServers-NSG DomainControllers-NSG Servers-NSG; do
  az network nsg rule list -g Production-RG --nsg-name $nsg \
    --query "[?destinationPortRange=='3389'].name" -o tsv | \
    xargs -I{} az network nsg rule delete -g Production-RG --nsg-name $nsg --name {}
done

# Step 3 — Dissociate the 6 administrative public IPs from their NICs
for ip in ACC-001az-ip ADM-001az-ip DBSvr-001-ip DCSvr-001-ip DCSvr-002-ip LANSvr-001-ip; do
  nic_ip_config=$(az network public-ip show -g Production-RG -n $ip --query ipConfiguration.id -o tsv)
  nic=$(echo $nic_ip_config | awk -F'/' '{print $9}')
  cfg=$(echo $nic_ip_config | awk -F'/' '{print $11}')
  az network nic ip-config update -g Production-RG --nic-name $nic --name $cfg --remove publicIpAddress
done

# Step 4 — Optionally delete the public IP objects after dissociation
# (do this after a 30-day soft retention to confirm no rollback need)

Rollback: re-create the NSG rules (priority 100, src 198.51.100.10, port 3389), then re-attach each PIP to its NIC's ipconfig1.

Validation

az network nsg rule list -g Production-RG --nsg-name DBServers-NSG \
  --query "[?destinationPortRange=='3389']" -o table
# Expected: empty across all 5 NSGs

Blast radius of fix · High · admin access depends entirely on Bastion working. Do not execute without a verified Bastion path for every operator AND a verified break-glass alternate (e.g., Dana's IP on a temporary allowlist for 48 hours).

F-03High CVSS v3.1 7.5 · AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

Azure Bastion enableShareableLink is true.

Resource
/subscriptions/a1b2c3d4-…/resourceGroups/Production-RG/providers/Microsoft.Network/bastionHosts/Contoso_Bastion
Control
MCSB IM-1, IM-3 · NIST 800-53 IA-2(1) · CIS Azure § 6.x (network-level)

Evidence

$ jq '.bastion_hosts_full.value[0].properties | {enableShareableLink, enableIpConnect, enableTunneling, enableKerberos, scaleUnits}' < scripts/_security_extra.json
{
  "enableShareableLink": true,
  "enableIpConnect":     true,
  "enableTunneling":     true,
  "enableKerberos":      false,
  "scaleUnits":          2
}

Compromise

Shareable Link allows an Azure user with appropriate Bastion permission to mint a URL granting RDP / SSH access to a specific VM. The URL can be forwarded to anyone — including users without an Entra ID account, without Conditional Access evaluation, and without MFA. A single privileged user's mistake (mis-shared link), credential compromise, or insider action leaks production VM access via a copy-pasteable URL. The link recipient's identity is not authenticated against the tenant, so all subsequent activity through that session is attributable only to the Bastion log entry and the issuing user — making attribution difficult and bypassing the strong-authentication controls that normally gate Azure access.

Remediation · Azure CLI

az network bastion update --resource-group Production-RG --name Contoso_Bastion \
  --enable-shareable-link false

Rollback: az network bastion update --resource-group Production-RG --name Contoso_Bastion --enable-shareable-link true

Validation

az network bastion show -g Production-RG -n Contoso_Bastion \
  --query "{shareable:enableShareableLink,ipConnect:enableIpConnect,tunneling:enableTunneling}" -o table
# Expected: shareable=false

Blast radius of fix · Low · only breaks the share-by-link workflow. Standard browser-based Bastion connections continue working. Consider also disabling enableIpConnect unless the workflow specifically requires connecting to IP addresses outside the VNet.

F-04High DREAD 16 / 25 · D2 R3 E3 A4 D4

Defender for Cloud — every paid plan is disabled.

Resource
Subscription-scope pricings on Microsoft.Security/pricings
Control
CIS Azure Foundations v2.1.0 § 2.1.1–2.1.16 · MCSB DS-2, GS-1 · NIST 800-53 SI-4

Evidence

$ jq '.defender_pricings[] | select(.pricing_tier=="Free") | .name' < scripts/_security_extra.json
"VirtualMachines"            # Defender for Servers — disabled
"SqlServers"                 # Defender for SQL — disabled
"SqlServerVirtualMachines"   # Defender for SQL on machines — disabled ← protects the CRM DB
"StorageAccounts"            # Defender for Storage — disabled
"KeyVaults"                  # Defender for Key Vault — disabled
"Arm"                        # Defender for ARM — disabled
"Dns"                        # Defender for DNS — disabled
…

$ jq '.defender_secure_scores[0] | {current,max,percentage}' < scripts/_security_extra.json
{ "current": 16.8, "max": 36, "percentage": 0.4667 }

Compromise

There is no behavioural threat detection on the SQL Server (suspicious query patterns, SQL injection signals, brute-force on logins, anomalous data-volume reads), no malware / EDR signalling on the VMs (Defender for Servers P2 includes Defender for Endpoint at no extra licence), no compromise detection on ARM (e.g., suspicious role grants or resource creation patterns from a new IP), and no JIT VM Access (which requires Defender for Servers). An attacker operating under captured credentials inside this subscription receives no alerting, leaves no detection-grade signal, and faces no automated containment. The 46.67 % Secure Score reflects this.

Remediation · Azure CLI

# Enable Defender for SQL on SQL VMs (highest-value plan for this environment)
az security pricing create --name SqlServerVirtualMachines --tier 'Standard'

# Enable Defender for Servers Plan 2 (includes MDE, JIT, file integrity monitoring)
az security pricing create --name VirtualMachines --tier 'Standard' --subplan 'P2'

# Enable Defender for ARM (alerts on suspicious management-plane activity)
az security pricing create --name Arm --tier 'Standard'

# Enable Defender for DNS (catches DNS-based C2)
az security pricing create --name Dns --tier 'Standard'

# Enable Defender for Key Vault (when KV is deployed — see F-12)
az security pricing create --name KeyVaults --tier 'Standard'

Indicative monthly cost: Defender for SQL on a single VM ≈ $15, Defender for Servers P2 ≈ $15/VM × 6 = $90, Defender for ARM ≈ $4 per million ARM ops (≈ $5/mo for this scale), Defender for DNS ≈ $0.70 / 1M queries. Total ≈ $115–130 / mo for a comprehensive security baseline.

Rollback: az security pricing create --name <plan> --tier 'Free'

Validation

az security pricing list \
  --query "value[?contains('VirtualMachines SqlServerVirtualMachines Arm Dns',name)].{name:name,tier:pricingTier}" -o table
# Expected: all 'Standard'

Blast radius of fix · Low · Defender plans run as platform monitoring with no agent footprint on VMs (Defender for Servers P2 deploys MDE, which is well-behaved on Windows Server 2025).

F-05High DREAD 17 / 25 · D3 R4 E2 A5 D3

No diagnostic settings on any in-scope resource; Activity Log not exported.

Resources
All NSGs, all VMs, the VNet, the VPN Gateway, the Recovery Vault, the subscription scope itself
Control
CIS Azure v2.1.0 § 5.1.1, § 5.3, § 5.4 · MCSB LT-3, LT-4 · NIST 800-53 AU-2, AU-6, AU-11

Evidence

$ jq '.sub_activity_log_diag_settings.value | length' < scripts/_security_extra.json
0

$ jq '.resource_diag_settings[] | {r:.resource, count:(.body.value|length)}' < scripts/_security_extra.json
# All 11 resources return count=0

Compromise

Activity Log retention defaults to 90 days on the subscription, after which it is purged. Resource logs (NSG rule hits, VPN gateway tunnel events, vault delete / recover events, NIC flow records) are not retained at all. An adversary operating slowly enough to wait out the 90-day window leaves no recoverable trace of management-plane actions; an adversary operating loudly leaves traces that can be deleted in place by anyone with Activity Log access (no immutable copy). Forensic reconstruction of a breach is bounded by what platform telemetry survives in the default retention window, and no internal investigation can map "who changed the NSG on day X" past 90 days. This also blocks meeting any "audit log retention ≥ 1 year" obligation under NIST 800-53 AU-11.

Remediation · Bicep

// 1. Create a central Log Analytics workspace
resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: 'contoso-security-law'
  location: 'eastus'
  properties: {
    sku: { name: 'PerGB2018' }
    retentionInDays: 90  // hot retention; long-term goes to storage
    features: { immediatePurgeDataOn30Days: false }
  }
}

// 2. Subscription-scope Activity Log → workspace
resource subDiag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
  name: 'send-activity-log'
  scope: subscription()
  properties: {
    workspaceId: law.id
    logs: [
      { category: 'Administrative',  enabled: true }
      { category: 'Security',        enabled: true }
      { category: 'ServiceHealth',   enabled: true }
      { category: 'Alert',           enabled: true }
      { category: 'Recommendation',  enabled: true }
      { category: 'Policy',          enabled: true }
      { category: 'Autoscale',       enabled: true }
      { category: 'ResourceHealth',  enabled: true }
    ]
  }
}

// 3. Apply per-resource diagnostic settings — easiest as an Azure Policy
//    initiative "Configure Azure Monitor for resources" assigned at subscription scope.

Indicative cost: PerGB2018 LA pricing is $2.76 / GB ingested (eastus). For this scale: NSG hits + Activity Log + VM metrics ≈ 5–15 GB / mo = $14–41 / mo.

Rollback: az resource delete --ids <diagnosticSettings_id> per resource. Workspace itself uses 31-day soft-delete (recoverable).

Validation

az monitor diagnostic-settings list --resource /subscriptions/a1b2c3d4-… -o table
# Should return one entry (send-activity-log)

Blast radius of fix · Low · diagnostic settings are passive observers; cannot impact workload performance.

F-06High DREAD 17 / 25 · D3 R4 E2 A5 D3

Zero Activity Log alerts.

Scope
Subscription-wide
Control
CIS Azure v2.1.0 § 5.2.1–5.2.9 · MCSB IR-3 · NIST 800-53 IR-4, SI-4

Evidence

$ jq '.activity_log_alerts_raw.value | length' < scripts/_security_extra.json
0

Compromise

No real-time detection on the control-plane operations an attacker uses to expand access, suppress detection, or destroy evidence: Microsoft.Authorization/roleAssignments/write (privilege escalation), Microsoft.Network/networkSecurityGroups/securityRules/delete (firewall takedown), Microsoft.RecoveryServices/vaults/delete (backup destruction), Microsoft.Compute/virtualMachines/delete (production VM destroyed), Microsoft.KeyVault/vaults/delete (KV destroyed), Microsoft.Security/pricings/write (Defender plan disabled to suppress alerting). The defender's first chance to notice any of these is the next manual portal visit — by then the action is complete.

Remediation · Azure CLI

# Create an action group (email Dana + optionally SMS / Teams webhook)
az monitor action-group create -g Production-RG -n SecurityAlerts \
  --short-name SecAlert \
  --email-receivers name=dana email=dana.okoye@contoso.com

AG=$(az monitor action-group show -g Production-RG -n SecurityAlerts --query id -o tsv)
SUB=/subscriptions/a1b2c3d4-1111-4a22-9c33-0d44e55f6a77

# Alert: role assignment write
az monitor activity-log alert create -g Production-RG -n alert-role-assignment-write \
  --scope $SUB --action $AG \
  --condition category=Administrative \
  --condition operationName=Microsoft.Authorization/roleAssignments/write

# Alert: NSG rule delete
az monitor activity-log alert create -g Production-RG -n alert-nsg-rule-delete \
  --scope $SUB --action $AG \
  --condition category=Administrative \
  --condition operationName=Microsoft.Network/networkSecurityGroups/securityRules/delete

# Alert: Recovery Services vault delete
az monitor activity-log alert create -g Production-RG -n alert-vault-delete \
  --scope $SUB --action $AG \
  --condition category=Administrative \
  --condition operationName=Microsoft.RecoveryServices/vaults/delete

# Alert: Defender pricing tier change (downgrade)
az monitor activity-log alert create -g Production-RG -n alert-defender-pricing-change \
  --scope $SUB --action $AG \
  --condition category=Administrative \
  --condition operationName=Microsoft.Security/pricings/write

# Alert: Key Vault delete (after F-12 is implemented)
# Alert: VM delete on production tier

Rollback: az monitor activity-log alert delete -g Production-RG -n <name>

Validation

az monitor activity-log alert list -g Production-RG --query "[].{name:name,enabled:enabled}" -o table
# Expected: ≥ 4 alerts, all enabled

Blast radius of fix · Low · purely passive notification.

F-07High CVSS v3.1 7.5 · AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:H/A:H

Recovery Services vault — immutability and multi-user authorisation disabled.

Resource
/subscriptions/a1b2c3d4-…/resourceGroups/Production-RG/providers/Microsoft.RecoveryServices/vaults/Production-Vault
Control
MCSB DP-9 · NIST 800-53 CP-9, CP-10 · CIS Azure v2.1.0 § 7

Evidence

$ jq '.production_vault.properties.securitySettings' < scripts/_security_extra.json
{
  "immutabilitySettings":  { "state": null },                                          ← OFF
  "softDeleteSettings":    { "softDeleteState":"Enabled", "softDeleteRetentionPeriodInDays": 14 },
  "multiUserAuthorization": "Disabled"                                                 ← OFF
}

Compromise

With immutability off and multi-user authorisation off, an attacker who acquires Backup Contributor or higher can shorten retention, disable backup jobs, stop protection on protected items, and — after waiting out the 14-day soft-delete window — purge recovery points entirely. Soft-delete defends against accidental deletion but not against an adversary willing to wait, and the same RBAC that destroys can also shorten the soft-delete window. Recovery from a successful destructive intrusion against the production VMs would, after this, have no backup tier to fall back to.

Remediation · Azure CLI

# Step 1 — Enable immutability in "Unlocked" mode first (still reversible)
az backup vault update -g Production-RG -n Production-Vault \
  --immutability-state Unlocked

# Step 2 — After confirming all backups, retention policies, and operational
#         tooling are correct for ≥ 2 weeks, lock immutability:
# az backup vault update -g Production-RG -n Production-Vault \
#   --immutability-state Locked
# ⚠ Once Locked, immutability cannot be disabled. This is intentional —
#    it is the property that makes the backup tier untouchable by ransomware.

# Step 3 — Enable Multi-User Authorisation (MUA): requires a Resource Guard
az resource-guard create -g Production-RG -n contoso-rg-guard --location eastus
GUARD_ID=$(az resource-guard show -g Production-RG -n contoso-rg-guard --query id -o tsv)

az backup vault resource-guard-mapping update -g Production-RG -n Production-Vault \
  --resource-guard-id $GUARD_ID

Rollback: az backup vault update --immutability-state Disabled (only valid before Locked) + az backup vault resource-guard-mapping delete.

Validation

az backup vault show -g Production-RG -n Production-Vault \
  --query "properties.securitySettings.{imm:immutabilitySettings.state, mua:multiUserAuthorization}" -o table
# Expected: imm=Unlocked (then Locked after 2 weeks), mua=Enabled

Blast radius of fix · Medium · Immutability Unlocked is reversible; Locked is permanent (by design). MUA imposes a two-party approval flow on destructive backup operations — train operators before enabling.

F-08High CVSS v3.1 7.5 · information disclosure + amplification

DNS (TCP + UDP 53) open to the public internet on DCs and LANSvr.

Resources
DomainControllers-NSG rules 1010 / 1020, Servers-NSG rules 1010 / 1020. Affected VMs: DCSvr-001, DCSvr-002, LANSvr-001.
Control
CIS Azure v2.1.0 § 6.x · MCSB NS-2 · NIST 800-53 SC-7, SC-22

Evidence

$ jq '.network.nsgs[].rules[] | select(.dstPort=="53")' < scripts/_security_audit.json
[
  { "name":"PermitDNS-TCP","srcAddr":"*","direction":"Inbound","access":"Allow","protocol":"TCP","dstPort":"53" },
  { "name":"PermitDNS-UDP","srcAddr":"*","direction":"Inbound","access":"Allow","protocol":"UDP","dstPort":"53" }
  …  // repeated for Servers-NSG
]

Compromise

Two distinct risks. (a) Recursive open resolver — AD-DNS by default answers any query; attackers exploit this for DNS amplification reflection attacks against third parties, with the third party's view of the source-of-attack being this subscription's IP space (NIST SC-22 violation, listed in DDoS-mitigation guidance). (b) AD-integrated zone disclosurecorp.contoso.com records (every server, every workstation, every service principal name registered in AD-DNS) become enumerable by anyone on the internet via direct queries, providing a complete reconnaissance map of the internal estate before any authentication is attempted. dig @203.0.113.11 corp.contoso.com AXFR may or may not succeed depending on AD-DNS zone-transfer ACLs, but individual record probing always works.

Remediation · Azure CLI

# These ports should never be exposed externally; AD-DNS should serve clients via the VPN
# or via Private DNS Resolver, not directly.
for nsg in DomainControllers-NSG Servers-NSG; do
  az network nsg rule delete -g Production-RG --nsg-name $nsg --name PermitDNS-TCP
  az network nsg rule delete -g Production-RG --nsg-name $nsg --name PermitDNS-UDP
done

# Confirm internal VMs still resolve via the DCs (intra-VNet rule AllowVnetInBound permits)

Rollback: re-create the rules with the same priorities and srcPrefix=*.

Validation

for nsg in DomainControllers-NSG Servers-NSG; do
  az network nsg rule list -g Production-RG --nsg-name $nsg \
    --query "[?destinationPortRange=='53']" -o table
done
# Expected: empty

Blast radius of fix · Medium · verify no on-prem servers point at the DC public IPs for DNS resolution before applying. The proper path for on-prem is via the S2S VPN tunnel (currently 0 bytes — so this is unlikely to break anything in practice, but confirm with Dana).

F-09High Design-level · CIS finding

Domain controllers and SQL server have public IPs.

Resources
DCSvr-001-ip, DCSvr-002-ip, DBSvr-001-ip, LANSvr-001-ip
Control
CIS Azure v2.1.0 § 6.6 · MCSB NS-1 · NIST 800-53 SC-7

Evidence: see § 4 Public IPs table.

Compromise

Identity tier (DCs) and data tier (DB) should never carry public IPs in a production environment. Even with NSG narrowing to a single source IP, the existence of the attachment means: (1) any NSG rule mistake exposes the resource immediately to the internet — a single fat-fingered "Any" in a future rule edit is one click from full exposure; (2) the resource is enumerable by internet-wide scanners (Shodan, Censys) and shows up in their public datasets regardless of NSG state; (3) a process running on the host that binds to its public-facing interface (intentionally or by misconfiguration) gets an outbound path that bypasses any future Azure Firewall / NAT Gateway designed to centralise egress inspection.

Remediation: see F-02 Step 3 — all four public IPs are dissociated as part of that fix once Bastion + VPN-P2S are validated.

Validation

az network nic show -g Production-RG -n dbsvr-001887 \
  --query "ipConfigurations[].publicIPAddress" -o tsv
# Expected: empty

Blast radius of fix · High · gated on F-02 (Bastion + VPN ready).

F-10High (operational) DREAD 17 / 25 · D2 R3 E5 A4 D3

VPN gateway P2S authentication is unconfigured (operational lockout).

Resource
/subscriptions/a1b2c3d4-…/resourceGroups/Production-RG/providers/Microsoft.Network/virtualNetworkGateways/Azure-ContosoNet
Control
MCSB IM-1 · NIST 800-53 IA-2(11)

Evidence

$ jq '.virtual_network_gateways.value[0].properties.vpnClientConfiguration | {protocols:vpnClientProtocols, auth:vpnAuthenticationTypes, rootCerts:(vpnClientRootCertificates|length), aad:aadTenant, radius:radiusServerAddress}' < scripts/_security_extra.json
{
  "protocols": ["OpenVPN","IkeV2"],
  "auth":      [],
  "rootCerts": 0,
  "aad":       null,
  "radius":    null
}

Compromise

Remote workers cannot use the VPN — the protocols are set but no authentication method is configured, so no client can complete a handshake. This forces use of the direct VM public-IP RDP path (F-02), which is the largest current attack surface. The Bastion deployment partially mitigates this for sessions from devices that already have an Azure portal session, but does nothing for unmanaged endpoints needing direct in-VNet access. The compromise this creates is structural: the organisation cannot retire the public-IP RDP surface (F-02 / F-09) without first delivering this VPN auth path, so every day spent without P2S Entra ID auth is another day all six VM public IPs must stay attached.

Remediation · Terraform

resource "azurerm_virtual_network_gateway" "contosonet" {
  # ... existing config ...

  vpn_client_configuration {
    address_space        = ["172.16.201.0/24"]
    vpn_client_protocols = ["OpenVPN"]
    vpn_auth_types       = ["AAD"]

    aad_tenant   = "https://login.microsoftonline.com/e7f8a9b0-2222-4b33-8d44-1e55f6607b88/"
    aad_audience = "c632b3df-fb67-4d84-bdcf-b95ad541b5c8"  # Azure VPN client AAD app
    aad_issuer   = "https://sts.windows.net/e7f8a9b0-2222-4b33-8d44-1e55f6607b88/"
  }
}

Pre-requisite: a Conditional Access policy targeting the Azure VPN application that requires MFA + compliant device.

Rollback: remove the vpn_client_configuration block.

Validation

az network vnet-gateway show -g Production-RG -n Azure-ContosoNet \
  --query "vpnClientConfiguration.{auth:vpnAuthenticationTypes, aad:aadTenant}" -o table
# Expected: auth=AAD, aad=https://login.microsoftonline.com/e7f8a9b0-…/

Blast radius of fix · Medium · enables a new ingress path; verify the Conditional Access policy is correct before announcing access to users.

F-11Medium DREAD 14 / 25 · D2 R3 E3 A3 D3

NSG Flow Logs disabled — no visibility into the egress anomaly.

Resources
All 5 NSGs (no flow logs on Network Watcher)
Control
CIS Azure v2.1.0 § 6.5 · MCSB LT-3 · NIST 800-53 AU-12

Evidence

$ jq '.flow_logs.value | length' < scripts/_security_extra.json
0

Compromise

The DC outbound network egress anomaly (910 GB / month on DC1, 640 GB / month on DC2 — see dc-performance-20260514.md) cannot be characterised without flow logs. We can see how much is leaving but not where to. This blocks both the cost question (move backup destination to same region if applicable) and the security question — from host metrics alone we cannot distinguish "AzureBackup uploading to a vault" from "an unidentified process exfiltrating data". The 21–40 GB / day per-DC outbound is two-to-three orders of magnitude above expected for a domain controller, and absent flow logs the destination IPs are unknowable from outside the guest OS.

Remediation · Azure CLI

# Create a storage account for flow logs (eastus, LRS Standard)
az storage account create -g Production-RG -n contosoflowlogs$RANDOM \
  --location eastus --sku Standard_LRS --kind StorageV2 \
  --min-tls-version TLS1_2 --allow-blob-public-access false
SA=$(az storage account list -g Production-RG --query "[?starts_with(name,'contosoflowlogs')].id" -o tsv)

for nsg in ACC-001az-nsg ADM-001az-nsg DBServers-NSG DomainControllers-NSG Servers-NSG; do
  NSG_ID=$(az network nsg show -g Production-RG -n $nsg --query id -o tsv)
  az network watcher flow-log create -l eastus -n flowlog-$nsg \
    --nsg $NSG_ID --storage-account $SA --retention 90
done

# Optionally enable Traffic Analytics (Log Analytics workspace required)

Rollback: az network watcher flow-log delete -l eastus -n flowlog-<nsg>

Validation

az network watcher flow-log list -l eastus -o table
# Expected: 5 entries, enabled=True

Blast radius of fix · Low · flow logs are passive.

F-12Medium DREAD 14 / 25 · D3 R3 E2 A4 D2

No Key Vault deployed — secrets and certificates not centrally managed.

Scope
Subscription-wide (zero Microsoft.KeyVault/vaults exist)
Control
CIS Azure v2.1.0 § 8 · MCSB DS-6 · NIST 800-53 SC-12, SC-28

Evidence

$ jq '.key_vaults | length' < scripts/_security_audit.json
0

Compromise

There is no central, audited, RBAC-controlled secret store. Any secret material currently in use lives wherever the operator placed it — .env files on operator laptops, configuration files on VMs, or inline in the resource definition itself. Specific known examples:

  • The audit SP's client secret currently lives in .env on the consultant laptop (AZURE_CLIENT_SECRET=…).
  • The IPSec pre-shared key for the S2S ContosoNet connection (sharedKey: false in the API response — either currently null or stored outside Azure).
  • The Logic App azurevm connection holds whatever credential its V1 connector requires.
  • Any application credentials used by workloads on the VMs are stored in whatever local config those workloads expect.

Without a Key Vault there is no rotation lifecycle, no usage telemetry, no scoped access control on these credentials, and no place to put future credentials (Defender for SQL TDE key, CMKs for storage or disks once Key Vault exists).

Remediation · Bicep

resource kv 'Microsoft.KeyVault/vaults@2024-04-01-preview' = {
  name: 'contoso-prod-kv-${uniqueString(resourceGroup().id)}'
  location: 'eastus'
  properties: {
    tenantId:                 subscription().tenantId
    sku:                      { family: 'A', name: 'standard' }
    enableRbacAuthorization:  true       // NOT access policies
    enableSoftDelete:         true
    softDeleteRetentionInDays: 90
    enablePurgeProtection:    true
    publicNetworkAccess:      'Disabled'
    networkAcls: {
      defaultAction: 'Deny'
      bypass:        'AzureServices'
    }
  }
}
// Plus a Private Endpoint into vNet-001 for runtime access from VMs.

Rollback: az keyvault delete (90-day soft-delete recoverable, purge-protection forces recovery).

Validation

az keyvault list --query "[].{name:name, rbac:properties.enableRbacAuthorization, pna:properties.publicNetworkAccess, purge:properties.enablePurgeProtection}" -o table
# Expected: rbac=true, pna=Disabled, purge=true

Blast radius of fix · Low · new resource only.

F-13Medium DREAD 14 / 25 · D4 R2 E2 A4 D2

No resource locks on production resources.

Scope
All resources (subscription-wide query returned 0 locks)
Control
CIS Azure v2.1.0 § 10 · MCSB GS-6

Evidence

$ jq '.locks.value | length' < scripts/_security_extra.json
0

Impact. A single mis-clicked Azure portal action by anyone with Contributor or above (currently: 1 user) deletes a production VM, the SQL VM, or the only domain controller pair. Resource locks at CanNotDelete prevent the API call from succeeding regardless of RBAC.

Remediation · Azure CLI

# Lock the entire production resource group against deletion
az lock create -g Production-RG --name "no-delete-production" --lock-type CanNotDelete \
  --notes "Production environment — break-glass change required to delete"

# Optionally lock individual high-value resources
for r in DBSvr-001 DCSvr-001 DCSvr-002 Production-Vault Azure-ContosoNet Contoso_Bastion; do
  az lock create --resource-group Production-RG --resource-name $r \
    --resource-type <type> --name "no-delete-$r" --lock-type CanNotDelete
done

Rollback: az lock delete --name no-delete-production --resource-group Production-RG

Validation

az lock list -g Production-RG -o table
# Expected: 1+ lock entries

Blast radius of fix · Low · locks block destructive operations only; do not affect runtime.

F-14Medium DREAD 13 / 25 · D3 R3 E2 A3 D2

Two users hold subscription Owner; no PIM / no break-glass distinction.

Scope
Subscription scope role assignments
Control
CIS Azure v2.1.0 § 1.21, § 1.23 · MCSB PA-1, PA-2 · NIST 800-53 AC-6(5)

Evidence

Role IDs decoded — 8e3af657-… = Owner, b24988ac-… = Contributor, acdd72a7-… = Reader

Owner       (sub scope): 7a8b9c0d-7777-4088-9299-633aebb5fa43 (User)
Owner       (sub scope): 6f7a8b9c-6666-4f77-8188-5229daa4ef32 (User)   ← also Reader on same scope (redundant)
Contributor (sub scope): 8b9c0d1e-8888-4199-a3aa-744bfcc60b54 (User)
Reader      (sub scope): 9c0d1e2f-9999-42aa-b4bb-855c0dd71c65 (SP — this audit's identity)

Impact. Two permanent subscription Owners means two points where credential compromise produces total-environment takeover. Conditional Access for those users is unknown (SP lacks Directory.Read.All — see F-15). One of the two owners (6f7a8b9c-…) also carries Reader at the same scope, which is dead weight (Owner already grants read).

Remediation

Requires Entra ID Premium P2 for PIM. Without P2:

# Step 1 — Audit who the two Owner GUIDs are (requires Entra ID GA or a user with User.Read.All)
az ad user show --id 7a8b9c0d-7777-4088-9299-633aebb5fa43 -o table
az ad user show --id 6f7a8b9c-6666-4f77-8188-5229daa4ef32 -o table

# Step 2 — Remove the redundant Reader on 6f7a8b9c-…
ASSIGN_ID=$(az role assignment list --assignee 6f7a8b9c-6666-4f77-8188-5229daa4ef32 \
  --role Reader --scope /subscriptions/a1b2c3d4-… --query "[0].id" -o tsv)
az role assignment delete --ids $ASSIGN_ID

# Step 3 — Confirm both Owners have a corresponding break-glass account designated
#          and a Conditional Access policy that requires MFA + compliant device.

Rollback: re-create the role assignment via az role assignment create.

Validation

az role assignment list --scope /subscriptions/a1b2c3d4-… --role Owner -o table
# Expected: 2 entries (or 1 + break-glass) — no more

Blast radius of fix · Low (removing redundant Reader) to High (modifying actual Owner relationships — coordinate with Dana).

F-15Informational Evidence required

Entra ID directory read denied to the audit identity.

Resource
The audit SP itself (0c1d2e3f-3333-4c44-9e55-2f66a7718c99)

Evidence

$ jq '.entra' < scripts/_security_audit.json
{
  "service_principals_count_status": 200,
  "directory_roles_status":          403,
  "conditional_access_status":       403,
  "security_defaults_status":        403,
  "conditional_access_error": "AccessDenied: required scopes are missing in the token"
}

Impact. The audit cannot answer 8 of the CIS § 1.x identity controls without Entra ID directory read:

  • § 1.1 — security defaults enabled
  • § 1.2–1.13 — MFA, legacy auth, Conditional Access coverage
  • § 1.21–1.22 — guest invitation restrictions

These are foundational identity controls. Without evidence, they're marked Evidence required rather than Pass or Fail.

Remediation

  • Grant the audit SP Global Reader (recommended — broadest read, zero write), OR
  • Application permissions Directory.Read.All + Policy.Read.All on Microsoft Graph (admin consent required).

Validation: re-run scripts/azure_security_audit.py and confirm entra.conditional_access_status: 200 and a non-empty directory_roles list.

Blast radius of fix · Low · read-only permission grant.

F-16Medium DREAD 12 / 25 · D2 R2 E3 A3 D2

VMs lack managed identities; SP credentials stored in .env.

Resources
All 6 VMs (identity: None)
Control
MCSB IM-3 · NIST 800-53 IA-5(11) · CIS Azure § 1.20

Evidence

$ jq '.compute.vms[] | {name, identity}' < scripts/_security_audit.json
{ "name":"DBSvr-001","identity":null }    ← repeated for all 6

$ jq '.network.flow_logs.value | length' < scripts/_security_extra.json
0    ← no audit trail of secret usage in absence of Defender for ARM + flow logs

Impact. Any workload-to-Azure-API call from a VM has to use either (a) a stored SP secret (current pattern — secret lives in .env files distributed across operator laptops), or (b) a stored user credential. Managed identity assignment to a VM gives the workload an Entra-issued token that rotates automatically, is scoped via RBAC, and never appears in any file. The audit SP's secret has already been exposed to multiple Claude sessions (see CRM-identity report, "Side fix" section).

Remediation · Azure CLI

# Enable system-assigned managed identity on each VM
for vm in DBSvr-001 ADM-001az ACC-001az DCSvr-001 DCSvr-002 LANSvr-001; do
  az vm identity assign -g Production-RG -n $vm
done

# Then migrate workloads to use the identity (depends on what runs in each VM).
# Rotate the audit SP secret in parallel.

Rollback: az vm identity remove -g Production-RG -n $vm

Validation

az vm list -g Production-RG --query "[].{name:name,identity:identity.type}" -o table
# Expected: SystemAssigned for each VM

Blast radius of fix · Low · adding identity is non-disruptive; the workload migration that uses it is separately scoped.

F-17Low DREAD 8 / 25 · D2 R2 E1 A2 D1

No DDoS Protection Standard on VNet hosting public-facing workloads.

Resource
vNet-001 · enableDdosProtection: false
Control
CIS Azure v2.1.0 § 6.x · MCSB NS-5 · NIST 800-53 SC-5

Evidence

$ jq '.network.vnets[0] | {name, ddos: .ddos_protection}' < scripts/_security_audit.json
{ "name":"vNet-001","ddos":false }

Impact. Azure DDoS Protection Standard provides L3 / L4 mitigation for public IPs in the protected VNet. Without it, public-facing endpoints rely on Azure's free Basic DDoS protection, which is a shared pool. After F-02 + F-09 reduce the public-IP count to three (VPN GW + Bastion), DDoS Standard becomes much less important.

Remediation · Azure CLI

# Generally only worth deploying once the public attack surface is reduced
# AND if a public-facing workload requires guaranteed mitigation.
# Cost: $2,944 / mo flat regardless of how many VNets — suggests deferring until business need justifies.

Validation: post-remediation, vNet-001.enableDdosProtection = true.

Blast radius of fix · Low · but cost is high relative to fleet size.

F-18Low Security hygiene

Logic App API connection azurevm is broken (status Error).

Resource
/subscriptions/a1b2c3d4-…/resourceGroups/production-rg/providers/Microsoft.Web/connections/azurevm

Evidence

$ jq '.web_connections[]' < scripts/_security_extra.json
{ "name":"azurevm","kind":"V1","properties":{ "api":{"displayName":"Azure VM","name":"azurevm"},
  "overallStatus":"Error","changedTime":"2026-05-11T23:50:12Z" } }

Impact. A V1 API connection to "Azure VM" was created on or before 2026-05-11 and has been in Error state since. V1 connections store credentials at the connector layer; even when broken, the credential reference persists. If unused, delete it.

Remediation · Azure CLI

az resource delete --ids /subscriptions/a1b2c3d4-…/resourceGroups/production-rg/providers/Microsoft.Web/connections/azurevm

Validation: az resource list --resource-type Microsoft.Web/connections -g production-rg -o table returns no rows.

Blast radius of fix · Low · if there's a Logic App that depends on it (none currently visible in the inventory), it will fail. Verify with Dana first.

F-19Low / Informational

Disabled DevTest auto-shutdown schedule on ADM-001az.

Resource
/subscriptions/a1b2c3d4-…/resourceGroups/PRODUCTION-RG/providers/microsoft.devtestlab/schedules/shutdown-computevm-ADM-001AZ

Evidence: see § 3 inventory.

Impact. An auto-shutdown was configured for ADM-001az then disabled (and notifications turned off). Operationally fine — informational only. Noted because schedules with notifications disabled can mask costs / unexpected shutdowns later if re-enabled.

Remediation: none required. If permanently unused: delete it.

Blast radius of fix · Low

F-20Informational

Disk encryption is platform-managed (no CMK).

Resources
All 10 managed disks (encryption.type: EncryptionAtRestWithPlatformKey)
Control
CIS Azure v2.1.0 § 8.x · MCSB DP-5 · NIST 800-53 SC-12

Evidence: encryption blocks across all disks show type: EncryptionAtRestWithPlatformKey. No disk encryption sets exist.

Impact. Disks are encrypted with Microsoft-managed keys (default). For most workloads this is acceptable — CMK adds rotation control and key custody but creates operational overhead. Since no Key Vault exists (F-12), CMK is not currently possible.

Remediation: deferred until Key Vault is deployed (F-12).

Blast radius of fix · Medium · disk re-encryption requires VM stop.

§ 6 · Positive findings — controls that pass

Twelve things the environment gets right.

ControlStatusEvidence
All VMs use Generation 2 + Trusted Launch (Secure Boot + vTPM)Passsecurity_profile.securityType: TrustedLaunch, uefiSettings.secureBootEnabled: true, vTpmEnabled: true on all 6 VMs
Standard SKU public IPs (not Basic — deprecated 2025)PassAll 9 public IPs sku: Standard
Recovery vault soft-delete enabled with enhanced securityPasssoftDeleteState: Enabled, retention 14d, enhancedSecurityState: Enabled
Geo-redundant backup storagePassstandardTierStorageRedundancy: GeoRedundant
No custom RBAC roles with wildcard actionsPassZero custom roles defined
No deny assignments to bypassPassZero deny assignments
No classic administratorsPass (inferred)Role assignments are all RBAC roleDefinitions
Storage account contosostorageacct1 (RA-GRS, public, unused) deletedPassConfirmed via Resource Graph — microsoft.storage/storageaccounts count = 0 at audit time
No publicly accessible storage containersN/ANo storage accounts
Network Watcher deployedPassNetworkWatcher_eastus exists (flow logs not enabled — see F-11)
Active-active VPN gatewayPass (resilience)activeActive: True, capacity 2
Azure Bastion deployedPass (architectural)Contoso_Bastion Standard SKU, 2 scale units — though see F-03 for Shareable Link

§ 7 · CIS Azure Foundations v2.1.0 — control gap summary

Where the audit could conclude, and where it couldn't.

SectionPassFailN/AEvidence required
§ 1 — Identity and Access00022 · SP lacks Directory.Read.All (F-15)
§ 2 — Microsoft Defender for Cloud01400 (F-04)
§ 3 — Storage Accounts— (no storage accounts)
§ 4 — Database Services— (no Azure SQL DB / MI — SQL on a VM)
§ 5 — Logging and Monitoring0900 (F-05, F-06, F-11)
§ 6 — Networking1500 (F-01, F-02, F-08, F-09, F-11)
§ 7 — Virtual Machines2100 (Trusted Launch passes, encryption platform-managed F-20)
§ 8 — Key Vault— (no Key Vault — F-12)
§ 9 — App Service
§ 10 — Governance and Locks0200 (F-13, F-14)

§ 8 · Recommended sequencing

Six phases. Detection before public-surface reduction.

Each phase is gated by what makes the next one safe.

Phase 0 · This week Risk · Low

Today. No business impact.

F-03 (disable Bastion shareable link) · F-15 (grant Global Reader to audit SP)

2 items
Phase 1 · Week 2 Risk · Low

Detection. Passive observers + cost.

F-04 (Defender for Servers + SQL + ARM + DNS) · F-05 (LA workspace + diagnostic settings) · F-06 (4 Activity Log alerts) · F-11 (NSG flow logs)

4 items
Phase 2 · Week 3 Risk · Medium

Backup hardening. Operational change.

F-07 (vault immutability Unlocked → Locked after 14 d; MUA enabled)

1 item
Phase 3 · Weeks 4–5 Risk · Medium

Identity & access path.

F-10 (VPN P2S Entra ID auth) · F-14 (Owner review + remove redundant Reader) · F-16 (managed identities)

3 items
Phase 4 · Week 6+ Risk · High

Public-surface reduction. Gated on Phase 3.

F-01 (close SQL 1433 to internet) · F-02 (close all VM RDP) · F-09 (drop 4 public IPs) · F-08 (close DNS to internet)

4 items
Phase 5 · Week 7 Risk · Low

Governance.

F-12 (Key Vault) · F-13 (resource locks) · F-18 (delete broken connector) · F-20 (CMK when KV ready)

4 items

§ 9 · What this audit cannot tell you

Engagement upgrade required.

These items require RBAC the audit SP does not have:

To close these gaps, grant the audit SP Global Reader (Entra) and Reader at the management group level (already covered).

One Critical, nine High. Phase 0 ships this week.

Disable the Bastion shareable link, grant the audit SP Global Reader, and we hand back a clean Phase-1 detection-layer plan within seven days. Phase 4's public-surface reduction is gated on the VPN P2S work in Phase 3 — sequence matters.

Talk to a security engineer →