Skip to content

Commit

Permalink
Merge pull request #944 from Ran-br/ranbr/sqlServerOnMachinesProtecti…
Browse files Browse the repository at this point in the history
…onStatusReport

Add Script to Retrieve SQL Servers on Machines Protection Status
  • Loading branch information
tarosler authored Feb 18, 2025
2 parents 4502f4c + 1710a5b commit 50caca6
Show file tree
Hide file tree
Showing 2 changed files with 398 additions and 0 deletions.
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).

---

0 comments on commit 50caca6

Please sign in to comment.