# Tanium Sensor App Config Dot Net Map

```powershell
# Tanium Sensor: App Config Dot Net Runtime
# Returns tab-delimited rows: ConfigFile | AppName | RuntimeVersion | RuntimeSKU | Source | Publisher
# Deduped via HashSet — each config file parsed exactly once
#
# NOTE: Configure this sensor's Result Type as "Multiple Text Columns" (or an
# equivalent Column Set) with 6 columns in this order: ConfigFile, AppName,
# RuntimeVersion, RuntimeSKU, Source, Publisher — otherwise the tab-delimited
# row will render as a single blob column instead of sortable fields.
#
# Publisher is read from the embedded Authenticode signer on the companion
# .exe (only applies to *.exe.config rows — web.config/app.config rows without
# a 1:1 exe have no single binary to check and are left blank). This is a
# fast local identity read (no chain validation, no revocation check, no
# network call) — a triage heuristic, not a verified trust decision. Values:
#   "Microsoft"          - signer CN contains Microsoft; likely patchable via
#                          Windows/SQL Update
#   "<Signer CN>"        - signed by a named third-party vendor; needs a
#                          vendor upgrade
#   "Unsigned/Unknown"   - no embedded signature found; likely custom/internal app
#
# In addition to app-declared rows, this sensor also emits HOST-level rows
# (ConfigFile = "HOST") reporting what's actually installed on the machine —
# .NET Framework 4.x/3.5 (registry) and every installed .NET Core/5+ and
# ASP.NET Core shared runtime (filesystem). Source for these rows is prefixed
# "InstalledRuntime:" so they can be filtered separately from app-declared
# rows, and are what you compare app declarations against to find the actual
# gap. Because these rows are always present, the "No runtime declarations
# found" fallback below will now rarely fire — check Source for
# "InstalledRuntime:" vs everything else, not $results.Count, to know whether
# any app-level declarations were found.

# --- Debug toggle -----------------------------------------------------------
# Flip to $true for manual/console testing to see elapsed time and file count
# on stderr (does not pollute the Write-Output stream Tanium parses as
# results). Leave $false for production deployment.
$Debug = $false
# -----------------------------------------------------------------------------

$results        = [System.Collections.Generic.List[string]]::new()
$processedFiles = [System.Collections.Generic.HashSet[string]]::new(
                    [System.StringComparer]::OrdinalIgnoreCase)
$publisherCache = [System.Collections.Generic.Dictionary[string,string]]::new(
                    [System.StringComparer]::OrdinalIgnoreCase)
$sw             = [System.Diagnostics.Stopwatch]::StartNew()
$timeCap        = 25    # seconds — exits cleanly before sensor timeout
$maxHits        = 500

$searchRoots = @(
    "$env:SystemDrive\inetpub",
    "$env:ProgramFiles",
    "${env:ProgramFiles(x86)}",
    "$env:SystemDrive\apps",
    "$env:SystemDrive\websites",
    "$env:SystemDrive\services"
) | Where-Object { $_ -and (Test-Path $_) }

$skipPattern    = '\\(' + (@(
    # OS / runtime internals
    'Windows'
    'Microsoft\.NET'
    'dotnet\\host'
    '\$Recycle'
    'Tanium'
    'node_modules'
    '\.git'
    # Dev tooling — not a deployed app dependency
    'Microsoft SQL Server Management Studio[^\\]*'
    'Microsoft Visual Studio\\Installer'
    'Microsoft Visual Studio\\Shared'
    'Windows Kits'
    # SQL Server setup/installer payloads — cached installers, not the running engine
    'Setup Bootstrap\\Update Cache'
    'Setup Bootstrap\\SQL\d+'
    # SCCM site-server staging internals — not live services
    'EasySetupPayload'
    'CMUStaging'
    'cd\.latest'
) -join '|') + ')\\'
$configExtensions = @('.config')   # covers web.config, app.config, *.exe.config
$configFileNames  = @('web.config', 'app.config')

# --- Safe, non-lazy recursive walk -----------------------------------------
# Directory.EnumerateFiles()/EnumerateDirectories() are lazy: exceptions
# (e.g. UnauthorizedAccessException on a locked-down ACL folder) are thrown
# mid-foreach, outside any try/catch wrapped around the initial call. That
# silently aborts the entire enumeration for that root. GetFiles()/
# GetDirectories() are eager — wrapping each call per-directory lets us
# prune a single bad branch instead of losing the whole tree.
function Get-ConfigFilesSafe {
    param(
        [string]$Root,
        [string]$SkipPattern,
        [System.Diagnostics.Stopwatch]$Stopwatch,
        [int]$TimeCapSeconds
    )

    $stack = [System.Collections.Generic.Stack[string]]::new()
    $stack.Push($Root)

    while ($stack.Count -gt 0) {
        if ($Stopwatch.Elapsed.TotalSeconds -ge $TimeCapSeconds) { return }

        $dir = $stack.Pop()

        # Queue subdirectories (skip known noise up front to save I/O)
        try {
            $subDirs = [System.IO.Directory]::GetDirectories($dir)
        } catch { $subDirs = @() }

        foreach ($d in $subDirs) {
            if ($d -notmatch $SkipPattern) { $stack.Push($d) }
        }

        # Get candidate files in this directory only
        try {
            $files = [System.IO.Directory]::GetFiles($dir, '*.config')
        } catch { continue }

        foreach ($f in $files) {
            if ($f -match $SkipPattern) { continue }

            $name = [System.IO.Path]::GetFileName($f)
            $isMatch =
                $configFileNames -contains $name.ToLowerInvariant() -or
                $f.EndsWith('.exe.config', [System.StringComparison]::OrdinalIgnoreCase)

            if ($isMatch) { $f }   # yield
        }
    }
}
# -----------------------------------------------------------------------------

# --- Publisher tagging -------------------------------------------------------
# Only meaningful for *.exe.config (the companion .exe is a real signable
# binary). web.config/app.config sit next to a whole app folder, not a single
# exe, so there's nothing specific to check the signature of.
#
# Uses X509Certificate::CreateFromSignedFile() rather than
# Get-AuthenticodeSignature. That cmdlet's chain validation can perform an
# online CRL/OCSP revocation check per file, which can hang for several
# seconds with no bound this script controls, on a host with no outbound
# access. CreateFromSignedFile reads the embedded signer certificate straight
# off disk — no network call, no chain build, no revocation check, so the
# time cap stays enforceable. This is a fast identity heuristic, not a
# validated trust decision — fine for inventory/triage, not a security check.
function Get-PublisherTag {
    param(
        [string]$ConfigPath,
        [System.Collections.Generic.Dictionary[string,string]]$Cache
    )

    if ($ConfigPath -notmatch '\.exe\.config$') { return '' }

    $exePath = $ConfigPath.Substring(0, $ConfigPath.Length - '.config'.Length)

    if ($Cache.ContainsKey($exePath)) { return $Cache[$exePath] }

    $tag = 'Unsigned/Unknown'
    try {
        if (Test-Path -LiteralPath $exePath) {
            $cert = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($exePath)
            if ($cert -and $cert.Subject -match 'CN=("[^"]+"|[^,]+)') {
                $cn  = $matches[1].Trim('"')
                $tag = if ($cn -match 'Microsoft') { 'Microsoft' } else { $cn }
            }
        }
    } catch {
        # CreateFromSignedFile throws on unsigned files — leave as Unsigned/Unknown
    }

    $Cache[$exePath] = $tag
    return $tag
}
# -----------------------------------------------------------------------------

# --- Installed runtime inventory ---------------------------------------------
# Reports what's actually installed on the host, so results can be compared
# against what the app-config scan says apps *declare* they need — that
# comparison is the actual dependency-gap question for remediation.
#
# Deliberately filesystem/registry-only, no process spawn (e.g. no
# `dotnet --list-runtimes`) — avoids process-creation overhead and keeps this
# section fast and reliable regardless of PATH state on the host.
#
# Emits rows in the same 6-column shape, tagged via Source so they're
# filterable separately from app-declared rows:
#   Source = InstalledRuntime:NETFramework
#   Source = InstalledRuntime:Microsoft.NETCore.App        (.NET Core/5+ runtime)
#   Source = InstalledRuntime:Microsoft.AspNetCore.App      (ASP.NET Core runtime)
#   Source = InstalledRuntime:Microsoft.WindowsDesktop.App  (WPF/WinForms runtime, if present)
function Add-InstalledRuntimeRows {
    param([System.Collections.Generic.List[string]]$ResultsList)

    # .NET Framework 4.x — single latest version per machine, keyed off the
    # documented Release DWORD (see Microsoft's "How to: Determine which
    # .NET Framework versions are installed" release-id table).
    try {
        $ndpKey = 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full'
        if (Test-Path $ndpKey) {
            $release  = (Get-ItemProperty -Path $ndpKey -Name Release -ErrorAction Stop).Release
            $fxVersion = if     ($release -ge 533320) { '4.8.1' }
                         elseif ($release -ge 528040) { '4.8'   }
                         elseif ($release -ge 461808) { '4.7.2' }
                         elseif ($release -ge 461308) { '4.7.1' }
                         elseif ($release -ge 460798) { '4.7'   }
                         elseif ($release -ge 394802) { '4.6.2' }
                         elseif ($release -ge 394254) { '4.6.1' }
                         elseif ($release -ge 393295) { '4.6'   }
                         elseif ($release -ge 379893) { '4.5.2' }
                         elseif ($release -ge 378675) { '4.5.1' }
                         elseif ($release -ge 378389) { '4.5'   }
                         else                          { "Unknown" }
            $ResultsList.Add("HOST`t`t$fxVersion`tRelease=$release`tInstalledRuntime:NETFramework`tMicrosoft")
        }
    } catch { }

    # .NET Framework 3.5 (and by extension 2.0/3.0) — legacy apps still
    # sometimes require this as a separate, independently-installed feature.
    try {
        $legacyKey = 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5'
        if (Test-Path $legacyKey) {
            $installed = (Get-ItemProperty -Path $legacyKey -Name Install -ErrorAction SilentlyContinue).Install
            if ($installed -eq 1) {
                $ResultsList.Add("HOST`t`t3.5`t`tInstalledRuntime:NETFramework`tMicrosoft")
            }
        }
    } catch { }

    # .NET Core / .NET 5+ shared runtimes — every installed runtime version
    # lives as its own folder under dotnet\shared\<RuntimeName>\<Version>.
    $dotnetRoots = @(
        "$env:ProgramFiles\dotnet\shared",
        "${env:ProgramFiles(x86)}\dotnet\shared"
    ) | Where-Object { $_ -and (Test-Path $_) }

    foreach ($sharedRoot in $dotnetRoots) {
        try {
            $runtimeDirs = [System.IO.Directory]::GetDirectories($sharedRoot)
        } catch { continue }

        foreach ($runtimeDir in $runtimeDirs) {
            $runtimeName = [System.IO.Path]::GetFileName($runtimeDir)

            try {
                $versionDirs = [System.IO.Directory]::GetDirectories($runtimeDir)
            } catch { continue }

            foreach ($vDir in $versionDirs) {
                $ver = [System.IO.Path]::GetFileName($vDir)
                $ResultsList.Add("HOST`t`t$ver`t`tInstalledRuntime:$runtimeName`tMicrosoft")
            }
        }
    }
}
# -----------------------------------------------------------------------------

:rootLoop foreach ($root in $searchRoots) {
    if ($sw.Elapsed.TotalSeconds -ge $timeCap -or $results.Count -ge $maxHits) { break }

    foreach ($fp in (Get-ConfigFilesSafe -Root $root -SkipPattern $skipPattern -Stopwatch $sw -TimeCapSeconds $timeCap)) {

        if ($sw.Elapsed.TotalSeconds -ge $timeCap) { break rootLoop }
        if (-not $processedFiles.Add($fp))         { continue }   # dedup

        try {
            [xml]$xml = Get-Content -LiteralPath $fp -Raw -ErrorAction Stop
        } catch { continue }

        $app       = [System.IO.Path]::GetFileNameWithoutExtension($fp) -replace '\.exe$|\.dll$', ''
        $publisher = Get-PublisherTag -ConfigPath $fp -Cache $publisherCache

        # <startup><supportedRuntime version="" sku="" />
        $xml.SelectNodes('//startup/supportedRuntime') | ForEach-Object {
            $v = $_.GetAttribute('version'); $s = $_.GetAttribute('sku')
            if ($v -or $s) { $results.Add("$fp`t$app`t$v`t$s`tsupportedRuntime`t$publisher") }
        }

        # <runtime><supportedRuntime> (alternate placement)
        $xml.SelectNodes('//runtime/supportedRuntime') | ForEach-Object {
            $v = $_.GetAttribute('version'); $s = $_.GetAttribute('sku')
            if ($v -or $s) { $results.Add("$fp`t$app`t$v`t$s`tsupportedRuntime(runtime)`t$publisher") }
        }

        # SDK-style: <TargetFramework>net6.0</TargetFramework>
        $xml.SelectNodes('//*[local-name()="TargetFramework" or local-name()="TargetFrameworks"]') |
        ForEach-Object {
            $tf = $_.InnerText.Trim()
            if ($tf) { $results.Add("$fp`t$app`t$tf`t`tTargetFramework`t$publisher") }
        }

        # web.config: <compilation targetFramework="4.7.2">
        $xml.SelectNodes('//compilation[@targetFramework]') | ForEach-Object {
            $tf = $_.GetAttribute('targetFramework')
            if ($tf) { $results.Add("$fp`t$app`t$tf`t`tweb.config/compilation`t$publisher") }
        }

        # web.config: <httpRuntime targetFramework="4.7.2">
        $xml.SelectNodes('//httpRuntime[@targetFramework]') | ForEach-Object {
            $tf = $_.GetAttribute('targetFramework')
            if ($tf) { $results.Add("$fp`t$app`t$tf`t`tweb.config/httpRuntime`t$publisher") }
        }

        if ($results.Count -ge $maxHits) { break rootLoop }
    }
}

# Host installed-runtime inventory — run regardless of whether the app-config
# scan above hit its time cap or maxHits, since this section is a handful of
# registry reads and directory listings (negligible cost) and is essential
# context even on a partial config scan.
Add-InstalledRuntimeRows -ResultsList $results

if ($Debug) {
    [Console]::Error.WriteLine(
        "[DEBUG] Elapsed: $([math]::Round($sw.Elapsed.TotalSeconds, 2))s | " +
        "Files processed: $($processedFiles.Count) | " +
        "Hits: $($results.Count) | " +
        "TimeCapHit: $($sw.Elapsed.TotalSeconds -ge $timeCap) | " +
        "MaxHitsHit: $($results.Count -ge $maxHits)"
    )
}

if ($results.Count -eq 0) {
    Write-Output "No runtime declarations found`t`t`t`t`t"
} else {
    $results | ForEach-Object { Write-Output $_ }
}
```