modules/SecurityPosture/SecurityPosture.psm1

<#
    SecurityPosture.psm1 - security configuration snapshot (read-only).
    Produces: SecurityPosture, LocalUsers, LocalGroups, LocalGroupMembers,
              FirewallProfiles, UserRightsAssignments, LocalAccountsWithNonExpiringPasswords,
              SensitiveUserRightsGrants.
    NEVER collects password hashes, keys, or secrets.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='SecurityPosture'; DisplayName='Security Posture'; Category='Security'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('SecurityPosture','LocalUsers','LocalGroups','LocalGroupMembers','FirewallProfiles','UserRightsAssignments','LocalAccountsWithNonExpiringPasswords','SensitiveUserRightsGrants')
        ProducesRisks=$true; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$true
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    $lim = @()
    if (-not $Context.IsAdmin) { $lim = @('Not elevated: some security posture data may be incomplete.') }
    [pscustomobject]@{ ModuleName='SecurityPosture'; CanRun=$true; Status='Ready'; Reason=''; Limitations=$lim }
}

function Get-LocalAdministrators {
    $members = [System.Collections.Generic.List[object]]::new()
    try {
        if (Get-CommandAvailable -Name 'Get-LocalGroupMember') {
            $grp = $null
            try { $grp = Get-LocalGroup -SID 'S-1-5-32-544' -ErrorAction SilentlyContinue } catch { }
            if (-not $grp) { try { $grp = Get-LocalGroup -Name 'Administrators' -ErrorAction SilentlyContinue } catch { } }
            if ($grp) {
                foreach ($m in (Get-LocalGroupMember -Group $grp.Name -ErrorAction SilentlyContinue)) {
                    $members.Add([pscustomobject]@{ Group='Administrators'; Member=$m.Name; ObjectClass=[string]$m.ObjectClass; PrincipalSource=[string]$m.PrincipalSource })
                }
            }
        }
        if ($members.Count -eq 0) {
            $r = Invoke-CommandLineSafe -FilePath 'net.exe' -Arguments @('localgroup','Administrators') -TimeoutSeconds 30
            if ($r.Succeeded) {
                $lines = $r.StdOut -split "`r?`n"
                $capture = $false
                foreach ($ln in $lines) {
                    if ($ln -match '^-{3,}') { $capture = $true; continue }
                    if ($capture) {
                        if ($ln -match 'command completed') { break }
                        if ($ln.Trim()) { $members.Add([pscustomobject]@{ Group='Administrators'; Member=$ln.Trim(); ObjectClass=''; PrincipalSource='' }) }
                    }
                }
            }
        }
    } catch { }
    return ,@($members)
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $users = [System.Collections.Generic.List[object]]::new()
    $groups = [System.Collections.Generic.List[object]]::new()
    $members = [System.Collections.Generic.List[object]]::new()
    $fwProfiles = [System.Collections.Generic.List[object]]::new()
    $rights = [System.Collections.Generic.List[object]]::new()

    # ---- Local users / groups ----
    try {
        if (Get-CommandAvailable -Name 'Get-LocalUser') {
            foreach ($u in (Get-LocalUser -ErrorAction SilentlyContinue)) {
                $users.Add([pscustomobject]@{ Name=$u.Name; Enabled=$u.Enabled; LastLogon=(Normalize-DateTime $u.LastLogon); PasswordLastSet=(Normalize-DateTime $u.PasswordLastSet); PasswordNeverExpires=$u.PasswordNeverExpires; Description=$u.Description })
            }
        } else {
            foreach ($u in (Invoke-CimSafe -ClassName 'Win32_UserAccount' -Filter 'LocalAccount=True')) {
                $users.Add([pscustomobject]@{ Name=$u.Name; Enabled=(-not $u.Disabled); LastLogon=$null; PasswordLastSet=$null; PasswordNeverExpires=$u.PasswordExpires -eq $false; Description=$u.Description })
            }
        }
    } catch { Add-Limitation -Context $Context -Module 'SecurityPosture' -Message 'Local user enumeration failed.' -Reason $_.Exception.Message | Out-Null }

    try {
        if (Get-CommandAvailable -Name 'Get-LocalGroup') {
            foreach ($g in (Get-LocalGroup -ErrorAction SilentlyContinue)) { $groups.Add([pscustomobject]@{ Name=$g.Name; Description=$g.Description }) }
        } else {
            # LocalAccounts module needs WMF 5.1+ (e.g. absent on 2012 R2). Win32_Group is CIM-based.
            foreach ($g in (Invoke-CimSafe -ClassName 'Win32_Group' -Filter 'LocalAccount = True')) {
                $groups.Add([pscustomobject]@{ Name=[string]$g.Name; Description=[string]$g.Description })
            }
        }
    } catch { }

    $admins = Get-LocalAdministrators
    foreach ($a in $admins) { $members.Add($a) }
    # A few other sensitive groups
    foreach ($gname in @('Remote Desktop Users','Backup Operators','Hyper-V Administrators')) {
        try {
            if (Get-CommandAvailable -Name 'Get-LocalGroupMember') {
                foreach ($m in (Get-LocalGroupMember -Group $gname -ErrorAction SilentlyContinue)) {
                    $members.Add([pscustomobject]@{ Group=$gname; Member=$m.Name; ObjectClass=[string]$m.ObjectClass; PrincipalSource=[string]$m.PrincipalSource })
                }
            } else {
                # Same LocalAccounts-module gap as Get-LocalAdministrators; same net.exe fallback.
                $r = Invoke-CommandLineSafe -FilePath 'net.exe' -Arguments @('localgroup', "`"$gname`"") -TimeoutSeconds 30
                if ($r.Succeeded) {
                    $capture = $false
                    foreach ($ln in ($r.StdOut -split "`r?`n")) {
                        if ($ln -match '^-{3,}') { $capture = $true; continue }
                        if ($capture) {
                            if ($ln -match 'command completed') { break }
                            if ($ln.Trim()) { $members.Add([pscustomobject]@{ Group=$gname; Member=$ln.Trim(); ObjectClass=''; PrincipalSource='' }) }
                        }
                    }
                }
            }
        } catch { }
    }

    # ---- Firewall profiles ----
    try {
        if (Get-CommandAvailable -Name 'Get-NetFirewallProfile') {
            foreach ($p in (Get-NetFirewallProfile -ErrorAction SilentlyContinue)) {
                $fwProfiles.Add([pscustomobject]@{ Name=$p.Name; Enabled=([bool]$p.Enabled); DefaultInbound=[string]$p.DefaultInboundAction; DefaultOutbound=[string]$p.DefaultOutboundAction })
            }
        } else {
            foreach ($prof in @('DomainProfile','StandardProfile','PublicProfile')) {
                $v = Get-RegistryValueSafe -Path ("HKLM:\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\{0}" -f $prof) -Name 'EnableFirewall'
                $nm = switch ($prof) { 'DomainProfile' {'Domain'} 'StandardProfile' {'Private'} 'PublicProfile' {'Public'} }
                $fwProfiles.Add([pscustomobject]@{ Name=$nm; Enabled=([bool]($v -eq 1)); DefaultInbound=''; DefaultOutbound='' })
            }
        }
    } catch { Add-Limitation -Context $Context -Module 'SecurityPosture' -Message 'Firewall profile query failed.' -Reason $_.Exception.Message | Out-Null }

    # ---- User rights (optional, read-only export via secedit) ----
    try {
        $rawDir = $Context.Paths['Raw']
        if ($rawDir -and (Get-CommandAvailable -Name 'secedit.exe')) {
            $cfg = Join-Path $rawDir 'userrights.inf'
            $r = Invoke-CommandLineSafe -FilePath 'secedit.exe' -Arguments @('/export','/cfg',("`"$cfg`""),'/areas','USER_RIGHTS') -TimeoutSeconds 60
            if ((Test-Path $cfg)) {
                foreach ($ln in (Get-Content -LiteralPath $cfg -ErrorAction SilentlyContinue)) {
                    if ($ln -match '^(Se\w+)\s*=\s*(.+)$') { $rights.Add([pscustomobject]@{ Right=$Matches[1]; Principals=$Matches[2].Trim() }) }
                }
            } else { Add-Limitation -Context $Context -Module 'SecurityPosture' -Message 'User rights export not available.' | Out-Null }
        }
    } catch { Add-Limitation -Context $Context -Module 'SecurityPosture' -Message 'User rights collection failed.' -Reason $_.Exception.Message | Out-Null }

    # ---- Posture summary (one row) ----
    $posture = New-PostureRow -Context $Context -FirewallProfiles $fwProfiles -AdminCount $admins.Count -Users $users

    # ---- Derived datasets for findings the generic rule engine can't express directly ----
    $nonExpiring = Get-NonExpiringPasswordAccounts -Users $users
    $sensitiveGrants = Get-SensitiveUserRightsGrants -Rights $rights

    return ,@{
        SecurityPosture=@($posture); LocalUsers=@($users); LocalGroups=@($groups); LocalGroupMembers=@($members)
        FirewallProfiles=@($fwProfiles); UserRightsAssignments=@($rights)
        LocalAccountsWithNonExpiringPasswords=@($nonExpiring); SensitiveUserRightsGrants=@($sensitiveGrants)
    }
}

function Get-NonExpiringPasswordAccounts {
    <#
        Enabled local accounts whose password never expires. Split out as its own pure function
        (rather than an inline Where-Object in Invoke-DiscoveryCollection) specifically so it's
        directly testable - the RiskEngine's condition language only ever tests ONE field per
        row (see Test-RuleCondition in RiskEngine.psm1), so it cannot express "Enabled=true AND
        PasswordNeverExpires=true" as a single declarative rule. The collector does that
        filtering here instead (same pattern RiskEngine.psm1's own doc-comment endorses for
        ConfigDependencyHints) and exposes only the already-filtered rows; a plain
        datasetNotEmpty rule then does the rest.
    #>

    param([object[]]$Users = @())
    return ,@($Users | Where-Object { $_.Enabled -and $_.PasswordNeverExpires })
}

function Get-SensitiveUserRightsGrants {
    <#
        Sensitive rights where a grant to anything beyond the expected built-in set is worth a
        human look - not an exhaustive privilege list, just the ones most directly tied to local
        privilege escalation or credential theft if handed to the wrong account. Baseline is
        WELL-KNOWN SIDS, not friendly names - confirmed live that `secedit /export /areas
        USER_RIGHTS` renders built-in groups as raw SIDs (e.g. "*S-1-5-32-544" for
        Administrators), only resolving to a plain name for an actual custom/domain account (a
        real service account name showed up unresolved in the same export). That distinction is
        exactly what makes this check work: anything that ISN'T one of these well-known SIDs is,
        by construction, a real named principal worth a second look. Same "one field per rule"
        engine limitation as Get-NonExpiringPasswordAccounts above is why this filtering lives
        in the collector rather than risk-rules.json.
    #>

    param([object[]]$Rights = @())
    $sensitiveRightsBaseline = @{
        'SeDebugPrivilege'         = @('S-1-5-32-544')                                 # Administrators
        'SeTakeOwnershipPrivilege' = @('S-1-5-32-544')                                 # Administrators
        'SeLoadDriverPrivilege'    = @('S-1-5-32-544', 'S-1-5-32-550')                 # Administrators, Print Operators
        'SeTcbPrivilege'           = @()                                               # nobody, by default
        'SeBackupPrivilege'        = @('S-1-5-32-544', 'S-1-5-32-549', 'S-1-5-32-551') # Administrators, Server Operators, Backup Operators
        'SeRestorePrivilege'       = @('S-1-5-32-544', 'S-1-5-32-549', 'S-1-5-32-551')
    }
    $sensitiveGrants = [System.Collections.Generic.List[object]]::new()
    foreach ($r in $Rights) {
        if (-not $sensitiveRightsBaseline.ContainsKey($r.Right)) { continue }
        $expected = $sensitiveRightsBaseline[$r.Right]
        $principals = @($r.Principals -split ',' | ForEach-Object { $_.Trim().TrimStart('*') } | Where-Object { $_ })
        $unexpected = @($principals | Where-Object { $expected -notcontains $_ })
        if ($unexpected.Count -gt 0) {
            $sensitiveGrants.Add([pscustomobject]@{ Right = $r.Right; UnexpectedPrincipals = ($unexpected -join ', '); AllPrincipals = $r.Principals })
        }
    }
    return ,@($sensitiveGrants)
}

function New-PostureRow {
    param([object]$Context, $FirewallProfiles, [int]$AdminCount, [object[]]$Users = @())
    $regGet = { param($p,$n) Get-RegistryValueSafe -Path $p -Name $n }

    # SMBv1
    $smb1 = $false
    try {
        if (Get-CommandAvailable -Name 'Get-SmbServerConfiguration') { $smb1 = [bool]((Get-SmbServerConfiguration -ErrorAction SilentlyContinue).EnableSMB1Protocol) }
        else { $v = & $regGet 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters' 'SMB1'; if ($null -ne $v) { $smb1 = [bool]($v -ne 0) } }
    } catch { }

    # RDP + NLA
    $fDeny = & $regGet 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections'
    $rdpEnabled = ($fDeny -eq 0)
    $ua = & $regGet 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' 'UserAuthentication'
    $nla = ($ua -eq 1)

    # Defender / AV / EDR
    $defender = $false
    try { if (Get-CommandAvailable -Name 'Get-MpComputerStatus') { $st = Get-MpComputerStatus -ErrorAction SilentlyContinue; $defender = [bool]($st.AntivirusEnabled -or $st.RealTimeProtectionEnabled) } } catch { }
    $thirdAv = $false
    try { foreach ($av in (Invoke-CimSafe -ClassName 'AntiVirusProduct' -Namespace 'root/SecurityCenter2')) { if ($av.displayName -and $av.displayName -notmatch 'Defender') { $thirdAv = $true } } } catch { }
    $edrSvc = $false
    try {
        if ($Context.DataSets.Contains('Services')) {
            $edrSvc = (@($Context.DataSets['Services'].Rows | Where-Object { $_.DisplayName -match '(?i)SentinelOne|CrowdStrike|CSFalcon|Cylance|Carbon Black|Huntress|Sophos|Bitdefender|Defender for Endpoint|Sense' }).Count -gt 0)
        }
    } catch { }
    $anyAv = ($defender -or $thirdAv -or $edrSvc)

    # UAC
    $uac = (( & $regGet 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' 'EnableLUA') -eq 1)

    # TLS 1.0 / 1.1 (client+server 'Enabled' value; absent => OS default, report as unknown->assume enabled on older OS)
    $tls10 = Test-TlsProtocolEnabled -Protocol 'TLS 1.0'
    $tls11 = Test-TlsProtocolEnabled -Protocol 'TLS 1.1'

    # WinRM
    $winrm = $false
    try { $ws = Get-Service -Name 'WinRM' -ErrorAction SilentlyContinue; $winrm = ($ws -and $ws.Status -eq 'Running') } catch { }

    # LAPS
    $laps = $false
    try { if ((Test-RegistryPathSafe 'HKLM:\SOFTWARE\Policies\Microsoft Services\AdmPwd') -or (Test-RegistryPathSafe 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\LAPS')) { $laps = $true } } catch { }

    # BitLocker
    $bl = $false
    try { if (Get-CommandAvailable -Name 'Get-BitLockerVolume') { $bl = (@(Get-BitLockerVolume -ErrorAction SilentlyContinue | Where-Object { $_.ProtectionStatus -eq 'On' }).Count -gt 0) } } catch { }

    # Guest account - built-in, well-known name on every Windows install (may be renamed by
    # policy, but an unrenamed "Guest" is the common case and the one worth flagging cheaply).
    $guestEnabled = $false
    try { $guestEnabled = [bool](@($Users | Where-Object { $_.Name -eq 'Guest' -and $_.Enabled }).Count -gt 0) } catch { }

    # SMB signing (server side - this box acting as a file/print server for others, the
    # relevant direction for an MSP scoping THIS server). Same cmdlet-first/registry-fallback
    # shape as the SMBv1 check above; RequireSecuritySignature is the same property/value on
    # both surfaces.
    $smbSigningRequired = $false
    try {
        if (Get-CommandAvailable -Name 'Get-SmbServerConfiguration') { $smbSigningRequired = [bool]((Get-SmbServerConfiguration -ErrorAction SilentlyContinue).RequireSecuritySignature) }
        else { $v = & $regGet 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters' 'RequireSecuritySignature'; if ($null -ne $v) { $smbSigningRequired = [bool]($v -ne 0) } }
    } catch { }

    # NTLM - LmCompatibilityLevel below 3 permits LM/NTLMv1, both long-deprecated and
    # crackable/relayable. Absent (not explicitly configured) is left $null and NOT flagged -
    # same "cannot confirm -> don't over-claim" contract Test-TlsProtocolEnabled already uses,
    # since the real OS default varies by version and this toolkit never assumes worse than it
    # can prove.
    $lmLevel = & $regGet 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' 'LmCompatibilityLevel'
    $ntlmLegacyAllowed = ($null -ne $lmLevel) -and ([int]$lmLevel -lt 3)

    [pscustomobject]@{
        Smb1Enabled=$smb1; RdpEnabled=$rdpEnabled; NlaEnabled=$nla; RdpEnabledNoNla=($rdpEnabled -and -not $nla)
        DefenderEnabled=$defender; ThirdPartyAvPresent=$thirdAv; AnyAvOrEdrDetected=$anyAv
        UacEnabled=$uac; Tls10Enabled=$tls10; Tls11Enabled=$tls11; WinRmEnabled=$winrm
        LapsDetected=$laps; BitLockerAnyProtected=$bl; LocalAdminCount=$AdminCount
        GuestAccountEnabled=$guestEnabled; SmbServerSigningRequired=$smbSigningRequired
        LmCompatibilityLevel=$(if ($null -ne $lmLevel) { [int]$lmLevel } else { $null }); NtlmLegacyCompatibilityAllowed=$ntlmLegacyAllowed
    }
}

function Test-TlsProtocolEnabled {
    param([string]$Protocol)
    try {
        $base = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\$Protocol\Server"
        $en = Get-RegistryValueSafe -Path $base -Name 'Enabled'
        if ($null -ne $en) { return [bool]($en -ne 0) }
        # Not explicitly configured -> cannot confirm; report false (NotDetected) to avoid over-claiming.
        return $false
    } catch { return $false }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } }
    Add-DataSet -Context $Context -Name 'SecurityPosture'       -Description 'Security configuration snapshot (booleans + admin count).' -Rows (& $get 'SecurityPosture')       -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
    Add-DataSet -Context $Context -Name 'LocalUsers'            -Description 'Local user accounts (no hashes).'                        -Rows (& $get 'LocalUsers')            -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
    Add-DataSet -Context $Context -Name 'LocalGroups'           -Description 'Local groups.'                                          -Rows (& $get 'LocalGroups')           -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
    Add-DataSet -Context $Context -Name 'LocalGroupMembers'     -Description 'Members of sensitive local groups.'                     -Rows (& $get 'LocalGroupMembers')     -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
    Add-DataSet -Context $Context -Name 'FirewallProfiles'      -Description 'Windows Firewall profile states.'                       -Rows (& $get 'FirewallProfiles')      -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
    Add-DataSet -Context $Context -Name 'UserRightsAssignments' -Description 'User rights assignments (secedit export).'              -Rows (& $get 'UserRightsAssignments') -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
    Add-DataSet -Context $Context -Name 'LocalAccountsWithNonExpiringPasswords' -Description 'Enabled local accounts with a non-expiring password.' -Rows (& $get 'LocalAccountsWithNonExpiringPasswords') -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
    Add-DataSet -Context $Context -Name 'SensitiveUserRightsGrants' -Description 'Sensitive user rights (SeDebugPrivilege and similar) granted beyond the expected built-in accounts.' -Rows (& $get 'SensitiveUserRightsGrants') -Visibility 'Internal' -SourceModule 'SecurityPosture' | Out-Null
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','New-PostureRow','Test-TlsProtocolEnabled','Get-LocalAdministrators','Get-NonExpiringPasswordAccounts','Get-SensitiveUserRightsGrants'