§ 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 disclosure — corp.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.