modules/ServicesTasks/ServicesTasks.psm1

<#
    ServicesTasks.psm1 - Windows services and scheduled tasks (read-only).
    Produces: Services, ServiceDependencies, ScheduledTasks. Adds dependency edges.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='ServicesTasks'; DisplayName='Services and Scheduled Tasks'; Category='System'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('Services','ServiceDependencies','ScheduledTasks')
        ProducesRisks=$true; ProducesFollowUpQuestions=$true; SupportsDeepMode=$true; SupportsComplianceLens=$true
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    [pscustomobject]@{ ModuleName='ServicesTasks'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() }
}

function Get-ServiceExePath {
    param([string]$PathName)
    if ([string]::IsNullOrWhiteSpace($PathName)) { return '' }
    $p = $PathName.Trim()
    if ($p.StartsWith('"')) {
        $end = $p.IndexOf('"', 1)
        if ($end -gt 1) { return $p.Substring(1, $end - 1) }
    }
    # Unquoted: take up to the first token ending in .exe (case-insensitive), else first whitespace.
    $m = [regex]::Match($p, '^(.*?\.exe)\b', 'IgnoreCase')
    if ($m.Success) { return $m.Groups[1].Value }
    $sp = $p.IndexOf(' ')
    if ($sp -gt 0) { return $p.Substring(0, $sp) }
    return $p
}

function Test-DomainAccount {
    param([string]$Account)
    if ([string]::IsNullOrWhiteSpace($Account)) { return $false }
    $a = $Account.Trim()
    if ($a -notmatch '^[^\\@]+\\[^\\@]+$') {
        # Could be UPN form user@domain
        if ($a -match '^[^\\@]+@[^\\@]+$') { return $true }
        return $false
    }
    $domain = ($a -split '\\')[0]
    $wellKnown = @('NT AUTHORITY','NT SERVICE','BUILTIN','.', $env:COMPUTERNAME, 'LOCALSYSTEM','APPLICATION')
    if ($wellKnown -contains $domain) { return $false }
    return $true
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $services = [System.Collections.Generic.List[object]]::new()
    $deps = [System.Collections.Generic.List[object]]::new()
    $tasks = [System.Collections.Generic.List[object]]::new()
    $winRoot = $env:SystemRoot
    if (-not $winRoot) { $winRoot = 'C:\Windows' }

    # ---- Services ----
    try {
        $svc = Invoke-CimSafe -ClassName 'Win32_Service'
        foreach ($s in $svc) {
            try {
                $exe = Get-ServiceExePath -PathName $s.PathName
                $exists = $false
                if ($exe) { try { $exists = Test-Path -LiteralPath $exe } catch { $exists = $false } }
                $unquoted = $false
                if ($s.PathName -and ($s.PathName.Trim() -notmatch '^\s*"') -and ($exe -match '\s') ) { $unquoted = $true }
                $underWin = ($exe -and $exe.ToLowerInvariant().StartsWith($winRoot.ToLowerInvariant()))
                $auto = ($s.StartMode -eq 'Auto' -or $s.StartMode -eq 'Automatic')
                $nonMsAuto = ($auto -and $exe -and -not $underWin)
                $inProfile = ([bool]($exe -match '(?i)\\Users\\'))
                $onNetwork = ([bool]($exe -match '^\\\\'))
                $services.Add([pscustomobject]@{
                    Name=$s.Name; DisplayName=$s.DisplayName; Status=$s.State; StartMode=$s.StartMode
                    StartName=$s.StartName; PathName=(Redact-SensitiveValue -InputString $s.PathName -Context $Context)
                    Description=$s.Description; ServiceType=$s.ServiceType; ProcessId=$s.ProcessId
                    ExecutablePath=$exe; PathExists=$exists; UnquotedPathWithSpaces=$unquoted; IsNonMicrosoftAutoStart=$nonMsAuto
                    RunsFromUserProfile=$inProfile; RunsFromNetworkPath=$onNetwork
                })
            } catch { }
        }
    } catch { Add-Limitation -Context $Context -Module 'ServicesTasks' -Message 'Service enumeration failed.' -Reason $_.Exception.Message | Out-Null }

    # ---- Service dependencies (best-effort via Get-Service) ----
    try {
        foreach ($g in (Get-Service -ErrorAction SilentlyContinue)) {
            try {
                foreach ($d in @($g.ServicesDependedOn)) { $deps.Add([pscustomobject]@{ Service=$g.Name; DependsOn=$d.Name; Direction='DependsOn' }) }
            } catch { }
        }
    } catch { }

    # ---- Scheduled tasks ----
    if (Get-CommandAvailable -Name 'Get-ScheduledTask') {
        try {
            foreach ($t in (Get-ScheduledTask -ErrorAction SilentlyContinue)) {
                try {
                    $info = $null
                    try { $info = Get-ScheduledTaskInfo -TaskName $t.TaskName -TaskPath $t.TaskPath -ErrorAction SilentlyContinue } catch { }
                    $actionsText = ''
                    try {
                        $parts = foreach ($a in @($t.Actions)) { (@($a.Execute, $a.Arguments) | Where-Object { $_ }) -join ' ' }
                        $actionsText = ($parts -join ' | ')
                    } catch { }
                    $actionsText = Redact-SensitiveValue -InputString $actionsText -Context $Context
                    $principal = ''
                    try { $principal = if ($t.Principal.UserId) { $t.Principal.UserId } else { $t.Principal.GroupId } } catch { }
                    $ltr = if ($info) { $info.LastTaskResult } else { $null }
                    $failed = $false
                    if ($null -ne $ltr) { $failed = (($ltr -ne 0) -and ($ltr -ne 267011) -and ($ltr -ne 267009)) }
                    $tasks.Add([pscustomobject]@{
                        TaskName=$t.TaskName; TaskPath=$t.TaskPath; State=[string]$t.State; Principal=$principal
                        RunLevel=[string]$t.Principal.RunLevel; ActionsText=$actionsText
                        LastTaskResult=$ltr; LastRunTime=(Normalize-DateTime ($info.LastRunTime)); NextRunTime=(Normalize-DateTime ($info.NextRunTime))
                        UsesUncPath=([bool]($actionsText -match '\\\\[^\\]')); LastRunFailed=$failed
                        RunsAsDomainAccount=(Test-DomainAccount -Account $principal)
                        RunsScript=([bool]($actionsText -match '(?i)\.(ps1|bat|cmd|vbs|py|js|jar)\b|powershell|cscript|wscript|python|java')); PerformsBackupExportImport=([bool]($actionsText -match '(?i)backup|export|import|robocopy|\bbcp\b|sqlcmd|\bdump\b'))
                    })
                } catch { }
            }
        } catch { Add-Limitation -Context $Context -Module 'ServicesTasks' -Message 'Scheduled task enumeration failed.' -Reason $_.Exception.Message | Out-Null }
    } else {
        # Fallback: schtasks.exe (READ-ONLY query)
        try {
            $r = Invoke-CommandLineSafe -FilePath 'schtasks.exe' -Arguments @('/query','/fo','CSV','/v') -TimeoutSeconds 90
            if ($r.Succeeded -and $r.StdOut) {
                $csv = $r.StdOut | ConvertFrom-Csv
                foreach ($row in $csv) {
                    if ($row.TaskName -and $row.TaskName -ne 'TaskName') {
                        $actionsText = Redact-SensitiveValue -InputString ([string]$row.'Task To Run') -Context $Context
                        $principal = [string]$row.'Run As User'
                        $tasks.Add([pscustomobject]@{
                            TaskName=$row.TaskName; TaskPath=''; State=[string]$row.Status; Principal=$principal
                            RunLevel=''; ActionsText=$actionsText; LastTaskResult=$row.'Last Result'
                            LastRunTime=$row.'Last Run Time'; NextRunTime=$row.'Next Run Time'
                            UsesUncPath=([bool]($actionsText -match '\\\\[^\\]')); LastRunFailed=$false
                            RunsAsDomainAccount=(Test-DomainAccount -Account $principal)
                            RunsScript=([bool]($actionsText -match '(?i)\.(ps1|bat|cmd|vbs|py|js|jar)\b|powershell|cscript|wscript|python|java')); PerformsBackupExportImport=([bool]($actionsText -match '(?i)backup|export|import|robocopy|\bbcp\b|sqlcmd|\bdump\b'))
                        })
                    }
                }
            } else { Add-Limitation -Context $Context -Module 'ServicesTasks' -Message 'schtasks fallback did not return data.' | Out-Null }
        } catch { Add-Limitation -Context $Context -Module 'ServicesTasks' -Message 'Scheduled task fallback failed.' -Reason $_.Exception.Message | Out-Null }
    }

    return ,@{ Services=@($services); ServiceDependencies=@($deps); ScheduledTasks=@($tasks) }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $svc = @(if ($RawData.Services) { @($RawData.Services) } else { @() })
    $deps = @(if ($RawData.ServiceDependencies) { @($RawData.ServiceDependencies) } else { @() })
    $tasks = @(if ($RawData.ScheduledTasks) { @($RawData.ScheduledTasks) } else { @() })
    Add-DataSet -Context $Context -Name 'Services' -Description 'Windows services with logon accounts, paths, and heuristics.' -Rows $svc -Visibility 'Internal' -SourceModule 'ServicesTasks' | Out-Null
    Add-DataSet -Context $Context -Name 'ServiceDependencies' -Description 'Service dependency relationships.' -Rows $deps -Visibility 'Internal' -SourceModule 'ServicesTasks' | Out-Null
    Add-DataSet -Context $Context -Name 'ScheduledTasks' -Description 'Scheduled tasks with principals, actions (redacted), and results.' -Rows $tasks -Visibility 'Internal' -SourceModule 'ServicesTasks' | Out-Null

    # Dependency edges
    foreach ($s in $svc) {
        try {
            if (Test-DomainAccount -Account $s.StartName) {
                Add-DependencyEdge -Context $Context -SourceType 'Service' -SourceName $s.Name -DependencyType 'RunsAs' -Target $s.StartName -Evidence 'Service logon account' -Confidence 'Confirmed' -SourceDataset 'Services' -ProjectImpact 'Cutover Complexity' -ValidationQuestion ("Who owns the service account {0}?" -f $s.StartName) | Out-Null
            }
        } catch { }
    }
    foreach ($t in $tasks) {
        try {
            if ($t.UsesUncPath) {
                Add-DependencyEdge -Context $Context -SourceType 'ScheduledTask' -SourceName $t.TaskName -DependencyType 'UsesPath' -Target 'UNC path' -Evidence $t.ActionsText -Confidence 'Confirmed' -SourceDataset 'ScheduledTasks' -ProjectImpact 'Data Migration' -ValidationQuestion 'Does this UNC path dependency still exist after migration?' | Out-Null
            }
        } catch { }
    }
}

function Get-DiscoveryFollowUpQuestions {
    param([object]$Context)
    # RiskEngine covers most; add one summarizing question if domain-account services exist.
    try {
        if ($Context.DataSets.Contains('Services')) {
            $domSvc = @($Context.DataSets['Services'].Rows | Where-Object { Test-DomainAccount -Account $_.StartName })
            if ($domSvc.Count -gt 0) {
                Add-FollowUpQuestion -Context $Context -Category 'Service accounts' -Module 'ServicesTasks' -Audience 'Both' -Question 'For each service running under a domain account, who owns the account and where is its password/gMSA configuration documented?' | Out-Null
            }
        }
    } catch { }
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-DiscoveryFollowUpQuestions','Get-ServiceExePath','Test-DomainAccount'