diff --git a/Powershell scripts/Defender for SQL servers on machines status report/Get-SqlVMProtectionStatusReport.ps1 b/Powershell scripts/Defender for SQL servers on machines status report/Get-SqlVMProtectionStatusReport.ps1 new file mode 100644 index 000000000..74429b0dc --- /dev/null +++ b/Powershell scripts/Defender for SQL servers on machines status report/Get-SqlVMProtectionStatusReport.ps1 @@ -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." diff --git a/Powershell scripts/Defender for SQL servers on machines status report/README.md b/Powershell scripts/Defender for SQL servers on machines status report/README.md new file mode 100644 index 000000000..e0a7414ad --- /dev/null +++ b/Powershell scripts/Defender for SQL servers on machines status report/README.md @@ -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 `` with either the **Subscription ID** or **Subscription Name**: + +```powershell +.\Get-SqlVMProtectionStatusReport.ps1 -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_.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). + +---