Skip to content

.NET App Dependencies (Sensor)

This page shows how to create the .NET App Dependencies sensor in the Tanium Console, and how to use it to find out which apps use each .NET runtime before you remove or upgrade it.

What it returns: one row per app and per .NET framework the app asks for: the framework, the version the app asks for, the rule the app uses to pick a version, the installed version it would actually load, where that copy of .NET comes from, the app's architecture, and where the app was found (IIS site, Windows service, app, or developer tool). Machines with no .NET apps return a single None found row.

It covers .NET Core 3.1 and .NET 5 and later (including ASP.NET Core and the Windows Desktop runtime). It does not cover .NET Framework 4.x, which is part of Windows and is fixed by Windows updates.

Test status

Run through Tanium on Windows 11 and Windows Server lab endpoints: machines without .NET apps, desktop apps that carry their own .NET, a product that ships a private .NET runtime (SQL Server Management Studio 21), an ASP.NET Core site in IIS outside C:\inetpub, two Windows services (one started from its own .exe, one through dotnet.exe), and an app pinned to a version that is not installed. See Test results.


How the sensor works

Every .NET app (Core 3.1, 5 and later) ships a small file next to its program called <app>.runtimeconfig.json. It says which .NET framework the app needs, which version, and how strict the app is about that version. The sensor finds those files, reads them, and works out which installed copy of .NET the app would really load.

  1. Lists the installed .NET runtimes. It reads the install folder from HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\<x64 or x86>\InstallLocation (usually C:\Program Files\dotnet) and lists every version under its shared folder. Version folders left behind by an uninstall (no .dll files inside) are ignored.
  2. Finds the apps. It looks for *.runtimeconfig.json in this order, so the most important places are covered even if time runs out:
    1. IIS site and application folders, from C:\Windows\System32\inetsrv\config\applicationHost.config
    2. The folder of every Windows service program, from HKLM\SYSTEM\CurrentControlSet\Services\<service>\ImagePath. For a service started as dotnet.exe <app>.dll, it uses the folder of the .dll
    3. C:\Program Files, C:\Program Files (x86), C:\ProgramData (except C:\ProgramData\Microsoft), and C:\inetpub, 5 folder levels deep
    4. Each user profile's AppData\Local\Programs and .dotnet\tools
    5. Other fixed drives such as D:\, 4 folder levels deep
  3. Works out which .NET each app loads. It applies the same rules .NET uses at startup (see Roll forward). If no installed shared runtime fits, it looks in the app's own product folder for a private copy of .NET (a folder with host\fxr and shared inside). Some products ship one and point their own programs at it.
  4. Labels each app by where it was found: IIS, Service, DevTool, or App.

It skips folders that only add noise or time: C:\Windows, C:\Program Files\WindowsApps (except Store apps that run as a service), the .NET install folder itself, Visual Studio install folders (the Visual Studio Installer updates those), Windows Defender, WinSxS, node_modules, .git, Package Cache, and test runner files named testhost*.

The script stops itself after 40 seconds and adds a Scan incomplete row naming the folders it did not finish. It also stops scanning any one folder tree after 15,000 folders, so a single huge folder (an agent queue or cache) cannot use the whole time. On a lab laptop with more than 85,000 folders under C:\Program Files (x86) the full scan took about 7 seconds.

Roll forward in plain words

An app does not always need the exact version it was built with. Its roll forward setting decides how far .NET may go from the version it asks for:

Roll Forward Meaning Example: app asks for 8.0.0
Minor (the default) Same major version, newest patch of the closest minor version Loads 8.0.33. Will not load 10.0 if 8 is missing
Major Same as Minor, but may move to the next major version if its own is missing Loads 8.0.33, or 10.0 if no 8 is installed
LatestMinor Newest version of the same major version Loads the newest 8.x
LatestMajor Newest version installed, any major version Loads 10.0 if installed
LatestPatch Newest patch of the exact minor version Loads 8.0.x only
Disable Exactly the version asked for Loads 8.0.0 only. Almost never installed, so the app fails

This is why removing .NET 8 breaks some apps and not others: an app set to Minor stops starting, while an app set to Major moves to .NET 10 on its own.


Use cases

Before removing a .NET runtime. Ask the sensor on the endpoints first. If no row resolves to that version, nothing found on the machine loads it and it can go. If rows do, the App Path, Context, and Category columns tell you which app and who owns it.

.NET 8 and .NET 9 end of support (November 10, 2026). Apps set to Major or LatestMajor move to .NET 10 by themselves once it is installed. Apps set to Minor stay on 8 or 9 and need a new build from the vendor or developer.

Finding .NET that patching never reaches. Rows with Deployment SelfContained or PrivateRuntime are apps carrying their own copy of .NET. Updating the shared runtime does not change them, and vulnerability scanners often do not report them. Only an update of that product fixes them.

Finding apps that are already broken. None installed means the app asks for a .NET version that is not on the machine, shared or private. It fails to start today, or that part of it is never used.

Fleet view. One question across all Windows machines lists every IIS site, service, and app running on each .NET major version.


Before you start

  • You need a role that can create sensors in the content set you plan to use.
  • If a sensor with the same name already exists, edit it instead of creating a second one.
  • Copy the script with the copy button on its code block so nothing is changed.

Create the sensor

Step 1: Open a new sensor

  1. Sign in to the Tanium Console.
  2. Open the main menu, then go to Administration > Content > Sensors.
  3. Click Create Sensor.

Step 2: Details section

Field Value
Name .NET App Dependencies
Description Lists .NET (Core 3.1, 5 and later) apps and the runtime each one actually loads. For every runtimeconfig.json found in IIS site folders, Windows service folders, Program Files, ProgramData, user Programs folders and other fixed drives, returns the framework and version the app asks for, its roll-forward setting, the installed version .NET would pick, whether that comes from the shared install, a private copy in the product folder, or a copy bundled with the app, the app architecture, and the IIS site or service it belongs to. Stops itself after 40 seconds and adds a Scan incomplete row. Returns None found when no .NET apps are found. .NET Framework 4.x is not covered. Read-only.
Content Set Your own custom content set, restricted to your endpoint team

Step 3: Settings section

Field Value
Category Miscellaneous (or any category you use for custom sensors)
Result Type Text
Max Sensor Age 24 and set the unit dropdown to Hours (lower it while you test a change)
Max String Age Leave Enable unchecked
Max Strings Leave Enable unchecked
Ignore case in result values Checked
Hide this sensor from sensor lists and parse results Unchecked
Split into multiple columns Checked

Step 4: Column settings

  1. Set Delimiter to |.
  2. Add these 9 columns in this order.
Index Column Name Value Type Ignore case Hidden
0 Framework Text Checked Unchecked
1 Requested Text Checked Unchecked
2 Roll Forward Text Checked Unchecked
3 Resolves To Text Checked Unchecked
4 Deployment Text Checked Unchecked
5 Arch Text Checked Unchecked
6 Category Text Checked Unchecked
7 Context Text Checked Unchecked
8 App Path Text Checked Unchecked

Requested and Resolves To are Text, not Version, because they can hold None installed, 8.0.21 (private), or 10.0.14 (bundled).

Step 5: Parameters section

None. Leave this section empty.

Step 6: Scripts section

Platform Enable Query Type Script
Windows Checked PowerShell Windows script below
Linux Checked UnixShell Stub below
Mac Checked UnixShell Stub below
Solaris Checked UnixShell Stub below
AIX Checked UnixShell Stub below

Set Timeout to 60 seconds, above the script's own 40-second limit.

<#
.SYNOPSIS
    Tanium sensor: .NET App Dependencies (Windows)

.DESCRIPTION
    Lists the applications on this machine that depend on a .NET (Core / 5+) shared
    runtime, plus self-contained apps that carry their own copy of the runtime.

    Each app declares what it needs in <app>.runtimeconfig.json. The sensor reads that
    file, then works out which installed runtime the app would actually load, using the
    same roll-forward rules the .NET host uses. That answers two questions:
      1. If I remove runtime X, which apps stop starting?
      2. Which apps bundle their own runtime, so patching the shared runtime will not fix them?

    .NET Framework is not covered. It is an OS component and cannot be removed per app.

    Where it looks (in priority order, so a time-out still covers the important places):
      IIS site and application folders (applicationHost.config)
      Windows service executables (registry ImagePath, including dotnet.exe app.dll services)
      Program Files, Program Files (x86), ProgramData (except ProgramData\Microsoft), C:\inetpub
      Each user profile's AppData\Local\Programs and .dotnet\tools
      Other fixed drives (shallow scan)

    Read-only. No network calls. No WMI. Safe under 32-bit or 64-bit PowerShell.

    Output, one row per app per framework, pipe-delimited:
      Framework|Requested|RollForward|ResolvesTo|Deployment|Arch|Category|Context|AppPath

    Deployment values:
      FrameworkDependent  loads a shared runtime from the machine's dotnet install
      PrivateRuntime      loads a runtime the product ships in its own folder (Context shows where)
      SelfContained       the runtime is bundled with the app itself

    ResolvesTo = "None installed" means neither the shared install nor a private runtime in the
    app's folder tree can satisfy the app: it will fail to start today.
    Visual Studio install trees are skipped (the VS Installer services them). Other developer
    tools (.nuget, .vscode, JetBrains, .dotnet\tools) are collapsed to one row per product.
#>

$ErrorActionPreference = 'SilentlyContinue'

# ---- Tunables ---------------------------------------------------------------
$TimeBudgetSec   = 40     # stop scanning after this many seconds and report a partial result
$MaxDepth        = 5      # folder depth under Program Files, ProgramData and user Programs folders
                          # (.NET apps keep runtimeconfig.json next to the exe, usually 2 to 4 levels down)
$WebDepth        = 8      # folder depth under IIS site folders
$ExtraDriveDepth = 4      # folder depth for non-system fixed drives (D:\, E:\ ...)
$MaxDirsPerRoot  = 15000  # stop one huge folder tree (agent queues, caches) from using the whole budget
# -----------------------------------------------------------------------------

$sw         = [System.Diagnostics.Stopwatch]::StartNew()
$cmp        = [System.StringComparer]::OrdinalIgnoreCase
$incomplete = New-Object 'System.Collections.Generic.HashSet[string]' ($cmp)

# Real paths even when Tanium runs the sensor in 32-bit PowerShell
$pf64  = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
$pf86  = ${env:ProgramFiles(x86)}
$sys32 = if (Test-Path "$env:windir\Sysnative") { "$env:windir\Sysnative" } else { "$env:windir\System32" }
$osArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
$osArch = switch ($osArch) { 'AMD64' { 'x64' } 'ARM64' { 'arm64' } default { 'x86' } }

function Get-RegValue([string]$subKey, [string]$name, [Microsoft.Win32.RegistryView]$view) {
    try {
        $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $view)
        $key  = $base.OpenSubKey($subKey)
        if ($key) { $v = $key.GetValue($name); $key.Close(); return $v }
    } catch {}
    return $null
}

function ConvertTo-Ver([string]$s) {
    if ($s -match '^(\d+)\.(\d+)\.(\d+)(-.+)?$') {
        return New-Object psobject -Property @{
            V    = [version]("{0}.{1}.{2}" -f $matches[1], $matches[2], $matches[3])
            Pre  = [bool]$matches[4]
            Text = $s
        }
    }
    return $null
}

# ---- Installed shared runtimes ---------------------------------------------
# Key: "<arch>|<framework name>"  Value: list of version objects
$installed   = @{}
$dotnetRoots = New-Object 'System.Collections.Generic.HashSet[string]' ($cmp)
$archList    = @('x64', 'x86')
if ($osArch -eq 'arm64') { $archList = @('arm64', 'x64', 'x86') }

foreach ($arch in $archList) {
    $root = Get-RegValue "SOFTWARE\dotnet\Setup\InstalledVersions\$arch" 'InstallLocation' ([Microsoft.Win32.RegistryView]::Registry32)
    if (-not $root) {
        $root = switch ($arch) {
            'x86'   { if ($pf86) { Join-Path $pf86 'dotnet' } }
            'x64'   { if ($osArch -eq 'arm64') { Join-Path $pf64 'dotnet\x64' } else { Join-Path $pf64 'dotnet' } }
            'arm64' { Join-Path $pf64 'dotnet' }
        }
    }
    if (-not $root -or -not [System.IO.Directory]::Exists($root)) { continue }
    $root = $root.TrimEnd('\')
    [void]$dotnetRoots.Add($root)
    $shared = Join-Path $root 'shared'
    if (-not [System.IO.Directory]::Exists($shared)) { continue }
    foreach ($fwDir in [System.IO.Directory]::GetDirectories($shared)) {
        $fwName = [System.IO.Path]::GetFileName($fwDir)
        foreach ($verDir in [System.IO.Directory]::GetDirectories($fwDir)) {
            # Skip folders left behind by an uninstall
            if ([System.IO.Directory]::GetFiles($verDir, '*.dll').Length -eq 0) { continue }
            $v = ConvertTo-Ver ([System.IO.Path]::GetFileName($verDir))
            if (-not $v) { continue }
            $k = "$arch|$fwName"
            if (-not $installed.ContainsKey($k)) { $installed[$k] = New-Object System.Collections.ArrayList }
            [void]$installed[$k].Add($v)
        }
    }
}

# ---- Roll-forward (mirrors the .NET host rules) ----------------------------
function Select-MinorRoll($ge, $r, [bool]$allowMajor) {
    $pool = @($ge | Where-Object { $_.V.Major -eq $r.Major })
    if ($pool.Count -eq 0 -and $allowMajor) {
        $higher = @($ge | Where-Object { $_.V.Major -gt $r.Major })
        if ($higher.Count -gt 0) {
            $m    = ($higher | Sort-Object { $_.V } | Select-Object -First 1).V.Major
            $pool = @($higher | Where-Object { $_.V.Major -eq $m })
        }
    }
    if ($pool.Count -eq 0) { return $null }
    $sameMinor = @($pool | Where-Object { $_.V.Major -eq $r.Major -and $_.V.Minor -eq $r.Minor })
    if ($sameMinor.Count -gt 0) { return ($sameMinor | Sort-Object { $_.V } -Descending | Select-Object -First 1) }
    $lowMinor = ($pool | Sort-Object { $_.V } | Select-Object -First 1).V.Minor
    return ($pool | Where-Object { $_.V.Minor -eq $lowMinor } | Sort-Object { $_.V } -Descending | Select-Object -First 1)
}

function Resolve-Framework([string]$arch, [string]$fwName, $req, [string]$rollForward) {
    $cands = @()
    if ($installed.ContainsKey("$arch|$fwName")) { $cands = @($installed["$arch|$fwName"]) }
    $pick = Select-Candidate $cands $req $rollForward
    if ($pick) { return $pick.Text }
    return $null
}

function Select-Candidate($cands, $req, [string]$rollForward) {
    # Release builds are preferred; previews only count if the app asked for a preview
    $cands = @($cands | Where-Object { $req.Pre -or -not $_.Pre })
    $r  = $req.V
    $ge = @($cands | Where-Object { $_.V -ge $r })
    $pick = switch ($rollForward.ToLowerInvariant()) {
        'disable'     { $cands | Where-Object { $_.Text -eq $req.Text } | Select-Object -First 1 }
        'latestpatch' { $ge | Where-Object { $_.V.Major -eq $r.Major -and $_.V.Minor -eq $r.Minor } | Sort-Object { $_.V } -Descending | Select-Object -First 1 }
        'latestminor' { $ge | Where-Object { $_.V.Major -eq $r.Major } | Sort-Object { $_.V } -Descending | Select-Object -First 1 }
        'latestmajor' { $ge | Sort-Object { $_.V } -Descending | Select-Object -First 1 }
        'major'       { Select-MinorRoll $ge $r $true }
        default       { Select-MinorRoll $ge $r $false }   # Minor is the .NET default
    }
    return $pick
}

# ---- Private runtimes shipped inside an app's own folder tree ----------------
# Some products (SSMS 21 and other Visual Studio shell apps, for example) carry a private
# .NET runtime, laid out like a normal dotnet folder (host\fxr + shared\<framework>\<version>),
# and point their own processes at it. Only checked when the shared runtime cannot satisfy
# the app, so it never hides a real dependency on the shared install.
$privateByDir = @{}
function Test-DotNetRoot([string]$d) {
    [System.IO.Directory]::Exists((Join-Path $d 'host\fxr')) -and [System.IO.Directory]::Exists((Join-Path $d 'shared'))
}
function Get-PrivateRoots([string]$appDir) {
    if ($privateByDir.ContainsKey($appDir)) { return $privateByDir[$appDir] }
    $roots = New-Object System.Collections.ArrayList
    $a = $appDir
    for ($level = 0; $level -le 5 -and $a; $level++) {
        if ($stopDirs.Contains($a.TrimEnd('\'))) { break }     # never climb into Program Files itself
        if (Test-DotNetRoot $a) { [void]$roots.Add($a) }
        $dn = Join-Path $a 'dotnet'
        if ([System.IO.Directory]::Exists($dn)) {
            # Look a few levels inside <ancestor>\dotnet for a runtime root (e.g. dotnet\net8.0\runtime)
            $q = New-Object System.Collections.Queue
            $q.Enqueue(@($dn, 0))
            while ($q.Count -gt 0) {
                $it = $q.Dequeue(); $d = $it[0]
                if (Test-DotNetRoot $d) { if (-not $roots.Contains($d)) { [void]$roots.Add($d) }; continue }
                if ($it[1] -ge 3) { continue }
                try { foreach ($s in [System.IO.Directory]::GetDirectories($d)) { $q.Enqueue(@($s, ($it[1] + 1))) } } catch {}
            }
        }
        $a = [System.IO.Path]::GetDirectoryName($a)
    }
    $privateByDir[$appDir] = $roots
    return $roots
}
function Get-RootVersions([string]$root, [string]$fwName) {
    $list = @()
    $fwDir = Join-Path $root "shared\$fwName"
    if (-not [System.IO.Directory]::Exists($fwDir)) { return $list }
    foreach ($verDir in [System.IO.Directory]::GetDirectories($fwDir)) {
        if ([System.IO.Directory]::GetFiles($verDir, '*.dll').Length -eq 0) { continue }
        $v = ConvertTo-Ver ([System.IO.Path]::GetFileName($verDir))
        if ($v) { $list += $v }
    }
    return $list
}

# ---- App architecture from the apphost .exe PE header ----------------------
function Get-PEArch([string]$path) {
    $fs = $null
    try {
        $fs = [System.IO.File]::Open($path, 'Open', 'Read', 'ReadWrite')
        $br = New-Object System.IO.BinaryReader($fs)
        if ($br.ReadUInt16() -ne 0x5A4D) { return $null }      # 'MZ'
        $fs.Position = 0x3C
        $pe = $br.ReadInt32()
        if ($pe -le 0 -or $pe -gt ($fs.Length - 6)) { return $null }
        $fs.Position = $pe + 4
        switch ($br.ReadUInt16()) { 0x8664 { 'x64' } 0x014C { 'x86' } 0xAA64 { 'arm64' } default { $null } }
    } catch { $null } finally { if ($fs) { $fs.Close() } }
}

# ---- Context: IIS sites and Windows services -------------------------------
$iisPaths = @{}   # physical path (with trailing \) -> "IIS: Site/app"
$appHost  = Join-Path $sys32 'inetsrv\config\applicationHost.config'
if (Test-Path $appHost) {
    try {
        [xml]$x = [System.IO.File]::ReadAllText($appHost)
        foreach ($site in $x.configuration.'system.applicationHost'.sites.site) {
            foreach ($app in @($site.application)) {
                foreach ($vd in @($app.virtualDirectory)) {
                    if (-not $vd.physicalPath) { continue }
                    $p = [Environment]::ExpandEnvironmentVariables($vd.physicalPath).TrimEnd('\') + '\'
                    $label = "IIS: $($site.name)$($app.path)".TrimEnd('/')
                    if (-not $iisPaths.ContainsKey($p)) { $iisPaths[$p] = $label }
                }
            }
        }
    } catch {}
}
$iisKeys = @($iisPaths.Keys | Sort-Object { $_.Length } -Descending)

$svcDirs = New-Object 'System.Collections.Generic.Dictionary[string,string]' ($cmp)
try {
    $svcRoot = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Services')
    foreach ($svc in $svcRoot.GetSubKeyNames()) {
        $k = $svcRoot.OpenSubKey($svc)
        if (-not $k) { continue }
        $img = [string]$k.GetValue('ImagePath', $null, 'DoNotExpandEnvironmentNames')
        $k.Close()
        if (-not $img) { continue }
        $img = [Environment]::ExpandEnvironmentVariables($img)
        $exe = $null
        if ($img -match '^\s*"([^"]+)"') { $exe = $matches[1] }
        elseif ($img -match '^\s*(.+?\.exe)(\s|$)') { $exe = $matches[1] }
        if (-not $exe -or $exe -notmatch '^[A-Za-z]:\\') { continue }
        # Service hosted by dotnet.exe: the app is the .dll argument
        if ([System.IO.Path]::GetFileName($exe) -ieq 'dotnet.exe' -and $img -match '"?([A-Za-z]:\\[^"]+?\.dll)"?') { $exe = $matches[1] }
        if ($exe.StartsWith($env:windir, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
        $dir = [System.IO.Path]::GetDirectoryName($exe)
        if (-not $dir) { continue }
        if ($svcDirs.ContainsKey($dir)) { $svcDirs[$dir] = $svcDirs[$dir] + ',' + $svc } else { $svcDirs[$dir] = $svc }
    }
    $svcRoot.Close()
} catch {}

# ---- Filesystem walk --------------------------------------------------------
$visited   = New-Object 'System.Collections.Generic.HashSet[string]' ($cmp)
$found     = New-Object 'System.Collections.Generic.HashSet[string]' ($cmp)
$skipPaths = New-Object 'System.Collections.Generic.HashSet[string]' ($cmp)
$stopDirs  = New-Object 'System.Collections.Generic.HashSet[string]' ($cmp)   # private-runtime search never climbs past these
foreach ($p in @($pf64, $pf86, $env:ProgramData, $env:SystemDrive)) { if ($p) { [void]$stopDirs.Add($p.TrimEnd('\')) } }
foreach ($p in @($env:windir, (Join-Path $pf64 'WindowsApps'), (Join-Path $env:ProgramData 'Microsoft')) + @($dotnetRoots)) { if ($p) { [void]$skipPaths.Add($p.TrimEnd('\')) } }
# Visual Studio trees hold hundreds of test-host templates (one per .NET version, never run as
# apps) and are serviced by the VS Installer, so they are skipped: noise, and most of the scan time.
$skipNames = @('$recycle.bin', 'system volume information', 'winsxs', 'node_modules', '.git', 'package cache',
               'microsoft visual studio', 'visualstudio', 'windows defender', 'windows defender advanced threat protection')

function Invoke-Scan([string]$root, [int]$maxDepth) {
    if (-not $root -or -not [System.IO.Directory]::Exists($root)) { return }
    # Keep "D:\" intact; "D:" would mean the current folder on drive D
    $start = if ($root.Length -gt 3) { $root.TrimEnd('\') } else { $root }
    $stack = New-Object System.Collections.Stack
    $stack.Push(@((New-Object System.IO.DirectoryInfo $start), 0))
    $count = 0
    while ($stack.Count -gt 0) {
        if ($sw.Elapsed.TotalSeconds -gt $TimeBudgetSec -or $count -ge $MaxDirsPerRoot) { [void]$incomplete.Add($start); return }
        $item = $stack.Pop(); $di = $item[0]; $depth = $item[1]
        $dir = if ($di.FullName.Length -gt 3) { $di.FullName.TrimEnd('\') } else { $di.FullName }
        if ($skipPaths.Contains($dir)) { continue }
        if (-not $visited.Add($dir)) { continue }
        $count++
        try {
            foreach ($f in [System.IO.Directory]::GetFiles($dir, '*.runtimeconfig.json')) {
                if (-not $f.EndsWith('.runtimeconfig.json', [System.StringComparison]::OrdinalIgnoreCase)) { continue }
                if ([System.IO.Path]::GetFileName($f) -like 'testhost*') { continue }   # test runner templates
                [void]$found.Add($f)
            }
        } catch {}
        if ($depth -ge $maxDepth) { continue }
        $subs = $null
        try { $subs = $di.GetDirectories() } catch { continue }   # DirectoryInfo carries attributes: no extra call per folder
        foreach ($s in $subs) {
            if ($s.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { continue }
            if ($skipNames -contains $s.Name.ToLowerInvariant()) { continue }
            $stack.Push(@($s, ($depth + 1)))
        }
    }
}

# Highest-value places first
foreach ($p in $iisKeys)       { Invoke-Scan $p $WebDepth }
foreach ($p in $svcDirs.Keys)  { Invoke-Scan $p 2 }
foreach ($p in @($pf64, $pf86, $env:ProgramData, "$env:SystemDrive\inetpub")) { Invoke-Scan $p $MaxDepth }

try {
    $pl = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList')
    foreach ($sid in $pl.GetSubKeyNames()) {
        if ($sid -notmatch '^S-1-5-21-') { continue }
        $k = $pl.OpenSubKey($sid); $prof = [string]$k.GetValue('ProfileImagePath'); $k.Close()
        if (-not $prof) { continue }
        $prof = [Environment]::ExpandEnvironmentVariables($prof)
        Invoke-Scan (Join-Path $prof 'AppData\Local\Programs') $MaxDepth
        Invoke-Scan (Join-Path $prof '.dotnet\tools') $MaxDepth
    }
    $pl.Close()
} catch {}

foreach ($d in [System.IO.DriveInfo]::GetDrives()) {
    if ($d.DriveType -ne 'Fixed' -or -not $d.IsReady) { continue }
    if ($d.Name.TrimEnd('\') -ieq $env:SystemDrive) { continue }
    Invoke-Scan $d.RootDirectory.FullName $ExtraDriveDepth
}

# ---- Build rows -------------------------------------------------------------
$devRegex = '\\Microsoft Visual Studio\\|\\ServiceHub\\|\\BuildTools\\|\\testhost|\\\.nuget\\|\\\.vscode\\|\\\.dotnet\\tools\\|\\JetBrains\\|\\Microsoft SDKs\\'

function Get-Context([string]$dir, [string]$file) {
    $d = $dir.TrimEnd('\') + '\'
    foreach ($k in $iisKeys) { if ($d.StartsWith($k, [System.StringComparison]::OrdinalIgnoreCase)) { return @('IIS', $iisPaths[$k], $file) } }
    if ($svcDirs.ContainsKey($dir.TrimEnd('\'))) { return @('Service', ('Service: ' + $svcDirs[$dir.TrimEnd('\')]), $file) }
    if ($file -match $devRegex) {
        # Collapse developer tools to one row per product
        $rootPath = $dir
        if     ($file -match '^(.*?\\Microsoft Visual Studio\\[^\\]+\\[^\\]+)') { $rootPath = $matches[1] }
        elseif ($file -match '^(.*?\\JetBrains\\[^\\]+)')                      { $rootPath = $matches[1] }
        elseif ($file -match '^(.*?\\\.(nuget|vscode))\\')                     { $rootPath = $matches[1] }
        elseif ($file -match '^(.*?\\\.dotnet\\tools)\\')                      { $rootPath = $matches[1] }
        return @('DevTool', '', $rootPath)
    }
    return @('App', '', $file)
}

$rows = New-Object 'System.Collections.Generic.HashSet[string]' ($cmp)
foreach ($file in $found) {
    $dir     = [System.IO.Path]::GetDirectoryName($file)
    $ctx     = Get-Context $dir $file
    $appName = [System.IO.Path]::GetFileName($file) -replace '\.runtimeconfig\.json$', ''
    $exe     = Join-Path $dir ($appName + '.exe')
    $arch    = $null
    if ([System.IO.File]::Exists($exe)) { $arch = Get-PEArch $exe }
    $archLabel = if ($arch) { $arch } else { "$osArch (dotnet.exe)" }
    if (-not $arch) { $arch = $osArch }

    $ro = $null
    try { $ro = ([System.IO.File]::ReadAllText($file) | ConvertFrom-Json).runtimeOptions } catch {}
    if (-not $ro) {
        [void]$rows.Add(('Unreadable runtimeconfig|||||{0}|{1}|{2}|{3}' -f $archLabel, $ctx[0], $ctx[1], $ctx[2]))
        continue
    }

    $appRoll = 'Minor'
    if ($ro.rollForward) { $appRoll = [string]$ro.rollForward }
    elseif ($null -ne $ro.rollForwardOnNoCandidateFx) {
        $appRoll = switch ([int]$ro.rollForwardOnNoCandidateFx) { 0 { 'LatestPatch' } 2 { 'Major' } default { 'Minor' } }
    }

    if ($ro.includedFrameworks) {
        foreach ($fw in @($ro.includedFrameworks)) {
            [void]$rows.Add(('{0}|{1}|n/a|{1} (bundled)|SelfContained|{2}|{3}|{4}|{5}' -f $fw.name, $fw.version, $archLabel, $ctx[0], $ctx[1], $ctx[2]))
        }
        continue
    }

    $fws = @()
    if ($ro.frameworks) { $fws = @($ro.frameworks) } elseif ($ro.framework) { $fws = @($ro.framework) }
    foreach ($fw in $fws) {
        if (-not $fw.name -or -not $fw.version) { continue }
        $req  = ConvertTo-Ver ([string]$fw.version)
        $roll = if ($fw.rollForward) { [string]$fw.rollForward } else { $appRoll }
        $deploy  = 'FrameworkDependent'
        $context = $ctx[1]
        if (-not $req) {
            $res = 'Unparsed version'
        } else {
            $res = Resolve-Framework $arch $fw.name $req $roll
            if (-not $res) {
                # Shared runtime cannot satisfy it: check for a runtime the app ships itself
                foreach ($pr in (Get-PrivateRoots $dir)) {
                    $pick = Select-Candidate (Get-RootVersions $pr $fw.name) $req $roll
                    if ($pick) {
                        $res     = "$($pick.Text) (private)"
                        $deploy  = 'PrivateRuntime'
                        $context = (@($ctx[1], "Runtime: $pr") | Where-Object { $_ }) -join '; '
                        break
                    }
                }
                if (-not $res) { $res = 'None installed' }
            }
        }
        [void]$rows.Add(('{0}|{1}|{2}|{3}|{4}|{5}|{6}|{7}|{8}' -f $fw.name, $fw.version, $roll, $res, $deploy, $archLabel, $ctx[0], $context, $ctx[2]))
    }
}

if ($incomplete.Count -gt 0) {
    [void]$rows.Add(('Scan incomplete||||||||Not fully scanned: {0} ({1} folders, {2:N0}s)' -f (($incomplete | Sort-Object) -join '; '), $visited.Count, $sw.Elapsed.TotalSeconds))
}
if ($rows.Count -eq 0) {
    Write-Output 'None found||||||||'
} else {
    $rows | Sort-Object | ForEach-Object { Write-Output $_ }
}
#!/bin/sh
echo "Not Applicable||||||||"

The tunable values are at the top of the Windows script: the 40-second time budget, the folder depths, and the 15,000-folder cap per folder tree.

Step 7: Save

Scroll to the bottom of the page and click Save.

Step 8: Test

  1. Go to Interact.
  2. Ask:

    Get Computer Name and .NET App Dependencies from all machines with Computer Name contains "<test machine>"
    
  3. Check that you see 9 columns, and either one row per app or a single None found row.


Ask the question

If you gave the sensor a different name (for example with a Lab - prefix), use that name in every question below.

All Windows machines:

Get Computer Name and .NET App Dependencies from all machines with Is Windows equals True

Everything that loads .NET 8 (change 8. to the major version you want to remove):

Get Computer Name and .NET App Dependencies from all machines with .NET App Dependencies:Resolves To starts with "8."

Apps that carry their own copy of .NET:

Get Computer Name and .NET App Dependencies from all machines with .NET App Dependencies:Deployment equals "SelfContained"
Get Computer Name and .NET App Dependencies from all machines with .NET App Dependencies:Deployment equals "PrivateRuntime"

Apps that cannot find the .NET they need:

Get Computer Name and .NET App Dependencies from all machines with .NET App Dependencies:Resolves To equals "None installed"

IIS sites on .NET:

Get Computer Name and .NET App Dependencies from all machines with .NET App Dependencies:Category equals "IIS"

To save the results, click Export above the results grid and choose CSV. Keep Computer Name in the question so the export has a machine column. The .NET Remediation Plan tool reads that export.


Reading the results

Column What it tells you
Framework Microsoft.NETCore.App (base runtime), Microsoft.AspNetCore.App (web apps), or Microsoft.WindowsDesktop.App (Windows Forms and WPF apps). An app can ask for more than one, so it can have more than one row
Requested The version written in the app's runtimeconfig.json, usually x.0.0
Roll Forward How strict the app is about the version. See Roll forward in plain words. n/a for apps that bundle .NET
Resolves To The installed version .NET would load for this app. (private) means a copy inside the product folder, (bundled) a copy inside the app itself. None installed means nothing fits
Deployment FrameworkDependent (uses the shared .NET install), PrivateRuntime (uses a copy inside the product folder), or SelfContained (the app carries its own .NET)
Arch Architecture of the app's program file. x64 (dotnet.exe) means the app has no .exe of its own and is started through dotnet.exe
Category IIS, Service, DevTool (Visual Studio Code, JetBrains, NuGet, .NET tools), or App
Context The IIS site (IIS: <site>/<app>) or service name (Service: <name>), and for private copies the folder of that copy (Runtime: <path>)
App Path The runtimeconfig.json file. Developer tools are shortened to one row per product folder

Example rows (values are illustrative):

Microsoft.AspNetCore.App|8.0.0|Minor|8.0.33|FrameworkDependent|x64|IIS|IIS: www.example.com|D:\Sites\Example\Example.Web.runtimeconfig.json
Microsoft.NETCore.App|8.0.0|Minor|8.0.33|FrameworkDependent|x64|Service|Service: ExampleSvc|C:\Program Files\Example\ExampleSvc.runtimeconfig.json
Microsoft.NETCore.App|8.0.0|Major|8.0.21 (private)|PrivateRuntime|x64|DevTool|Runtime: C:\Program Files\Microsoft SQL Server Management Studio 21\Release\dotnet\net8.0\runtime|C:\Program Files\Microsoft SQL Server Management Studio 21\Release\Common7\ServiceHub\Controller
Microsoft.NETCore.App|10.0.14|n/a|10.0.14 (bundled)|SelfContained|x64|App||C:\Program Files\ExampleApp\ExampleApp.runtimeconfig.json
Microsoft.NETCore.App|8.0.0|Disable|None installed|FrameworkDependent|x64 (dotnet.exe)|Service|Service: ExampleWorker|C:\Apps\ExampleWorker\ExampleWorker.runtimeconfig.json
None found||||||||

What to do with a row

What you see Meaning Next step
No row resolves to the version you want to remove Nothing found on the machine loads it Remove that runtime. On servers, confirm with the owner first (see Limits)
FrameworkDependent, Roll Forward Major or LatestMajor The app moves to a newer major version by itself Install the newer .NET first, then remove the old one. Test on one machine
FrameworkDependent, Roll Forward Minor The app stays on its major version Keep that version patched. Moving off it needs a new build of the app from the vendor or developer
SelfContained or PrivateRuntime The product carries its own .NET Update the product. Updating the shared runtime does not change it
None installed The app cannot start, or that part of it is never used Ask the app owner whether it is still in use before installing anything for it

The full decision guide is in .NET Runtimes.


Troubleshooting

Result Meaning What to do
Scan incomplete row Time ran out, or one folder tree hit the 15,000-folder cap. The row names the folders that were not finished Rows above it are still valid. If one folder is always named (an agent queue, a cache), add its folder name to $skipNames in the script
TSE-Error: Sensor evaluation timed out The sensor Timeout is below 60 seconds Edit the sensor and recheck Step 6
TSE-Error: No Sensor Definition for this Platform The Linux, Mac, Solaris, or AIX script tab was left empty Edit the sensor and add the stub script to those tabs (Step 6)
All values in one column The column split was not saved Edit the sensor and recheck Step 4
Unreadable runtimeconfig The file could not be read as JSON Seen on old copies that products leave behind after an update. If the App Path is a live app, check the file with the vendor
Unparsed version The app asks for a four-part version such as 8.0.0.0 Read the Roll Forward column. Major or LatestMajor means the app takes the newest .NET installed
None installed for an app you know works The product uses a private copy of .NET in a layout the sensor does not recognize Look for the private copy with Find File by Name, for example Find File by Name[hostfxr.dll,C:\Program Files\<Product>,]

Limits

  • .NET Framework 4.x is not covered. It is part of Windows. Windows updates fix it.
  • Only the folders listed in How the sensor works are searched. An app started by a scheduled task or from another folder on C:\ (for example C:\Tools) is not found. Check those with Scheduled Tasks - By Command and Running Processes - Paths - Hashes (look for \dotnet\dotnet.exe in the Path column).
  • Installed runtimes that nothing uses get no row. The sensor lists apps, not runtimes. A runtime that appears in your vulnerability findings but in no Resolves To value is the one nothing uses.
  • Old version folders show up as apps. Some products keep old copies after updating. Dell TechHub, for example, keeps C:\Program Files\Dell\DTP\<Component>Deps\<version> folders that often show None installed. The copies in use are in the <Component>SubAgent folders.
  • Tools inside NuGet package folders show up as apps. A site folder that contains packages\<package>\tools\ (for example the Entity Framework ef6 tool) gives a row that is not a real app.
  • Context shows the first match. A folder used by two IIS sites shows only one of them.

Test results

Case Expected Result
Windows PowerShell 5.1 parser No errors Pass
Domain controller and clients without .NET apps None found Pass
Laptop with desktop apps that bundle .NET 8 and .NET 10 SelfContained, (bundled) Pass
First version on a laptop with more than 85,000 folders under one agent folder Finish inside the time limit Failed (Scan incomplete after 12,000 folders). Fixed with the folder depth, the 15,000-folder cap, and skipping Visual Studio folders. Full scan now about 7 seconds
First version on Visual Studio Build Tools Useful rows only Failed (16 rows of test runner noise). Fixed by skipping Visual Studio folders and testhost* files
SQL Server Management Studio 21, no shared .NET installed 8.0.21 (private), PrivateRuntime First version showed None installed. Fixed with the private-runtime check. Pass
Product with a private copy that lacks one framework That framework still None installed Pass
ASP.NET Core site in IIS at C:\Sites (outside C:\inetpub) Category IIS, Context IIS: <site> Pass
Service started from its own .exe Category Service, Arch x64 Pass
Service started as dotnet.exe <app>.dll Category Service, Arch x64 (dotnet.exe) Pass
ASP.NET Core app asking for 8.0.0, Roll Forward Minor, 8.0.33 installed Two rows (AspNetCore.App and NETCore.App), both 8.0.33 Pass
Same app changed to Roll Forward Disable None installed on that app only Pass
Through Tanium as SYSTEM Same rows as a local run Pass