.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.
- Lists the installed .NET runtimes. It reads the install folder from
HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\<x64 or x86>\InstallLocation(usuallyC:\Program Files\dotnet) and lists every version under itssharedfolder. Version folders left behind by an uninstall (no.dllfiles inside) are ignored. - Finds the apps. It looks for
*.runtimeconfig.jsonin this order, so the most important places are covered even if time runs out:- IIS site and application folders, from
C:\Windows\System32\inetsrv\config\applicationHost.config - The folder of every Windows service program, from
HKLM\SYSTEM\CurrentControlSet\Services\<service>\ImagePath. For a service started asdotnet.exe <app>.dll, it uses the folder of the.dll C:\Program Files,C:\Program Files (x86),C:\ProgramData(exceptC:\ProgramData\Microsoft), andC:\inetpub, 5 folder levels deep- Each user profile's
AppData\Local\Programsand.dotnet\tools - Other fixed drives such as
D:\, 4 folder levels deep
- IIS site and application folders, from
- 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\fxrandsharedinside). Some products ship one and point their own programs at it. - Labels each app by where it was found:
IIS,Service,DevTool, orApp.
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
- Sign in to the Tanium Console.
- Open the main menu, then go to Administration > Content > Sensors.
- 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
- Set Delimiter to
|. - 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 $_ }
}
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
- Go to Interact.
-
Ask:
-
Check that you see 9 columns, and either one row per app or a single
None foundrow.
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:
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 exampleC:\Tools) is not found. Check those with Scheduled Tasks - By Command and Running Processes - Paths - Hashes (look for\dotnet\dotnet.exein 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 showNone installed. The copies in use are in the<Component>SubAgentfolders. - Tools inside NuGet package folders show up as apps. A site folder that contains
packages\<package>\tools\(for example the Entity Frameworkef6tool) 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 |
Related
- .NET Runtimes: what to do with each kind of .NET finding
- .NET Remediation Plan: turns a findings export and this sensor's export into a plan per endpoint
- Scheduled Tasks - By Command and Running Processes - Paths - Hashes: find .NET apps started from folders this sensor does not search
- Pseudonymize Exports: share the exports without real machine names