Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Script to Retrieve SQL Servers on Machines Protection Status #944

Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
<#
.SYNOPSIS
Retrieves registry values from all underlying VMs of your SQL Virtual Machines (in the scope of the current subscription)
for every SQL instance (under HKLM:\SOFTWARE\Microsoft\AzureDefender\SQL\) and exports the results to an Excel file.

.DESCRIPTION
For each SQL VM (as returned by Get-AzSqlVM), this script:
- Determines its underlying Virtual Machine.
- Invokes a run command (with -AsJob) on that VM. The remote script:
• Enumerates all instance names (subkeys) under HKLM:\SOFTWARE\Microsoft\AzureDefender\SQL\
• Retrieves the registry values "SqlQueryProtection_Status" and "SqlQueryProtection_Timestamp"
• Converts the .NET ticks timestamp into an ISO 8601 date/time
• Outputs a JSON array of objects (one per SQL instance)
- The local script waits for all jobs to complete, parses each job’s JSON output (using the Message property).
- Finally, the results are exported to an Excel file.

.NOTES
- Requires the Az modules (Az.Accounts, Az.Compute, Az.SqlVirtualMachine) and the ImportExcel module.
- Ensure you are connected to your Azure account (Connect-AzAccount).
#>

param(
[Parameter(Mandatory=$true)]
[string]$SubscriptionIdOrName
)

# ----------------------
# Connect to Azure if not already connected and set the subscription context
# ----------------------
if (-not $SubscriptionIdOrName -or [string]::IsNullOrWhiteSpace($SubscriptionIdOrName)) {
Write-Error "A valid subscription id or name must be provided."
exit
}
if (-not (Get-AzContext)) { Connect-AzAccount }

$subscription = Get-AzSubscription | Where-Object { $_.Id -eq $SubscriptionIdOrName -or $_.Name -eq $SubscriptionIdOrName }

if (-not $subscription) {
Write-Error "Subscription not found. Exiting."
exit
}

Write-Output "Processing subscription: $($subscription.Name) ($($subscription.Id))"
Set-AzContext -SubscriptionId $subscription.Id | Out-Null

# Import Excel for the output
Import-Module ImportExcel -ErrorAction Stop

# ----------------------
# 1. Define Remote Script
# ----------------------
$remoteScript = @'
$baseRegPath = "HKLM:\SOFTWARE\Microsoft\AzureDefender\SQL"
$results = @()

# Enumerate each subkey (instance) under the SQL key
try {
$instances = Get-ChildItem -Path $baseRegPath -ErrorAction SilentlyContinue
foreach ($instance in $instances) {
$instanceName = $instance.PSChildName
try {
# Attempt to retrieve the registry values for this instance.
$regValues = Get-ItemProperty -Path $instance.PSPath -Name "SqlQueryProtection_Status", "SqlQueryProtection_Timestamp" -ErrorAction Stop

# Convert the .NET ticks (100-nanosecond intervals since 0001-01-01) into an ISO 8601 timestamp.
$ticks = $regValues.SqlQueryProtection_Timestamp
$baseDate = [datetime]"0001-01-01T00:00:00Z"
$dt = $baseDate.AddTicks($ticks)
$iso = $dt.ToString("o")

# Build the output object for this instance.
$obj = [PSCustomObject]@{
InstanceName = $instanceName
ProtectionStatus = $regValues.SqlQueryProtection_Status
LastUpdate = $iso
}
$results += $obj
}
catch {
Write-Error "Failed to retrieve registry values for instance '$instanceName'. Error: $_"
}
}
}
catch {
Write-Error "Failed to enumerate SQL registry keys under $baseRegPath. Error: $_"
}

# Output the collected objects as JSON.
$results | ConvertTo-Json -Depth 4
'@

# ----------------------
# 2. Loop Through SQL VMs and Start Jobs
# ----------------------
$jobs = @()
$finalResults = @()

# Retrieve SQL Virtual Machines in this subscription.
$sqlVms = Get-AzSqlVM
if (-not $sqlVms) {
Write-Output "No SQL VMs found in subscription $($subscription.Name). Exiting."
exit
}
Write-Output "Found $($sqlVms.Count) SQL Virtual Machines. Initiating processing of SQL VM protection status checks..."

foreach ($sqlVm in $sqlVms) {
# Get the underlying Virtual Machine's resource id from either VirtualMachineId or VirtualMachineResourceId.
if ($sqlVm.VirtualMachineId) {
$underlyingVmResourceId = $sqlVm.VirtualMachineId
}
elseif ($sqlVm.VirtualMachineResourceId) {
$underlyingVmResourceId = $sqlVm.VirtualMachineResourceId
}
else {
Write-Warning "SQL VM '$($sqlVm.Name)' does not have an underlying Virtual Machine resource id. Skipping."
$obj = [PSCustomObject]@{
"SQL VM Name" = $sqlVm.Name
"Instance Name" = ""
"Protection Status" = ""
"Last Update" = ""
"SQL VM Resource ID" = ""
"Failure Reason" = "No underlying Virtual Machine resource id"
}
$finalResults += $obj
continue
}

# Parse the resource id to extract the resource group and VM name.
# Expected format: /subscriptions/{subId}/resourceGroups/{rgName}/providers/Microsoft.Compute/virtualMachines/{vmName}
$parts = $underlyingVmResourceId -split '/'
if ($parts.Count -lt 9) {
Write-Warning "Unexpected resource id format for SQL VM '$($sqlVm.Name)'. Skipping."
$obj = [PSCustomObject]@{
"SQL VM Name" = $sqlVm.Name
"Instance Name" = ""
"Protection Status" = ""
"Last Update" = ""
"SQL VM Resource ID" = $underlyingVmResourceId
"Failure Reason" = "Unexpected resource id format"
}
$finalResults += $obj
continue
}

$vmResourceGroup = $parts[4]
$vmName = $parts[8]

# Invoke the run command on the underlying VM as a job.
$job = Invoke-AzVMRunCommand -ResourceGroupName $vmResourceGroup `
-Name $vmName `
-CommandId 'RunPowerShellScript' `
-ScriptString $remoteScript `
-AsJob

# Attach extra metadata to the job for later aggregation.
$job | Add-Member -MemberType NoteProperty -Name "VmName" -Value $vmName -Force
$job | Add-Member -MemberType NoteProperty -Name "SqlVmName" -Value $sqlVm.Name -Force
$job | Add-Member -MemberType NoteProperty -Name "SQLVMResourceId" -Value $sqlVm.ResourceId -Force

$jobs += $job
}

# ----------------------
# 3. Process Job Outputs and Aggregate Results
# ----------------------
# Process each job’s output.
if ($jobs.Count -gt 0) {
Write-Output "Waiting for all run command jobs to complete..."
Wait-Job -Job $jobs

foreach ($job in $jobs) {
if ($job.State -eq 'Failed') {
$jobErrorMessage = $job.Error[0].Exception.Message
if ($jobErrorMessage -match "authorization to perform action") {
Write-Warning "Authorization failed for VM '$($job.VmName)'. Error: $jobErrorMessage"
}
elseif ($jobErrorMessage -match "requires the VM to be running") {
Write-Warning "The operation requires the VM '$($job.VmName)' to be running. Error: $jobErrorMessage."
}
else {
Write-Warning "Failed to retrieve protection status from machine '$($job.VmName)'. Error: $jobErrorMessage"
}

# Add the failed job details to the final results with empty fields for status and last update
$obj = [PSCustomObject]@{
"SQL VM Name" = $job.SqlVmName
"Instance Name" = ""
"Protection Status" = ""
"Last Update" = ""
"SQL VM Resource ID" = $job.SQLVMResourceId
"Failure Reason" = $jobErrorMessage
}
$finalResults += $obj
continue
}

try {
$jobOutput = Receive-Job -Job $job
$jsonOutput = $jobOutput.Value[0].Message

# Check if the job output message is empty or white space.
if ([string]::IsNullOrWhiteSpace($jsonOutput)) {
Write-Warning "Protection status could not be retrieved from SQL VM '$($job.SqlVmName)'."
$obj = [PSCustomObject]@{
"SQL VM Name" = $job.SqlVmName
"Instance Name" = ""
"Protection Status" = ""
"Last Update" = ""
"SQL VM Resource ID" = $job.SQLVMResourceId
"Failure Reason" = "No protection status information was found on the machine."
}
$finalResults += $obj
continue
}

try {
$parsed = $jsonOutput | ConvertFrom-Json
}
catch {
Write-Warning "Failed to parse JSON output for SQL VM '$($job.SqlVmName)'. Raw output: $jsonOutput"
continue
}

# Ensure the parsed output is an array.
if ($parsed -isnot [System.Collections.IEnumerable]) {
$parsed = @($parsed)
}

# Check if the parsed array is empty.
if (-not $parsed -or $parsed.Count -eq 0) {
Write-Warning "Protection status could not be retrieved from SQL VM '$($job.SqlVmName)'."
$obj = [PSCustomObject]@{
"SQL VM Name" = $job.SqlVmName
"Instance Name" = ""
"Protection Status" = ""
"Last Update" = ""
"SQL VM Resource ID" = $job.SQLVMResourceId
"Failure Reason" = "No protection status information was found on the machine."
}
$finalResults += $obj
continue
}

foreach ($item in $parsed) {
$obj = [PSCustomObject]@{
"SQL VM Name" = $job.SqlVmName
"Instance Name" = $item.InstanceName
"Protection Status" = $item.ProtectionStatus
"Last Update" = $item.LastUpdate.ToString("o")
"SQL VM Resource ID" = $job.SQLVMResourceId
"Failure Reason" = ""
}
$finalResults += $obj
}
}
catch {
Write-Warning "Failed to retrieve protection status for SQL VM '$($job.SqlVmName)'. Error details: $_."
Write-Warning "Please verify that the VM is running, accessible, that the SQL IaaS Extension and Defender for SQL provisioning is successful."
$obj = [PSCustomObject]@{
"SQL VM Name" = $job.SqlVmName
"Instance Name" = ""
"Protection Status" = ""
"Last Update" = ""
"SQL VM Resource ID" = $job.SQLVMResourceId
"Failure Reason" = "No protection status information was found on the machine."
}
$finalResults += $obj
}
}
}

# ----------------------
# 4. Export Results to Excel with Subscription ID in the filename and versioning if needed
# ----------------------
$baseName = "SqlVmProtectionResults_$($subscription.Id)"
$excelFile = "$baseName.xlsx"
$version = 1

while (Test-Path $excelFile) {
$excelFile = "${baseName}($version).xlsx"
$version++
}

$finalResults | Export-Excel -Path $excelFile -AutoSize -WorksheetName "SQLVMs"
$failedCount = ($finalResults | Where-Object { $_."Failure Reason" -and $_."Failure Reason".Trim() -ne "" }).Count
$successCount = $finalResults.Count - $failedCount
$totalCount = $finalResults.Count

Write-Output "Out of $totalCount total instances found, successfully retrieved protection status for $successCount, and failed for $failedCount."
Write-Output "Export complete. Results saved to $excelFile."
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Get-SqlVMProtectionStatusReport.ps1

## Validating SQL Instance Protection Under The *Microsoft Defender for SQL Servers on Machines* Plan

## Overview
Defender for SQL on machines provides comprehensive security for SQL servers hosted on Azure Virtual Machines, on-premises infrastructure, and Azure Arc-enabled servers.<!-- -->
It helps you discover and mitigate potential [database vulnerabilities](https://learn.microsoft.com/en-us/azure/defender-for-cloud/sql-azure-vulnerability-assessment-overview)<!-- -->
and alerts you to [anomalous activies](https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-sql-introduction?source=recommendations#advanced-threat-protection) that might indicate a threat to your databases.
For further details about the plan and how to enable protection, [please visit this page](https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-sql-usage).

The `Get-SqlVMProtectionStatusReport.ps1` script is designed to retrieve and report the **Microsoft Defender for SQL** protection status from all **SQL Virtual Machines** within a specified Azure subscription.

## Prerequisites
Before using this script, ensure you meet the following requirements:

### 1. PowerShell Modules
Install the necessary Azure PowerShell modules:
```powershell
Install-Module -Name Az -AllowClobber -Scope CurrentUser
Install-Module -Name ImportExcel -Scope CurrentUser
```
If the modules are already installed, ensure they are updated:
```powershell
Update-Module -Name Az
Update-Module -Name ImportExcel
```

### 2. Azure Authentication
Log in to your Azure account:
```powershell
Connect-AzAccount
```
Ensure you have **permissions** to run `Invoke-AzVMRunCommand` on the target VMs.

### 3. Ensure that the Microsoft Defender for SQL servers on machines plan is enabled
Ensure **Microsoft Defender for SQL** is enabled on your **subscription**.
More details on Defender for SQL can be found here:
👉 [Microsoft Defender for SQL on machines](https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-sql-usage)

---

## Usage Instructions
### Step 1: Run the Script
To execute the script, run the following command, replacing `<SubscriptionIdOrName>` with either the **Subscription ID** or **Subscription Name**:

```powershell
.\Get-SqlVMProtectionStatusReport.ps1 -SubscriptionIdOrName "<SubscriptionIdOrName>"
```

### Step 2: Review the Report
- The script will generate an **Excel file** containing the results.
- The file is saved in the same directory where the script is run.
- The output filename follows this format:
```plaintext
SqlVmProtectionResults_<SubscriptionId>.xlsx
```

- **Status Explanation:**
- **Protected:** Defender for SQL is actively protecting the instance. It is important to check the "Last Update" field to ensure the information is recent and not outdated.
- **Not Protected:** Defender for SQL encountered issues while protecting the instance. This indicates that some intervention is required to enable successful protection.
- **Inactive:** Defender for SQL is running on the machine, but the SQL instance is either paused or stopped.
- **Empty:** The protection status could not be retrieved or the status does not exist on the machine. In this case, assume that the instance is not protected by Defender for SQL.

### Step 3: Analyze and Troubleshoot
- Open the Excel file and review the protection status of your SQL instances.
- If any SQL server instance is **unprotected**, please refer to the **following troubleshooting guide**:
👉 [Troubleshooting SQL server on machines](https://learn.microsoft.com/en-us/azure/defender-for-cloud/troubleshoot-sql-machines-guide)

---

## How The Script Works
The script:
1. **Retrieves SQL Virtual Machines** (`Get-AzSqlVM`) in the provided **Azure Subscription**.
2. **Identifies the Underlying VM** for each SQL VM.
3. **Runs a Remote PowerShell Command** (`Invoke-AzVMRunCommand -AsJob`) on each VM to:
- Enumerate SQL instances under `HKLM:\SOFTWARE\Microsoft\AzureDefender\SQL\`
- Retrieve registry values of the protection status and the timestamp of its last update
- Convert timestamps to **ISO 8601** format.
4. **Aggregates and Formats Data** for all SQL instances found on each VM.
5. **Exports to an Excel Report**, which includes:
- SQL VM Name
- SQL Instance Name
- Protection Status
- Last Update Time
- SQL VM Resource ID
- Failure Reason (if applicable)

---

## Troubleshooting & FAQ

### **Q1: I get an error: "Subscription not found."**
**Solution:** Ensure you provide the correct **Subscription ID** or **Subscription Name**.

### **Q2: The report shows empty results.**
**Solution:** Ensure:
- The subscription contains **SQL VMs**.
- You have the correct **Azure role permissions** to access these VMs.
- **Microsoft Defender for SQL server on machines** plan is enabled for the specified subscription.

### **Q3: I get an "Invoke-AzVMRunCommand failed" error.**
**Solution:** Ensure:
- The target **VMs are running**.
- The **VM agent is installed** on each VM (required for `Invoke-AzVMRunCommand`).

For additional guidance, check 👉 [Invoke-AzVMRunCommand](https://learn.microsoft.com/en-us/powershell/module/az.compute/invoke-azvmruncommand?view=azps-13.2.0).

---