|
#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 owner’s 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 |
|
} |
|
} |