Skip to content

Instantly share code, notes, and snippets.

@rojenzaman
Last active July 16, 2026 11:22
Show Gist options
  • Select an option

  • Save rojenzaman/ebb5a75ddac1a4afb1cd47ef0ddd2097 to your computer and use it in GitHub Desktop.

Select an option

Save rojenzaman/ebb5a75ddac1a4afb1cd47ef0ddd2097 to your computer and use it in GitHub Desktop.
windows-remote-access-audit

Windows Remote-Access Security Auditor

A read-only PowerShell security auditor for Windows 10 and Windows 11. It helps an authorized computer owner review remote-access software, RMM agents, persistence, listening ports, active connections, remote sessions, local administrators, Windows remote-management exposure, and Microsoft Defender configuration.

The tool reports evidence; it does not remove software, stop services, change firewall rules, quarantine files, or upload system information.

Requirements

  • Windows 10 or Windows 11
  • Windows PowerShell 5.1
  • An Administrator PowerShell window for complete inspection coverage
  • Internet access only for the initial Gist download

The script can run without Administrator privileges, but protected processes, services, firewall rules, WMI persistence, Defender settings, sessions, and system-wide registry locations may be unavailable. Use an Administrator window when auditing a customer computer.

Source

Gist page:

https://gist.github.com/rojenzaman/ebb5a75ddac1a4afb1cd47ef0ddd2097

Pinned raw script:

https://gist.githubusercontent.com/rojenzaman/ebb5a75ddac1a4afb1cd47ef0ddd2097/raw/65d68b459aa7fb831ff192b383813623ae102268/windows-remote-access-audit.ps1

Review scripts from public sources before running them.

Download with PowerShell

Open Windows PowerShell and run:

$DownloadDirectory = Join-Path $env:USERPROFILE 'Downloads\WindowsRemoteAccessAudit'
$ScriptPath = Join-Path $DownloadDirectory 'windows-remote-access-audit.ps1'

New-Item -ItemType Directory -Path $DownloadDirectory -Force | Out-Null
Invoke-WebRequest -UseBasicParsing `
    -Uri 'https://gist.githubusercontent.com/rojenzaman/ebb5a75ddac1a4afb1cd47ef0ddd2097/raw/65d68b459aa7fb831ff192b383813623ae102268/windows-remote-access-audit.ps1' `
    -OutFile $ScriptPath

Get-Item -LiteralPath $ScriptPath

This downloads only the PowerShell script to the current user's Downloads folder. The auditor itself does not contact external services when it runs.

Run as Administrator

Administrator access is recommended for full inspection coverage.

Option 1: Open an Administrator window manually

  1. Open the Start menu and search for Windows PowerShell.
  2. Select Run as administrator and approve the UAC prompt.
  3. Run:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
Set-Location (Join-Path $env:USERPROFILE 'Downloads\WindowsRemoteAccessAudit')
.\windows-remote-access-audit.ps1 -Mode Once

-Scope Process applies only to the current PowerShell process. Closing the window restores the previous execution-policy behavior.

Option 2: Start the audit through a UAC prompt

Run this from the PowerShell window used for the download:

$Arguments = '-NoExit -NoProfile -ExecutionPolicy Bypass -File "{0}" -Mode Once' -f $ScriptPath
Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $Arguments

The elevated window remains open after the audit so the terminal result panel can be reviewed.

Common commands

Run one audit and exit:

.\windows-remote-access-audit.ps1 -Mode Once

Include SHA256 hashing of accessible executables:

.\windows-remote-access-audit.ps1 -Mode Once -EnableHashing

Create and compare a baseline:

.\windows-remote-access-audit.ps1 -Mode Baseline -BaselinePath .\baseline.json
.\windows-remote-access-audit.ps1 -Mode Compare -BaselinePath .\baseline.json

Continuously monitor the computer until Ctrl+C is pressed:

.\windows-remote-access-audit.ps1 -Mode Monitor -IntervalSeconds 30

Write reports to a selected directory:

.\windows-remote-access-audit.ps1 -Mode Once -OutputDirectory 'C:\Security Audit\Reports'

Explicitly request a Microsoft Defender Quick Scan:

.\windows-remote-access-audit.ps1 -Mode Once -EnableDefenderScan

The script never starts a full or offline Defender scan.

Results

The terminal result panel shows:

  • Overall review status
  • Critical, High, Medium, Low, and Informational totals
  • Priority findings and recommended next inspection steps
  • Known remote-access products
  • RDP, Remote Assistance, WinRM, and remote-session exposure
  • Exact report paths

Full evidence is saved locally as timestamped JSON, CSV, and text reports. A baseline JSON file is also produced in Baseline mode. Reports may contain local usernames, command lines, paths, IP addresses, installed products, and hashes; handle them as sensitive customer data.

Use -NoConsoleOutput only when terminal output must be suppressed.

Important limitations

Windows does not provide a universally reliable native log of every application that captures the screen. Protected, injected, renamed, kernel-level, very short-lived, or artifact-free software may require dedicated incident-response tooling.

Known remote-administration software and enabled RDP, WinRM, SSH, SMB, or Remote Assistance are reported for authorization review; they are not automatically classified as malware. The absence of findings does not prove that surveillance software is absent.

Removal

Stop Monitor mode with Ctrl+C, then delete the downloaded script and any report or baseline files that are no longer required. The auditor does not install a service, scheduled task, firewall rule, registry value, or permanent WMI subscription.

#requires -Version 5.1
<#
.SYNOPSIS
Read-only audit of Windows remote-access, monitoring, persistence, and exposure state.
.DESCRIPTION
Produces local JSON, CSV, and text reports. It never uploads data or remediates findings.
Risk scoring is conservative: 0-9 Informational, 10-24 Low, 25-49 Medium,
50-89 High, and 90-100 Critical. Critical additionally requires at least three
strong indicators; a single weak or administrative configuration indicator can
never create a Critical finding.
#>
[CmdletBinding()]
param(
[Parameter()]
[ValidateSet('Once', 'Monitor', 'Baseline', 'Compare')]
[string]$Mode = 'Once',
[Parameter()]
[ValidateRange(10, 86400)]
[int]$IntervalSeconds = 60,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$OutputDirectory = '.\audit-reports',
[Parameter()]
[string]$BaselinePath = '',
[Parameter()]
[string]$KnownSoftwarePath = '',
[Parameter()]
[switch]$NoConsoleOutput,
[Parameter()]
[switch]$IncludeMicrosoftEntries,
[Parameter()]
[switch]$EnableHashing,
[Parameter()]
[switch]$EnableDefenderScan
)
Set-StrictMode -Version Latest
$script:ToolName = 'Windows Remote Access Audit'
$script:ToolVersion = '1.1.0'
$script:SuppressConsole = [bool]$NoConsoleOutput
$script:HashingEnabled = [bool]$EnableHashing
$script:IncludeMicrosoft = [bool]$IncludeMicrosoftEntries
$script:FileMetadataCache = @{}
$script:KnownSoftwareDefinitions = @()
$script:AuditErrors = New-Object System.Collections.Generic.List[object]
$script:MonitorSeen = @{}
$script:Utf8Bom = New-Object System.Text.UTF8Encoding($true)
function Get-SafePropertyValue {
[CmdletBinding()]
param(
[AllowNull()][object]$InputObject,
[Parameter(Mandatory)][string]$Name,
[AllowNull()][object]$Default = $null
)
if ($null -eq $InputObject) { return $Default }
if ($InputObject -is [Collections.IDictionary] -and $InputObject.Contains($Name)) {
$dictionaryValue = $InputObject[$Name]
if ($null -ne $dictionaryValue) { return $dictionaryValue }
}
$property = $InputObject.PSObject.Properties[$Name]
if ($null -eq $property -or $null -eq $property.Value) { return $Default }
return $property.Value
}
function ConvertTo-NormalizedPath {
[CmdletBinding()]
param([AllowNull()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return '' }
$expanded = [Environment]::ExpandEnvironmentVariables($Path.Trim().Trim('"'))
try { $expanded = [IO.Path]::GetFullPath($expanded) } catch { }
return $expanded.TrimEnd('\').ToLowerInvariant()
}
function ConvertTo-StableText {
[CmdletBinding()]
param([AllowNull()][object]$Value)
if ($null -eq $Value) { return '' }
return (($Value | ConvertTo-Json -Depth 12 -Compress) -replace '\s+', ' ').Trim()
}
function Add-AuditError {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Check,
[Parameter(Mandatory)][string]$Message,
[bool]$AdministratorRecommended = $false
)
$script:AuditErrors.Add([pscustomobject]@{
Timestamp = (Get-Date).ToUniversalTime().ToString('o')
Check = $Check
Message = $Message
AdministratorRecommended = $AdministratorRecommended
})
Write-AuditLog -Level 'WARNING' -Message ("{0}: {1}" -f $Check, $Message)
}
function Write-AuditLog {
[CmdletBinding()]
param(
[ValidateSet('INFO', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL', 'WARNING')]
[string]$Level = 'INFO',
[Parameter(Mandatory)][string]$Message
)
if ($script:SuppressConsole) { return }
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Information -MessageData $line -InformationAction Continue
}
function Test-IsAdministrator {
[CmdletBinding()]
param()
try {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
} catch {
return $false
}
}
function Get-SystemMetadata {
[CmdletBinding()]
param()
$os = $null
$computer = $null
try { $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop } catch { Add-AuditError 'System metadata' $_.Exception.Message }
try { $computer = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop } catch { Add-AuditError 'Computer metadata' $_.Exception.Message }
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = Get-SafePropertyValue $computer 'Domain' ''
Manufacturer = Get-SafePropertyValue $computer 'Manufacturer' ''
Model = Get-SafePropertyValue $computer 'Model' ''
OperatingSystem = Get-SafePropertyValue $os 'Caption' ([Environment]::OSVersion.VersionString)
OperatingSystemVersion = Get-SafePropertyValue $os 'Version' ([Environment]::OSVersion.Version.ToString())
BuildNumber = Get-SafePropertyValue $os 'BuildNumber' ''
Architecture = Get-SafePropertyValue $os 'OSArchitecture' ''
PowerShellVersion = $PSVersionTable.PSVersion.ToString()
IsAdministrator = Test-IsAdministrator
AuditTimeUtc = (Get-Date).ToUniversalTime().ToString('o')
TimeZone = [TimeZoneInfo]::Local.Id
}
}
function Get-BuiltInKnownSoftwareDefinitions {
[CmdletBinding()]
param()
$items = @(
@('AnyDesk','anydesk'), @('RustDesk','rustdesk'), @('TeamViewer','teamviewer'),
@('ScreenConnect / ConnectWise Control','screenconnect|connectwisecontrol|connectwise control'),
@('Splashtop','splashtop|srmanager|strwinclt'), @('UltraVNC','ultravnc|uvnc'),
@('TightVNC','tightvnc|tvnserver|tvnviewer'), @('RealVNC','realvnc|vncserver|vncviewer'),
@('TigerVNC','tigervnc|winvnc4'), @('Remote Utilities','remote utilities|rutserver|rfusclient'),
@('AeroAdmin','aeroadmin'), @('Ammyy Admin','ammyy|aa_v3'), @('DWService','dwservice|dwagent'),
@('MeshCentral','meshcentral|meshagent'), @('Tactical RMM','tacticalrmm|tactical rmm|trmm'),
@('Atera','ateraagent|atera agent'), @('NinjaOne','ninjaone|ninjarmm|ninja rmm'),
@('LogMeIn','logmein|lmi_rescue'), @('GoTo Resolve / GoToAssist','goto resolve|gotoassist|g2ax'),
@('RemotePC','remotepc|rpcservice'), @('Zoho Assist','zohoassist|zoho assist'),
@('BeyondTrust / Bomgar','beyondtrust|bomgar'), @('Dameware','dameware|dwrcc|dwrcs'),
@('NetSupport','netsupport|client32'), @('SimpleHelp','simplehelp'), @('Action1','action1'),
@('Level.io','level.io|level rmm|level-agent'), @('Pulseway','pulseway|pcmonitor'),
@('Kaseya','kaseya|agentmon|kausrtsk'), @('Syncro','syncro|kabuto'),
@('N-able','n-able|ncentral|n-central|solarwinds msp'),
@('ManageEngine remote tools','manageengine|desktopcentral|uems_agent|dcagentservice'),
@('Chrome Remote Desktop','chrome remote desktop|chromoting|remoting_host'),
@('Microsoft Remote Desktop','mstsc|msrdc'), @('Microsoft Quick Assist','quickassist'),
@('Parsec','parsec'), @('Sunshine','sunshine'), @('Moonlight','moonlight'),
@('NoMachine','nomachine|nxserver|nxservice'), @('Radmin','radmin|r_server'),
@('Supremo','supremo'), @('Getscreen.me','getscreen'), @('VNC-compatible software','winvnc|vnc service')
)
foreach ($item in $items) {
[pscustomobject]@{ Name = $item[0]; Patterns = @($item[1] -split '\|'); Source = 'BuiltIn' }
}
}
function Import-KnownSoftwareDefinitions {
[CmdletBinding()]
param([AllowEmptyString()][string]$Path = '')
$definitions = @(Get-BuiltInKnownSoftwareDefinitions)
if ([string]::IsNullOrWhiteSpace($Path)) { return $definitions }
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
Add-AuditError 'Known software definitions' ("Optional definition file not found: {0}" -f $Path)
return $definitions
}
try {
$json = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
foreach ($entry in @($json)) {
$name = [string](Get-SafePropertyValue $entry 'Name' '')
$patterns = @((Get-SafePropertyValue $entry 'Patterns' @()) | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ([string]::IsNullOrWhiteSpace($name) -or $patterns.Count -eq 0) {
throw 'Each definition must contain a non-empty Name and Patterns array.'
}
$definitions += [pscustomobject]@{ Name = $name; Patterns = $patterns; Source = 'Custom' }
}
} catch {
Add-AuditError 'Known software definitions' $_.Exception.Message
}
return $definitions
}
function Find-KnownSoftwareMatch {
[CmdletBinding()]
param([AllowNull()][object[]]$Values)
$text = (@($Values) | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) -join "`n"
if ([string]::IsNullOrWhiteSpace($text)) { return @() }
$matches = New-Object System.Collections.Generic.List[object]
foreach ($definition in @($script:KnownSoftwareDefinitions)) {
foreach ($pattern in @($definition.Patterns)) {
if ($text.IndexOf([string]$pattern, [StringComparison]::OrdinalIgnoreCase) -ge 0) {
$matches.Add([pscustomobject]@{ Name = $definition.Name; Pattern = [string]$pattern; Source = $definition.Source })
break
}
}
}
return $matches.ToArray()
}
function Get-CodeSignatureInformation {
[CmdletBinding()]
param([AllowNull()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return [pscustomobject]@{ Status = 'FileUnavailable'; SignerSubject = ''; IsSigned = $false }
}
try {
$signature = Get-AuthenticodeSignature -LiteralPath $Path -ErrorAction Stop
[pscustomobject]@{
Status = [string]$signature.Status
SignerSubject = if ($null -ne $signature.SignerCertificate) { [string]$signature.SignerCertificate.Subject } else { '' }
IsSigned = ($signature.Status -eq 'Valid')
}
} catch {
[pscustomobject]@{ Status = 'InspectionFailed'; SignerSubject = ''; IsSigned = $false }
}
}
function Get-FileHashInformation {
[CmdletBinding()]
param([AllowNull()][string]$Path, [bool]$Enabled = $script:HashingEnabled)
if (-not $Enabled) { return [pscustomobject]@{ Algorithm = ''; Hash = ''; Status = 'Disabled' } }
if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return [pscustomobject]@{ Algorithm = 'SHA256'; Hash = ''; Status = 'FileUnavailable' }
}
try {
$hash = Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop
return [pscustomobject]@{ Algorithm = 'SHA256'; Hash = $hash.Hash; Status = 'Computed' }
} catch {
return [pscustomobject]@{ Algorithm = 'SHA256'; Hash = ''; Status = 'InspectionFailed' }
}
}
function Get-SuspiciousExecutableMetadata {
[CmdletBinding()]
param([AllowNull()][string]$Path)
$normalized = ConvertTo-NormalizedPath $Path
$writableRoots = @($env:TEMP, $env:TMP, $env:APPDATA, $env:LOCALAPPDATA, $env:USERPROFILE) |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { ConvertTo-NormalizedPath $_ }
$isWritable = $false
$location = 'Other'
foreach ($root in $writableRoots) {
if ($normalized -eq $root -or $normalized.StartsWith($root + '\', [StringComparison]::OrdinalIgnoreCase)) {
$isWritable = $true
$location = if ($root -eq (ConvertTo-NormalizedPath $env:TEMP) -or $root -eq (ConvertTo-NormalizedPath $env:TMP)) { 'Temp' } else { 'UserWritable' }
break
}
}
$programData = ConvertTo-NormalizedPath $env:ProgramData
$isProgramData = -not [string]::IsNullOrWhiteSpace($programData) -and $normalized.StartsWith($programData + '\', [StringComparison]::OrdinalIgnoreCase)
if ($isProgramData) { $location = 'ProgramData' }
[pscustomobject]@{
NormalizedPath = $normalized
Exists = (-not [string]::IsNullOrWhiteSpace($Path) -and (Test-Path -LiteralPath $Path -PathType Leaf))
IsUserWritableLocation = $isWritable
IsProgramDataLocation = $isProgramData
LocationClass = $location
}
}
function Get-ExecutableFileMetadata {
[CmdletBinding()]
param([AllowNull()][string]$Path)
$normalized = ConvertTo-NormalizedPath $Path
if ([string]::IsNullOrWhiteSpace($normalized)) {
return [pscustomobject]@{ Path=''; Exists=$false; CompanyName=''; FileDescription=''; ProductName=''; FileVersion=''; SignatureStatus='FileUnavailable'; SignerSubject=''; IsSigned=$false; SHA256=''; HashStatus=if($script:HashingEnabled){'FileUnavailable'}else{'Disabled'}; IsUserWritableLocation=$false; IsProgramDataLocation=$false; LocationClass='Unknown' }
}
$cacheKey = $normalized
try {
if (Test-Path -LiteralPath $Path -PathType Leaf) {
$file = Get-Item -LiteralPath $Path -ErrorAction Stop
$cacheKey = '{0}|{1}|{2}|{3}' -f $normalized, $file.Length, $file.LastWriteTimeUtc.Ticks, $script:HashingEnabled
}
} catch { }
if ($script:FileMetadataCache.ContainsKey($cacheKey)) { return $script:FileMetadataCache[$cacheKey] }
$suspicious = Get-SuspiciousExecutableMetadata -Path $Path
$version = $null
if ($suspicious.Exists) {
try { $version = (Get-Item -LiteralPath $Path -ErrorAction Stop).VersionInfo } catch { }
}
$signature = Get-CodeSignatureInformation -Path $Path
$hash = Get-FileHashInformation -Path $Path
$metadata = [pscustomobject]@{
Path = [string]$Path
Exists = $suspicious.Exists
CompanyName = [string](Get-SafePropertyValue $version 'CompanyName' '')
FileDescription = [string](Get-SafePropertyValue $version 'FileDescription' '')
ProductName = [string](Get-SafePropertyValue $version 'ProductName' '')
FileVersion = [string](Get-SafePropertyValue $version 'FileVersion' '')
SignatureStatus = $signature.Status
SignerSubject = $signature.SignerSubject
IsSigned = $signature.IsSigned
SHA256 = $hash.Hash
HashStatus = $hash.Status
IsUserWritableLocation = $suspicious.IsUserWritableLocation
IsProgramDataLocation = $suspicious.IsProgramDataLocation
LocationClass = $suspicious.LocationClass
}
$script:FileMetadataCache[$cacheKey] = $metadata
return $metadata
}
function Get-RiskAssessment {
[CmdletBinding()]
param(
[ValidateRange(0,100)][int]$BaseScore = 0,
[string[]]$Indicators = @()
)
$weights = @{
KnownRemote=10; Running=5; ActivePublicConnection=25; ListeningPort=10;
UserWritable=20; ProgramData=10; Unsigned=10; InvalidSignature=25;
Persistence=20; AutoStart=10; Hidden=15; SuspiciousCommand=15;
MissingExecutable=15; NewComparedWithBaseline=15; NewAdministrator=50;
SecurityWeakening=20; UnexpectedAccount=15; MultiplePersistence=15
}
$score = $BaseScore
$strong = 0
foreach ($indicator in @($Indicators | Select-Object -Unique)) {
if ($weights.ContainsKey($indicator)) { $score += [int]$weights[$indicator] }
if ($indicator -in @('ActivePublicConnection','UserWritable','InvalidSignature','Persistence','Hidden','NewAdministrator','MultiplePersistence')) { $strong++ }
}
$score = [Math]::Min(100, $score)
$severity = if ($score -ge 90 -and $strong -ge 3) { 'Critical' } elseif ($score -ge 50) { 'High' } elseif ($score -ge 25) { 'Medium' } elseif ($score -ge 10) { 'Low' } else { 'Informational' }
[pscustomobject]@{ Score = $score; Severity = $severity; StrongIndicatorCount = $strong; Indicators = @($Indicators | Select-Object -Unique) }
}
function Write-AuditFinding {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Category,
[Parameter(Mandatory)][string]$Title,
[Parameter(Mandatory)][string]$Description,
[AllowNull()][object]$Evidence,
[int]$BaseScore = 0,
[string[]]$Indicators = @(),
[AllowNull()][Nullable[int]]$ProcessId,
[string]$ExecutablePath = '',
[string]$Username = '',
[string]$RemoteAddress = '',
[string]$RecommendedNextStep = 'Verify the item owner, business purpose, installation source, and recent activity before taking action.',
[bool]$NewComparedWithBaseline = $false,
[string]$EntityId = '',
[switch]$Quiet
)
if ($NewComparedWithBaseline -and $Indicators -notcontains 'NewComparedWithBaseline') { $Indicators += 'NewComparedWithBaseline' }
$risk = Get-RiskAssessment -BaseScore $BaseScore -Indicators $Indicators
$finding = [pscustomobject]@{
Timestamp = (Get-Date).ToUniversalTime().ToString('o')
Category = $Category
Severity = $risk.Severity
RiskScore = $risk.Score
FindingTitle = $Title
Description = $Description
Evidence = $Evidence
ProcessId = $ProcessId
ExecutablePath = $ExecutablePath
Username = $Username
RemoteAddress = $RemoteAddress
RecommendedNextInspectionStep = $RecommendedNextStep
NewComparedWithBaseline = $NewComparedWithBaseline
RiskIndicators = $risk.Indicators
EntityId = $EntityId
}
if (-not $Quiet) {
$logLevel = if ($risk.Severity -eq 'Informational') { 'INFO' } else { $risk.Severity.ToUpperInvariant() }
Write-AuditLog -Level $logLevel -Message $Title
}
return $finding
}
function Split-ServiceImagePath {
[CmdletBinding()]
param([AllowNull()][string]$CommandLine)
$raw = [string]$CommandLine
if ([string]::IsNullOrWhiteSpace($raw)) { return [pscustomobject]@{ ExecutablePath=''; Arguments=''; IsQuoted=$false; HasUnquotedPathRisk=$false; IsMalformed=$true } }
$trimmed = $raw.Trim()
$quoted = $trimmed.StartsWith('"')
$executable = ''
$arguments = ''
if ($quoted) {
$closing = $trimmed.IndexOf('"', 1)
if ($closing -gt 1) { $executable = $trimmed.Substring(1, $closing - 1); $arguments = $trimmed.Substring($closing + 1).Trim() }
} else {
$match = [regex]::Match($trimmed, '^(?<exe>.+?\.(?:exe|com|bat|cmd|ps1))(?=\s|$)', [Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($match.Success) { $executable = $match.Groups['exe'].Value; $arguments = $trimmed.Substring($match.Length).Trim() }
else { $executable = ($trimmed -split '\s+', 2)[0]; $arguments = $trimmed.Substring([Math]::Min($executable.Length, $trimmed.Length)).Trim() }
}
$executable = [Environment]::ExpandEnvironmentVariables($executable)
$risk = (-not $quoted -and $executable.Contains(' ') -and $executable -match '(?i)\.(exe|com)$')
[pscustomobject]@{ ExecutablePath=$executable; Arguments=$arguments; IsQuoted=$quoted; HasUnquotedPathRisk=$risk; IsMalformed=[string]::IsNullOrWhiteSpace($executable) }
}
function Test-IpAddressClassification {
[CmdletBinding()]
param([AllowNull()][string]$Address)
$kind = 'Unknown'
$ip = $null
if (-not [Net.IPAddress]::TryParse([string]$Address, [ref]$ip)) { return [pscustomobject]@{ Address=[string]$Address; Classification=$kind; IsPublic=$false } }
if ([Net.IPAddress]::IsLoopback($ip)) { $kind = 'Loopback' }
elseif ($ip.AddressFamily -eq [Net.Sockets.AddressFamily]::InterNetwork) {
$b = $ip.GetAddressBytes()
if ($b[0] -eq 10 -or ($b[0] -eq 172 -and $b[1] -ge 16 -and $b[1] -le 31) -or ($b[0] -eq 192 -and $b[1] -eq 168)) { $kind = 'Private' }
elseif ($b[0] -eq 169 -and $b[1] -eq 254) { $kind = 'LinkLocal' }
elseif ($b[0] -ge 224 -and $b[0] -le 239) { $kind = 'Multicast' }
elseif ($b[0] -eq 0 -or $b[0] -eq 127 -or $b[0] -ge 240) { $kind = 'Reserved' }
else { $kind = 'Public' }
} else {
$bytes = $ip.GetAddressBytes()
if ($ip.IsIPv6LinkLocal) { $kind = 'LinkLocal' }
elseif ($ip.IsIPv6Multicast) { $kind = 'Multicast' }
elseif (($bytes[0] -band 0xFE) -eq 0xFC) { $kind = 'Private' }
elseif ($ip.Equals([Net.IPAddress]::IPv6Any)) { $kind = 'Unspecified' }
else { $kind = 'Public' }
}
[pscustomobject]@{ Address=$ip.ToString(); Classification=$kind; IsPublic=($kind -eq 'Public') }
}
function Get-NetworkConnections {
[CmdletBinding()]
param()
$results = New-Object System.Collections.Generic.List[object]
if (Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue) {
try {
foreach ($connection in @(Get-NetTCPConnection -State Established -ErrorAction Stop)) {
$classification = Test-IpAddressClassification -Address ([string]$connection.RemoteAddress)
$results.Add([pscustomobject]@{
Protocol='TCP'; LocalAddress=[string]$connection.LocalAddress; LocalPort=[int]$connection.LocalPort
RemoteAddress=[string]$connection.RemoteAddress; RemotePort=[int]$connection.RemotePort
State=[string]$connection.State; OwningProcess=[int]$connection.OwningProcess
RemoteAddressClass=$classification.Classification; IsPublicRemote=$classification.IsPublic
StableId=('tcp|{0}|{1}|{2}|{3}' -f $connection.LocalAddress,$connection.LocalPort,$connection.RemoteAddress,$connection.RemotePort).ToLowerInvariant()
})
}
} catch { Add-AuditError 'TCP connections' $_.Exception.Message $true }
} else { Add-AuditError 'TCP connections' 'Get-NetTCPConnection is unavailable.' }
return $results.ToArray()
}
function Get-ListeningPorts {
[CmdletBinding()]
param()
$results = New-Object System.Collections.Generic.List[object]
if (Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue) {
try {
foreach ($item in @(Get-NetTCPConnection -State Listen -ErrorAction Stop)) {
$results.Add([pscustomobject]@{
Protocol='TCP'; LocalAddress=[string]$item.LocalAddress; LocalPort=[int]$item.LocalPort
OwningProcess=[int]$item.OwningProcess; State='Listen'; IsAllInterfaces=($item.LocalAddress -in @('0.0.0.0','::'))
StableId=('tcp|{0}|{1}' -f $item.LocalAddress,$item.LocalPort).ToLowerInvariant()
})
}
} catch { Add-AuditError 'TCP listeners' $_.Exception.Message $true }
}
if (Get-Command Get-NetUDPEndpoint -ErrorAction SilentlyContinue) {
try {
foreach ($item in @(Get-NetUDPEndpoint -ErrorAction Stop)) {
$results.Add([pscustomobject]@{
Protocol='UDP'; LocalAddress=[string]$item.LocalAddress; LocalPort=[int]$item.LocalPort
OwningProcess=[int]$item.OwningProcess; State='Bound'; IsAllInterfaces=($item.LocalAddress -in @('0.0.0.0','::'))
StableId=('udp|{0}|{1}' -f $item.LocalAddress,$item.LocalPort).ToLowerInvariant()
})
}
} catch { Add-AuditError 'UDP endpoints' $_.Exception.Message $true }
}
return $results.ToArray()
}
function Get-ProcessOwnerName {
[CmdletBinding()]
param([Parameter(Mandatory)][object]$CimProcess)
try {
$owner = Invoke-CimMethod -InputObject $CimProcess -MethodName GetOwner -ErrorAction Stop
if ([int](Get-SafePropertyValue $owner 'ReturnValue' 1) -eq 0) {
$domain = [string](Get-SafePropertyValue $owner 'Domain' '')
$user = [string](Get-SafePropertyValue $owner 'User' '')
if (-not [string]::IsNullOrWhiteSpace($domain)) { return "$domain\$user" }
return $user
}
} catch { }
return ''
}
function Get-RunningProcesses {
[CmdletBinding()]
param(
[object[]]$Connections = @(),
[object[]]$Listeners = @()
)
Write-AuditLog -Level INFO -Message 'Starting process inspection'
$connectionMap = @{}
foreach ($connection in @($Connections)) {
$key = [string]$connection.OwningProcess
if (-not $connectionMap.ContainsKey($key)) { $connectionMap[$key] = New-Object System.Collections.Generic.List[object] }
$connectionMap[$key].Add($connection)
}
$listenerMap = @{}
foreach ($listener in @($Listeners)) {
$key = [string]$listener.OwningProcess
if (-not $listenerMap.ContainsKey($key)) { $listenerMap[$key] = New-Object System.Collections.Generic.List[object] }
$listenerMap[$key].Add($listener)
}
$ownerMap = @{}
try {
foreach ($nativeProcess in @(Get-Process -IncludeUserName -ErrorAction Stop)) {
$ownerMap[[string]$nativeProcess.Id] = [string](Get-SafePropertyValue $nativeProcess 'UserName' '')
}
} catch {
Add-AuditError 'Process owners' 'Some process owner names could not be collected in one pass; remaining process metadata is still available.' $true
}
$results = New-Object System.Collections.Generic.List[object]
try {
$processes = @(Get-CimInstance -ClassName Win32_Process -ErrorAction Stop)
} catch {
Add-AuditError 'Processes' $_.Exception.Message $true
return @()
}
foreach ($process in $processes) {
try {
$pidValue = [int](Get-SafePropertyValue $process 'ProcessId' 0)
$path = [string](Get-SafePropertyValue $process 'ExecutablePath' '')
$command = [string](Get-SafePropertyValue $process 'CommandLine' '')
$metadata = Get-ExecutableFileMetadata -Path $path
$matches = @(Find-KnownSoftwareMatch -Values @($process.Name,$path,$command,$metadata.CompanyName,$metadata.ProductName,$metadata.FileDescription))
$activeConnections = if ($connectionMap.ContainsKey([string]$pidValue)) { $connectionMap[[string]$pidValue].ToArray() } else { @() }
$activeListeners = if ($listenerMap.ContainsKey([string]$pidValue)) { $listenerMap[[string]$pidValue].ToArray() } else { @() }
$created = Get-SafePropertyValue $process 'CreationDate' $null
$creationUtc = if ($null -ne $created) { try { ([datetime]$created).ToUniversalTime().ToString('o') } catch { '' } } else { '' }
$stablePath = if (-not [string]::IsNullOrWhiteSpace($metadata.Path)) { ConvertTo-NormalizedPath $metadata.Path } else { [string]$process.Name }
$results.Add([pscustomobject]@{
ProcessName=[string]$process.Name; ProcessId=$pidValue; ParentProcessId=[int](Get-SafePropertyValue $process 'ParentProcessId' 0)
ExecutablePath=$path; NormalizedPath=(ConvertTo-NormalizedPath $path); CommandLine=$command
Owner=if($ownerMap.ContainsKey([string]$pidValue)){$ownerMap[[string]$pidValue]}else{''}; CreationTimeUtc=$creationUtc
CompanyName=$metadata.CompanyName; FileDescription=$metadata.FileDescription; ProductName=$metadata.ProductName; FileVersion=$metadata.FileVersion
SignatureStatus=$metadata.SignatureStatus; SignerSubject=$metadata.SignerSubject; IsSigned=$metadata.IsSigned
SHA256=$metadata.SHA256; HashStatus=$metadata.HashStatus; IsUserWritableLocation=$metadata.IsUserWritableLocation
IsProgramDataLocation=$metadata.IsProgramDataLocation; KnownSoftwareMatches=@($matches); ActiveTcpConnections=@($activeConnections)
ListeningPorts=@($activeListeners); StableId=('process|{0}|{1}' -f ([string]$process.Name).ToLowerInvariant(),$stablePath)
})
} catch {
Add-AuditError 'Individual process' ("PID {0}: {1}" -f (Get-SafePropertyValue $process 'ProcessId' '?'), $_.Exception.Message)
}
}
return $results.ToArray()
}
function Get-InstalledServices {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting service inspection'
$results = New-Object System.Collections.Generic.List[object]
try { $services = @(Get-CimInstance -ClassName Win32_Service -ErrorAction Stop) }
catch { Add-AuditError 'Services' $_.Exception.Message $true; return @() }
foreach ($service in $services) {
try {
$parsed = Split-ServiceImagePath -CommandLine ([string]$service.PathName)
$metadata = Get-ExecutableFileMetadata -Path $parsed.ExecutablePath
$matches = @(Find-KnownSoftwareMatch -Values @($service.Name,$service.DisplayName,$service.PathName,$metadata.CompanyName,$metadata.ProductName))
$results.Add([pscustomobject]@{
ServiceName=[string]$service.Name; DisplayName=[string]$service.DisplayName; State=[string]$service.State
StartMode=[string]$service.StartMode; ServiceAccount=[string]$service.StartName; CommandLine=[string]$service.PathName
ExecutablePath=$parsed.ExecutablePath; NormalizedPath=(ConvertTo-NormalizedPath $parsed.ExecutablePath); Arguments=$parsed.Arguments
ProcessId=[int](Get-SafePropertyValue $service 'ProcessId' 0); SignatureStatus=$metadata.SignatureStatus
SignerSubject=$metadata.SignerSubject; IsSigned=$metadata.IsSigned; CompanyName=$metadata.CompanyName
SHA256=$metadata.SHA256; HashStatus=$metadata.HashStatus; Exists=$metadata.Exists
IsUserWritableLocation=$metadata.IsUserWritableLocation; IsProgramDataLocation=$metadata.IsProgramDataLocation
HasUnquotedPathRisk=$parsed.HasUnquotedPathRisk; IsMalformedPath=$parsed.IsMalformed
KnownSoftwareMatches=@($matches); StableId=('service|{0}' -f ([string]$service.Name).ToLowerInvariant())
})
} catch { Add-AuditError 'Individual service' ("{0}: {1}" -f (Get-SafePropertyValue $service 'Name' '?'), $_.Exception.Message) }
}
return $results.ToArray()
}
function Get-RegistryValueEntries {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$EntryType,
[string[]]$ExcludedNames = @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider')
)
$results = New-Object System.Collections.Generic.List[object]
if (-not (Test-Path -LiteralPath $Path)) { return @() }
try {
$item = Get-ItemProperty -LiteralPath $Path -ErrorAction Stop
foreach ($property in $item.PSObject.Properties) {
if ($property.Name -in $ExcludedNames) { continue }
$command = [string]$property.Value
$matches = @(Find-KnownSoftwareMatch -Values @($property.Name,$command,$Path))
$results.Add([pscustomobject]@{
EntryType=$EntryType; Location=$Path; Name=[string]$property.Name; Command=$command
KnownSoftwareMatches=@($matches); StableId=('{0}|{1}|{2}' -f $EntryType,$Path,$property.Name).ToLowerInvariant()
})
}
} catch { Add-AuditError $EntryType $_.Exception.Message $true }
return $results.ToArray()
}
function Get-StartupEntries {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting startup and registry persistence inspection'
$results = New-Object System.Collections.Generic.List[object]
$runKeys = @(
'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Run',
'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\RunOnce',
'Registry::HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
'Registry::HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce',
'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run',
'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\RunOnce'
)
foreach ($key in $runKeys) { foreach ($entry in @(Get-RegistryValueEntries -Path $key -EntryType 'RegistryRun')) { $results.Add($entry) } }
foreach ($path in @(
'Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\AppCertDlls',
'Registry::HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows NT\CurrentVersion\Windows\AppCertDlls'
)) {
foreach ($entry in @(Get-RegistryValueEntries -Path $path -EntryType 'AppCertDLL')) { $results.Add($entry) }
}
foreach ($path in @(
'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows NT\CurrentVersion\Windows',
'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Winlogon'
)) {
if (-not (Test-Path -LiteralPath $path)) { continue }
try {
$item=Get-ItemProperty -LiteralPath $path -ErrorAction Stop
foreach($name in @('Load','Run','Taskman')) {
$property=$item.PSObject.Properties[$name]
if($null -ne $property -and -not [string]::IsNullOrWhiteSpace([string]$property.Value)) {
$command=[string]$property.Value
$results.Add([pscustomobject]@{EntryType='LegacyLogonPersistence';Location=$path;Name=$name;Command=$command;KnownSoftwareMatches=@(Find-KnownSoftwareMatch @($name,$command));StableId=('legacylogon|{0}|{1}' -f $path,$name).ToLowerInvariant()})
}
}
} catch { Add-AuditError 'Legacy logon persistence' $_.Exception.Message $true }
}
$specialKeys = @(
@{Path='Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Winlogon'; Type='Winlogon'; Names=@('Shell','Userinit')},
@{Path='Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows'; Type='AppInitDLL'; Names=@('AppInit_DLLs','LoadAppInit_DLLs','RequireSignedAppInit_DLLs')},
@{Path='Registry::HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows NT\CurrentVersion\Windows'; Type='AppInitDLL32'; Names=@('AppInit_DLLs','LoadAppInit_DLLs','RequireSignedAppInit_DLLs')}
)
foreach ($spec in $specialKeys) {
if (-not (Test-Path -LiteralPath $spec.Path)) { continue }
try {
$item = Get-ItemProperty -LiteralPath $spec.Path -ErrorAction Stop
foreach ($name in $spec.Names) {
$prop = $item.PSObject.Properties[$name]
if ($null -eq $prop) { continue }
$command = [string]$prop.Value
$results.Add([pscustomobject]@{ EntryType=$spec.Type; Location=$spec.Path; Name=$name; Command=$command; KnownSoftwareMatches=@(Find-KnownSoftwareMatch @($name,$command)); StableId=('{0}|{1}|{2}' -f $spec.Type,$spec.Path,$name).ToLowerInvariant() })
}
} catch { Add-AuditError $spec.Type $_.Exception.Message $true }
}
$startupFolders = @(
[Environment]::GetFolderPath('Startup'),
[Environment]::GetFolderPath('CommonStartup')
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($folder in $startupFolders) {
if (-not (Test-Path -LiteralPath $folder)) { continue }
try {
foreach ($file in @(Get-ChildItem -LiteralPath $folder -Force -File -ErrorAction Stop)) {
if ($file.Name -ieq 'desktop.ini') { continue }
$results.Add([pscustomobject]@{ EntryType='StartupFolder'; Location=$folder; Name=$file.Name; Command=$file.FullName; KnownSoftwareMatches=@(Find-KnownSoftwareMatch @($file.Name,$file.FullName)); StableId=('startupfolder|{0}' -f (ConvertTo-NormalizedPath $file.FullName)) })
}
} catch { Add-AuditError 'Startup folder' $_.Exception.Message $true }
}
$ifeoRoots = @(
'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution Options',
'Registry::HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows NT\CurrentVersion\Image File Execution Options'
)
foreach ($root in $ifeoRoots) {
if (-not (Test-Path -LiteralPath $root)) { continue }
try {
foreach ($subKey in @(Get-ChildItem -LiteralPath $root -ErrorAction Stop)) {
$item = Get-ItemProperty -LiteralPath $subKey.PSPath -Name Debugger -ErrorAction SilentlyContinue
if ($null -ne $item) {
$debugger = [string]$item.Debugger
$results.Add([pscustomobject]@{ EntryType='IFEO Debugger'; Location=$subKey.PSPath; Name=$subKey.PSChildName; Command=$debugger; KnownSoftwareMatches=@(Find-KnownSoftwareMatch @($subKey.PSChildName,$debugger)); StableId=('ifeo|{0}' -f ([string]$subKey.PSChildName).ToLowerInvariant()) })
}
}
} catch { Add-AuditError 'IFEO debugger persistence' $_.Exception.Message $true }
}
foreach ($root in @(
'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\SilentProcessExit',
'Registry::HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows NT\CurrentVersion\SilentProcessExit'
)) {
if(-not (Test-Path -LiteralPath $root)){continue}
try {
foreach($subKey in @(Get-ChildItem -LiteralPath $root -ErrorAction Stop)) {
$item=Get-ItemProperty -LiteralPath $subKey.PSPath -ErrorAction Stop
$monitor=[string](Get-SafePropertyValue $item 'MonitorProcess' '')
if(-not [string]::IsNullOrWhiteSpace($monitor)) {
$results.Add([pscustomobject]@{EntryType='SilentProcessExit Monitor';Location=$subKey.PSPath;Name=$subKey.PSChildName;Command=$monitor;KnownSoftwareMatches=@(Find-KnownSoftwareMatch @($subKey.PSChildName,$monitor));StableId=('silentprocessexit|{0}' -f ([string]$subKey.PSChildName).ToLowerInvariant())})
}
}
} catch { Add-AuditError 'SilentProcessExit persistence' $_.Exception.Message $true }
}
return $results.ToArray()
}
function Get-ScheduledTaskEntries {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting scheduled task inspection'
$results = New-Object System.Collections.Generic.List[object]
if (-not (Get-Command Get-ScheduledTask -ErrorAction SilentlyContinue)) { Add-AuditError 'Scheduled tasks' 'Get-ScheduledTask is unavailable.'; return @() }
try { $tasks = @(Get-ScheduledTask -ErrorAction Stop) } catch { Add-AuditError 'Scheduled tasks' $_.Exception.Message $true; return @() }
foreach ($task in $tasks) {
try {
$actionText = @($task.Actions | ForEach-Object { ('{0} {1} {2}' -f (Get-SafePropertyValue $_ 'Execute' ''),(Get-SafePropertyValue $_ 'Arguments' ''),(Get-SafePropertyValue $_ 'WorkingDirectory' '')).Trim() }) -join '; '
$matches = @(Find-KnownSoftwareMatch -Values @($task.TaskName,$task.TaskPath,$actionText,$task.Description))
$results.Add([pscustomobject]@{
TaskName=[string]$task.TaskName; TaskPath=[string]$task.TaskPath; State=[string]$task.State
Author=[string](Get-SafePropertyValue $task 'Author' ''); Description=[string](Get-SafePropertyValue $task 'Description' '')
Hidden=[bool](Get-SafePropertyValue $task.Settings 'Hidden' $false); Actions=$actionText
PrincipalUserId=[string](Get-SafePropertyValue $task.Principal 'UserId' ''); RunLevel=[string](Get-SafePropertyValue $task.Principal 'RunLevel' '')
KnownSoftwareMatches=@($matches); StableId=('task|{0}{1}' -f ([string]$task.TaskPath).ToLowerInvariant(),([string]$task.TaskName).ToLowerInvariant())
})
} catch { Add-AuditError 'Individual scheduled task' ("{0}: {1}" -f (Get-SafePropertyValue $task 'TaskName' '?'), $_.Exception.Message) }
}
return $results.ToArray()
}
function Get-WmiPersistenceEntries {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting WMI persistence inspection'
$results = New-Object System.Collections.Generic.List[object]
$classes = @('__EventFilter','CommandLineEventConsumer','ActiveScriptEventConsumer','LogFileEventConsumer','NTEventLogEventConsumer','__FilterToConsumerBinding')
foreach ($class in $classes) {
try {
foreach ($item in @(Get-CimInstance -Namespace 'root\subscription' -ClassName $class -ErrorAction Stop)) {
$name = [string](Get-SafePropertyValue $item 'Name' '')
$details = [ordered]@{}
foreach ($property in @('Query','QueryLanguage','CommandLineTemplate','ExecutablePath','ScriptText','ScriptingEngine','Filter','Consumer')) {
$value = Get-SafePropertyValue $item $property $null
if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { $details[$property] = [string]$value }
}
$serialized = ConvertTo-StableText $details
$results.Add([pscustomobject]@{ EntryType=$class; Name=$name; Details=$details; KnownSoftwareMatches=@(Find-KnownSoftwareMatch @($class,$name,$serialized)); StableId=('wmi|{0}|{1}|{2}' -f $class.ToLowerInvariant(),$name.ToLowerInvariant(),$serialized.ToLowerInvariant()) })
}
} catch { Add-AuditError ("WMI persistence {0}" -f $class) $_.Exception.Message $true }
}
return $results.ToArray()
}
function Initialize-WtsSessionApi {
[CmdletBinding()]
param()
if ('ZenDrive.WtsSessionApi' -as [type]) { return $true }
$source = @'
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
namespace ZenDrive {
public class WtsSessionRecord {
public int SessionId { get; set; }
public string StationName { get; set; }
public string State { get; set; }
public string UserName { get; set; }
public string DomainName { get; set; }
public string ClientName { get; set; }
public string ClientAddress { get; set; }
}
public static class WtsSessionApi {
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
private struct WTS_SESSION_INFO { public int SessionID; public IntPtr pWinStationName; public int State; }
[StructLayout(LayoutKind.Sequential)]
private struct WTS_CLIENT_ADDRESS { public int AddressFamily; [MarshalAs(UnmanagedType.ByValArray, SizeConst=20)] public byte[] Address; }
[DllImport("wtsapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
private static extern bool WTSEnumerateSessions(IntPtr server, int reserved, int version, out IntPtr info, out int count);
[DllImport("wtsapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)]
private static extern bool WTSQuerySessionInformation(IntPtr server, int sessionId, int infoClass, out IntPtr buffer, out int bytes);
[DllImport("wtsapi32.dll")] private static extern void WTSFreeMemory(IntPtr memory);
private static string QueryString(int id, int cls) {
IntPtr p; int bytes;
if (!WTSQuerySessionInformation(IntPtr.Zero, id, cls, out p, out bytes) || p == IntPtr.Zero) return "";
try { return Marshal.PtrToStringUni(p) ?? ""; } finally { WTSFreeMemory(p); }
}
private static string QueryAddress(int id) {
IntPtr p; int bytes;
if (!WTSQuerySessionInformation(IntPtr.Zero, id, 14, out p, out bytes) || p == IntPtr.Zero) return "";
try {
WTS_CLIENT_ADDRESS value = (WTS_CLIENT_ADDRESS)Marshal.PtrToStructure(p, typeof(WTS_CLIENT_ADDRESS));
if (value.AddressFamily == 2 && value.Address != null && value.Address.Length >= 6)
return String.Format("{0}.{1}.{2}.{3}", value.Address[2], value.Address[3], value.Address[4], value.Address[5]);
return "";
} finally { WTSFreeMemory(p); }
}
public static WtsSessionRecord[] Enumerate() {
var list = new List<WtsSessionRecord>(); IntPtr p; int count;
if (!WTSEnumerateSessions(IntPtr.Zero, 0, 1, out p, out count)) return list.ToArray();
try {
int size = Marshal.SizeOf(typeof(WTS_SESSION_INFO)); long current = p.ToInt64();
for (int i=0; i<count; i++) {
var info = (WTS_SESSION_INFO)Marshal.PtrToStructure(new IntPtr(current), typeof(WTS_SESSION_INFO));
string state = Enum.IsDefined(typeof(System.Diagnostics.ProcessWindowStyle), info.State) ? info.State.ToString() : info.State.ToString();
string[] states = {"Active","Connected","ConnectQuery","Shadow","Disconnected","Idle","Listen","Reset","Down","Initializing"};
if (info.State >= 0 && info.State < states.Length) state = states[info.State];
list.Add(new WtsSessionRecord { SessionId=info.SessionID, StationName=Marshal.PtrToStringUni(info.pWinStationName) ?? "", State=state,
UserName=QueryString(info.SessionID, 5), DomainName=QueryString(info.SessionID, 7), ClientName=QueryString(info.SessionID, 10), ClientAddress=QueryAddress(info.SessionID) });
current += size;
}
} finally { WTSFreeMemory(p); }
return list.ToArray();
}
}
}
'@
try { Add-Type -TypeDefinition $source -Language CSharp -ErrorAction Stop; return $true }
catch { Add-AuditError 'Remote sessions API' $_.Exception.Message; return $false }
}
function Get-RemoteSessions {
[CmdletBinding()]
param()
$results = New-Object System.Collections.Generic.List[object]
if (-not (Initialize-WtsSessionApi)) { return @() }
try {
foreach ($session in @([ZenDrive.WtsSessionApi]::Enumerate())) {
$user = if (-not [string]::IsNullOrWhiteSpace($session.DomainName)) { '{0}\{1}' -f $session.DomainName,$session.UserName } else { $session.UserName }
$type = if ($session.StationName -match '(?i)^rdp') { 'RemoteDesktop' } elseif ($session.StationName -eq 'Console') { 'Console' } else { 'Other' }
$results.Add([pscustomobject]@{
SessionId=[int]$session.SessionId; StationName=[string]$session.StationName; SessionType=$type
State=[string]$session.State; Username=[string]$user; ClientName=[string]$session.ClientName
ClientAddress=[string]$session.ClientAddress; StableId=('session|{0}|{1}' -f $session.SessionId,([string]$user).ToLowerInvariant())
})
}
} catch { Add-AuditError 'Remote sessions' $_.Exception.Message }
return $results.ToArray()
}
function Get-LocalAccountInformation {
[CmdletBinding()]
param()
$results = New-Object System.Collections.Generic.List[object]
try {
foreach ($user in @(Get-CimInstance -ClassName Win32_UserAccount -Filter "LocalAccount=True" -ErrorAction Stop)) {
$results.Add([pscustomobject]@{
Name=[string]$user.Name; SID=[string]$user.SID; Enabled=(-not [bool]$user.Disabled)
LockedOut=[bool](Get-SafePropertyValue $user 'Lockout' $false); PasswordRequired=[bool](Get-SafePropertyValue $user 'PasswordRequired' $false)
LastLogon='Unavailable from Win32_UserAccount'; AccountCreated='Unavailable from Win32_UserAccount'
StableId=('user|{0}' -f ([string]$user.SID).ToLowerInvariant())
})
}
} catch { Add-AuditError 'Local user accounts' $_.Exception.Message $true }
return $results.ToArray()
}
function Get-LocalAdministrators {
[CmdletBinding()]
param()
$results = New-Object System.Collections.Generic.List[object]
try {
$group = Get-CimInstance -ClassName Win32_Group -Filter "SID='S-1-5-32-544'" -ErrorAction Stop
if ($null -eq $group) { throw 'The built-in Administrators group SID could not be resolved.' }
foreach ($member in @(Get-CimAssociatedInstance -InputObject $group -Association Win32_GroupUser -ErrorAction Stop)) {
$sid = [string](Get-SafePropertyValue $member 'SID' '')
$domain = [string](Get-SafePropertyValue $member 'Domain' '')
$name = [string](Get-SafePropertyValue $member 'Name' '')
$results.Add([pscustomobject]@{
Name=$name; Domain=$domain; FullName=if([string]::IsNullOrWhiteSpace($domain)){$name}else{"$domain\$name"}
SID=$sid; AccountType=[string]$member.CimClass.CimClassName; Disabled=[bool](Get-SafePropertyValue $member 'Disabled' $false)
StableId=if(-not [string]::IsNullOrWhiteSpace($sid)){('admin|{0}' -f $sid.ToLowerInvariant())}else{('admin|{0}\{1}' -f $domain.ToLowerInvariant(),$name.ToLowerInvariant())}
})
}
} catch { Add-AuditError 'Local administrators (SID S-1-5-32-544)' $_.Exception.Message $true }
return $results.ToArray()
}
function Get-RemoteDesktopConfiguration {
[CmdletBinding()]
param()
$terminalPath = 'Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Terminal Server'
$tcpPath = 'Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
try {
$terminal = Get-ItemProperty -LiteralPath $terminalPath -ErrorAction Stop
$tcp = Get-ItemProperty -LiteralPath $tcpPath -ErrorAction Stop
[pscustomobject]@{
Enabled=([int](Get-SafePropertyValue $terminal 'fDenyTSConnections' 1) -eq 0)
NetworkLevelAuthenticationEnabled=([int](Get-SafePropertyValue $tcp 'UserAuthentication' 0) -eq 1)
Port=[int](Get-SafePropertyValue $tcp 'PortNumber' 3389)
SecurityLayer=[int](Get-SafePropertyValue $tcp 'SecurityLayer' -1)
StableId='configuration|rdp'
}
} catch { Add-AuditError 'Remote Desktop configuration' $_.Exception.Message $true; [pscustomobject]@{ Enabled=$null; NetworkLevelAuthenticationEnabled=$null; Port=$null; SecurityLayer=$null; StableId='configuration|rdp' } }
}
function Get-RemoteAssistanceConfiguration {
[CmdletBinding()]
param()
$path = 'Registry::HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Remote Assistance'
try {
$item = Get-ItemProperty -LiteralPath $path -ErrorAction Stop
[pscustomobject]@{
SolicitedRemoteAssistanceEnabled=([int](Get-SafePropertyValue $item 'fAllowToGetHelp' 0) -eq 1)
UnsolicitedRemoteAssistanceEnabled=([int](Get-SafePropertyValue $item 'fAllowFullControl' 0) -eq 1)
MaxTicketExpiry=[int](Get-SafePropertyValue $item 'MaxTicketExpiry' 0)
StableId='configuration|remoteassistance'
}
} catch { Add-AuditError 'Remote Assistance configuration' $_.Exception.Message; [pscustomobject]@{ SolicitedRemoteAssistanceEnabled=$null; UnsolicitedRemoteAssistanceEnabled=$null; MaxTicketExpiry=$null; StableId='configuration|remoteassistance' } }
}
function Get-WinRMConfiguration {
[CmdletBinding()]
param()
$service = $null
try { $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='WinRM'" -ErrorAction Stop } catch { Add-AuditError 'WinRM service' $_.Exception.Message }
$listeners = New-Object System.Collections.Generic.List[object]
try {
if (Test-Path -Path WSMan:\localhost\Listener -ErrorAction Stop) {
foreach ($listener in @(Get-ChildItem -Path WSMan:\localhost\Listener -ErrorAction Stop)) {
$values = @{}
foreach ($child in @(Get-ChildItem -LiteralPath $listener.PSPath -ErrorAction Stop)) { $values[$child.Name] = $child.Value }
$listeners.Add([pscustomobject]@{ Address=[string]$values['Address']; Transport=[string]$values['Transport']; Port=[string]$values['Port']; Hostname=[string]$values['Hostname']; Enabled=[string]$values['Enabled'] })
}
}
} catch { Add-AuditError 'WinRM listeners' $_.Exception.Message $true }
[pscustomobject]@{
ServicePresent=($null -ne $service); ServiceState=[string](Get-SafePropertyValue $service 'State' 'Unavailable')
StartMode=[string](Get-SafePropertyValue $service 'StartMode' 'Unavailable'); Listeners=$listeners.ToArray()
PowerShellRemotingAvailable=(($null -ne $service) -and $service.State -eq 'Running' -and $listeners.Count -gt 0)
StableId='configuration|winrm'
}
}
function Get-RemoteServiceExposure {
[CmdletBinding()]
param([object[]]$Listeners = @())
$results = New-Object System.Collections.Generic.List[object]
foreach ($name in @('sshd','RemoteRegistry','LanmanServer','TermService','SessionEnv','UmRdpService')) {
try {
$service = Get-CimInstance -ClassName Win32_Service -Filter ("Name='{0}'" -f $name) -ErrorAction Stop
if ($null -ne $service) { $results.Add([pscustomobject]@{ ServiceName=$name; Present=$true; State=[string]$service.State; StartMode=[string]$service.StartMode; StableId=('exposure|service|{0}' -f $name.ToLowerInvariant()) }) }
} catch {
if ($_.Exception.Message -notmatch '(?i)not found|no instance') { Add-AuditError ("Remote service {0}" -f $name) $_.Exception.Message }
}
}
$smbListeners = @($Listeners | Where-Object { $_.Protocol -eq 'TCP' -and $_.LocalPort -in @(139,445) })
$results.Add([pscustomobject]@{ ServiceName='SMB network listeners'; Present=($smbListeners.Count -gt 0); State=if($smbListeners.Count -gt 0){'Listening'}else{'Not observed'}; StartMode='NotApplicable'; StableId='exposure|smb-listeners' })
return $results.ToArray()
}
function Get-RemoteFirewallRules {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting remote administration firewall inspection'
$results = New-Object System.Collections.Generic.List[object]
if (-not (Get-Command Get-NetFirewallRule -ErrorAction SilentlyContinue)) { Add-AuditError 'Firewall rules' 'Get-NetFirewallRule is unavailable.'; return @() }
try { $rules = @(Get-NetFirewallRule -ErrorAction Stop) } catch { Add-AuditError 'Firewall rules' $_.Exception.Message $true; return @() }
$portMap=@{}; $addressMap=@{}; $applicationMap=@{}
try {
foreach($filter in @(Get-NetFirewallPortFilter -All -ErrorAction Stop)) {
$portMap[[string]$filter.InstanceID]=@('{0}:{1}-{2}' -f $filter.Protocol,($filter.LocalPort -join ','),($filter.RemotePort -join ','))
}
foreach($filter in @(Get-NetFirewallAddressFilter -All -ErrorAction Stop)) {
$addressMap[[string]$filter.InstanceID]=@('Local={0};Remote={1}' -f ($filter.LocalAddress -join ','),($filter.RemoteAddress -join ','))
}
foreach($filter in @(Get-NetFirewallApplicationFilter -All -ErrorAction Stop)) {
$applicationMap[[string]$filter.InstanceID]=@([string]$filter.Program)
}
} catch { Add-AuditError 'Firewall filter details' $_.Exception.Message $true }
$terms = '(?i)remote|rdp|winrm|ssh|vnc|support|assistance|administration|desktop|smb|file and printer'
foreach ($rule in $rules) {
$search = @(
(Get-SafePropertyValue $rule 'Name' ''),(Get-SafePropertyValue $rule 'DisplayName' ''),
(Get-SafePropertyValue $rule 'Group' ''),(Get-SafePropertyValue $rule 'DisplayGroup' ''),
(Get-SafePropertyValue $rule 'Description' ''),(Get-SafePropertyValue $rule 'Service' ''),
(Get-SafePropertyValue $rule 'Program' '')
) -join ' '
if ($search -notmatch $terms) { continue }
try {
$instanceId=[string]$rule.InstanceID
$ports = if($portMap.ContainsKey($instanceId)){@($portMap[$instanceId])}else{@()}
$addresses = if($addressMap.ContainsKey($instanceId)){@($addressMap[$instanceId])}else{@()}
$applications = if($applicationMap.ContainsKey($instanceId)){@($applicationMap[$instanceId])}else{@()}
$results.Add([pscustomobject]@{
Name=[string]$rule.Name; DisplayName=[string]$rule.DisplayName; Enabled=[string]$rule.Enabled
Direction=[string]$rule.Direction; Action=[string]$rule.Action; Profile=[string]$rule.Profile
Service=[string](Get-SafePropertyValue $rule 'Service' ''); Program=[string](Get-SafePropertyValue $rule 'Program' '')
Ports=@($ports); Addresses=@($addresses); Applications=@($applications)
StableId=('firewall|{0}' -f ([string]$rule.Name).ToLowerInvariant())
})
} catch { Add-AuditError 'Individual firewall rule' ("{0}: {1}" -f (Get-SafePropertyValue $rule 'Name' '?'), $_.Exception.Message) }
}
return $results.ToArray()
}
function Get-InstalledRemoteAccessSoftware {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting installed software inspection'
$results = New-Object System.Collections.Generic.List[object]
$roots = @(
'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'Registry::HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
foreach ($root in $roots) {
try {
foreach ($item in @(Get-ItemProperty -Path $root -ErrorAction Stop)) {
$name = [string](Get-SafePropertyValue $item 'DisplayName' '')
if ([string]::IsNullOrWhiteSpace($name)) { continue }
$matches = @(Find-KnownSoftwareMatch -Values @(
$name,(Get-SafePropertyValue $item 'Publisher' ''),(Get-SafePropertyValue $item 'InstallLocation' ''),
(Get-SafePropertyValue $item 'UninstallString' '')
))
if ($matches.Count -eq 0) { continue }
$results.Add([pscustomobject]@{
DisplayName=$name; DisplayVersion=[string](Get-SafePropertyValue $item 'DisplayVersion' '')
Publisher=[string](Get-SafePropertyValue $item 'Publisher' ''); InstallLocation=[string](Get-SafePropertyValue $item 'InstallLocation' '')
InstallDate=[string](Get-SafePropertyValue $item 'InstallDate' ''); UninstallString=[string](Get-SafePropertyValue $item 'UninstallString' '')
KnownSoftwareMatches=@($matches); StableId=('software|{0}|{1}' -f $name.ToLowerInvariant(),([string](Get-SafePropertyValue $item 'DisplayVersion' '')).ToLowerInvariant())
})
}
} catch { Add-AuditError 'Installed software registry' ("{0}: {1}" -f $root,$_.Exception.Message) $true }
}
try {
$quickAssist = Get-AppxPackage -Name 'MicrosoftCorporationII.QuickAssist' -AllUsers -ErrorAction Stop
foreach ($app in @($quickAssist)) {
$results.Add([pscustomobject]@{ DisplayName='Microsoft Quick Assist'; DisplayVersion=[string]$app.Version; Publisher=[string]$app.Publisher; InstallLocation=[string]$app.InstallLocation; InstallDate=''; UninstallString=''; KnownSoftwareMatches=@(Find-KnownSoftwareMatch @('Quick Assist')); StableId='software|microsoft quick assist' })
}
} catch {
if ($_.Exception.Message -notmatch '(?i)not found|no package') { Add-AuditError 'Quick Assist package inspection' $_.Exception.Message $true }
}
return @($results | Sort-Object StableId -Unique)
}
function Get-DefenderConfiguration {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting Microsoft Defender inspection'
if (-not (Get-Command Get-MpComputerStatus -ErrorAction SilentlyContinue)) {
return [pscustomobject]@{ Available=$false; Status=$null; Preferences=$null; ThreatDetections=@(); StableId='configuration|defender' }
}
$status = $null; $preferences = $null; $threats = @()
try { $raw = Get-MpComputerStatus -ErrorAction Stop; $status = [pscustomobject]@{ AntivirusEnabled=Get-SafePropertyValue $raw 'AntivirusEnabled' $null; AntispywareEnabled=Get-SafePropertyValue $raw 'AntispywareEnabled' $null; RealTimeProtectionEnabled=Get-SafePropertyValue $raw 'RealTimeProtectionEnabled' $null; BehaviorMonitorEnabled=Get-SafePropertyValue $raw 'BehaviorMonitorEnabled' $null; IsTamperProtected=Get-SafePropertyValue $raw 'IsTamperProtected' $null; AntivirusSignatureLastUpdated=Get-SafePropertyValue $raw 'AntivirusSignatureLastUpdated' $null } } catch { Add-AuditError 'Defender status' $_.Exception.Message $true }
try { $raw = Get-MpPreference -ErrorAction Stop; $preferences = [pscustomobject]@{ ExclusionPath=@(Get-SafePropertyValue $raw 'ExclusionPath' @()); ExclusionProcess=@(Get-SafePropertyValue $raw 'ExclusionProcess' @()); ExclusionExtension=@(Get-SafePropertyValue $raw 'ExclusionExtension' @()); ExclusionIpAddress=@(Get-SafePropertyValue $raw 'ExclusionIpAddress' @()); DisableRealtimeMonitoring=Get-SafePropertyValue $raw 'DisableRealtimeMonitoring' $null; DisableBehaviorMonitoring=Get-SafePropertyValue $raw 'DisableBehaviorMonitoring' $null } } catch { Add-AuditError 'Defender preferences' $_.Exception.Message $true }
if (Get-Command Get-MpThreatDetection -ErrorAction SilentlyContinue) {
try { $threats = @(Get-MpThreatDetection -ErrorAction Stop | Select-Object -First 50 InitialDetectionTime,LastThreatStatusChangeTime,ThreatID,Resources,ActionSuccess) } catch { Add-AuditError 'Defender threat detections' $_.Exception.Message $true }
}
[pscustomobject]@{ Available=$true; Status=$status; Preferences=$preferences; ThreatDetections=@($threats); StableId='configuration|defender' }
}
function Test-MicrosoftExecutable {
[CmdletBinding()]
param([AllowNull()][object]$Item)
$company = [string](Get-SafePropertyValue $Item 'CompanyName' '')
$signer = [string](Get-SafePropertyValue $Item 'SignerSubject' '')
$path = ConvertTo-NormalizedPath ([string](Get-SafePropertyValue $Item 'ExecutablePath' ''))
$windowsRoot = ConvertTo-NormalizedPath $env:windir
return ($company -match '(?i)microsoft' -or $signer -match '(?i)microsoft' -or (-not [string]::IsNullOrWhiteSpace($windowsRoot) -and $path.StartsWith($windowsRoot + '\')))
}
function Get-AuditFindings {
[CmdletBinding()]
param([Parameter(Mandatory)][object]$Snapshot)
$findings = New-Object System.Collections.Generic.List[object]
$processById = @{}
foreach ($process in @($Snapshot.Processes)) { $processById[[string]$process.ProcessId] = $process }
foreach ($software in @($Snapshot.InstalledRemoteAccessSoftware)) {
$matchNames = @($software.KnownSoftwareMatches | ForEach-Object { $_.Name }) -join ', '
$findings.Add((Write-AuditFinding -Category 'KnownSoftware' -Title 'Known remote-access software detected' -Description ("{0} is installed. Legitimate remote administration software is not automatically malware." -f $software.DisplayName) -Evidence ([pscustomobject]@{ Product=$software.DisplayName; Version=$software.DisplayVersion; Publisher=$software.Publisher; Matches=$matchNames }) -BaseScore 0 -ExecutablePath $software.InstallLocation -EntityId $software.StableId -RecommendedNextStep 'Confirm the software is authorized, patched, configured with strong authentication, and still required.'))
}
foreach ($process in @($Snapshot.Processes)) {
$indicators = New-Object System.Collections.Generic.List[string]
$known = @($process.KnownSoftwareMatches)
$public = @($process.ActiveTcpConnections | Where-Object { $_.IsPublicRemote })
if ($known.Count -gt 0) { $indicators.Add('KnownRemote'); $indicators.Add('Running') }
if ($public.Count -gt 0) { $indicators.Add('ActivePublicConnection') }
if ($process.IsUserWritableLocation) { $indicators.Add('UserWritable') }
elseif ($process.IsProgramDataLocation) { $indicators.Add('ProgramData') }
if ($process.SignatureStatus -in @('HashMismatch','NotTrusted','NotSupportedFileFormat','UnknownError')) { $indicators.Add('InvalidSignature') }
elseif (-not $process.IsSigned -and -not [string]::IsNullOrWhiteSpace($process.ExecutablePath)) { $indicators.Add('Unsigned') }
$suspiciousCommand = $process.CommandLine -match '(?i)(-enc(?:odedcommand)?\b|-windowstyle\s+hidden|downloadstring|frombase64string|javascript:|scrobj\.dll|urlcache|bitsadmin)'
if ($suspiciousCommand) { $indicators.Add('SuspiciousCommand') }
$parent = if ($processById.ContainsKey([string]$process.ParentProcessId)) { $processById[[string]$process.ParentProcessId] } else { $null }
$interpreterParent = $null -ne $parent -and $parent.ProcessName -match '(?i)^(powershell|pwsh|wscript|cscript|mshta|rundll32|regsvr32|cmd)\.exe$'
if ($interpreterParent -and ($process.IsUserWritableLocation -or $known.Count -gt 0)) { $indicators.Add('SuspiciousCommand') }
if ($known.Count -gt 0) {
$remoteNames = @($known | ForEach-Object { $_.Name } | Select-Object -Unique) -join ', '
$findings.Add((Write-AuditFinding -Category 'Process' -Title 'Known remote-access software detected' -Description ("A running process matches known remote-access software: {0}. This is an authorization review finding, not a malware verdict." -f $remoteNames) -Evidence ([pscustomobject]@{ ProcessName=$process.ProcessName; Matches=$remoteNames; PublicConnections=@($public | Select-Object RemoteAddress,RemotePort) }) -Indicators $indicators.ToArray() -ProcessId $process.ProcessId -ExecutablePath $process.ExecutablePath -Username $process.Owner -RemoteAddress (@($public | ForEach-Object { $_.RemoteAddress }) -join ',') -EntityId $process.StableId -RecommendedNextStep 'Verify the active session, software configuration, account access, and whether the owner approved this remote-access process.'))
} elseif ($process.IsUserWritableLocation -and $public.Count -gt 0 -and -not $process.IsSigned) {
$findings.Add((Write-AuditFinding -Category 'Process' -Title 'Unsigned writable-path process has a public connection' -Description 'An unsigned executable running from a user-writable directory maintains an established connection to a public IP address.' -Evidence ([pscustomobject]@{ ProcessName=$process.ProcessName; SignatureStatus=$process.SignatureStatus; Connections=@($public | Select-Object RemoteAddress,RemotePort) }) -BaseScore 10 -Indicators $indicators.ToArray() -ProcessId $process.ProcessId -ExecutablePath $process.ExecutablePath -Username $process.Owner -RemoteAddress (@($public | ForEach-Object { $_.RemoteAddress }) -join ',') -EntityId $process.StableId -RecommendedNextStep 'Inspect the file origin, signature, hash, parent process, connection destination, and startup mechanisms before considering containment.'))
} elseif (($process.IsUserWritableLocation -and -not $process.IsSigned) -or $process.SignatureStatus -in @('HashMismatch','NotTrusted','UnknownError') -or $suspiciousCommand -or ($interpreterParent -and $process.IsUserWritableLocation)) {
$findings.Add((Write-AuditFinding -Category 'Process' -Title 'Process has suspicious execution metadata' -Description 'The process has one or more writable-path, signature, command-line, or interpreter-parent indicators that warrant review.' -Evidence ([pscustomobject]@{ ProcessName=$process.ProcessName; ParentProcess=if($null -ne $parent){$parent.ProcessName}else{''}; SignatureStatus=$process.SignatureStatus; Location=if($process.IsUserWritableLocation){'UserWritable'}elseif($process.IsProgramDataLocation){'ProgramData'}else{'Other'} }) -Indicators $indicators.ToArray() -ProcessId $process.ProcessId -ExecutablePath $process.ExecutablePath -Username $process.Owner -EntityId $process.StableId))
} elseif ([string]::IsNullOrWhiteSpace($process.ExecutablePath) -and $process.ProcessId -notin @(0,4)) {
$findings.Add((Write-AuditFinding -Category 'Process' -Title 'Process executable path is unavailable' -Description 'Windows did not expose an executable path for this process. This can be normal for protected processes or reflect reduced privileges.' -Evidence ([pscustomobject]@{ ProcessName=$process.ProcessName; ProcessId=$process.ProcessId }) -BaseScore 0 -ProcessId $process.ProcessId -Username $process.Owner -EntityId $process.StableId -Quiet))
}
}
foreach ($service in @($Snapshot.Services)) {
$indicators = New-Object System.Collections.Generic.List[string]
$known = @($service.KnownSoftwareMatches)
if ($known.Count -gt 0) { $indicators.Add('KnownRemote') }
if ($service.State -eq 'Running') { $indicators.Add('Running') }
if ($service.StartMode -eq 'Auto') { $indicators.Add('AutoStart') }
if ($service.IsUserWritableLocation) { $indicators.Add('UserWritable'); $indicators.Add('Persistence') }
elseif ($service.IsProgramDataLocation) { $indicators.Add('ProgramData') }
if (-not $service.IsSigned -and $service.Exists) { $indicators.Add('Unsigned') }
if (-not $service.Exists) { $indicators.Add('MissingExecutable') }
if ($service.IsMalformedPath) { $indicators.Add('SuspiciousCommand') }
if ($known.Count -gt 0) {
$findings.Add((Write-AuditFinding -Category 'Service' -Title 'Known remote-access software detected' -Description 'A Windows service matches known remote-access or RMM software. Confirm that this service is authorized.' -Evidence ([pscustomobject]@{ ServiceName=$service.ServiceName; DisplayName=$service.DisplayName; State=$service.State; Matches=@($known.Name) }) -Indicators $indicators.ToArray() -ProcessId $(if($service.ProcessId -gt 0){$service.ProcessId}else{$null}) -ExecutablePath $service.ExecutablePath -Username $service.ServiceAccount -EntityId $service.StableId))
} elseif (($service.StartMode -eq 'Auto' -and $service.IsUserWritableLocation) -or ($service.IsProgramDataLocation -and -not (Test-MicrosoftExecutable $service)) -or -not $service.Exists -or $service.IsMalformedPath) {
$findings.Add((Write-AuditFinding -Category 'Service' -Title 'Service has an unusual executable path' -Description 'A service uses a writable, ProgramData, missing, or malformed executable path that warrants validation.' -Evidence ([pscustomobject]@{ ServiceName=$service.ServiceName; StartMode=$service.StartMode; State=$service.State; Exists=$service.Exists; SignatureStatus=$service.SignatureStatus }) -Indicators $indicators.ToArray() -ProcessId $(if($service.ProcessId -gt 0){$service.ProcessId}else{$null}) -ExecutablePath $service.ExecutablePath -Username $service.ServiceAccount -EntityId $service.StableId))
}
if ($service.HasUnquotedPathRisk) {
$findings.Add((Write-AuditFinding -Category 'ServicePathSecurity' -Title 'Unquoted service path risk detected' -Description 'A service executable path contains spaces and is not quoted. This is reported separately from remote-access detection.' -Evidence ([pscustomobject]@{ ServiceName=$service.ServiceName; CommandLine=$service.CommandLine }) -BaseScore 15 -ExecutablePath $service.ExecutablePath -Username $service.ServiceAccount -EntityId ($service.StableId + '|unquoted') -RecommendedNextStep 'Confirm the effective executable path and review directory permissions; change service configuration only through an approved remediation process.'))
}
}
foreach ($entry in @($Snapshot.StartupEntries)) {
$parsed = Split-ServiceImagePath -CommandLine $entry.Command
$metadata = Get-ExecutableFileMetadata -Path $parsed.ExecutablePath
$known = @($entry.KnownSoftwareMatches)
$indicators = @('Persistence')
if ($known.Count -gt 0) { $indicators += 'KnownRemote' }
if ($metadata.IsUserWritableLocation) { $indicators += 'UserWritable' }
if (-not $metadata.IsSigned -and $metadata.Exists) { $indicators += 'Unsigned' }
$defaultWinlogon = $entry.EntryType -eq 'Winlogon' -and (($entry.Name -eq 'Shell' -and $entry.Command -ieq 'explorer.exe') -or ($entry.Name -eq 'Userinit' -and $entry.Command -match '(?i)userinit\.exe,?$'))
if ($known.Count -gt 0) {
$findings.Add((Write-AuditFinding -Category 'Persistence' -Title 'Known remote-access software detected' -Description 'A startup or registry persistence entry matches known remote-access software.' -Evidence ([pscustomobject]@{ Type=$entry.EntryType; Name=$entry.Name; Command=$entry.Command; Matches=@($known.Name) }) -Indicators $indicators -ExecutablePath $parsed.ExecutablePath -EntityId $entry.StableId))
} elseif (-not $defaultWinlogon -and ($metadata.IsUserWritableLocation -or ($metadata.Exists -and -not $metadata.IsSigned) -or $entry.EntryType -in @('IFEO Debugger','AppInitDLL','AppInitDLL32'))) {
$findings.Add((Write-AuditFinding -Category 'Persistence' -Title 'Startup persistence entry warrants review' -Description 'A startup entry uses a writable or unsigned executable, or a sensitive injection/debugger persistence location.' -Evidence ([pscustomobject]@{ Type=$entry.EntryType; Name=$entry.Name; Command=$entry.Command; SignatureStatus=$metadata.SignatureStatus }) -Indicators $indicators -ExecutablePath $parsed.ExecutablePath -EntityId $entry.StableId))
}
}
foreach ($task in @($Snapshot.ScheduledTasks)) {
$known = @($task.KnownSoftwareMatches)
$parsed = Split-ServiceImagePath -CommandLine $task.Actions
$metadata = Get-ExecutableFileMetadata -Path $parsed.ExecutablePath
$indicators = @('Persistence')
if ($known.Count -gt 0) { $indicators += 'KnownRemote' }
if ($task.Hidden) { $indicators += 'Hidden' }
if ($metadata.IsUserWritableLocation) { $indicators += 'UserWritable' }
if (-not $metadata.IsSigned -and $metadata.Exists) { $indicators += 'Unsigned' }
$isMicrosoftTask = $task.TaskPath -like '\Microsoft\*'
if ($known.Count -gt 0) {
$findings.Add((Write-AuditFinding -Category 'ScheduledTask' -Title 'Known remote-access software detected' -Description 'A scheduled task action or metadata matches known remote-access software.' -Evidence ([pscustomobject]@{ Task=($task.TaskPath+$task.TaskName); Hidden=$task.Hidden; Actions=$task.Actions; Matches=@($known.Name) }) -Indicators $indicators -ExecutablePath $parsed.ExecutablePath -Username $task.PrincipalUserId -EntityId $task.StableId))
} elseif (-not [string]::IsNullOrWhiteSpace($task.Actions) -and ($metadata.IsUserWritableLocation -or ((-not $isMicrosoftTask -or $script:IncludeMicrosoft) -and $task.Hidden -and $metadata.Exists -and -not $metadata.IsSigned))) {
$findings.Add((Write-AuditFinding -Category 'ScheduledTask' -Title 'Scheduled task action warrants review' -Description 'A scheduled task launches from a writable location or combines hidden execution with weak trust metadata.' -Evidence ([pscustomobject]@{ Task=($task.TaskPath+$task.TaskName); Hidden=$task.Hidden; Actions=$task.Actions; SignatureStatus=$metadata.SignatureStatus }) -Indicators $indicators -ExecutablePath $parsed.ExecutablePath -Username $task.PrincipalUserId -EntityId $task.StableId))
}
}
foreach ($entry in @($Snapshot.WmiPersistence)) {
$known = @($entry.KnownSoftwareMatches)
$isMicrosoft = $entry.Name -like 'SCM Event Log*' -or (ConvertTo-StableText $entry.Details) -match '(?i)\\windows\\|microsoft|MSFT_SCMEventLogEvent|SCM Event Log'
if ($known.Count -gt 0 -or -not $isMicrosoft -or $script:IncludeMicrosoft) {
$indicators = @('Persistence')
if ($known.Count -gt 0) { $indicators += 'KnownRemote' }
$findings.Add((Write-AuditFinding -Category 'WmiPersistence' -Title $(if($known.Count -gt 0){'Known remote-access software detected'}else{'WMI persistence entry detected'}) -Description 'A permanent WMI subscription component exists in root\subscription. Validate its owner and consumer behavior.' -Evidence ([pscustomobject]@{ Type=$entry.EntryType; Name=$entry.Name; Details=$entry.Details; Matches=@($known | ForEach-Object { $_.Name }) }) -BaseScore $(if($entry.EntryType -eq '__FilterToConsumerBinding'){5}else{10}) -Indicators $indicators -EntityId $entry.StableId))
}
}
# Correlation is deliberately path-based and conservative. A Critical score
# requires an unsigned writable-path process with a public connection plus
# multiple distinct persistence artifacts; no single indicator can qualify.
foreach ($process in @($Snapshot.Processes | Where-Object { $_.IsUserWritableLocation -and -not $_.IsSigned -and @($_.ActiveTcpConnections | Where-Object IsPublicRemote).Count -gt 0 })) {
$path = ConvertTo-NormalizedPath $process.ExecutablePath
if ([string]::IsNullOrWhiteSpace($path)) { continue }
$leaf = [IO.Path]::GetFileName($path)
$persistence = New-Object System.Collections.Generic.List[object]
foreach ($entry in @($Snapshot.StartupEntries)) { if ([string]$entry.Command -match [regex]::Escape($leaf)) { $persistence.Add([pscustomobject]@{Type='Startup';Id=$entry.StableId;Command=$entry.Command}) } }
foreach ($task in @($Snapshot.ScheduledTasks)) { if ([string]$task.Actions -match [regex]::Escape($leaf)) { $persistence.Add([pscustomobject]@{Type='ScheduledTask';Id=$task.StableId;Command=$task.Actions}) } }
foreach ($service in @($Snapshot.Services)) { if ((ConvertTo-NormalizedPath $service.ExecutablePath) -eq $path) { $persistence.Add([pscustomobject]@{Type='Service';Id=$service.StableId;Command=$service.CommandLine}) } }
if ($persistence.Count -gt 0) {
$indicators=@('ActivePublicConnection','UserWritable','Unsigned','Persistence')
if ($persistence.Count -ge 2) { $indicators += 'MultiplePersistence' }
$findings.Add((Write-AuditFinding -Category 'Correlation' -Title 'Active writable-path process correlates with persistence' -Description 'An unsigned user-writable executable has a public connection and is referenced by one or more persistence mechanisms. Multiple persistence artifacts are required before this correlation can score Critical.' -Evidence ([pscustomobject]@{ProcessName=$process.ProcessName;PublicConnections=@($process.ActiveTcpConnections|Where-Object IsPublicRemote|Select-Object RemoteAddress,RemotePort);Persistence=$persistence.ToArray()}) -BaseScore 10 -Indicators $indicators -ProcessId $process.ProcessId -ExecutablePath $process.ExecutablePath -Username $process.Owner -RemoteAddress (@($process.ActiveTcpConnections|Where-Object IsPublicRemote|ForEach-Object{$_.RemoteAddress}) -join ',') -EntityId ($process.StableId+'|correlation') -RecommendedNextStep 'Validate the file and connection immediately, then inspect every correlated persistence artifact and its creation history before deciding on containment.'))
}
}
$remotePorts = @(3389,4899,5650,5900,5901,5938,6568,7070,21115,21116,21117,21118,21119)
foreach ($listener in @($Snapshot.ListeningPorts)) {
$owner = if ($processById.ContainsKey([string]$listener.OwningProcess)) { $processById[[string]$listener.OwningProcess] } else { $null }
$knownOwner = $null -ne $owner -and @($owner.KnownSoftwareMatches).Count -gt 0
$microsoftOwner = $null -ne $owner -and (Test-MicrosoftExecutable $owner)
if (($listener.LocalPort -in $remotePorts -and ($knownOwner -or -not $microsoftOwner)) -or ($listener.IsAllInterfaces -and $null -ne $owner -and $owner.IsUserWritableLocation)) {
$indicators = @('ListeningPort')
if ($knownOwner) { $indicators += 'KnownRemote' }
if ($null -ne $owner -and $owner.IsUserWritableLocation) { $indicators += 'UserWritable' }
$findings.Add((Write-AuditFinding -Category 'Network' -Title 'Unexpected or remote-control listening port detected' -Description 'A process is listening on a known remote-control port or a writable-path process is bound to all interfaces.' -Evidence ([pscustomobject]@{ Protocol=$listener.Protocol; LocalAddress=$listener.LocalAddress; LocalPort=$listener.LocalPort; ProcessName=if($null-ne $owner){$owner.ProcessName}else{'Unknown'} }) -Indicators $indicators -ProcessId $listener.OwningProcess -ExecutablePath $(if($null-ne $owner){$owner.ExecutablePath}else{''}) -EntityId $listener.StableId -RecommendedNextStep 'Identify the owning process and service, confirm the expected bind scope, and review matching firewall rules.'))
}
}
$rdp = $Snapshot.RemoteDesktopConfiguration
if ($rdp.Enabled -eq $true) {
$findings.Add((Write-AuditFinding -Category 'RemoteConfiguration' -Title 'Remote Desktop is enabled' -Description 'Windows Remote Desktop is enabled. This is an exposure/configuration finding, not a malware finding.' -Evidence $rdp -BaseScore $(if($rdp.NetworkLevelAuthenticationEnabled){0}else{15}) -Indicators $(if($rdp.NetworkLevelAuthenticationEnabled){@()}else{@('SecurityWeakening')}) -EntityId $rdp.StableId -RecommendedNextStep 'Confirm RDP is required, Network Level Authentication is enabled, authorized users are limited, and firewall scope is appropriate.'))
}
$ra = $Snapshot.RemoteAssistanceConfiguration
if ($ra.SolicitedRemoteAssistanceEnabled -eq $true -or $ra.UnsolicitedRemoteAssistanceEnabled -eq $true) {
$findings.Add((Write-AuditFinding -Category 'RemoteConfiguration' -Title 'Remote Assistance is enabled' -Description 'Windows Remote Assistance is enabled for solicited or unsolicited assistance.' -Evidence $ra -BaseScore $(if($ra.UnsolicitedRemoteAssistanceEnabled){15}else{0}) -EntityId $ra.StableId -RecommendedNextStep 'Confirm the configured Remote Assistance mode and authorized helper policy match the owners intent.'))
}
$winrm = $Snapshot.WinRMConfiguration
if ($winrm.ServiceState -eq 'Running' -or @($winrm.Listeners).Count -gt 0) {
$findings.Add((Write-AuditFinding -Category 'RemoteConfiguration' -Title 'WinRM or PowerShell remoting is exposed' -Description 'The WinRM service is running or one or more WinRM listeners exist. This is an administrative exposure finding.' -Evidence $winrm -BaseScore 5 -EntityId $winrm.StableId -RecommendedNextStep 'Confirm remoting is required, inspect listener transport and firewall scope, and review endpoint authorization.'))
}
foreach ($exposure in @($Snapshot.RemoteServiceExposure | Where-Object { $_.Present -and $_.State -in @('Running','Listening') })) {
$findings.Add((Write-AuditFinding -Category 'RemoteConfiguration' -Title ("Remote administration exposure: {0}" -f $exposure.ServiceName) -Description 'A Windows service or listener used for remote administration is active. Enabled SMB, SSH, Remote Registry, or RDP-related services are not automatically malicious.' -Evidence $exposure -BaseScore 0 -EntityId $exposure.StableId -Quiet))
}
foreach ($session in @($Snapshot.RemoteSessions | Where-Object { $_.State -in @('Active','Disconnected') })) {
$findings.Add((Write-AuditFinding -Category 'Session' -Title ("{0} session observed" -f $session.State) -Description 'An interactive console or Remote Desktop session is active or disconnected.' -Evidence $session -BaseScore $(if($session.SessionType -eq 'RemoteDesktop' -and $session.State -eq 'Active'){10}else{0}) -Username $session.Username -RemoteAddress $session.ClientAddress -EntityId $session.StableId -RecommendedNextStep 'Confirm the username, client address, session state, and timing are expected.'))
}
foreach ($rule in @($Snapshot.RemoteFirewallRules | Where-Object { $_.Enabled -eq 'True' -and $_.Action -eq 'Allow' })) {
$findings.Add((Write-AuditFinding -Category 'Firewall' -Title 'Enabled firewall rule permits remote administration traffic' -Description 'An enabled allow rule appears related to remote desktop, assistance, administration, SSH, SMB, WinRM, VNC, or support software.' -Evidence $rule -BaseScore 0 -EntityId $rule.StableId -Quiet -RecommendedNextStep 'Review the rule profile, remote address scope, service or program, local ports, and continued business need.'))
}
$defender = $Snapshot.DefenderConfiguration
if ($defender.Available -and $null -ne $defender.Preferences) {
$preference = $defender.Preferences
$exclusions = @($preference.ExclusionPath) + @($preference.ExclusionProcess) + @($preference.ExclusionExtension) + @($preference.ExclusionIpAddress)
foreach ($exclusion in @($exclusions | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) })) {
$unusual = [string]$exclusion -match '(?i)\\users\\|\\temp\\|appdata|\*|^[a-z]:\\?$'
$findings.Add((Write-AuditFinding -Category 'Defender' -Title $(if($unusual){'Unusual Microsoft Defender exclusion detected'}else{'Microsoft Defender exclusion configured'}) -Description 'A Defender exclusion can reduce inspection coverage. It may be legitimate but should be narrowly scoped and documented.' -Evidence ([pscustomobject]@{ Exclusion=[string]$exclusion; Unusual=$unusual }) -BaseScore $(if($unusual){15}else{5}) -Indicators $(if($unusual){@('SecurityWeakening')}else{@()}) -EntityId ('defender|exclusion|'+([string]$exclusion).ToLowerInvariant()) -RecommendedNextStep 'Verify who configured the exclusion, why it is required, and whether its scope can be safely narrowed.'))
}
if ($null -ne $defender.Status -and ($defender.Status.RealTimeProtectionEnabled -eq $false -or $defender.Status.BehaviorMonitorEnabled -eq $false -or $defender.Status.AntivirusEnabled -eq $false)) {
$findings.Add((Write-AuditFinding -Category 'Defender' -Title 'Microsoft Defender protection component is disabled' -Description 'One or more Defender antivirus, real-time, or behavior monitoring components report disabled.' -Evidence $defender.Status -BaseScore 25 -Indicators @('SecurityWeakening') -EntityId 'configuration|defender|protection'))
}
}
return $findings.ToArray()
}
function Invoke-SystemAudit {
[CmdletBinding()]
param()
$script:AuditErrors.Clear()
$metadata = Get-SystemMetadata
if (-not $metadata.IsAdministrator) { Write-AuditLog -Level WARNING -Message 'Not running as Administrator; process owners, protected paths, firewall, Defender, WMI, and session coverage may be reduced.' }
$connections = @(Get-NetworkConnections)
$listeners = @(Get-ListeningPorts)
$snapshot = [pscustomobject]@{
SystemMetadata=$metadata
Processes=@(Get-RunningProcesses -Connections $connections -Listeners $listeners)
Services=@(Get-InstalledServices)
StartupEntries=@(Get-StartupEntries)
ScheduledTasks=@(Get-ScheduledTaskEntries)
WmiPersistence=@(Get-WmiPersistenceEntries)
NetworkConnections=$connections
ListeningPorts=$listeners
RemoteSessions=@(Get-RemoteSessions)
LocalUsers=@(Get-LocalAccountInformation)
LocalAdministrators=@(Get-LocalAdministrators)
RemoteDesktopConfiguration=Get-RemoteDesktopConfiguration
RemoteAssistanceConfiguration=Get-RemoteAssistanceConfiguration
WinRMConfiguration=Get-WinRMConfiguration
RemoteServiceExposure=@(Get-RemoteServiceExposure -Listeners $listeners)
RemoteFirewallRules=@(Get-RemoteFirewallRules)
InstalledRemoteAccessSoftware=@(Get-InstalledRemoteAccessSoftware)
DefenderConfiguration=Get-DefenderConfiguration
}
$findings = @(Get-AuditFindings -Snapshot $snapshot)
[pscustomobject]@{ Snapshot=$snapshot; Findings=$findings; Errors=$script:AuditErrors.ToArray(); CompletedUtc=(Get-Date).ToUniversalTime().ToString('o') }
}
function Get-StringSha256 {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Text)
$algorithm = [Security.Cryptography.SHA256]::Create()
try {
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
return ([BitConverter]::ToString($algorithm.ComputeHash($bytes))).Replace('-','')
} finally { $algorithm.Dispose() }
}
function ConvertTo-BaselineItem {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Collection,
[Parameter(Mandatory)][object]$Item
)
$key = [string](Get-SafePropertyValue $Item 'StableId' '')
if ([string]::IsNullOrWhiteSpace($key)) { throw "A $Collection baseline item has no StableId." }
$data = switch ($Collection) {
'Processes' { [ordered]@{ ProcessName=$Item.ProcessName; ExecutablePath=(ConvertTo-NormalizedPath $Item.ExecutablePath); CommandLine=$Item.CommandLine; Owner=$Item.Owner; CompanyName=$Item.CompanyName; SignatureStatus=$Item.SignatureStatus; SHA256=$Item.SHA256 } }
'Services' { [ordered]@{ ServiceName=$Item.ServiceName; DisplayName=$Item.DisplayName; State=$Item.State; StartMode=$Item.StartMode; ServiceAccount=$Item.ServiceAccount; ExecutablePath=(ConvertTo-NormalizedPath $Item.ExecutablePath); CommandLine=$Item.CommandLine; SignatureStatus=$Item.SignatureStatus; SHA256=$Item.SHA256 } }
'ScheduledTasks' { [ordered]@{ TaskName=$Item.TaskName; TaskPath=$Item.TaskPath; State=$Item.State; Hidden=$Item.Hidden; Actions=$Item.Actions; PrincipalUserId=$Item.PrincipalUserId } }
'StartupEntries' { [ordered]@{ EntryType=$Item.EntryType; Location=$Item.Location; Name=$Item.Name; Command=$Item.Command } }
'WmiPersistence' { [ordered]@{ EntryType=$Item.EntryType; Name=$Item.Name; Details=$Item.Details } }
'LocalAdministrators' { [ordered]@{ Name=$Item.Name; Domain=$Item.Domain; SID=$Item.SID; AccountType=$Item.AccountType; Disabled=$Item.Disabled } }
'ListeningPorts' { [ordered]@{ Protocol=$Item.Protocol; LocalAddress=$Item.LocalAddress; LocalPort=$Item.LocalPort; State=$Item.State; IsAllInterfaces=$Item.IsAllInterfaces } }
'InstalledRemoteAccessSoftware' { [ordered]@{ DisplayName=$Item.DisplayName; DisplayVersion=$Item.DisplayVersion; Publisher=$Item.Publisher; InstallLocation=(ConvertTo-NormalizedPath $Item.InstallLocation) } }
'RemoteFirewallRules' { [ordered]@{ Name=$Item.Name; Enabled=$Item.Enabled; Direction=$Item.Direction; Action=$Item.Action; Profile=$Item.Profile; Service=$Item.Service; Program=(ConvertTo-NormalizedPath $Item.Program); Ports=@($Item.Ports | Sort-Object); Addresses=@($Item.Addresses | Sort-Object) } }
'RemoteDesktopConfiguration' { [ordered]@{ Enabled=$Item.Enabled; NetworkLevelAuthenticationEnabled=$Item.NetworkLevelAuthenticationEnabled; Port=$Item.Port; SecurityLayer=$Item.SecurityLayer } }
'WinRMConfiguration' { [ordered]@{ ServicePresent=$Item.ServicePresent; ServiceState=$Item.ServiceState; StartMode=$Item.StartMode; Listeners=@($Item.Listeners | Sort-Object Transport,Address,Port); PowerShellRemotingAvailable=$Item.PowerShellRemotingAvailable } }
default { [ordered]@{ Value=$Item } }
}
$text = ConvertTo-StableText ([pscustomobject]$data)
[pscustomobject]@{ Key=$key.ToLowerInvariant(); Fingerprint=(Get-StringSha256 $text); Data=[pscustomobject]$data }
}
function ConvertTo-SystemBaseline {
[CmdletBinding()]
param([Parameter(Mandatory)][object]$Snapshot)
$collections = [ordered]@{}
foreach ($name in @('Processes','Services','ScheduledTasks','StartupEntries','WmiPersistence','LocalAdministrators','ListeningPorts','InstalledRemoteAccessSoftware','RemoteFirewallRules')) {
$items = @(Get-SafePropertyValue $Snapshot $name @())
$records = New-Object System.Collections.Generic.List[object]
foreach ($item in $items) {
try { $records.Add((ConvertTo-BaselineItem -Collection $name -Item $item)) }
catch { Add-AuditError ("Baseline {0}" -f $name) $_.Exception.Message }
}
$collections[$name] = @($records | Sort-Object Key)
}
foreach ($name in @('RemoteDesktopConfiguration','WinRMConfiguration')) {
$item = Get-SafePropertyValue $Snapshot $name $null
$collections[$name] = if ($null -eq $item) { @() } else { @((ConvertTo-BaselineItem -Collection $name -Item $item)) }
}
[pscustomobject]@{
SchemaVersion='1.0'; ToolVersion=$script:ToolVersion; CreatedUtc=(Get-Date).ToUniversalTime().ToString('o')
ComputerName=[string](Get-SafePropertyValue $Snapshot.SystemMetadata 'ComputerName' $env:COMPUTERNAME)
OperatingSystem=[string](Get-SafePropertyValue $Snapshot.SystemMetadata 'OperatingSystem' '')
Collections=[pscustomobject]$collections
}
}
function New-SystemBaseline {
[CmdletBinding()]
param(
[Parameter(Mandatory)][object]$Snapshot,
[Parameter(Mandatory)][string]$Path
)
$fullPath = [IO.Path]::GetFullPath($Path)
$parent = Split-Path -Parent $fullPath
if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null }
$baseline = ConvertTo-SystemBaseline -Snapshot $Snapshot
[IO.File]::WriteAllText($fullPath, ($baseline | ConvertTo-Json -Depth 30), $script:Utf8Bom)
Write-AuditLog -Level INFO -Message ("Baseline written: {0}" -f $fullPath)
return $baseline
}
function Import-SystemBaseline {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Baseline file not found: $Path" }
$baseline = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ([string](Get-SafePropertyValue $baseline 'SchemaVersion' '') -ne '1.0' -or $null -eq (Get-SafePropertyValue $baseline 'Collections' $null)) { throw 'The baseline schema is invalid or unsupported.' }
return $baseline
}
function Compare-SystemBaseline {
[CmdletBinding(DefaultParameterSetName='Objects')]
param(
[Parameter(Mandatory,ParameterSetName='Objects')][object]$Baseline,
[Parameter(Mandatory,ParameterSetName='Objects')][object]$CurrentSnapshot,
[Parameter(Mandatory,ParameterSetName='Path')][string]$BaselinePath,
[Parameter(Mandatory,ParameterSetName='Path')][object]$Snapshot
)
if ($PSCmdlet.ParameterSetName -eq 'Path') { $Baseline = Import-SystemBaseline -Path $BaselinePath; $CurrentSnapshot = $Snapshot }
$current = ConvertTo-SystemBaseline -Snapshot $CurrentSnapshot
$results = New-Object System.Collections.Generic.List[object]
foreach ($property in $current.Collections.PSObject.Properties) {
$name = $property.Name
$currentItems = @($property.Value)
$baselineProperty = $Baseline.Collections.PSObject.Properties[$name]
$baselineItems = if ($null -ne $baselineProperty) { @($baselineProperty.Value) } else { @() }
$oldMap = @{}; $newMap = @{}
foreach ($item in $baselineItems) { $oldMap[[string]$item.Key] = $item }
foreach ($item in $currentItems) { $newMap[[string]$item.Key] = $item }
foreach ($key in @($newMap.Keys | Sort-Object)) {
$status = if (-not $oldMap.ContainsKey($key)) { 'Added' } elseif ([string]$oldMap[$key].Fingerprint -ne [string]$newMap[$key].Fingerprint) { 'Changed' } else { 'Unchanged' }
$results.Add([pscustomobject]@{ Collection=$name; Key=$key; Status=$status; Previous=if($oldMap.ContainsKey($key)){$oldMap[$key].Data}else{$null}; Current=$newMap[$key].Data })
}
foreach ($key in @($oldMap.Keys | Where-Object { -not $newMap.ContainsKey($_) } | Sort-Object)) {
$results.Add([pscustomobject]@{ Collection=$name; Key=$key; Status='Removed'; Previous=$oldMap[$key].Data; Current=$null })
}
}
return $results.ToArray()
}
function Add-BaselineComparisonFindings {
[CmdletBinding()]
param(
[Parameter(Mandatory)][object]$Audit,
[Parameter(Mandatory)][object[]]$Comparison
)
$newKeys = @{}
foreach ($change in @($Comparison | Where-Object { $_.Status -in @('Added','Changed') })) { $newKeys[[string]$change.Key] = $change }
foreach ($finding in @($Audit.Findings)) {
if ($newKeys.ContainsKey(([string]$finding.EntityId).ToLowerInvariant())) {
$finding.NewComparedWithBaseline = $true
$indicators = @($finding.RiskIndicators) + 'NewComparedWithBaseline'
$risk = Get-RiskAssessment -BaseScore 0 -Indicators $indicators
if ($risk.Score -gt $finding.RiskScore) { $finding.RiskScore=$risk.Score; $finding.Severity=$risk.Severity; $finding.RiskIndicators=$risk.Indicators }
}
}
$extra = New-Object System.Collections.Generic.List[object]
foreach ($change in @($Comparison | Where-Object { $_.Status -in @('Added','Changed') })) {
if (@($Audit.Findings | Where-Object { $_.EntityId -eq $change.Key }).Count -gt 0) { continue }
switch ($change.Collection) {
'LocalAdministrators' {
if ($change.Status -eq 'Added') { $extra.Add((Write-AuditFinding -Category 'Account' -Title 'New local administrator appears compared with baseline' -Description 'A principal was added to the local built-in Administrators group after the baseline.' -Evidence $change.Current -BaseScore 0 -Indicators @('NewAdministrator','NewComparedWithBaseline') -Username ([string](Get-SafePropertyValue $change.Current 'Name' '')) -NewComparedWithBaseline $true -EntityId $change.Key -RecommendedNextStep 'Verify the account SID, creator, intended owner, logon history, and authorization immediately.')) }
}
'Services' {
$changedPath = $change.Status -eq 'Changed' -and (ConvertTo-NormalizedPath ([string](Get-SafePropertyValue $change.Previous 'ExecutablePath' ''))) -ne (ConvertTo-NormalizedPath ([string](Get-SafePropertyValue $change.Current 'ExecutablePath' '')))
$extra.Add((Write-AuditFinding -Category 'Service' -Title $(if($changedPath){'Service executable path changed compared with baseline'}else{'Service added or changed compared with baseline'}) -Description 'A service was added or its stable configuration changed after the baseline.' -Evidence $change -BaseScore $(if($changedPath){30}else{10}) -Indicators @('Persistence','NewComparedWithBaseline') -ExecutablePath ([string](Get-SafePropertyValue $change.Current 'ExecutablePath' '')) -NewComparedWithBaseline $true -EntityId $change.Key))
}
'ScheduledTasks' { $extra.Add((Write-AuditFinding -Category 'ScheduledTask' -Title 'Scheduled task added or changed compared with baseline' -Description 'A scheduled task or its action changed after the baseline.' -Evidence $change -BaseScore 5 -Indicators @('Persistence','NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
'StartupEntries' { $extra.Add((Write-AuditFinding -Category 'Persistence' -Title 'Startup entry added or changed compared with baseline' -Description 'A startup persistence entry changed after the baseline.' -Evidence $change -BaseScore 5 -Indicators @('Persistence','NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
'WmiPersistence' { $extra.Add((Write-AuditFinding -Category 'WmiPersistence' -Title 'WMI persistence added or changed compared with baseline' -Description 'A permanent WMI subscription component changed after the baseline.' -Evidence $change -BaseScore 10 -Indicators @('Persistence','NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
'ListeningPorts' { $extra.Add((Write-AuditFinding -Category 'Network' -Title 'New listening port appears compared with baseline' -Description 'A TCP listening port or UDP endpoint was added after the baseline.' -Evidence $change.Current -BaseScore 10 -Indicators @('NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
'InstalledRemoteAccessSoftware' { $extra.Add((Write-AuditFinding -Category 'KnownSoftware' -Title 'Known remote-access software detected' -Description 'Remote-access software appears installed after the baseline. This does not classify the product as malware.' -Evidence $change.Current -BaseScore 0 -Indicators @('KnownRemote','NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
'RemoteFirewallRules' { $extra.Add((Write-AuditFinding -Category 'Firewall' -Title 'Remote administration firewall rule added or changed' -Description 'A relevant firewall rule changed after the baseline.' -Evidence $change -BaseScore 0 -Indicators @('NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
'RemoteDesktopConfiguration' { $extra.Add((Write-AuditFinding -Category 'RemoteConfiguration' -Title 'Remote Desktop configuration changed compared with baseline' -Description 'Remote Desktop enablement, NLA, port, or security layer changed.' -Evidence $change -BaseScore 5 -Indicators @('NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
'WinRMConfiguration' { $extra.Add((Write-AuditFinding -Category 'RemoteConfiguration' -Title 'WinRM configuration changed compared with baseline' -Description 'WinRM service or listener configuration changed.' -Evidence $change -BaseScore 5 -Indicators @('NewComparedWithBaseline') -NewComparedWithBaseline $true -EntityId $change.Key)) }
}
}
$Audit.Findings = @($Audit.Findings) + $extra.ToArray()
return $Audit
}
function Export-AuditReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)][object]$Audit,
[Parameter(Mandatory)][string]$Directory,
[AllowNull()][object[]]$Comparison = @(),
[string]$Prefix = 'audit'
)
$fullDirectory = [IO.Path]::GetFullPath($Directory)
if (-not (Test-Path -LiteralPath $fullDirectory)) { New-Item -ItemType Directory -Path $fullDirectory -Force -ErrorAction Stop | Out-Null }
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
$base = Join-Path $fullDirectory ("{0}-{1}" -f $Prefix,$timestamp)
$jsonPath = $base + '.json'; $csvPath = $base + '.csv'; $textPath = $base + '.txt'
$report = [pscustomobject]@{
ReportMetadata=[pscustomobject]@{ ToolName=$script:ToolName; ToolVersion=$script:ToolVersion; GeneratedUtc=(Get-Date).ToUniversalTime().ToString('o'); Mode=$Mode; HashingEnabled=$script:HashingEnabled; IncludeMicrosoftEntries=$script:IncludeMicrosoft }
System=$Audit.Snapshot.SystemMetadata; Findings=@($Audit.Findings | Sort-Object RiskScore -Descending)
Snapshot=$Audit.Snapshot; BaselineComparison=@($Comparison); InspectionErrors=@($Audit.Errors)
}
[IO.File]::WriteAllText($jsonPath, ($report | ConvertTo-Json -Depth 40), $script:Utf8Bom)
$columns = @('Timestamp','Category','Severity','RiskScore','FindingTitle','Description','Evidence','ProcessId','ExecutablePath','Username','RemoteAddress','RecommendedNextInspectionStep','NewComparedWithBaseline','RiskIndicators','EntityId')
$csvObjects = @($Audit.Findings | ForEach-Object {
[pscustomobject]@{
Timestamp=$_.Timestamp; Category=$_.Category; Severity=$_.Severity; RiskScore=$_.RiskScore; FindingTitle=$_.FindingTitle
Description=$_.Description; Evidence=(ConvertTo-StableText $_.Evidence); ProcessId=$_.ProcessId; ExecutablePath=$_.ExecutablePath
Username=$_.Username; RemoteAddress=$_.RemoteAddress; RecommendedNextInspectionStep=$_.RecommendedNextInspectionStep
NewComparedWithBaseline=$_.NewComparedWithBaseline; RiskIndicators=(@($_.RiskIndicators) -join ';'); EntityId=$_.EntityId
}
})
if ($csvObjects.Count -gt 0) { $csvLines = @($csvObjects | ConvertTo-Csv -NoTypeInformation) }
else { $csvLines = @(($columns | ForEach-Object { '"{0}"' -f ($_ -replace '"','""') }) -join ',') }
[IO.File]::WriteAllLines($csvPath, [string[]]$csvLines, $script:Utf8Bom)
$severityCounts = @{}
foreach ($severity in @('Critical','High','Medium','Low','Informational')) { $severityCounts[$severity] = @($Audit.Findings | Where-Object { $_.Severity -eq $severity }).Count }
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add($script:ToolName); $lines.Add(('Generated: {0}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz')))
$lines.Add(('Computer: {0}' -f $Audit.Snapshot.SystemMetadata.ComputerName)); $lines.Add(('Administrator: {0}' -f $Audit.Snapshot.SystemMetadata.IsAdministrator))
$lines.Add(('Findings: {0} (Critical {1}, High {2}, Medium {3}, Low {4}, Informational {5})' -f @($Audit.Findings).Count,$severityCounts.Critical,$severityCounts.High,$severityCounts.Medium,$severityCounts.Low,$severityCounts.Informational))
$lines.Add(('Inspection errors: {0}' -f @($Audit.Errors).Count)); $lines.Add('')
foreach ($finding in @($Audit.Findings | Sort-Object RiskScore -Descending | Select-Object -First 100)) {
$lines.Add(('[{0}] Score {1}: {2}' -f $finding.Severity,$finding.RiskScore,$finding.FindingTitle))
$lines.Add((' {0}' -f $finding.Description)); if (-not [string]::IsNullOrWhiteSpace($finding.ExecutablePath)) { $lines.Add((' Path: {0}' -f $finding.ExecutablePath)) }
$lines.Add((' Next: {0}' -f $finding.RecommendedNextInspectionStep)); $lines.Add('')
}
if (@($Audit.Findings).Count -gt 100) { $lines.Add(('Text summary truncated to 100 of {0} findings; JSON and CSV contain all findings.' -f @($Audit.Findings).Count)) }
[IO.File]::WriteAllLines($textPath, $lines.ToArray(), $script:Utf8Bom)
Write-AuditLog -Level INFO -Message ("Reports written under {0}" -f $fullDirectory)
[pscustomobject]@{ Json=$jsonPath; Csv=$csvPath; Text=$textPath }
}
function Write-ConsoleAuditSummary {
[CmdletBinding()]
param(
[Parameter(Mandatory)][object]$Audit,
[Parameter(Mandatory)][object]$ReportPaths,
[AllowNull()][object[]]$Comparison = @(),
[string]$Heading = 'AUDIT RESULT',
[string]$BaselineFile = ''
)
if ($script:SuppressConsole) { return }
$findings = @($Audit.Findings)
$counts = @{}
foreach ($severity in @('Critical','High','Medium','Low','Informational')) {
$counts[$severity] = @($findings | Where-Object { $_.Severity -eq $severity }).Count
}
$status = if ($counts.Critical -gt 0 -or $counts.High -gt 0) {
'URGENT REVIEW REQUIRED'
} elseif ($counts.Medium -gt 0) {
'REVIEW RECOMMENDED'
} elseif ($counts.Low -gt 0) {
'LOW-RISK ITEMS PRESENT'
} else {
'NO ELEVATED FINDINGS'
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('')
$lines.Add(('=' * 72))
$lines.Add($Heading)
$lines.Add(('=' * 72))
$lines.Add(('Status: {0}' -f $status))
$lines.Add(('Coverage: {0}' -f $(if ($Audit.Snapshot.SystemMetadata.IsAdministrator) { 'Administrator' } else { 'Reduced (not Administrator)' })))
$lines.Add(('Findings: Critical {0} | High {1} | Medium {2} | Low {3} | Informational {4}' -f $counts.Critical,$counts.High,$counts.Medium,$counts.Low,$counts.Informational))
$lines.Add(('Inspection errors: {0}' -f @($Audit.Errors).Count))
$priority = @($findings | Where-Object { $_.Severity -in @('Critical','High','Medium') } | Sort-Object RiskScore -Descending | Select-Object -First 10)
$lines.Add('')
$lines.Add('Priority findings:')
if ($priority.Count -eq 0) {
$lines.Add(' None at Medium, High, or Critical severity.')
} else {
foreach ($finding in $priority) {
$detail = '[{0}] Score {1}: {2}' -f $finding.Severity,$finding.RiskScore,$finding.FindingTitle
$evidenceValue = [string](Get-SafePropertyValue $finding.Evidence 'Exclusion' '')
if ([string]::IsNullOrWhiteSpace($evidenceValue)) { $evidenceValue = [string](Get-SafePropertyValue $finding.Evidence 'Task' '') }
if ([string]::IsNullOrWhiteSpace($evidenceValue)) { $evidenceValue = [string](Get-SafePropertyValue $finding.Evidence 'ServiceName' '') }
if (-not [string]::IsNullOrWhiteSpace([string]$finding.ExecutablePath)) { $detail += ' | ' + $finding.ExecutablePath }
elseif (-not [string]::IsNullOrWhiteSpace($evidenceValue)) { $detail += ' | ' + $evidenceValue }
elseif (-not [string]::IsNullOrWhiteSpace([string]$finding.RemoteAddress)) { $detail += ' | Remote: ' + $finding.RemoteAddress }
$lines.Add(' ' + $detail)
$lines.Add((' Next: {0}' -f $finding.RecommendedNextInspectionStep))
}
if (@($findings | Where-Object { $_.Severity -in @('Critical','High','Medium') }).Count -gt 10) {
$lines.Add(' Additional priority findings are available in the reports.')
}
}
$knownNames = New-Object System.Collections.Generic.List[string]
foreach ($finding in @($findings | Where-Object { $_.FindingTitle -eq 'Known remote-access software detected' })) {
$name = [string](Get-SafePropertyValue $finding.Evidence 'Product' '')
if ([string]::IsNullOrWhiteSpace($name)) { $name = [string](Get-SafePropertyValue $finding.Evidence 'ProcessName' '') }
if ([string]::IsNullOrWhiteSpace($name)) { $name = [string](Get-SafePropertyValue $finding.Evidence 'ServiceName' '') }
if ([string]::IsNullOrWhiteSpace($name)) { $name = [string]$finding.ExecutablePath }
if (-not [string]::IsNullOrWhiteSpace($name) -and -not $knownNames.Contains($name)) { $knownNames.Add($name) }
}
$lines.Add('')
$lines.Add('Known remote-access software observed:')
if ($knownNames.Count -eq 0) { $lines.Add(' None matched.') }
else { foreach ($name in $knownNames.ToArray()) { $lines.Add(' ' + $name) } }
$rdp = $Audit.Snapshot.RemoteDesktopConfiguration
$ra = $Audit.Snapshot.RemoteAssistanceConfiguration
$winrm = $Audit.Snapshot.WinRMConfiguration
$remoteSessionCount = @($Audit.Snapshot.RemoteSessions | Where-Object { $_.SessionType -eq 'RemoteDesktop' -and $_.State -in @('Active','Disconnected') }).Count
$lines.Add('')
$lines.Add('Remote administration exposure:')
$lines.Add((' Remote Desktop: {0}; NLA: {1}; port: {2}' -f $(if($rdp.Enabled -eq $true){'Enabled'}elseif($rdp.Enabled -eq $false){'Disabled'}else{'Unavailable'}),$(if($rdp.NetworkLevelAuthenticationEnabled -eq $true){'Enabled'}elseif($rdp.NetworkLevelAuthenticationEnabled -eq $false){'Disabled'}else{'Unavailable'}),$(if($null -ne $rdp.Port){$rdp.Port}else{'Unavailable'})))
$lines.Add((' Remote Assistance: solicited {0}; unsolicited {1}' -f $(if($ra.SolicitedRemoteAssistanceEnabled -eq $true){'Enabled'}elseif($ra.SolicitedRemoteAssistanceEnabled -eq $false){'Disabled'}else{'Unavailable'}),$(if($ra.UnsolicitedRemoteAssistanceEnabled -eq $true){'Enabled'}elseif($ra.UnsolicitedRemoteAssistanceEnabled -eq $false){'Disabled'}else{'Unavailable'})))
$lines.Add((' WinRM: {0}; listeners: {1}; RDP sessions: {2}' -f $winrm.ServiceState,@($winrm.Listeners).Count,$remoteSessionCount))
if (@($Comparison).Count -gt 0) {
$lines.Add('')
$lines.Add(('Baseline changes: Added {0} | Removed {1} | Changed {2} | Unchanged {3}' -f @($Comparison | Where-Object Status -eq 'Added').Count,@($Comparison | Where-Object Status -eq 'Removed').Count,@($Comparison | Where-Object Status -eq 'Changed').Count,@($Comparison | Where-Object Status -eq 'Unchanged').Count))
}
$lines.Add('')
$lines.Add('Reports:')
$lines.Add((' JSON: {0}' -f $ReportPaths.Json))
$lines.Add((' CSV: {0}' -f $ReportPaths.Csv))
$lines.Add((' Text: {0}' -f $ReportPaths.Text))
if (-not [string]::IsNullOrWhiteSpace($BaselineFile)) { $lines.Add((' Baseline: {0}' -f $BaselineFile)) }
$lines.Add('')
$lines.Add('The absence of findings does not prove that surveillance software is absent.')
$lines.Add(('=' * 72))
foreach ($line in $lines.ToArray()) { Write-Information -MessageData $line -InformationAction Continue }
}
function Test-AndRecordMonitorFinding {
[CmdletBinding()]
param([Parameter(Mandatory)][object]$Finding)
$keyText = '{0}|{1}|{2}|{3}' -f $Finding.Category,$Finding.FindingTitle,$Finding.EntityId,(ConvertTo-StableText $Finding.Evidence)
$key = Get-StringSha256 $keyText
if ($script:MonitorSeen.ContainsKey($key)) { return $false }
if ($script:MonitorSeen.Count -ge 5000) {
$cutoff = (Get-Date).ToUniversalTime().AddDays(-7)
foreach ($oldKey in @($script:MonitorSeen.Keys)) { if ([datetime]$script:MonitorSeen[$oldKey] -lt $cutoff) { $script:MonitorSeen.Remove($oldKey) } }
if ($script:MonitorSeen.Count -ge 5000) { $script:MonitorSeen.Clear() }
}
$script:MonitorSeen[$key] = (Get-Date).ToUniversalTime()
return $true
}
function Register-TemporaryAuditEvents {
[CmdletBinding()]
param()
$sourceIds = New-Object System.Collections.Generic.List[string]
$definitions = @(
@{ Id=('WRAA.Process.{0}' -f $PID); Query="SELECT * FROM __InstanceCreationEvent WITHIN 2 WHERE TargetInstance ISA 'Win32_Process'" },
@{ Id=('WRAA.Service.{0}' -f $PID); Query="SELECT * FROM __InstanceModificationEvent WITHIN 5 WHERE TargetInstance ISA 'Win32_Service'" }
)
foreach ($definition in $definitions) {
try {
Register-CimIndicationEvent -Namespace 'root/cimv2' -Query $definition.Query -SourceIdentifier $definition.Id -ErrorAction Stop | Out-Null
$sourceIds.Add($definition.Id)
} catch { Add-AuditError 'Temporary event monitoring' ("{0}: {1}" -f $definition.Id,$_.Exception.Message) $true }
}
$sessionSource='WRAA.Session.{0}' -f $PID
try {
Register-ObjectEvent -InputObject ([Microsoft.Win32.SystemEvents]) -EventName SessionSwitch -SourceIdentifier $sessionSource -ErrorAction Stop | Out-Null
$sourceIds.Add($sessionSource)
} catch { Add-AuditError 'Temporary event monitoring' ("{0}: {1}" -f $sessionSource,$_.Exception.Message) $true }
return $sourceIds.ToArray()
}
function Remove-TemporaryAuditEvents {
[CmdletBinding()]
param([string[]]$SourceIdentifiers)
foreach ($source in @($SourceIdentifiers)) {
try { Unregister-Event -SourceIdentifier $source -ErrorAction SilentlyContinue } catch { }
try { Remove-Event -SourceIdentifier $source -ErrorAction SilentlyContinue } catch { }
try { Get-Job -Name $source -ErrorAction SilentlyContinue | Remove-Job -Force -ErrorAction SilentlyContinue } catch { }
}
}
function Receive-TemporaryAuditEvents {
[CmdletBinding()]
param([string[]]$SourceIdentifiers)
$findings = New-Object System.Collections.Generic.List[object]
foreach ($source in @($SourceIdentifiers)) {
foreach ($eventRecord in @(Get-Event -SourceIdentifier $source -ErrorAction SilentlyContinue)) {
try {
$eventObject = $eventRecord.SourceEventArgs.NewEvent
if ($source -like 'WRAA.Process.*') {
$target = Get-SafePropertyValue $eventObject 'TargetInstance' $null
$name = [string](Get-SafePropertyValue $target 'Name' 'Unknown')
$processId = [int](Get-SafePropertyValue $target 'ProcessId' 0)
$path = [string](Get-SafePropertyValue $target 'ExecutablePath' '')
$matches = @(Find-KnownSoftwareMatch @($name,$path,(Get-SafePropertyValue $target 'CommandLine' '')))
$finding = Write-AuditFinding -Category 'ProcessEvent' -Title 'New process observed by real-time monitor' -Description 'A native CIM process creation event was received; the periodic scan provides full executable and network metadata.' -Evidence ([pscustomobject]@{ ProcessName=$name; ProcessId=$processId; KnownSoftwareMatches=@($matches | ForEach-Object { $_.Name }) }) -BaseScore $(if($matches.Count -gt 0){5}else{0}) -Indicators $(if($matches.Count -gt 0){@('KnownRemote','Running')}else{@()}) -ProcessId $processId -ExecutablePath $path -EntityId ('processevent|{0}|{1}|{2}' -f $name.ToLowerInvariant(),(ConvertTo-NormalizedPath $path),$eventRecord.TimeGenerated.Ticks) -Quiet
$findings.Add($finding)
Write-AuditLog -Level INFO -Message ("New process event: {0} (PID {1})" -f $name,$processId)
} elseif ($source -like 'WRAA.Service.*') {
$target = Get-SafePropertyValue $eventObject 'TargetInstance' $null
$previous = Get-SafePropertyValue $eventObject 'PreviousInstance' $null
$newState = [string](Get-SafePropertyValue $target 'State' '')
$oldState = [string](Get-SafePropertyValue $previous 'State' '')
$newPath = [string](Get-SafePropertyValue $target 'PathName' '')
$oldPath = [string](Get-SafePropertyValue $previous 'PathName' '')
if ($newState -ne $oldState -or $newPath -ne $oldPath) {
$name = [string](Get-SafePropertyValue $target 'Name' 'Unknown')
$finding = Write-AuditFinding -Category 'ServiceEvent' -Title 'Service state or executable path changed' -Description 'A native temporary CIM service modification event was received.' -Evidence ([pscustomobject]@{ ServiceName=$name; PreviousState=$oldState; CurrentState=$newState; PreviousPath=$oldPath; CurrentPath=$newPath }) -BaseScore $(if($newPath -ne $oldPath){25}else{0}) -Indicators $(if($newPath -ne $oldPath){@('Persistence')}else{@()}) -EntityId ('serviceevent|{0}|{1}|{2}' -f $name.ToLowerInvariant(),$newState,$eventRecord.TimeGenerated.Ticks) -Quiet
$findings.Add($finding)
Write-AuditLog -Level INFO -Message ("Service event: {0} changed from {1} to {2}" -f $name,$oldState,$newState)
}
} else {
$reason = [string](Get-SafePropertyValue $eventRecord.SourceEventArgs 'Reason' '')
$sessionId = ''
$findings.Add((Write-AuditFinding -Category 'SessionEvent' -Title 'Interactive session change observed' -Description 'A native Windows session change event was received; periodic session enumeration provides user and client details.' -Evidence ([pscustomobject]@{ Reason=$reason; SessionId=$sessionId }) -BaseScore 0 -EntityId ('sessionevent|{0}|{1}|{2}' -f $sessionId,$reason,$eventRecord.TimeGenerated.Ticks) -Quiet))
Write-AuditLog -Level INFO -Message ("Session change event: session {0}, reason {1}" -f $sessionId,$reason)
}
} catch { Add-AuditError 'Event processing' $_.Exception.Message }
finally { Remove-Event -EventIdentifier $eventRecord.EventIdentifier -ErrorAction SilentlyContinue }
}
}
return $findings.ToArray()
}
function Start-RealtimeMonitoring {
[CmdletBinding()]
param(
[ValidateRange(10,86400)][int]$IntervalSeconds = 60,
[Parameter(Mandatory)][string]$OutputDirectory,
[ValidateRange(0,1000)][int]$MaximumCycles = 0
)
Write-AuditLog -Level INFO -Message ("Starting hybrid monitoring with a {0}-second polling interval" -f $IntervalSeconds)
$sourceIds = @()
$baseline = $null
$cycle = 0
try {
$sourceIds = @(Register-TemporaryAuditEvents)
$initial = Invoke-SystemAudit
foreach ($finding in @($initial.Findings)) { [void](Test-AndRecordMonitorFinding $finding) }
$initialPaths = Export-AuditReport -Audit $initial -Directory $OutputDirectory -Prefix 'monitor'
Write-ConsoleAuditSummary -Audit $initial -ReportPaths $initialPaths -Heading 'MONITOR INITIAL RESULT'
$baseline = ConvertTo-SystemBaseline -Snapshot $initial.Snapshot
$cycle++
while ($MaximumCycles -eq 0 -or $cycle -lt $MaximumCycles) {
$deadline = (Get-Date).AddSeconds($IntervalSeconds)
$eventFindings = New-Object System.Collections.Generic.List[object]
while ((Get-Date) -lt $deadline) {
foreach ($finding in @(Receive-TemporaryAuditEvents -SourceIdentifiers $sourceIds)) { $eventFindings.Add($finding) }
Start-Sleep -Milliseconds 500
}
$audit = Invoke-SystemAudit
$comparison = @(Compare-SystemBaseline -Baseline $baseline -CurrentSnapshot $audit.Snapshot)
$audit = Add-BaselineComparisonFindings -Audit $audit -Comparison $comparison
$unique = New-Object System.Collections.Generic.List[object]
foreach ($finding in $eventFindings.ToArray() + @($audit.Findings)) { if (Test-AndRecordMonitorFinding $finding) { $unique.Add($finding) } }
$audit.Findings = $unique.ToArray()
if ($audit.Findings.Count -gt 0 -or @($comparison | Where-Object { $_.Status -ne 'Unchanged' }).Count -gt 0) {
$monitorPaths = Export-AuditReport -Audit $audit -Directory $OutputDirectory -Comparison $comparison -Prefix 'monitor'
Write-ConsoleAuditSummary -Audit $audit -ReportPaths $monitorPaths -Comparison $comparison -Heading 'MONITOR UPDATE'
} else { Write-AuditLog -Level INFO -Message 'Periodic scan completed with no newly reportable findings or state changes' }
$baseline = ConvertTo-SystemBaseline -Snapshot $audit.Snapshot
$cycle++
}
} catch [Management.Automation.PipelineStoppedException] {
Write-AuditLog -Level INFO -Message 'Monitoring interrupted; cleaning up temporary subscriptions'
} catch {
Add-AuditError 'Realtime monitoring' $_.Exception.Message
} finally {
Remove-TemporaryAuditEvents -SourceIdentifiers $sourceIds
Write-AuditLog -Level INFO -Message 'Realtime monitoring stopped; temporary event subscriptions removed'
}
}
function Start-RequestedDefenderQuickScan {
[CmdletBinding()]
param()
Write-AuditLog -Level INFO -Message 'Starting Microsoft Defender Quick Scan because -EnableDefenderScan was explicitly requested'
if (-not (Get-Command Start-MpScan -ErrorAction SilentlyContinue)) { Add-AuditError 'Defender Quick Scan' 'Start-MpScan is unavailable.'; return }
try { Start-MpScan -ScanType QuickScan -ErrorAction Stop; Write-AuditLog -Level INFO -Message 'Microsoft Defender Quick Scan request completed' }
catch { Add-AuditError 'Defender Quick Scan' $_.Exception.Message $true }
}
function Invoke-WindowsRemoteAccessAuditMain {
[CmdletBinding()]
param()
try { $resolvedOutput = [IO.Path]::GetFullPath($OutputDirectory) } catch { throw "Invalid OutputDirectory: $($_.Exception.Message)" }
if ([string]::IsNullOrWhiteSpace($BaselinePath)) { $script:BaselinePath = Join-Path $resolvedOutput 'baseline.json' } else { try { $script:BaselinePath = [IO.Path]::GetFullPath($BaselinePath) } catch { throw "Invalid BaselinePath: $($_.Exception.Message)" } }
if (-not [string]::IsNullOrWhiteSpace($KnownSoftwarePath)) { try { $script:KnownSoftwarePath = [IO.Path]::GetFullPath($KnownSoftwarePath) } catch { throw "Invalid KnownSoftwarePath: $($_.Exception.Message)" } }
$script:KnownSoftwareDefinitions = @(Import-KnownSoftwareDefinitions -Path $KnownSoftwarePath)
Write-AuditLog -Level INFO -Message ("Starting {0} mode" -f $Mode)
if ($EnableDefenderScan) { Start-RequestedDefenderQuickScan }
switch ($Mode) {
'Monitor' { Start-RealtimeMonitoring -IntervalSeconds $IntervalSeconds -OutputDirectory $resolvedOutput }
'Baseline' {
$audit = Invoke-SystemAudit
[void](New-SystemBaseline -Snapshot $audit.Snapshot -Path $BaselinePath)
$paths = Export-AuditReport -Audit $audit -Directory $resolvedOutput
Write-ConsoleAuditSummary -Audit $audit -ReportPaths $paths -Heading 'BASELINE AUDIT RESULT' -BaselineFile $BaselinePath
}
'Compare' {
$baseline = Import-SystemBaseline -Path $BaselinePath
$audit = Invoke-SystemAudit
$comparison = @(Compare-SystemBaseline -Baseline $baseline -CurrentSnapshot $audit.Snapshot)
$audit = Add-BaselineComparisonFindings -Audit $audit -Comparison $comparison
$paths = Export-AuditReport -Audit $audit -Directory $resolvedOutput -Comparison $comparison
Write-ConsoleAuditSummary -Audit $audit -ReportPaths $paths -Comparison $comparison -Heading 'BASELINE COMPARISON RESULT'
}
default {
$audit = Invoke-SystemAudit
$paths = Export-AuditReport -Audit $audit -Directory $resolvedOutput
Write-ConsoleAuditSummary -Audit $audit -ReportPaths $paths
}
}
Write-AuditLog -Level INFO -Message ("{0} mode completed" -f $Mode)
}
if ($MyInvocation.InvocationName -ne '.') {
try { Invoke-WindowsRemoteAccessAuditMain }
catch {
Write-AuditLog -Level CRITICAL -Message ("Audit could not continue: {0}" -f $_.Exception.Message)
exit 1
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment