modules/ConfigDependencyScan/ConfigDependencyScan.psm1

<#
    ConfigDependencyScan.psm1 - scan safe config files for hardcoded dependencies (read-only).
    Gated: runs only in Deep mode or with -IncludeConfigDependencyScan.
    Produces: ConfigDependencyHints, CriticalPaths. Redacts secrets; reports where, never the value.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='ConfigDependencyScan'; DisplayName='Config Dependency Scan'; Category='Applications'; Version='1.0.0'
        DefaultInFast=$false; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole=$null; EstimatedImpact='Medium'; CanRunAsSystem=$true
        ProducesDatasets=@('ConfigDependencyHints','CriticalPaths')
        ProducesRisks=$true; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$false
    }
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    $enabled = ($Context.Mode -eq 'Deep') -or ([bool]$Context.Parameters['IncludeConfigDependencyScan'])
    if ($enabled) { return [pscustomobject]@{ ModuleName='ConfigDependencyScan'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } }
    [pscustomobject]@{ ModuleName='ConfigDependencyScan'; CanRun=$false; Status='NotApplicable'; Reason='Enable with -IncludeConfigDependencyScan or Deep mode.'; Limitations=@() }
}

function Get-ConfigScanRoots {
    param([object]$Context)
    $roots = [System.Collections.Generic.List[string]]::new()
    foreach ($r in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData, 'C:\inetpub')) { if ($r -and (Test-Path -LiteralPath $r)) { [void]$roots.Add($r) } }
    # Install dirs discovered from apps/services
    try {
        if ($Context.DataSets.Contains('InstalledApplications')) {
            foreach ($a in @($Context.DataSets['InstalledApplications'].Rows)) { if ($a.InstallLocation -and (Test-Path -LiteralPath $a.InstallLocation)) { [void]$roots.Add($a.InstallLocation) } }
        }
    } catch { }
    return ,@($roots | Select-Object -Unique)
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    $hints=[System.Collections.Generic.List[object]]::new(); $critical=[System.Collections.Generic.List[object]]::new()
    $maxMB = [int]$Context.Parameters['ConfigScanMaxFileSizeMB']; if ($maxMB -le 0) { $maxMB = 10 }
    $maxBytes = $maxMB * 1MB
    $exts = @('.config','.ini','.json','.xml','.yml','.yaml','.properties','.udl','.dsn','.env','.bat','.cmd','.ps1','.vbs')
    $fileCap = 3000; $scanned = 0; $capped = $false
    $hintCap = 5000
    # Bound the recursion. Without -Depth, Get-ChildItem -Recurse walks entire install trees
    # before the loop even starts, so $fileCap (which limits files PARSED) could not stop the
    # expensive part. Config files of interest live near the top of an install directory.
    $scanDepth = 8
    $winRoot = ($env:SystemRoot); if (-not $winRoot) { $winRoot = 'C:\Windows' }
    $usersRoot = 'C:\Users'

    # IIS auto-backs up its whole config into a new C:\inetpub\history\CFGHISTORY_nnnnnnnnnn
    # folder on every change and keeps many of them - each one a near-complete duplicate of the
    # last, so scanning all of them just re-finds the same facts repeatedly (measured: ~19% of
    # all hints on a lab box with just 6 backups). Keep only the newest one; the folder name's
    # numeric suffix is IIS's own ordering.
    $historyRoot = 'C:\inetpub\history'
    $latestHistoryDir = ''
    if (Test-Path -LiteralPath $historyRoot) {
        try {
            $latest = Get-ChildItem -LiteralPath $historyRoot -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending | Select-Object -First 1
            if ($latest) { $latestHistoryDir = $latest.FullName.ToLowerInvariant() }
        } catch { }
    }

    $indicators = @(
        @{ Type='SqlServer'; Rx='(?i)(server|data source)\s*=' }, @{ Type='Database'; Rx='(?i)(initial catalog|database)\s*=' },
        @{ Type='ConnectionString'; Rx='(?i)connectionstring' }, @{ Type='JDBC'; Rx='(?i)jdbc:' }, @{ Type='ODBC'; Rx='(?i)\bodbc\b' },
        @{ Type='SMTP'; Rx='(?i)smtp' }, @{ Type='LDAP'; Rx='(?i)ldap://|ldaps://' }, @{ Type='HTTP'; Rx='(?i)https?://' },
        @{ Type='UNCPath'; Rx='\\\\[A-Za-z0-9._-]+\\[^\s"'']+' }, @{ Type='IPAddress'; Rx='\b(\d{1,3}\.){3}\d{1,3}\b' },
        @{ Type='Secret'; Rx='(?i)(password|secret|api[_ ]?key|token|user id|username|license)\s*[:=]' }, @{ Type='LicenseServer'; Rx='(?i)license.?server|lmgrd|flexlm|@\d' }
    )

    $roots = Get-ConfigScanRoots -Context $Context
    foreach ($root in $roots) {
        if ($capped) { break }
        try {
            $files = Get-ChildItem -LiteralPath $root -Recurse -Depth $scanDepth -File -ErrorAction SilentlyContinue | Where-Object { $exts -contains $_.Extension.ToLower() }
            foreach ($f in $files) {
                if ($scanned -ge $fileCap) { $capped = $true; break }
                try {
                    if ($f.Length -gt $maxBytes) { continue }
                    $lower = $f.FullName.ToLowerInvariant()
                    if ((-not [bool]$Context.Parameters['IncludeWindowsFolder']) -and $lower.StartsWith($winRoot.ToLowerInvariant())) { continue }
                    if ((-not [bool]$Context.Parameters['IncludeUserProfiles']) -and $lower.StartsWith($usersRoot.ToLowerInvariant())) { continue }
                    if ($lower.StartsWith(($historyRoot + '\').ToLowerInvariant()) -and (-not $lower.StartsWith($latestHistoryDir))) { continue }
                    $scanned++
                    $lines = Get-Content -LiteralPath $f.FullName -ErrorAction SilentlyContinue
                    $ln = 0
                    foreach ($line in $lines) {
                        $ln++
                        if ([string]::IsNullOrWhiteSpace($line)) { continue }
                        foreach ($ind in $indicators) {
                            if ($line -match $ind.Rx) {
                                $redacted = Redact-SensitiveValue -InputString ($line.Trim()) -Context $Context
                                if ($redacted.Length -gt 300) { $redacted = $redacted.Substring(0,300) }
                                $hints.Add([pscustomobject]@{ FilePath=$f.FullName; FileType=$f.Extension; IndicatorType=$ind.Type; RedactedLine=$redacted; RelatedApp=(Split-Path (Split-Path $f.FullName -Parent) -Leaf); Confidence='Likely'; LineNumber=$ln })
                                break
                            }
                        }
                        if ($hints.Count -ge $hintCap) { $capped = $true; break }
                    }
                } catch { }
                if ($capped) { break }   # hint cap reached - stop opening further files
            }
        } catch { Add-Limitation -Context $Context -Module 'ConfigDependencyScan' -Message ("Scan of '{0}' failed or partial." -f $root) -Reason $_.Exception.Message | Out-Null }
    }
    if ($capped) { Add-Limitation -Context $Context -Module 'ConfigDependencyScan' -Message ("Config scan stopped early (file cap {0}, hint cap {1}, max depth {2}); results may be incomplete." -f $fileCap, $hintCap, $scanDepth) -Impact 'Coverage capped' | Out-Null }

    # ---- Critical paths from datasets ----
    try {
        $addPath = {
            param($path,$src,$reason,$related,$impact)
            if ([string]::IsNullOrWhiteSpace($path)) { return }
            $exists = $false; try { $exists = Test-Path -LiteralPath $path } catch { }
            $critical.Add([pscustomobject]@{ Path=$path; Source=$src; ReasonItMatters=$reason; Exists=$exists; SizeIfSafe=''; RelatedServiceOrApp=$related; Confidence='Likely'; PotentialProjectImpact=$impact })
        }
        if ($Context.DataSets.Contains('Services')) { foreach ($s in @($Context.DataSets['Services'].Rows | Where-Object { $_.ExecutablePath })) { & $addPath $s.ExecutablePath 'Service' 'Service executable location' $s.Name 'Cutover Complexity' } }
        if ($Context.DataSets.Contains('SmbShares')) { foreach ($s in @($Context.DataSets['SmbShares'].Rows | Where-Object { $_.IsUserShare })) { & $addPath $s.Path 'SmbShare' 'Shared data location' $s.Name 'Data Migration' } }
        if ($Context.DataSets.Contains('IisSites')) { foreach ($s in @($Context.DataSets['IisSites'].Rows | Where-Object { $_.PhysicalPath })) { & $addPath $s.PhysicalPath 'IIS' 'Web content location' $s.Name 'Cutover Complexity' } }
    } catch { }

    return ,@{ ConfigDependencyHints=@($hints); CriticalPaths=@($critical) }
}

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 'ConfigDependencyHints' -Description 'Hardcoded dependencies found in config files (secrets redacted).' -Rows (& $get 'ConfigDependencyHints') -Visibility 'Internal' -SourceModule 'ConfigDependencyScan' | Out-Null
    Add-DataSet -Context $Context -Name 'CriticalPaths'         -Description 'Paths critical to app/service function.'                     -Rows (& $get 'CriticalPaths')         -Visibility 'Internal' -SourceModule 'ConfigDependencyScan' | Out-Null
    # Dependency edges from config hints: ONE aggregate edge per indicator type, not one edge
    # per hint. A busy server yields thousands of per-hint edges (5000 of 5156 edges = 97% of
    # the graph on the first real Deep run), all with empty Target, burying the actionable
    # Service/Process/Server edges. Per-file detail remains complete in ConfigDependencyHints.
    $byType = @{}
    foreach ($h in (& $get 'ConfigDependencyHints')) {
        $t = [string]$h.IndicatorType
        if (-not $byType.ContainsKey($t)) { $byType[$t] = @{ Count = 0; Files = @{} } }
        $byType[$t].Count++
        if ($h.FilePath) { $byType[$t].Files[[string]$h.FilePath] = $true }
    }
    foreach ($t in ($byType.Keys | Sort-Object)) {
        try { Add-DependencyEdge -Context $Context -SourceType 'ConfigFile' -SourceName ("{0} config file(s)" -f $byType[$t].Files.Count) -DependencyType $t -Target '' -Evidence ("{0} '{1}' hint(s) across {2} config file(s); per-file detail in the ConfigDependencyHints dataset." -f $byType[$t].Count, $t, $byType[$t].Files.Count) -Confidence 'Likely' -SourceDataset 'ConfigDependencyHints' -ProjectImpact 'Data Migration' -ValidationQuestion 'Do these dependencies still exist after migration, and who can update them?' | Out-Null } catch { }
    }
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Get-ConfigScanRoots'