How to Start a Windows Defender Scan with PowerShell

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:

  1. Automation – Trigger scans programmatically.
  2. 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 ' ', '&nbsp;'
        }
        
        # 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)&nbsp;&nbsp;<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>&nbsp;&nbsp;Name"
                $HtmlTable = $HtmlTable -replace '<th>Path', "<th style='width: 20em'><i class='fa-solid fa-folder'></i>&nbsp;&nbsp;Path"
                $HtmlTable = $HtmlTable -replace '<th>Time', "<th style='width: 19em'><i class='fa-solid fa-clock'></i>&nbsp;&nbsp;Time"
                $HtmlTable = $HtmlTable -replace '<th>Action', "<th style='width: 7em'><i class='fa-solid fa-shield-virus'></i>&nbsp;&nbsp;Action"
                $HtmlTable = $HtmlTable -replace '<th>SHA1', "<th style='width: 19em'>SHA1&nbsp;&nbsp;<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>&nbsp;&nbsp;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

  1. Set-CustomField: Validates and updates NinjaOne custom fields. Especially useful for documenting scan results in a centralized dashboard.
  2. Get-DefenderScanResults: Parses Windows Event Logs for relevant threat detection events (e.g., malware detected, actions taken).
  3. Get-DetectedThreats: Enhances event data with SHA1/SHA256 hashes from Defender logs and includes VirusTotal links for deeper investigation.
  4. 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

FeaturePowerShell ScriptManual GUI
ScalabilityHighLow
AutomationYesNo
LoggingDetailedMinimal
IntegrationNinjaOne Custom FieldsNone

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.

Next Steps

Building an efficient and effective IT team requires a centralized solution that acts as your core service delivery tool. NinjaOne enables IT teams to monitor, manage, secure, and support all their devices, wherever they are, without the need for complex on-premises infrastructure.

Learn more about NinjaOne Remote Script Deployment, check out a live tour, or start your free trial of the NinjaOne platform.

Categories:

You might also like