modules/IIS/IIS.psm1

<#
    IIS.psm1 - IIS sites, bindings, app pools, applications, virtual directories (read-only).
    Role-gated. Produces: IisSites, IisBindings, IisAppPools, IisApplications, IisVirtualDirectories.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='IIS'; DisplayName='IIS / Web Server'; Category='Web'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole='Web-Server'; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('IisSites','IisBindings','IisAppPools','IisApplications','IisVirtualDirectories')
        ProducesRisks=$true; ProducesFollowUpQuestions=$true; SupportsDeepMode=$true; SupportsComplianceLens=$true
    }
}

function Get-IisAppCmdPath { $p = Join-Path $env:windir 'system32\inetsrv\appcmd.exe'; if (Test-Path $p) { return $p } return $null }

function Test-IisPresent {
    if (Get-Service -Name 'W3SVC' -ErrorAction SilentlyContinue) { return $true }
    if (Get-IisAppCmdPath) { return $true }
    if (Get-ModuleAvailable -Name 'WebAdministration') { return $true }
    return $false
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    if (Test-IisPresent) { return [pscustomobject]@{ ModuleName='IIS'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } }
    [pscustomobject]@{ ModuleName='IIS'; CanRun=$false; Status='NotApplicable'; Reason='IIS (W3SVC / appcmd / WebAdministration) not present.'; Limitations=@() }
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $sites=[System.Collections.Generic.List[object]]::new(); $bindings=[System.Collections.Generic.List[object]]::new()
    $pools=[System.Collections.Generic.List[object]]::new(); $apps=[System.Collections.Generic.List[object]]::new(); $vdirs=[System.Collections.Generic.List[object]]::new()

    $useWA = (Get-ModuleAvailable -Name 'WebAdministration')
    if ($useWA) { try { Import-Module WebAdministration -ErrorAction SilentlyContinue } catch { $useWA=$false } }

    if ($useWA -and (Get-CommandAvailable -Name 'Get-Website')) {
        try {
            foreach ($s in (Get-Website -ErrorAction SilentlyContinue)) {
                $bindText = ''
                try { $bindText = (($s.bindings.Collection | ForEach-Object { $_.protocol + ' ' + $_.bindingInformation }) -join '; ') } catch { }
                $sites.Add([pscustomobject]@{ Name=$s.Name; State=[string]$s.State; Bindings=$bindText; PhysicalPath=$s.physicalPath; ApplicationPool=$s.applicationPool })
                try {
                    foreach ($b in $s.bindings.Collection) {
                        $thumb = ''
                        try { if ($b.certificateHash) { $thumb = ($b.certificateHash) } } catch { }
                        $bindings.Add([pscustomobject]@{ Site=$s.Name; Protocol=$b.protocol; BindingInformation=$b.bindingInformation; CertificateHash=$thumb })
                    }
                } catch { }
                if ($s.physicalPath) { Add-DependencyEdge -Context $Context -SourceType 'IIS Site' -SourceName $s.Name -DependencyType 'UsesPath' -Target $s.physicalPath -Evidence 'Site physical path' -Confidence 'Confirmed' -SourceDataset 'IisSites' -ProjectImpact 'Cutover Complexity' -ValidationQuestion 'Does this physical path move with the site?' | Out-Null }
            }
        } catch { Add-Limitation -Context $Context -Module 'IIS' -Message 'Get-Website failed; site enumeration may require elevation.' -Reason $_.Exception.Message | Out-Null }
        try {
            foreach ($ap in (Get-ChildItem IIS:\AppPools -ErrorAction SilentlyContinue)) {
                $idType=''; $user=''
                try { $idType=[string]$ap.processModel.identityType; $user=[string]$ap.processModel.userName } catch { }
                $custom = ($idType -eq 'SpecificUser' -or ($user -and (Test-IisIdentityIsDomainAccount $user)))
                $pools.Add([pscustomobject]@{ Name=$ap.Name; ManagedRuntimeVersion=[string]$ap.managedRuntimeVersion; ManagedPipelineMode=[string]$ap.managedPipelineMode; IdentityType=$idType; UserName=$user; UsesCustomIdentity=$custom; IsLegacyDotNet=([bool]([string]$ap.managedRuntimeVersion -match '^v[12]\.')) })
                if ($custom -and $user) { Add-DependencyEdge -Context $Context -SourceType 'IIS AppPool' -SourceName $ap.Name -DependencyType 'RunsAs' -Target $user -Evidence 'App pool identity' -Confidence 'Confirmed' -SourceDataset 'IisAppPools' -ProjectImpact 'Cutover Complexity' -ValidationQuestion 'Who owns this app pool identity?' | Out-Null }
            }
        } catch { }
        try { foreach ($a in (Get-WebApplication -ErrorAction SilentlyContinue)) { $apps.Add([pscustomobject]@{ Site=$a.GetParentElement().Attributes['name'].Value; Path=$a.path; AppPool=$a.applicationPool; PhysicalPath=$a.PhysicalPath }) } } catch { }
        try { foreach ($v in (Get-WebVirtualDirectory -ErrorAction SilentlyContinue)) { $vdirs.Add([pscustomobject]@{ Path=$v.path; PhysicalPath=$v.physicalPath }) } } catch { }
    } else {
        # appcmd text parse (READ-ONLY list)
        $appcmd = Get-IisAppCmdPath
        if ($appcmd) {
            try {
                $r = Invoke-CommandLineSafe -FilePath $appcmd -Arguments @('list','site') -TimeoutSeconds 45
                foreach ($ln in ($r.StdOut -split "`r?`n")) {
                    # Non-greedy up to ",state:" - a site can have several comma-separated
                    # protocol/bindingInformation tokens before state (e.g. http and https).
                    $m = [regex]::Match($ln, 'SITE\s+"([^"]+)"\s+\(id:(\d+),bindings:(.+?),state:(\w+)')
                    if ($m.Success) {
                        $siteName = $m.Groups[1].Value
                        $bindText = $m.Groups[3].Value
                        $sites.Add([pscustomobject]@{ Name=$siteName; State=$m.Groups[4].Value; Bindings=$bindText; PhysicalPath=''; ApplicationPool='' })
                        foreach ($tok in ($bindText -split ',')) {
                            $slash = $tok.IndexOf('/')
                            if ($slash -gt 0) {
                                $bindings.Add([pscustomobject]@{ Site=$siteName; Protocol=$tok.Substring(0,$slash); BindingInformation=$tok.Substring($slash+1); CertificateHash='' })
                            }
                        }
                    }
                }
                $rp = Invoke-CommandLineSafe -FilePath $appcmd -Arguments @('list','apppool') -TimeoutSeconds 45
                foreach ($ln in ($rp.StdOut -split "`r?`n")) {
                    $m = [regex]::Match($ln, 'APPPOOL\s+"([^"]+)"\s+\(MgdVersion:([^,]*),MgdMode:([^,]*),state:(\w+)')
                    if ($m.Success) { $pools.Add([pscustomobject]@{ Name=$m.Groups[1].Value; ManagedRuntimeVersion=$m.Groups[2].Value; ManagedPipelineMode=$m.Groups[3].Value; IdentityType=''; UserName=''; UsesCustomIdentity=$false; IsLegacyDotNet=([bool]($m.Groups[2].Value -match '^v[12]\.')) }) }
                }
            } catch { Add-Limitation -Context $Context -Module 'IIS' -Message 'appcmd parse failed.' -Reason $_.Exception.Message | Out-Null }
        } else { Add-Limitation -Context $Context -Module 'IIS' -Message 'No IIS management tooling available to enumerate sites.' | Out-Null }
    }

    return ,@{ IisSites=@($sites); IisBindings=@($bindings); IisAppPools=@($pools); IisApplications=@($apps); IisVirtualDirectories=@($vdirs) }
}

# True when an app-pool identity looks like DOMAIN\user rather than a built-in account.
function Test-IisIdentityIsDomainAccount { param([string]$acct) if ([string]::IsNullOrWhiteSpace($acct)) { return $false }; return ($acct -match '^[^\\@]+\\[^\\@]+$' -and $acct -notmatch '^(NT AUTHORITY|NT SERVICE|BUILTIN|IIS APPPOOL)\\') }

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 'IisSites'              -Description 'IIS websites.'            -Rows (& $get 'IisSites')              -Visibility 'Internal' -SourceModule 'IIS' | Out-Null
    Add-DataSet -Context $Context -Name 'IisBindings'           -Description 'IIS bindings + cert hashes.'-Rows (& $get 'IisBindings')          -Visibility 'Internal' -SourceModule 'IIS' | Out-Null
    Add-DataSet -Context $Context -Name 'IisAppPools'           -Description 'IIS application pools.'    -Rows (& $get 'IisAppPools')           -Visibility 'Internal' -SourceModule 'IIS' | Out-Null
    Add-DataSet -Context $Context -Name 'IisApplications'       -Description 'IIS applications.'         -Rows (& $get 'IisApplications')       -Visibility 'Internal' -SourceModule 'IIS' | Out-Null
    Add-DataSet -Context $Context -Name 'IisVirtualDirectories' -Description 'IIS virtual directories.'  -Rows (& $get 'IisVirtualDirectories') -Visibility 'Internal' -SourceModule 'IIS' | Out-Null
}

function Get-DiscoveryFollowUpQuestions {
    param([object]$Context)
    try { if ($Context.DataSets.Contains('IisSites') -and @($Context.DataSets['IisSites'].Rows).Count -gt 0) { Add-FollowUpQuestion -Context $Context -Category 'Applications' -Module 'IIS' -Audience 'Both' -Question 'Who owns each IIS-hosted web application, and are its certificate, host-header, and connection-string dependencies documented?' | Out-Null } } catch { }
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-DiscoveryFollowUpQuestions','Test-IisPresent','Get-IisAppCmdPath','Test-IisIdentityIsDomainAccount'