-
Notifications
You must be signed in to change notification settings - Fork 786
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #944 from Ran-br/ranbr/sqlServerOnMachinesProtecti…
…onStatusReport Add Script to Retrieve SQL Servers on Machines Protection Status
- Loading branch information
Showing
2 changed files
with
398 additions
and
0 deletions.
There are no files selected for viewing
290 changes: 290 additions & 0 deletions
290
...ts/Defender for SQL servers on machines status report/Get-SqlVMProtectionStatusReport.ps1
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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." |
108 changes: 108 additions & 0 deletions
108
Powershell scripts/Defender for SQL servers on machines status report/README.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). | ||
|
||
--- |