Malware detection and endpoint protection are critical pillars in the IT landscape, especially for organizations managing a distributed network of endpoints. Whether it’s protecting devices from ransomware or ensuring compliance with cybersecurity frameworks, running regular antivirus scans is a non-negotiable task. Automating these scans through scripting not only reduces overhead but also ensures consistency across environments. This blog post dives into a comprehensive PowerShell script that enables IT professionals to start a Windows Defender scan, manage execution time, and store results—all through automation.
Background
Microsoft Defender, formerly known as Windows Defender, is integrated into all modern Windows operating systems and provides a solid baseline for antivirus protection. However, managing and triggering Defender scans at scale—across a fleet of remote devices—can be cumbersome without automation. This script is built for IT administrators and Managed Service Providers (MSPs) using tools like NinjaOne to streamline security operations.
The PowerShell script leverages Defender’s command-line capabilities and enhances them with event log parsing, result formatting, and optional integration with NinjaOne WYSIWYG custom fields. It solves two major problems:
- Automation – Trigger scans programmatically.
- Accountability – Capture detailed results for auditing or dashboard reporting.
The Script:
#Requires -Version 5.1 <# .SYNOPSIS Starts a Windows Defender scan. .DESCRIPTION This script starts a Windows Defender scan based on the specified scan type and path. It also handles timeouts and saves the scan results to a Wysiwyg custom field if specified. By using this script, you indicate your acceptance of the following legal terms as well as our Terms of Use at https://www.ninjaone.com/terms-of-use. Ownership Rights: NinjaOne owns and will continue to own all right, title, and interest in and to the script (including the copyright). NinjaOne is giving you a limited license to use the script in accordance with these legal terms. Use Limitation: You may only use the script for your legitimate personal or internal business purposes, and you may not share the script with another party. Republication Prohibition: Under no circumstances are you permitted to re-publish the script in any script library or website belonging to or under the control of any other software provider. Warranty Disclaimer: The script is provided “as is” and “as available”, without warranty of any kind. NinjaOne makes no promise or guarantee that the script will be free from defects or that it will meet your specific needs or expectations. Assumption of Risk: Your use of the script is at your own risk. You acknowledge that there are certain inherent risks in using the script, and you understand and assume each of those risks. Waiver and Release: You will not hold NinjaOne responsible for any adverse or unintended consequences resulting from your use of the script, and you waive any legal or equitable rights or remedies you may have against NinjaOne relating to your use of the script. EULA: If you are a NinjaOne customer, your use of the script is subject to the End User License Agreement applicable to you (EULA). .PARAMETER PathToScan The path to scan. This is only required if the scan type is Custom. .PARAMETER ScanType The type of scan to perform. Options are Quick, Full, or Custom. Default is Quick. .PARAMETER TimeoutInMinutes The timeout in minutes for the scan. Default is 120 minutes. .PARAMETER ForceStopOnTimeout If specified, the scan will be forcefully stopped if it exceeds the timeout. .PARAMETER WysiwygCustomFieldName The name of the Wysiwyg custom field to store the scan results. .EXAMPLE -ScanType "Quick" ## EXAMPLE OUTPUT WITH pathToScanForCustomScan ## [Info] Installed Antivirus: Windows Defender [Info] Starting Windows Defender scan: [Info] Quick selected, will scan the system drive(C:), and will timeout in 120 minutes. [Info] Starting job for a Quick scan on the system drive. [Info] Job completed. [Info] Scan completed successfully. [Info] Scan results: Name : Virus:DOS/EICAR_Test_File Action : Quarantine Path : C:\test\eicarcom2.zip C:\test\eicarcom2.zip->eicar_com.zip->eicar.com Time : 3/24/2025 10:12:32 AM SHA1 : bec1b52d350d721c7e22a6d4bb0a92909893a3ae SHA256 : e1105070ba828007508566e28a2b8d4c65d192e9eaf3b7868382b7cae747b397 .EXAMPLE -ScanType "Full" ## EXAMPLE OUTPUT WITH scanType ## [Info] Installed Antivirus: Windows Defender [Info] Starting Windows Defender scan: [Info] Full selected, will scan the system drive(C:), and will timeout in 120 minutes. [Info] Starting job for a Full scan on the system drive. [Info] Job completed. [Info] Scan completed successfully. [Info] Scan results: Name : Virus:DOS/EICAR_Test_File Action : Quarantine Path : C:\test\eicarcom2.zip C:\test\eicarcom2.zip->eicar_com.zip->eicar.com Time : 3/24/2025 10:12:32 AM SHA1 : bec1b52d350d721c7e22a6d4bb0a92909893a3ae SHA256 : e1105070ba828007508566e28a2b8d4c65d192e9eaf3b7868382b7cae747b397 .EXAMPLE -ScanType "Custom" -PathToScan "C:\test" ## EXAMPLE OUTPUT WITH scanType ## [Info] Installed Antivirus: Windows Defender [Info] Starting Windows Defender scan: [Info] Custom selected, will scan the path provided(C:\test), and will timeout in 120 minutes. [Info] Starting job for a Custom scan on the system drive. [Info] Job completed. [Info] Scan completed successfully. [Info] Scan results: Name : Virus:DOS/EICAR_Test_File Action : Quarantine Path : C:\test\eicarcom2.zip C:\test\eicarcom2.zip->eicar_com.zip->eicar.com Time : 3/24/2025 10:12:32 AM SHA1 : bec1b52d350d721c7e22a6d4bb0a92909893a3ae SHA256 : e1105070ba828007508566e28a2b8d4c65d192e9eaf3b7868382b7cae747b397 .EXAMPLE -ForceStopOnTimeout -TimeoutInMinutes 120 ## EXAMPLE OUTPUT WITH forceStopOnTimeout ## [Info] Installed Antivirus: Windows Defender [Info] Starting Windows Defender scan: [Info] Quick selected, will scan the system drive(C:), and will timeout in 120 minutes. [Info] Starting job for a Quick scan on the system drive. [Info] Job completed. [Error] Scan exceeded the timeout of 120 minutes. Stopping the scan. .EXAMPLE -WysiwygCustomFieldName "WindowsDefenderScanResults" ## EXAMPLE OUTPUT WITH wysiwygCustomFieldName ## [Info] Installed Antivirus: Windows Defender [Info] Starting Windows Defender scan: [Info] Quick selected, will scan the system drive(C:), and will timeout in 120 minutes. [Info] Starting job for a Quick scan on the system drive. [Info] Job completed. [Info] Scan completed successfully. [Info] Attempting to set Custom Field 'WindowsDefenderScanResults'. [Info] Successfully set Custom Field 'WindowsDefenderScanResults'! [Info] Scan results: Name : Virus:DOS/EICAR_Test_File Action : Quarantine Path : C:\test\eicarcom2.zip C:\test\eicarcom2.zip->eicar_com.zip->eicar.com Time : 3/24/2025 10:12:32 AM SHA1 : bec1b52d350d721c7e22a6d4bb0a92909893a3ae SHA256 : e1105070ba828007508566e28a2b8d4c65d192e9eaf3b7868382b7cae747b397 .NOTES Minimum OS Architecture Supported: Windows 10, Windows Server 2016 Release Notes: Initial Release #> [CmdletBinding()] param ( [string]$PathToScan, [string]$ScanType, [int]$TimeoutInMinutes, [switch]$ForceStopOnTimeout, [string]$WysiwygCustomFieldName ) begin { function Set-CustomField { [CmdletBinding()] Param( [Parameter(Mandatory = $True)] [String]$Name, [Parameter()] [String]$Type, [Parameter(Mandatory = $True, ValueFromPipeline = $True)] $Value, [Parameter()] [String]$DocumentName, [Parameter()] [Switch]$Piped ) # Remove the non-breaking space character if ($Type -eq "WYSIWYG") { $Value = $Value -replace ' ', ' ' } # Measure the number of characters in the provided value $Characters = $Value | ConvertTo-Json | Measure-Object -Character | Select-Object -ExpandProperty Characters # Throw an error if the value exceeds the character limit of 200,000 characters if ($Piped -and $Characters -ge 200000) { throw [System.ArgumentOutOfRangeException]::New("Character limit exceeded: the value is greater than or equal to 200,000 characters.") } if (!$Piped -and $Characters -ge 45000) { throw [System.ArgumentOutOfRangeException]::New("Character limit exceeded: the value is greater than or equal to 45,000 characters.") } # Initialize a hashtable for additional documentation parameters $DocumentationParams = @{} # If a document name is provided, add it to the documentation parameters if ($DocumentName) { $DocumentationParams["DocumentName"] = $DocumentName } # Define a list of valid field types $ValidFields = "Attachment", "Checkbox", "Date", "Date or Date Time", "Decimal", "Dropdown", "Email", "Integer", "IP Address", "MultiLine", "MultiSelect", "Phone", "Secure", "Text", "Time", "URL", "WYSIWYG" # Warn the user if the provided type is not valid if ($Type -and $ValidFields -notcontains $Type) { Write-Warning "$Type is an invalid type. Please check here for valid types: https://ninjarmm.zendesk.com/hc/en-us/articles/16973443979789-Command-Line-Interface-CLI-Supported-Fields-and-Functionality" } # Define types that require options to be retrieved $NeedsOptions = "Dropdown" # If the property is being set in a document or field and the type needs options, retrieve them if ($DocumentName) { if ($NeedsOptions -contains $Type) { $NinjaPropertyOptions = Ninja-Property-Docs-Options -AttributeName $Name @DocumentationParams 2>&1 } } else { if ($NeedsOptions -contains $Type) { $NinjaPropertyOptions = Ninja-Property-Options -Name $Name 2>&1 } } # Throw an error if there was an issue retrieving the property options if ($NinjaPropertyOptions.Exception) { throw $NinjaPropertyOptions } # Process the property value based on its type switch ($Type) { "Checkbox" { # Convert the value to a boolean for Checkbox type $NinjaValue = [System.Convert]::ToBoolean($Value) } "Date or Date Time" { # Convert the value to a Unix timestamp for Date or Date Time type $Date = (Get-Date $Value).ToUniversalTime() $TimeSpan = New-TimeSpan (Get-Date "1970-01-01 00:00:00") $Date $NinjaValue = $TimeSpan.TotalSeconds } "Dropdown" { # Convert the dropdown value to its corresponding GUID $Options = $NinjaPropertyOptions -replace '=', ',' | ConvertFrom-Csv -Header "GUID", "Name" $Selection = $Options | Where-Object { $_.Name -eq $Value } | Select-Object -ExpandProperty GUID # Throw an error if the value is not present in the dropdown options if (!($Selection)) { throw [System.ArgumentOutOfRangeException]::New("Value is not present in dropdown options.") } $NinjaValue = $Selection } default { # For other types, use the value as is $NinjaValue = $Value } } # Set the property value in the document if a document name is provided if ($DocumentName) { $CustomField = Ninja-Property-Docs-Set -AttributeName $Name -AttributeValue $NinjaValue @DocumentationParams 2>&1 } else { try { # Otherwise, set the standard property value if ($Piped) { $CustomField = $NinjaValue | Ninja-Property-Set-Piped -Name $Name 2>&1 } else { $CustomField = Ninja-Property-Set -Name $Name -Value $NinjaValue 2>&1 } } catch { Write-Host -Object "[Error] Failed to set custom field." throw $_.Exception.Message } } # Throw an error if setting the property failed if ($CustomField.Exception) { throw $CustomField } } function Get-DefenderScanResults { $EventTypes = @( # Windows Defender event types: # https://learn.microsoft.com/en-us/defender-endpoint/troubleshoot-microsoft-defender-antivirus [PSCustomObject]@{Id = 1006; Name = "MALWARE_DETECTED" } [PSCustomObject]@{Id = 1007; Name = "MALWARE_ACTION_TAKEN" } [PSCustomObject]@{Id = 1119; Name = "MALWARE_ACTION_FAILED" } [PSCustomObject]@{Id = 1009; Name = "QUARANTINE_RESTORE" } [PSCustomObject]@{Id = 1010; Name = "QUARANTINE_RESTORE_FAILED" } [PSCustomObject]@{Id = 1011; Name = "QUARANTINE_DELETE" } [PSCustomObject]@{Id = 1012; Name = "QUARANTINE_DELETE_FAILED" } [PSCustomObject]@{Id = 1013; Name = "MALWARE_HISTORY_DELETE" } [PSCustomObject]@{Id = 1014; Name = "MALWARE_HISTORY_DELETE_FAILED" } [PSCustomObject]@{Id = 1015; Name = "BEHAVIOR_DETECTED" } [PSCustomObject]@{Id = 1116; Name = "STATE_MALWARE_DETECTED" } [PSCustomObject]@{Id = 1117; Name = "STATE_MALWARE_ACTION_TAKEN" } [PSCustomObject]@{Id = 1118; Name = "STATE_MALWARE_ACTION_FAILED" } [PSCustomObject]@{Id = 1119; Name = "STATE_MALWARE_ACTION_CRITICALLY_FAILED" } ) # Get the last scan start event (Event ID 1000) $lastScan = Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-Windows Defender/Operational' ID = 1000 } -ErrorAction SilentlyContinue | Sort-Object TimeCreated -Descending | Select-Object -First 1 if (-not $lastScan) { # Warn if no scan start event is found Write-Host "[Warn] No scan start event (ID 1000) found in event logs." return } $scanStartTime = $lastScan.TimeCreated # Get detection events (Event ID 1119) that occurred after the scan started $detectionEvents = Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-Windows Defender/Operational' ID = $EventTypes.Id } -ErrorAction SilentlyContinue | Where-Object { $_.TimeCreated -ge $scanStartTime } # Process each event to extract Name, Action, and Path using regex $results = foreach ($event in $detectionEvents) { $message = $event.Message $name = ([regex]::Match($message, "Name:\s*(.+)").Groups[1].Value).Trim() $action = ([regex]::Match($message, "Action:\s*(.+)").Groups[1].Value).Trim() $path = ([regex]::Match($message, "Path:\s*(.+)").Groups[1].Value).Trim() if ($name -and $path) { # Create a custom object with the extracted information [PSCustomObject]@{ Name = $name Action = "$( if ($action) { $action } else { $( # Get the event name based on the event ID $eventTypes | Where-Object { $_.Id -eq $event.Id } | Select-Object -ExpandProperty Name -First 1 ) -replace '_', ' ' } # Convert to uppercase using the casing rules of the invariant culture. )".ToUpperInvariant() Path = $path -replace '[a-z]+:_' -split ';' Time = $event.TimeCreated } } } return $results } function Get-DetectedThreats { [CmdletBinding()] Param() # Initialize a list to store parsed threat information $Threats = Get-DefenderScanResults # Initialize lists to store parsed results $parsedSDNQuery = [System.Collections.Generic.List[PSCustomObject]]::new() # Get the path to the Windows Defender logs $MpLogsPath = "$env:ProgramData\Microsoft\Windows Defender\Support" # Get the Windows Defender logs $MpLogs = Get-ChildItem -Path $MpLogsPath -Filter "MpLog*.log" -File -ErrorAction SilentlyContinue # Begin Resource Scan $MpLogs | ForEach-Object { # Read the file line by line $lines = Get-Content -Path $_.FullName foreach ($line in $lines) { # Parse SDN query events (now includes both SHA1 and SHA256) if ($line -match "(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z?) SDN:Issuing SDN query for (\\?.+?) \((\\?.+?)\) \(sha1=(\w{40}), sha2=(\w{64})\)") { $timestamp = ($matches[1] | Get-Date).ToUniversalTime() $filePath = $matches[2] $sha1 = $matches[4] $sha256 = $matches[5] # Store SDN query result $parsedSDNQuery.Add( [PSCustomObject]@{ Timestamp = $timestamp Path = $filePath -replace '\\\\\?\\' SHA1 = $sha1 SHA256 = $sha256 } ) } } } # Process each threat to add SHA1 and SHA256 hashes if available $Threats | ForEach-Object { $ThreatInfo = $_ # Find the corresponding SHA1 and SHA256 hashes by matching the path $SHAs = $parsedSDNQuery | Where-Object { $($_.Path | Split-Path -Leaf) -in $($ThreatInfo.Path | Split-Path -Leaf) } | Select-Object -First 1 if ($SHAs) { $ThreatInfo | Add-Member -MemberType NoteProperty -Name "SHA1" -Value ($SHAs | Select-Object -ExpandProperty SHA1) $ThreatInfo | Add-Member -MemberType NoteProperty -Name "SHA256" -Value ($SHAs | Select-Object -ExpandProperty SHA256) $ThreatInfo | Add-Member -MemberType NoteProperty -Name "Link" -Value "https://www.virustotal.com/gui/search/$($SHAs | Select-Object -ExpandProperty SHA1)" } else { $ThreatInfo | Add-Member -MemberType NoteProperty -Name "SHA1" -Value "Unavailable" $ThreatInfo | Add-Member -MemberType NoteProperty -Name "SHA256" -Value "Unavailable" $ThreatInfo | Add-Member -MemberType NoteProperty -Name "Link" -Value "" } # Output the results Write-Output $ThreatInfo } } function ConvertTo-HtmlTable { param ( [Parameter(Mandatory = $true)] [System.Collections.Generic.List[Object]] $Objects ) $StringBuilder = New-Object System.Text.StringBuilder # Create the HTML table header $StringBuilder.Append('<table><thead><tr>') | Out-Null $($Objects | Select-Object -First 1).PSObject.Properties.Name | ForEach-Object { $StringBuilder.Append("<th>$_</th>") | Out-Null } $StringBuilder.Append('</tr></thead><tbody>') | Out-Null # Loop through each object and create a table row for each $Objects | ForEach-Object { $CurrentObject = $_ $StringBuilder.Append("<tr>") | Out-Null $CurrentObject.PSObject.Properties.Name | ForEach-Object { $Name = $_ $StringBuilder.Append("<td>$($CurrentObject.$Name)</td>") | Out-Null } $StringBuilder.Append('</tr>') | Out-Null } # Create the HTML table footer $StringBuilder.Append('</tbody></table>') | Out-Null # Return the HTML table as a string return $StringBuilder.ToString() } # Output the installed antivirus products $( if ($PSVersionTable.PSVersion.Major -lt 3) { Get-WmiObject -Class antivirusproduct -Namespace root\securitycenter2 -ErrorAction SilentlyContinue } else { Get-CimInstance -ClassName antivirusproduct -Namespace root\securitycenter2 -ErrorAction SilentlyContinue } ) | Select-Object -Property displayName | ForEach-Object { Write-Host "[Info] Installed Antivirus: $($_.displayName)" } # Check if the Windows Defender module is available if ($(Get-Command -Module Defender).Count -eq 0) { Write-Host "[Error] The Windows Defender is not available. Please ensure that the Windows Defender is installed." exit 1 } # Check if Windows Defender is enabled, regardless of the operating system $DefenderStatus = Get-MpComputerStatus # Check if Windows Defender is enabled if ($DefenderStatus.AntivirusEnabled -eq $false -and $DefenderStatus.AntispywareEnabled -eq $false) { Write-Host "[Error] Windows Defender is not enabled. Please enable Windows Defender before starting a scan." exit 1 } if ($DefenderStatus.AMRunningMode -like "Not running" -or $DefenderStatus.AMRunningMode -like "Disabled") { Write-Host "[Error] Windows Defender is disabled or is not running. Please start Windows Defender before starting a scan." exit 1 } # Get script variables from environment variables if ("$env:pathToScanForCustomScan".Trim()) { $PathToScan = "$env:pathToScanForCustomScan".Trim() } # Check if the path to scan exists if ($PathToScan -and -not (Test-Path $PathToScan)) { Write-Host "[Error] The path ($PathToScan) does not exist." exit 1 } if ($env:scanType) { $ScanType = $env:scanType } if ($ScanType -notlike "Custom" -and $PathToScan) { Write-Host "[Error] The path to scan ($PathToScan) can only be used when the scan type is Custom." exit 1 } if ($env:timeoutInMinutes) { try { [int]$value = $env:timeoutInMinutes } catch { Write-Host "[Error] The Timeout In Minutes value ($env:timeoutInMinutes) is not a valid integer. Must be a positive integer and less than ($([int]::MaxValue))." exit 1 } if ($value -gt [int]::MaxValue) { $TimeoutInMinutes = [int]::MaxValue } elseif ($env:timeoutInMinutes -lt 1) { Write-Host "[Error] The Timeout In Minutes value ($env:timeoutInMinutes) is less than 1. Must be a positive integer and greater than 0." exit 1 } else { $TimeoutInMinutes = $env:timeoutInMinutes } } if ($env:forceStopOnTimeout -like "true") { $ForceStopOnTimeout = $true } if ("$env:wysiwygCustomFieldName".Trim()) { $WysiwygCustomFieldName = "$env:wysiwygCustomFieldName".Trim() } if ($TimeoutInMinutes -gt 2880) { Write-Host "[Error] The timeout ($TimeoutInMinutes) exceeds 2880 minutes. The scan will not be performed." exit 1 } if ($TimeoutInMinutes -gt 240) { Write-Host "[Warn] The timeout ($TimeoutInMinutes) exceeds 240 minutes." -NoNewline if ($env:wysiwygCustomFieldName) { Write-Host " The scan results may not be saved to the WYSIWYG custom field when the timeout exceeds 240 minutes." -NoNewline } Write-Host " Scan results may not be returned to the Activity Feed." Write-Host "[Info] The scan will still be performed." } } process { # Set the default scan type if (-not $ScanType) { $ScanType = "Quick" } # If the scan type is Custom, the path to scan is required if ($ScanType -like "Custom" -and -not $PathToScan) { Write-Host "[Error] The path to scan is required when the scan type is Custom." exit 1 } # Set the default timeout if (-not $TimeoutInMinutes) { $TimeoutInMinutes = 120 } Write-Host "[Info] Starting Windows Defender scan:" if ($ScanType -like "Custom") { Write-Host "[Info] $ScanType selected, will scan the path provided($PathToScan), and will timeout in $TimeoutInMinutes minutes." } else { Write-Host "[Info] $ScanType selected, will scan the system drive($env:SystemDrive), and will timeout in $TimeoutInMinutes minutes." } # Initialize variables to track the scan status $ScanFailedToRun = $false $ScanFailedToRunMessage = "" # Start the scan, wait for it to complete, and get the scan job try { # Start the scan job if ($ScanType -like "Custom") { # Start a custom scan with the specified path Write-Host "[Info] Starting job for a $ScanType scan on $PathToScan." $Job = Start-MpScan -ScanType "$($ScanType)Scan" -ScanPath $PathToScan -AsJob | Wait-Job -Timeout $($TimeoutInMinutes * 60) Write-Host "[Info] Job completed." } else { # Start the scan without a specified path Write-Host "[Info] Starting job for a $ScanType scan on the system drive." $Job = Start-MpScan -ScanType "$($ScanType)Scan" -AsJob | Wait-Job -Timeout $($TimeoutInMinutes * 60) Write-Host "[Info] Job completed." } } catch { Write-Host "[Error] Failed to start the scan." Write-Host "[Error] $($_.Exception.Message)" # Set the scan status to failed and save the error message $ScanFailedToRunMessage = $_.Exception.Message $ScanFailedToRun = $true } if ($Job.Finished) { $ScanTimedOut = $false } else { $ScanTimedOut = $true # Stop the scan if it exceeds the timeout, but only if the scan did not fail to run if ($ForceStopOnTimeout -and $ScanFailedToRun -eq $false) { Write-Host "[Error] Scan exceeded the timeout of $TimeoutInMinutes minutes. Stopping the scan." & "$env:ProgramFiles\Windows Defender\MpCmdRun.exe" -Scan -Cancel 2>&1 | Out-Null } } # Initialize variables to track the scan status $ErrorScanning = $false $ErrorSaving = $false # Check the scan status switch ($Job.State) { "Completed" { Write-Host "[Info] Scan completed successfully." } "Failed" { Write-Host "[Error] Scan failed." ; $ErrorScanning = $true } "Stopped" { Write-Host "[Info] Scan stopped." } Default { Write-Host "[Error] Scan did not complete." } } # Get the scan results $ScanResults = if ($ScanTimedOut) { Write-Output "Scan exceeded the timeout of $TimeoutInMinutes minutes." Write-Host "[Error] Scan exceeded the timeout of $TimeoutInMinutes minutes." } elseif ($ErrorScanning) { Write-Output "Scan failed." Write-Host "[Error] Scan failed." } elseif ($ScanFailedToRun) { Write-Output "Failed to start the scan. Reason: $ScanFailedToRunMessage" Write-Host "[Error] Failed to start the scan. Reason: $ScanFailedToRunMessage" } else { # Wait for more events to be logged Start-Sleep -Seconds 60 $Results = Get-DetectedThreats | ForEach-Object { [PSCustomObject]@{ Name = $_.Name Action = $_.Action Path = $_.Path -join [System.Environment]::NewLine # Join paths with a newline character Time = $_.Time SHA1 = $_.SHA1 SHA256 = $_.SHA256 "Virus Total" = $_.Link } } if ($Results) { Write-Output $Results } else { Write-Output "No threats detected from latest scan." Write-Host "[Info] No threats detected from latest scan." } } # Save the scan results to a Wysiwyg custom field if ($WysiwygCustomFieldName) { # Save the scan results to the Wysiwyg custom field try { Write-Host "[Info] Attempting to set Custom Field '$WysiwygCustomFieldName'." if ($ScanResults -is [String]) { Set-CustomField -Name $WysiwygCustomFieldName -Value $ScanResults -Type "WYSIWYG" } else { # Convert the scan results to HTML and set the custom field $HtmlTable = ConvertTo-HtmlTable -Objects $( $ScanResults | Select-Object Name, Action, @{ # Convert list of paths to an HTML table Name = "Path" Expression = { $_.Path -split [System.Environment]::NewLine -join "<br>" } }, Time, @{ Name = "SHA1" Expression = { if ($_."Virus Total") { # Create a link to VirusTotal for SHA1 "<a href='$($_."Virus Total")' target='_blank' rel='nofollow noopener noreferrer'>$($_.SHA1) <i class='fas fa-arrow-up-right-from-square'></i></a>" } else { "Unavailable" } } }, SHA256 ) # Add icons to the table headers and set widths $HtmlTable = $HtmlTable -replace '<th>Name', "<th style='width: 10em'><i class='fa-solid fa-file'></i> Name" $HtmlTable = $HtmlTable -replace '<th>Path', "<th style='width: 20em'><i class='fa-solid fa-folder'></i> Path" $HtmlTable = $HtmlTable -replace '<th>Time', "<th style='width: 19em'><i class='fa-solid fa-clock'></i> Time" $HtmlTable = $HtmlTable -replace '<th>Action', "<th style='width: 7em'><i class='fa-solid fa-shield-virus'></i> Action" $HtmlTable = $HtmlTable -replace '<th>SHA1', "<th style='width: 19em'>SHA1 <i class='fa-solid fa-arrow-up-right-from-square'></i>" $HtmlTable = $HtmlTable -replace '<th>SHA256', "<th style='width: 19em'>SHA256" $HtmlTable = $HtmlTable -replace "<table>", "<table style='white-space:nowrap;'>" # Add a card wrapper around the HTML table $HtmlTable = "<div class='card flex-grow-1'> <div class='card-title-box'> <div class='card-title'><i class='fa-solid fa-book'></i> Windows Defender Scan Results</div> </div> <div class='card-body' style='white-space: nowrap'> $HtmlTable </div> </div>" Set-CustomField -Name $WysiwygCustomFieldName -Value $HtmlTable -Type "WYSIWYG" -Piped } Write-Host "[Info] Successfully set Custom Field '$WysiwygCustomFieldName'!" } catch { Write-Host "[Error] $($_.Exception.Message)" $ErrorSaving = $true } } if ($Results) { Write-Host "[Info] Scan results:" Write-Host "" "$($Results | Select-Object -Property Name, Action, Path, Time, SHA1, SHA256 | Format-List | Out-String -Width 4000)".Trim() | Write-Host Write-Host "" } # Exit with an error code if there was an error scanning or the scan timed out if ($ErrorScanning -or $ScanTimedOut -or $ScanFailedToRun -or $ErrorSaving) { exit 1 } else { exit 0 } } end { }
Detailed Breakdown
The script is structured with modular functions to perform a full scan lifecycle—from initialization to result logging. Here’s a breakdown of how it works:
Parameter Definitions
- ScanType: Accepts “Quick”, “Full”, or “Custom”. Quick and Full scan the system drive; Custom scans a specified path.
- PathToScan: Required if
ScanType
is “Custom”. - TimeoutInMinutes: Defaults to 120 minutes. Prevents scans from running indefinitely.
- ForceStopOnTimeout: Optional. Forcefully cancels a scan that exceeds the timeout.
- WysiwygCustomFieldName: Optional. Saves the scan results to a NinjaOne custom field.
Core Functions
Set-CustomField
: Validates and updates NinjaOne custom fields. Especially useful for documenting scan results in a centralized dashboard.Get-DefenderScanResults
: Parses Windows Event Logs for relevant threat detection events (e.g., malware detected, actions taken).Get-DetectedThreats
: Enhances event data with SHA1/SHA256 hashes from Defender logs and includes VirusTotal links for deeper investigation.ConvertTo-HtmlTable
: Transforms results into a stylized HTML card, perfect for NinjaOne dashboards or reports.
Scan Execution Logic
- Validates inputs.
- Verifies Defender is installed, enabled, and running.
- Starts the scan using
Start-MpScan
with the selected parameters. - Monitors completion, enforces timeout rules, and optionally cancels long-running scans.
- Collects threat results, parses them into a readable format, and optionally stores them in NinjaOne.
Potential Use Cases
Scenario: A healthcare MSP managing 300 endpoints wants to ensure all machines run a weekly malware scan and report results to their central dashboard.
Solution:
- Set up a scheduled task or NinjaOne automation to run this script every Friday evening.
- Specify
-ScanType "Full"
and a timeout of 180 minutes. - Use
-WysiwygCustomFieldName "Weekly AV Scan"
to save results in the NinjaOne asset record.
Outcome: The MSP achieves full visibility into each machine’s antivirus status without manual checks, allowing for swift response if malware is found.
Comparisons
Scripted Defender Scan vs. Manual GUI Scan
Feature | PowerShell Script | Manual GUI |
Scalability | High | Low |
Automation | Yes | No |
Logging | Detailed | Minimal |
Integration | NinjaOne Custom Fields | None |
Script vs. Group Policy Scheduled Scan
- Group Policy is effective for blanket policies but lacks real-time reporting or granular control.
- This script offers dynamic control, timeout enforcement, and actionable feedback.
FAQs
Q1: What happens if Defender is not installed or running?
The script checks for Defender’s availability and exits with an error message if it’s missing or disabled.
Q2: Can I run this on Windows Server?
Yes, it supports Windows Server 2016 and newer.
Q3: What if the scan exceeds the timeout?
You can enable -ForceStopOnTimeout
to cancel the scan forcefully. A message will also be logged.
Q4: Is NinjaOne required?
The scan runs without NinjaOne. However, saving results to custom fields requires a NinjaOne integration.
Q5: How do I ensure scan results are captured accurately?
The script includes a Start-Sleep -Seconds 60
to give Defender time to log events before collecting them.
Implications
Running this script regularly allows IT admins to maintain a strong security posture, detect dormant threats, and ensure compliance. By integrating scan results into dashboards, it fosters transparency and accountability. It also helps mitigate risks by providing traceability of detected threats, actions taken, and hash-level details useful for threat hunting.
Recommendations
- Run with elevated permissions to ensure Defender can operate fully.
- Schedule scans during off-hours to reduce disruption to end users.
- Use Custom scan types for high-risk directories (e.g., downloads folder).
- Limit timeout to realistic durations (under 240 minutes) for reliable reporting.
- Combine with email alerts or ticketing automation for detected threats.
Final Thoughts
Automating Windows Defender scans with PowerShell is a practical step toward proactive cybersecurity management. This script exemplifies how combining native Windows tools with intelligent scripting can create scalable, transparent, and secure workflows.
For NinjaOne users, this script becomes even more powerful. By feeding scan results directly into WYSIWYG custom fields, IT professionals gain centralized visibility, enabling quicker decisions and better endpoint hygiene. Whether you’re an enterprise IT manager or an MSP technician, this solution adds measurable value to your security strategy.