drive-dependencies.ps1 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. <#
  2. .SYNOPSIS
  3. Find every system mechanism referencing a target drive letter or
  4. disk number. The "is it safe to disconnect?" check.
  5. .DESCRIPTION
  6. Before physically removing a failing drive (or setting it Offline),
  7. audit what's pointing at it: pagefile location, Windows Search index,
  8. scheduled tasks, services, user-profile symlinks/junctions, startup
  9. folder shortcuts, mounted volume mount points, and any drive
  10. references in the Windows Run keys.
  11. Default output is a human-readable table. -Json emits structured.
  12. Exit codes:
  13. 0 success
  14. 2 usage
  15. 3 not found (no such drive)
  16. .PARAMETER DriveLetter
  17. Single drive letter (e.g. 'Y'). Case-insensitive.
  18. .PARAMETER DiskNumber
  19. Physical disk number (from Get-Disk). The script resolves all drive
  20. letters mounted on that disk and checks each.
  21. .PARAMETER Json
  22. Machine-readable JSON output.
  23. .EXAMPLE
  24. scripts/drive-dependencies.ps1 -DriveLetter Y
  25. Audit all system references to Y: drive.
  26. .EXAMPLE
  27. scripts/drive-dependencies.ps1 -DiskNumber 1
  28. Audit all references to drive letters on physical disk 1.
  29. .EXAMPLE
  30. scripts/drive-dependencies.ps1 -DriveLetter Y -Json | jq '.dependencies[]'
  31. Machine-readable output for downstream tooling.
  32. .NOTES
  33. Output verdict at end:
  34. SAFE TO DISCONNECT — no critical references found
  35. WARNINGS — some references found but none boot-critical
  36. DO NOT DISCONNECT — boot-critical reference (pagefile, system, etc.)
  37. #>
  38. [CmdletBinding(DefaultParameterSetName='Letter')]
  39. param(
  40. [Parameter(Mandatory, ParameterSetName='Letter', Position=0)]
  41. [ValidatePattern('^[A-Za-z]$')]
  42. [string]$DriveLetter,
  43. [Parameter(Mandatory, ParameterSetName='Number')]
  44. [ValidateRange(0, 99)]
  45. [int]$DiskNumber,
  46. [switch]$Json
  47. )
  48. $ErrorActionPreference = 'Stop'
  49. . "$PSScriptRoot\_lib\common.ps1"
  50. # Resolve target drive letter(s)
  51. if ($PSCmdlet.ParameterSetName -eq 'Number') {
  52. $parts = Get-Partition -DiskNumber $DiskNumber -ErrorAction SilentlyContinue
  53. if (-not $parts) {
  54. Write-Log -Level FAIL -Message "No partitions found on disk $DiskNumber"
  55. exit $script:EXIT_NOT_FOUND
  56. }
  57. $targetLetters = @($parts | Where-Object { $_.DriveLetter } | ForEach-Object { "$($_.DriveLetter)" })
  58. if (-not $targetLetters) {
  59. Write-Log -Level WARN -Message "Disk $DiskNumber has no mounted drive letters (still audit-worthy for system-volume refs)"
  60. $targetLetters = @()
  61. }
  62. } else {
  63. $targetLetters = @($DriveLetter.ToUpper())
  64. # Verify the drive exists
  65. if (-not (Get-PSDrive -PSProvider FileSystem -Name $DriveLetter.ToUpper() -ErrorAction SilentlyContinue)) {
  66. Write-Log -Level WARN -Message "Drive ${DriveLetter}: not currently mounted — auditing references anyway"
  67. }
  68. }
  69. # Build a drive-letter regex that doesn't false-positive on URL schemes
  70. # (e.g. the 'e:' in 'file:'). Require the letter to be either at string
  71. # start, or preceded by a non-alpha character, and followed by `:\` or `:/`.
  72. $letterPattern = if ($targetLetters) {
  73. $letters = ($targetLetters | ForEach-Object { [regex]::Escape($_) }) -join '|'
  74. "(?:^|[^A-Za-z])($letters):[\\/]"
  75. } else { '__NOMATCH__' }
  76. # Force case-sensitive match so lowercase 'e' inside 'file:' won't match 'E:'
  77. function Test-DrivePath {
  78. param([string]$Text)
  79. if (-not $Text) { return $false }
  80. return [regex]::IsMatch($Text, $letterPattern)
  81. }
  82. $findings = New-Object System.Collections.Generic.List[hashtable]
  83. function Add-Dependency {
  84. param(
  85. [Parameter(Mandatory)][string]$Category,
  86. [Parameter(Mandatory)][string]$Name,
  87. [Parameter(Mandatory)][string]$Target,
  88. [Parameter(Mandatory)][ValidateSet('critical','warn','info')]$Severity
  89. )
  90. $findings.Add(@{ category=$Category; name=$Name; target=$Target; severity=$Severity })
  91. }
  92. if (-not $Json) {
  93. Write-Section "Drive dependency audit: $($targetLetters -join ', ')"
  94. }
  95. # ─────────────────────────────────────────────────────────────────────
  96. # 1. Pagefile location
  97. # ─────────────────────────────────────────────────────────────────────
  98. try {
  99. $pagefiles = Get-CimInstance Win32_PageFileSetting -ErrorAction SilentlyContinue
  100. foreach ($pf in $pagefiles) {
  101. if (Test-DrivePath $pf.Name) {
  102. Add-Dependency -Category 'pagefile' -Name $pf.Name -Target $pf.Name -Severity 'critical'
  103. }
  104. }
  105. } catch {}
  106. # ─────────────────────────────────────────────────────────────────────
  107. # 2. Windows Search index data directory
  108. # ─────────────────────────────────────────────────────────────────────
  109. try {
  110. $idxDir = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows Search' -Name DataDirectory -ErrorAction SilentlyContinue).DataDirectory
  111. if (Test-DrivePath $idxDir) {
  112. Add-Dependency -Category 'search-index' -Name 'Windows.edb' -Target $idxDir -Severity 'warn'
  113. }
  114. } catch {}
  115. # ─────────────────────────────────────────────────────────────────────
  116. # 3. Windows Search indexed scopes (paths in the crawl scope)
  117. # ─────────────────────────────────────────────────────────────────────
  118. try {
  119. $scopeKey = 'HKLM:\SOFTWARE\Microsoft\Windows Search\CrawlScopeManager\Windows\SystemIndex\WorkingSetRules'
  120. if (Test-Path $scopeKey) {
  121. Get-ChildItem $scopeKey -ErrorAction SilentlyContinue | ForEach-Object {
  122. $url = (Get-ItemProperty $_.PSPath -Name URL -ErrorAction SilentlyContinue).URL
  123. if (Test-DrivePath $url) {
  124. Add-Dependency -Category 'search-scope' -Name 'Indexed path' -Target $url -Severity 'warn'
  125. }
  126. }
  127. }
  128. } catch {}
  129. # ─────────────────────────────────────────────────────────────────────
  130. # 4. Scheduled tasks
  131. # ─────────────────────────────────────────────────────────────────────
  132. try {
  133. Get-ScheduledTask -ErrorAction SilentlyContinue | ForEach-Object {
  134. $task = $_
  135. foreach ($action in $task.Actions) {
  136. $strs = @($action.Execute, $action.Arguments, $action.WorkingDirectory) -join ' '
  137. if (Test-DrivePath $strs) {
  138. Add-Dependency -Category 'scheduled-task' -Name $task.TaskName -Target ($strs.Trim()) -Severity 'warn'
  139. break
  140. }
  141. }
  142. }
  143. } catch {}
  144. # ─────────────────────────────────────────────────────────────────────
  145. # 5. Services with binary path on target drive
  146. # ─────────────────────────────────────────────────────────────────────
  147. try {
  148. Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | ForEach-Object {
  149. if (Test-DrivePath $_.PathName) {
  150. $sev = if ($_.StartMode -eq 'Auto') { 'critical' } else { 'warn' }
  151. Add-Dependency -Category 'service' -Name $_.Name -Target $_.PathName -Severity $sev
  152. }
  153. }
  154. } catch {}
  155. # ─────────────────────────────────────────────────────────────────────
  156. # 6. User profile symlinks/junctions pointing at target
  157. # ─────────────────────────────────────────────────────────────────────
  158. try {
  159. Get-ChildItem $env:USERPROFILE -Force -ErrorAction SilentlyContinue |
  160. Where-Object { $_.Attributes -band [System.IO.FileAttributes]::ReparsePoint } |
  161. ForEach-Object {
  162. if ($_.Target -and (Test-DrivePath ($_.Target -join ' '))) {
  163. Add-Dependency -Category 'profile-symlink' -Name $_.Name -Target ($_.Target -join '; ') -Severity 'warn'
  164. }
  165. }
  166. } catch {}
  167. # ─────────────────────────────────────────────────────────────────────
  168. # 7. Startup folder shortcuts targeting drive
  169. # ─────────────────────────────────────────────────────────────────────
  170. try {
  171. $shell = New-Object -ComObject WScript.Shell
  172. foreach ($d in @("$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup",
  173. "$env:ALLUSERSPROFILE\Microsoft\Windows\Start Menu\Programs\StartUp")) {
  174. if (Test-Path $d) {
  175. Get-ChildItem $d -Filter *.lnk -ErrorAction SilentlyContinue | ForEach-Object {
  176. $sc = $shell.CreateShortcut($_.FullName)
  177. $combined = @($sc.TargetPath, $sc.WorkingDirectory, $sc.Arguments) -join ' '
  178. if (Test-DrivePath $combined) {
  179. Add-Dependency -Category 'startup-shortcut' -Name $_.Name -Target $sc.TargetPath -Severity 'warn'
  180. }
  181. }
  182. }
  183. }
  184. } catch {}
  185. # ─────────────────────────────────────────────────────────────────────
  186. # 8. Registry Run-key entries pointing at drive
  187. # ─────────────────────────────────────────────────────────────────────
  188. $runPaths = @(
  189. 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
  190. 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
  191. 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'
  192. )
  193. foreach ($p in $runPaths) {
  194. if (Test-Path $p) {
  195. (Get-ItemProperty $p -ErrorAction SilentlyContinue).PSObject.Properties |
  196. Where-Object { $_.Name -notmatch '^PS' -and (Test-DrivePath $_.Value) } |
  197. ForEach-Object {
  198. Add-Dependency -Category 'run-key' -Name $_.Name -Target $_.Value -Severity 'warn'
  199. }
  200. }
  201. }
  202. # ─────────────────────────────────────────────────────────────────────
  203. # 9. Volume mount points (a folder on C: that mounts the target volume)
  204. # ─────────────────────────────────────────────────────────────────────
  205. try {
  206. $partitions = Get-Partition -ErrorAction SilentlyContinue | Where-Object {
  207. $_.DriveLetter -and $targetLetters -contains "$($_.DriveLetter)"
  208. }
  209. foreach ($p in $partitions) {
  210. $vol = Get-Volume -Partition $p -ErrorAction SilentlyContinue
  211. if ($vol -and $vol.AccessPaths) {
  212. foreach ($path in $vol.AccessPaths) {
  213. if ($path -match '^[A-Z]:\\' -and $path -notmatch "^${($p.DriveLetter)}:") {
  214. Add-Dependency -Category 'mount-point' -Name "$($p.DriveLetter): mounted at" -Target $path -Severity 'warn'
  215. }
  216. }
  217. }
  218. }
  219. } catch {}
  220. # ─────────────────────────────────────────────────────────────────────
  221. # Output
  222. # ─────────────────────────────────────────────────────────────────────
  223. $criticalCount = ($findings | Where-Object { $_.severity -eq 'critical' }).Count
  224. $warnCount = ($findings | Where-Object { $_.severity -eq 'warn' }).Count
  225. $infoCount = ($findings | Where-Object { $_.severity -eq 'info' }).Count
  226. $verdict = if ($criticalCount -gt 0) {
  227. 'DO NOT DISCONNECT — boot-critical references found'
  228. } elseif ($warnCount -gt 0) {
  229. 'WARNINGS — some references found; review before disconnecting'
  230. } else {
  231. 'SAFE TO DISCONNECT — no system dependencies on this drive'
  232. }
  233. if ($Json) {
  234. @{
  235. targetLetters = $targetLetters
  236. dependencies = $findings
  237. critical = $criticalCount
  238. warnings = $warnCount
  239. verdict = $verdict
  240. } | ConvertTo-Json -Depth 5 | ForEach-Object { [Console]::Out.WriteLine($_) }
  241. } else {
  242. if (-not $findings) {
  243. [Console]::Out.WriteLine("")
  244. [Console]::Out.WriteLine(" No dependencies found.")
  245. } else {
  246. [Console]::Out.WriteLine("")
  247. $findings | Sort-Object { $_.category } | ForEach-Object {
  248. $tag = switch ($_.severity) { 'critical' {'[CRITICAL]'} 'warn' {'[WARN] '} default {'[INFO] '} }
  249. [Console]::Out.WriteLine((" {0} {1,-18} {2,-40} {3}" -f $tag, $_.category, $_.name.Substring(0,[Math]::Min(40,$_.name.Length)), $_.target.Substring(0,[Math]::Min(80,$_.target.Length))))
  250. }
  251. }
  252. Write-Section "VERDICT"
  253. [Console]::Out.WriteLine(" $verdict")
  254. [Console]::Out.WriteLine("")
  255. [Console]::Out.WriteLine(" Critical: $criticalCount Warnings: $warnCount")
  256. }
  257. if ($criticalCount -gt 0) { exit $script:EXIT_VALIDATION }
  258. exit $script:EXIT_OK