最新の .NET 6.0.35 を入れたのに 6.0.33 や 6.0.28 が残って脆弱性診断に引っかかる――Windows での .NET(.NET Core/.NET 6+)運用ではよくある悩みです。この記事では、サイドバイサイド設計を踏まえつつ「同一メジャーの旧パッチだけを安全に自動削除」する実践手順を、.NET Uninstall Tool/winget/PowerShell(レジストリ駆動)の3方式で体系的に解説します。Intune/SCCM での展開、検出・是正スクリプト、失敗時の対処まで一気通貫でまとめました。
旧バージョン .NET の自動アンインストール問題(背景と要点)
.NET Core 系(.NET 5 以降を含む)はサイドバイサイド方式です。Windows の .NET Framework 4.x のようなインプレース更新ではありません。そのため、6.0.35 を入れても 6.0.33/6.0.28…が同居します。多くの脆弱性スキャナは「最新以外の 6.0.x が残存=未更新」と判定するため、“同一メジャー(6.0)内の旧パッチだけを掃除”する運用が現実解になります。
- 目的:最新パッチのみを残す(例:6.0.35 を残し 6.0.33/6.0.28 を自動削除)
- 制約:メジャーバージョンまたぎ(6→8 など)は併存推奨。削除対象は「同一メジャーの旧パッチ」限定が基本。
- 前提:アプリが特定パッチを pin していないかを事前確認(後述)。
最初に押さえる「選定フロー」
数百〜数万台規模でも迷わないよう、方式選定の指針を先に示します。
| 意思決定の問い | Yes のとき | No のとき |
|---|---|---|
端末に C:\ProgramData\Package Cache が残っているか | .NET Uninstall Toolで一括整理(最優先) | 次の問いへ |
| winget クライアントを配布済みか(1.4+ 推奨) | winget スクリプトで「6.x の旧パッチだけ」削除 | PowerShell+レジストリで UninstallString 実行 |
3方式の比較(できること・注意点・向いている場面)
| 手段 | できること | 代表コマンド | 前提・注意点 | 向いている場面 |
|---|---|---|---|---|
| .NET Uninstall Tool (dotnet-core-uninstall) | SDK/Runtime/ASP.NET/WindowsDesktop を安全に列挙・削除。 “all-but-latest” で最新だけを残す整理が容易。 | dotnet-core-uninstall list dotnet-core-uninstall remove --runtime --all-but-latest --yes dotnet-core-uninstall remove --aspnet-runtime --all-but-latest --yes dotnet-core-uninstall remove --windowsdesktop --all-but-latest --yes dotnet-core-uninstall remove --sdk --all-but-latest --yes | Package Cache が必須。キャッシュが消されている端末では失敗。 Workload/Global Tool は対象外。 | 企業標準端末・VDI・共有 PC。 Intune/SCCM で「確実に・静かに」掃除したい。 |
| winget スクリプト | winget の ID/Version を使って指定系に削除。 JSON 出力の解析でパッチ比較も可能。 | # 例)Runtime 6 の最新以外を削除 $lines = winget list --id Microsoft.DotNet.Runtime.6 # (後述の完全スクリプト版推奨) | winget 導入必須。 ID 体系の変更や LTS 切替時はスクリプト保守が必要。 | Package Cache が無い/不整合な端末の救済。 ストア外アプリ混在環境の「実務的」解。 |
| PowerShell+レジストリ | Uninstall レジストリからDisplayName/Versionで抽出し、UninstallString を静かに実行。最も自由度が高い。 | # DisplayName から 6.0.* の旧パッチを判定・削除 # (後述の堅牢スクリプトを参照) | 自作ゆえにテスト・署名・ロギング設計が必須。 誤検出・誤削除のリスクはゼロにはならない。 | 閉域・特注端末/winget 不可な環境。 削除基準を細かく制御したい場合。 |
.NET Uninstall Tool を用いた安全最優先の一括整理
まずは公式ツールで「最新のみを残す」オペレーションを固めます。MSI を配布(Intune/ConfigMgr/スクリプト)したら、以下の手順で無人化可能です。
基本の流れ
- 現状把握:
dotnet-core-uninstall listで削除可能な項目を可視化。 - ドライラン:
--dry-runを併用して意図どおりか検証。 - 本実行:タイプ別に
--all-but-latestを適用。必要に応じて種類を分けて実行。
代表コマンド(静音・自動化向け)
REM 1) 一覧(何が「削除可能」かを確認)
dotnet-core-uninstall list
REM 2) ランタイム系(Console/ASP.NET/WindowsDesktop)をそれぞれ整理
dotnet-core-uninstall remove --runtime --all-but-latest --yes
dotnet-core-uninstall remove --aspnet-runtime --all-but-latest --yes
dotnet-core-uninstall remove --windowsdesktop --all-but-latest --yes
REM 3) SDK も整理(開発機のみ対象にするなどポリシー運用)
dotnet-core-uninstall remove --sdk --all-but-latest --yes
ポイント:「6→8 のメジャー共存」は維持したい場合、事前に list の結果をログ保存し、対象が 6.x の旧パッチだけになっているかを確認してからジョブ投入してください。現場ルールとして “6.x のみ掃除” に固定したいときは、後述の winget/PowerShell スクリプトで Major.Minor=6.0 のみをフィルタして削除するほうが明確です。
よくある失敗と回避策
- 「Package Cache が見当たりません」系:クリーンアップツールや手動で
C:\ProgramData\Package Cacheを消していると失敗します。
対処:キャッシュ保持ポリシーを徹底(Intune/ConfigMgr のタスクやサードパーティクリーナーで削除しない)。どうしても無い場合は winget/PowerShell 方式へ切替。 - アプリ依存で復旧要:まれに特定パッチを前提とするアプリが存在します。
対処:削除前にdotnet --list-runtimesとアプリ互換性メモを突き合わせ、「除外リスト」を管理(後述)。
winget スクリプトで「6.x の旧パッチだけ」を静かに削除
Package Cache が無い・不整合といった端末では、winget によるバージョン指定アンインストールが実用的です。以下は Runtime / WindowsDesktop / ASP.NET Core / SDK の「6 系のみ」を対象に、最新パッチ以外を削除する実践スクリプトです(x64/x86 を別系統として処理)。winget は 表形式のテキストを出力するため、ID と Version を正規表現で抜き出して比較します。
$ErrorActionPreference = 'Stop'
function Get-WingetEntries([string]$IdPattern) {
# winget list の出力から Id と Version を抽出(複数行になる場合あり)
$lines = winget list --id $IdPattern 2>$null
foreach ($line in $lines) {
if ($line -match '^\s*(?<name>.+?)\s+(?<id>Microsoft\.DotNet\.[\w\.]+)\s+(?<ver>\d+\.\d+\.\d+)\s+') {
[pscustomobject]@{
Id = $matches.id
Version = [version]$matches.ver
RawVersion = $matches.ver
Line = $line
}
}
}
}
function Remove-OldWinget([string]$Id) {
$items = Get-WingetEntries -IdPattern $Id | Where-Object { $_.Version.Major -eq 6 }
if (-not $items) { return }
$latest = ($items | Sort-Object Version -Descending | Select-Object -First 1).Version
$targets = $items | Where-Object { $_.Version -ne $latest }
foreach ($t in $targets) {
Write-Host "Uninstall: $($t.Id) $($t.RawVersion)"
winget uninstall --id $t.Id --version $t.RawVersion --silent --accept-source-agreements | Out-Null
}
}
# 6.x の旧パッチ掃除(各系統を個別に)
Remove-OldWinget -Id 'Microsoft.DotNet.Runtime.6'
Remove-OldWinget -Id 'Microsoft.DotNet.DesktopRuntime.6'
Remove-OldWinget -Id 'Microsoft.DotNet.AspNetCore.6'
Remove-OldWinget -Id 'Microsoft.DotNet.SDK.6'
コツ:環境により ID の表記差がある場合は、winget list で実在 ID を確認のうえ上記 ID を調整してください。より厳密に行うなら winget の JSON 出力(--output json)を ConvertFrom-Json で解析し、InstalledVersion を比較する方式に置き換えると堅牢です。
PowerShell+レジストリで「判定→削除」をフル制御
winget も使えない/強い制御が必要――そんな場面ではレジストリの Uninstall 情報を解析して削除します。DisplayName に含まれるパターンと DisplayVersion(例:6.0.35)から「同一アーキテクチャ内で最新以外」を判定し、QuietUninstallString(または UninstallString)を静かに実行します。
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$MajorMinor = '6.0', # 掃除するメジャー.minor(例:'6.0')
[string[]]$Kinds = @('Runtime','Desktop','AspNet','SDK'), # 対象の種類
[switch]$WhatIf,
[string]$LogPath = "$env:ProgramData\DotNetCleanup\uninstall.log"
)
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Path (Split-Path $LogPath) -Force | Out-Null
$regPaths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
function Get-DotNetItems {
foreach ($p in $regPaths) {
Get-ItemProperty -Path $p -ErrorAction SilentlyContinue | ForEach-Object {
$dn = $_.DisplayName
$dv = $_.DisplayVersion
if (-not $dn -or -not $dv) { return }
# 種別を判定
$kind = if ($dn -match 'SDK') {'SDK'}
elseif ($dn -match 'Windows\s+Desktop\s+Runtime') {'Desktop'}
elseif ($dn -match 'ASP\.NET\s+Core') {'AspNet'}
elseif ($dn -match '\.NET\s+Runtime') {'Runtime'}
else { $null }
if (-not $kind) { return }
# バージョンを抽出
$verStr = ([regex]::Match($dv, '\d+\.\d+\.\d+').Value)
if (-not $verStr) { $verStr = ([regex]::Match($dn, '\d+\.\d+\.\d+').Value) }
if (-not $verStr) { return }
$mm = ($verStr -split '\.')[0..1] -join '.'
if ($mm -ne $MajorMinor) { return }
$arch = if ($dn -match '\(x64\)' -or $_.DisplayName -match 'x64') {'x64'}
elseif ($dn -match '\(x86\)') {'x86'}
elseif ($dn -match 'arm64') {'arm64'}
else {'unknown'}
[pscustomobject]@{
Kind = $kind
Arch = $arch
Name = $dn
Version = [version]$verStr
RawVersion = $verStr
QuietUninstall = $_.QuietUninstallString
Uninstall = $_.UninstallString
PSChildName = $_.PSChildName
}
}
}
}
function Invoke-Uninstall([string]$cmd) {
if (-not $cmd) { return $false }
# msiexec パラメータを静音化
$fixed = $cmd -replace '/I\s+','/x ' # /I を /x に置換
if ($fixed -notmatch '/quiet') { $fixed += ' /quiet' }
if ($fixed -notmatch '/norestart') { $fixed += ' /norestart' }
if ($PSCmdlet.ShouldProcess($fixed,'Uninstall')) {
$p = Start-Process -FilePath 'cmd.exe' -ArgumentList "/c $fixed" -Wait -PassThru
Add-Content -Path $LogPath -Value ("[{0}] {1} ExitCode={2}" -f (Get-Date), $fixed, $p.ExitCode)
return ($p.ExitCode -eq 0)
}
return $true
}
$all = Get-DotNetItems | Where-Object { $Kinds -contains $_.Kind }
if (-not $all) { Write-Host '対象なし'; return }
$groups = $all | Group-Object Kind, Arch
foreach ($g in $groups) {
$sorted = $g.Group | Sort-Object Version -Descending
$keep = $sorted | Select-Object -First 1
$remove = $sorted | Select-Object -Skip 1
Write-Host ("[{0}/{1}] keep: {2} {3}" -f $g.Group[0].Kind,$g.Group[0].Arch,$keep.Name,$keep.RawVersion)
foreach ($r in $remove) {
Write-Host ("remove: {0} {1}" -f $r.Name, $r.RawVersion)
$cmd = if ($r.QuietUninstall) { $r.QuietUninstall } else { $r.Uninstall }
Invoke-Uninstall -cmd $cmd | Out-Null
}
}
安全装置:-WhatIf を付ければ実行内容だけを表示します。まずは 検証端末で -WhatIf 実行 → ログの確認 → 本番実行、の順で進めてください。
運用に効く 7 つの実務ポイント
- 依存の事前確認:アプリの起動スクリプトや構成ファイルで 特定パッチを pin していないか棚卸し。疑わしい端末は除外タグを付ける。
- キャッシュ保持ポリシー:Package Cache を消さない。クリーンアップ製品・メンテナンスタスクの除外リストに
C:\ProgramData\Package Cacheを加える。 - メジャー跨ぎの扱い:6 と 8 は併存前提。自動削除は「6.0.x の旧パッチだけ」。メジャー違いは温存。
- CI/CD・イメージ化:ゴールデンイメージ/コンテナに最新パッチだけを封入し、端末側は「掃除」より「上書きで常に最新」に寄せる。
- ログ設計:どの方式でも「一覧→削除→結果」を必ずログ化。後日の監査やトラブル調査に不可欠。
- ロールバック準備:万一のために旧パッチのインストーラを保管(閉域なら共有に配置)。復元手順を運用Runbookに明文化。
- 検出ジョブの自動化:
dotnet --list-runtimesとGet-ChildItem "C:\Program Files\dotnet\shared"を突き合わせ、最新以外の 6.0.* が存在したら Remediation を走らせる。
Intune / ConfigMgr への適用パターン
Intune デバイス構成(スクリプト)
- 検出スクリプト(Exit 1 で是正):「6.0.* が複数」「最大パッチ以外が存在」を検知したら
exit 1。ログは%ProgramData%\DotNetCleanup配下へ。 - 是正スクリプト:上掲の PowerShell スクリプト(
MajorMinor='6.0'固定)を実行。-WhatIfで試験後、本稼働へ切替。
ConfigMgr(アプリケーション/パッケージ)
- 検出法:レジストリ(Uninstall)における 同一 Kind+Arch で 6.0.* が 2 件以上 を検知。
- 配布:.NET Uninstall Tool を必須アプリ化し、依存プログラムとして
remove --all-but-latestを投入。Package Cache が無い端末向けに winget/PowerShell 版のフォールバックを別コレクションで配布。
検証・監査に使える確認コマンド集
# 端末の .NET ランタイム一覧
& "$env:ProgramFiles\dotnet\dotnet.exe" --list-runtimes
# 実体の存在確認(ランタイム)
Get-ChildItem "C:\Program Files\dotnet\shared\Microsoft.NETCore.App"
# 実体の存在確認(Windows Desktop)
Get-ChildItem "C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App"
# ASP.NET Core 共有フレームワーク
Get-ChildItem "C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App"
削除基準の「作り方」(誤爆を避けるための設計)
誤削除を避けるため、対象範囲を文字で定義します。
- 対象:Kind ∈ {Runtime, WindowsDesktop, ASP.NET Core, SDK}、Arch ∈ {x64, x86, arm64}、Major.Minor = 6.0
- 保持:各 Kind×Arch で「最大パッチ」1 本のみ保持
- 除外:除外タグが付く端末(またはソフトウェアインベントリ上の特定アプリが存在する端末)はスキップ
トラブル対応の実務ノウハウ
- 0x80070643(アンインストール不能):Uninstall 情報の破損。
対処:同じバージョンの MSI を再適用(修復)→ アンインストール再実行。不可なら winget で指定削除。 - Win32_Product は使わない:
Get-WmiObject Win32_Productは MSI の再構成を誘発し副作用が大きい。レジストリと専用ツールで扱う。 - アーキテクチャ混在:x64 と x86 の 6.0.* が同居する場合、アーキ別に最新を1つずつ残す(上掲スクリプトはその前提で実装)。
サンプル:検出(Intune Remediation の検出スクリプト例)
$ErrorActionPreference = 'Stop'
$mm = '6.0'
$reg = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
$items = foreach ($p in $reg) {
Get-ItemProperty $p -ErrorAction SilentlyContinue | Where-Object {
$_.DisplayVersion -match '^\d+\.\d+\.\d+$' -and
($_.DisplayName -match '(\.NET\s+Runtime|Windows\s+Desktop\s+Runtime|ASP\.NET\s+Core|\.NET\s+SDK)')
} | ForEach-Object {
$v = [regex]::Match($_.DisplayVersion,'\d+\.\d+\.\d+').Value
if (($v -split '\.')[0..1] -join '.' -ne $mm) { return }
[pscustomobject]@{
Kind = if ($_.DisplayName -match 'SDK') {'SDK'}
elseif ($_.DisplayName -match 'Windows\s+Desktop\s+Runtime') {'Desktop'}
elseif ($_.DisplayName -match 'ASP\.NET') {'AspNet'}
else {'Runtime'}
Arch = if ($_.DisplayName -match '\(x64\)') {'x64'}
elseif ($_.DisplayName -match '\(x86\)') {'x86'}
elseif ($_.DisplayName -match 'arm64') {'arm64'} else {'unknown'}
Version = [version]$v
}
}
}
$needFix = $false
$items | Group-Object Kind,Arch | ForEach-Object {
if ($_.Count -gt 1) { $needFix = $true }
}
if ($needFix) { exit 1 } else { exit 0 }
まとめ(運用の肝)
- 第一選択は .NET Uninstall Tool の自動化。Package Cache があれば –all-but-latest だけで SDK/Runtime を安全に整理できます。
- キャッシュがない/不整合端末には winget または PowerShell+レジストリのスクリプトで「同一メジャーの旧パッチのみ」削除を自動化します。
- .NET Core 系は SxS が前提。「最新のみを残す」方針を徹底するには、導入〜キャッシュ管理〜削除の標準化(ポリシー・ログ・除外設計)が鍵です。
付録:現場で役立つチェックリスト
| 項目 | 確認内容 | 状態 |
|---|---|---|
| 依存確認 | アプリの pin 有無・除外端末の特定 | 未着手/進行中/完了 |
| Package Cache | C:\ProgramData\Package Cache を保持するポリシー設定 | 未設定/設定済 |
| 方式選定 | Uninstall Tool/winget/PowerShell の住み分け | 決定/検討中 |
| ロギング | 一覧・削除・結果の集中保管(SIEM 連携) | 未実装/実装済 |
| ロールバック | 旧パッチの保管場所・復旧手順の整備 | 未整備/整備済 |
| 自動検出 | 最新以外の 6.0.* 検出→是正の運用回転 | 未導入/導入済 |
FAQ
Q. .NET 8 も入っているが、6 の古いパッチだけ消したい。
A. 本稿の winget/PowerShell では Major.Minor=6.0 をフィルタして処理します。Uninstall Tool を使う場合も、まず list で対象を確認し、6.0 系に限定して適用してください。
Q. SDK は消すべき?
A. 開発機以外は原則不要です。開発機は SDK の「機能バンド」ごとに最新のみを残す方針でもよいでしょう。運用チームでルール化を。
Q. 端末クリーンアップで Package Cache を削除してきた。
A. 以後は削除しない方針に改め、当面は winget/PowerShell 方式で救済しつつ、標準イメージ側で「最新のみ封入」を徹底してください。
実装の最短レシピ(忙しい人向け)
- Intune で .NET Uninstall Tool を配布。
- 検出:6.0.* が複数なら Exit 1、是正で Uninstall Tool or winget/PowerShell を実行。
- ログ:端末に残す+中央集約。
- 除外:pin アプリがある端末にはタグを付けてスキップ。

コメント