Загрузка данных


#requires -version 5.1

<#
    MTS Link uninstall audit
    ------------------------
    НИЧЕГО НЕ УДАЛЯЕТ.

    Ищет MTS Link:
      - HKLM, 64-bit registry view
      - HKLM, 32-bit registry view
      - HKU для загруженных пользователей
      - NTUSER.DAT для незагруженных пользовательских профилей

    Собирает:
      - DisplayName
      - DisplayVersion
      - Publisher
      - InstallLocation
      - UninstallString
      - QuietUninstallString
      - MSI ProductCode
      - Registry path
      - User SID / profile
      - Scope
      - 32/64-bit registry view

    MTS Link Meetings 1.6.0.0 -> KEEP
    всё остальное MTS Link     -> REMOVE_LATER
#>

[CmdletBinding()]
param(
    [string]$OutputDirectory = "$env:ProgramData\MTSLink-Audit"
)

$ErrorActionPreference = 'Stop'

# ----------------------------------------------------------------------
# Настройки
# ----------------------------------------------------------------------

$KeepProductName    = 'MTS Link Meetings'
$KeepProductVersion = '1.6.0.0'

# Ловим и английское "MTS Link", и русское "МТС Линк"
$MtsNameRegex = '^(?i:(MTS\s*Link|МТС\s*Линк))'

$Results = New-Object System.Collections.Generic.List[object]
$Warnings = New-Object System.Collections.Generic.List[string]


# ----------------------------------------------------------------------
# Вспомогательные функции
# ----------------------------------------------------------------------

function Get-RegValue {
    param(
        [Microsoft.Win32.RegistryKey]$Key,
        [string]$Name
    )

    try {
        return $Key.GetValue(
            $Name,
            $null,
            [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
        )
    }
    catch {
        return $null
    }
}


function Get-ProductCodeFromString {
    param(
        [string]$Text,
        [string]$KeyName
    )

    # Если имя uninstall-раздела уже GUID
    if ($KeyName -match '^\{[0-9A-Fa-f-]{36}\}$') {
        return $KeyName
    }

    # Или GUID содержится в uninstall-команде
    if ($Text -match '\{[0-9A-Fa-f-]{36}\}') {
        return $Matches[0]
    }

    return $null
}


function Read-UninstallKey {
    param(
        [Microsoft.Win32.RegistryHive]$Hive,
        [Microsoft.Win32.RegistryView]$View,

        [string]$UninstallPath,

        [ValidateSet('Machine','User')]
        [string]$Scope,

        [string]$UserSID,
        [string]$ProfilePath,

        [string]$LogicalRegistryRoot
    )

    $baseKey = $null
    $uninstallKey = $null

    try {
        $baseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey($Hive, $View)
        $uninstallKey = $baseKey.OpenSubKey($UninstallPath)

        if (-not $uninstallKey) {
            return
        }

        foreach ($subKeyName in $uninstallKey.GetSubKeyNames()) {

            $appKey = $null

            try {
                $appKey = $uninstallKey.OpenSubKey($subKeyName)

                if (-not $appKey) {
                    continue
                }

                $displayName = [string](Get-RegValue -Key $appKey -Name 'DisplayName')

                if ([string]::IsNullOrWhiteSpace($displayName)) {
                    continue
                }

                if ($displayName -notmatch $MtsNameRegex) {
                    continue
                }

                $displayVersion       = [string](Get-RegValue -Key $appKey -Name 'DisplayVersion')
                $publisher            = [string](Get-RegValue -Key $appKey -Name 'Publisher')
                $installLocation      = [string](Get-RegValue -Key $appKey -Name 'InstallLocation')
                $installSource        = [string](Get-RegValue -Key $appKey -Name 'InstallSource')
                $uninstallString      = [string](Get-RegValue -Key $appKey -Name 'UninstallString')
                $quietUninstallString = [string](Get-RegValue -Key $appKey -Name 'QuietUninstallString')
                $modifyPath           = [string](Get-RegValue -Key $appKey -Name 'ModifyPath')
                $windowsInstaller     = Get-RegValue -Key $appKey -Name 'WindowsInstaller'

                $productCode = Get-ProductCodeFromString `
                    -Text "$uninstallString $quietUninstallString $modifyPath" `
                    -KeyName $subKeyName

                # Какая команда сейчас является наиболее подходящей зарегистрированной
                # командой удаления. Ничего не запускаем.
                $effectiveCommand = $null
                $commandSource = $null

                if (-not [string]::IsNullOrWhiteSpace($quietUninstallString)) {
                    $effectiveCommand = $quietUninstallString
                    $commandSource = 'QuietUninstallString'
                }
                elseif (-not [string]::IsNullOrWhiteSpace($uninstallString)) {
                    $effectiveCommand = $uninstallString
                    $commandSource = 'UninstallString'
                }

                # Только информационное поле.
                # Эта команда НЕ запускается скриптом.
                $derivedMsiCommand = $null

                if (
                    $productCode -and
                    (
                        $windowsInstaller -eq 1 -or
                        $uninstallString -match '(?i)msiexec'
                    )
                ) {
                    $derivedMsiCommand =
                        "msiexec.exe /x $productCode /qn /norestart"
                }

                if (
                    $displayName -eq $KeepProductName -and
                    $displayVersion -eq $KeepProductVersion
                ) {
                    $action = 'KEEP'
                }
                else {
                    $action = 'REMOVE_LATER'
                }

                $viewName = if (
                    $View -eq [Microsoft.Win32.RegistryView]::Registry64
                ) {
                    '64-bit'
                }
                else {
                    '32-bit'
                }

                $logicalRegistryPath =
                    "$LogicalRegistryRoot\$subKeyName"

                $Results.Add(
                    [PSCustomObject]@{
                        Action                     = $action
                        Scope                      = $Scope
                        UserSID                    = $UserSID
                        ProfilePath                = $ProfilePath

                        DisplayName                = $displayName
                        DisplayVersion             = $displayVersion
                        Publisher                  = $publisher

                        RegistryView               = $viewName
                        RegistryPath               = $logicalRegistryPath
                        RegistryKeyName            = $subKeyName

                        InstallLocation            = $installLocation
                        InstallSource              = $installSource

                        UninstallString             = $uninstallString
                        QuietUninstallString        = $quietUninstallString
                        EffectiveUninstallCommand   = $effectiveCommand
                        EffectiveCommandSource      = $commandSource

                        WindowsInstaller            = $windowsInstaller
                        MsiProductCode              = $productCode

                        # Только подсказка для следующего этапа.
                        # Сейчас НЕ выполняется.
                        DerivedMsiSilentCommand     = $derivedMsiCommand
                    }
                )
            }
            catch {
                $Warnings.Add(
                    "Ошибка чтения $UninstallPath\$subKeyName : $($_.Exception.Message)"
                )
            }
            finally {
                if ($appKey) {
                    $appKey.Close()
                    $appKey.Dispose()
                }
            }
        }
    }
    catch {
        $Warnings.Add(
            "Ошибка чтения registry path '$UninstallPath': $($_.Exception.Message)"
        )
    }
    finally {
        if ($uninstallKey) {
            $uninstallKey.Close()
            $uninstallKey.Dispose()
        }

        if ($baseKey) {
            $baseKey.Close()
            $baseKey.Dispose()
        }
    }
}


function Test-UserHiveLoaded {
    param(
        [string]$SID
    )

    $users = $null
    $sidKey = $null

    try {
        $users = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
            [Microsoft.Win32.RegistryHive]::Users,
            [Microsoft.Win32.RegistryView]::Registry64
        )

        $sidKey = $users.OpenSubKey($SID)

        return ($null -ne $sidKey)
    }
    catch {
        return $false
    }
    finally {
        if ($sidKey) {
            $sidKey.Close()
            $sidKey.Dispose()
        }

        if ($users) {
            $users.Close()
            $users.Dispose()
        }
    }
}


function Scan-UserHive {
    param(
        [string]$MountedHiveName,
        [string]$RealSID,
        [string]$ProfilePath
    )

    $relativePath =
        "$MountedHiveName\Software\Microsoft\Windows\CurrentVersion\Uninstall"

    $logicalPath =
        "HKEY_USERS\$RealSID\Software\Microsoft\Windows\CurrentVersion\Uninstall"

    foreach ($view in @(
        [Microsoft.Win32.RegistryView]::Registry64,
        [Microsoft.Win32.RegistryView]::Registry32
    )) {
        Read-UninstallKey `
            -Hive ([Microsoft.Win32.RegistryHive]::Users) `
            -View $view `
            -UninstallPath $relativePath `
            -Scope 'User' `
            -UserSID $RealSID `
            -ProfilePath $ProfilePath `
            -LogicalRegistryRoot $logicalPath
    }
}


# ----------------------------------------------------------------------
# 1. Машинные установки
# ----------------------------------------------------------------------

Write-Host 'Scanning machine-wide uninstall registry...'

foreach ($view in @(
    [Microsoft.Win32.RegistryView]::Registry64,
    [Microsoft.Win32.RegistryView]::Registry32
)) {

    Read-UninstallKey `
        -Hive ([Microsoft.Win32.RegistryHive]::LocalMachine) `
        -View $view `
        -UninstallPath 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' `
        -Scope 'Machine' `
        -UserSID $null `
        -ProfilePath $null `
        -LogicalRegistryRoot 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
}


# ----------------------------------------------------------------------
# 2. Получаем профили пользователей
# ----------------------------------------------------------------------

Write-Host 'Enumerating user profiles...'

$profileBase = $null
$profileList = $null

$Profiles = @()

try {
    $profileBase = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
        [Microsoft.Win32.RegistryHive]::LocalMachine,
        [Microsoft.Win32.RegistryView]::Registry64
    )

    $profileList = $profileBase.OpenSubKey(
        'SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
    )

    foreach ($sid in $profileList.GetSubKeyNames()) {

        # Системные SID нам здесь не нужны
        if ($sid -in @(
            'S-1-5-18',
            'S-1-5-19',
            'S-1-5-20'
        )) {
            continue
        }

        $profileKey = $null

        try {
            $profileKey = $profileList.OpenSubKey($sid)

            $profilePath = [string]$profileKey.GetValue('ProfileImagePath')

            if ([string]::IsNullOrWhiteSpace($profilePath)) {
                continue
            }

            $profilePath =
                [Environment]::ExpandEnvironmentVariables($profilePath)

            $ntUserDat = Join-Path $profilePath 'NTUSER.DAT'

            # Если профиля физически нет, пропускаем
            if (-not (Test-Path -LiteralPath $ntUserDat)) {
                continue
            }

            $Profiles += [PSCustomObject]@{
                SID         = $sid
                ProfilePath = $profilePath
                NtUserDat   = $ntUserDat
            }
        }
        finally {
            if ($profileKey) {
                $profileKey.Close()
                $profileKey.Dispose()
            }
        }
    }
}
finally {
    if ($profileList) {
        $profileList.Close()
        $profileList.Dispose()
    }

    if ($profileBase) {
        $profileBase.Close()
        $profileBase.Dispose()
    }
}


# ----------------------------------------------------------------------
# 3. Пользовательские установки
# ----------------------------------------------------------------------

$counter = 0

foreach ($profile in $Profiles) {

    $counter++

    $sid = $profile.SID
    $profilePath = $profile.ProfilePath
    $ntUserDat = $profile.NtUserDat

    Write-Host "Scanning user: $sid [$profilePath]"

    if (Test-UserHiveLoaded -SID $sid) {

        # Hive уже загружен, например пользователь сейчас залогинен.
        Scan-UserHive `
            -MountedHiveName $sid `
            -RealSID $sid `
            -ProfilePath $profilePath

        continue
    }


    # ------------------------------------------------------------------
    # Hive не загружен.
    # Временно подключаем NTUSER.DAT только для чтения.
    # ------------------------------------------------------------------

    $safePid = $PID
    $mountName = "MTSLinkAudit_${safePid}_${counter}"

    $loadedByUs = $false

    try {
        Write-Host "  Loading offline hive: $ntUserDat"

        & "$env:SystemRoot\System32\reg.exe" `
            load "HKU\$mountName" "$ntUserDat" 2>&1 | Out-Null

        if ($LASTEXITCODE -ne 0) {
            $Warnings.Add(
                "Не удалось загрузить NTUSER.DAT для $sid : $ntUserDat"
            )
            continue
        }

        $loadedByUs = $true

        Scan-UserHive `
            -MountedHiveName $mountName `
            -RealSID $sid `
            -ProfilePath $profilePath
    }
    catch {
        $Warnings.Add(
            "Ошибка сканирования профиля $sid : $($_.Exception.Message)"
        )
    }
    finally {
        if ($loadedByUs) {

            # На всякий случай освобождаем RegistryKey handles перед reg unload
            [GC]::Collect()
            [GC]::WaitForPendingFinalizers()

            & "$env:SystemRoot\System32\reg.exe" `
                unload "HKU\$mountName" 2>&1 | Out-Null

            if ($LASTEXITCODE -ne 0) {
                $Warnings.Add(
                    "Не удалось выгрузить временный hive HKU\$mountName"
                )
            }
        }
    }
}


# ----------------------------------------------------------------------
# 4. Удаляем только полностью идентичные дубли из результата
# ----------------------------------------------------------------------

$Results = @(
    $Results |
        Sort-Object `
            Scope,
            UserSID,
            DisplayName,
            DisplayVersion,
            RegistryView,
            RegistryPath `
            -Unique
)


# ----------------------------------------------------------------------
# 5. Сохраняем отчёт
# ----------------------------------------------------------------------

if (-not (Test-Path -LiteralPath $OutputDirectory)) {
    New-Item `
        -Path $OutputDirectory `
        -ItemType Directory `
        -Force | Out-Null
}

$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'

$csvPath =
    Join-Path $OutputDirectory "MTSLink_Audit_$timestamp.csv"

$jsonPath =
    Join-Path $OutputDirectory "MTSLink_Audit_$timestamp.json"

$txtPath =
    Join-Path $OutputDirectory "MTSLink_Audit_$timestamp.txt"


$Results |
    Export-Csv `
        -LiteralPath $csvPath `
        -NoTypeInformation `
        -Encoding UTF8 `
        -Delimiter ';'


$Results |
    ConvertTo-Json -Depth 5 |
    Set-Content `
        -LiteralPath $jsonPath `
        -Encoding UTF8


$txt = New-Object System.Collections.Generic.List[string]

$txt.Add("ComputerName: $env:COMPUTERNAME")
$txt.Add("Audit time  : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
$txt.Add("Found       : $($Results.Count)")
$txt.Add("")

foreach ($item in $Results) {

    $txt.Add('============================================================')
    $txt.Add("Action       : $($item.Action)")
    $txt.Add("Name         : $($item.DisplayName)")
    $txt.Add("Version      : $($item.DisplayVersion)")
    $txt.Add("Scope        : $($item.Scope)")
    $txt.Add("RegistryView : $($item.RegistryView)")
    $txt.Add("UserSID      : $($item.UserSID)")
    $txt.Add("Profile      : $($item.ProfilePath)")
    $txt.Add("Registry     : $($item.RegistryPath)")
    $txt.Add("")
    $txt.Add("UninstallString:")
    $txt.Add("  $($item.UninstallString)")
    $txt.Add("")
    $txt.Add("QuietUninstallString:")
    $txt.Add("  $($item.QuietUninstallString)")
    $txt.Add("")
    $txt.Add("Effective command [$($item.EffectiveCommandSource)]:")
    $txt.Add("  $($item.EffectiveUninstallCommand)")

    if ($item.DerivedMsiSilentCommand) {
        $txt.Add("")
        $txt.Add("Derived MSI command - NOT EXECUTED:")
        $txt.Add("  $($item.DerivedMsiSilentCommand)")
    }

    $txt.Add("")
}

if ($Warnings.Count -gt 0) {
    $txt.Add('================ WARNINGS =================')
    foreach ($warning in $Warnings) {
        $txt.Add($warning)
    }
}

$txt |
    Set-Content `
        -LiteralPath $txtPath `
        -Encoding UTF8


# ----------------------------------------------------------------------
# 6. Вывод для SCCM / консоли
# ----------------------------------------------------------------------

Write-Host ''
Write-Host '================ MTS LINK AUDIT ================'
Write-Host "Computer : $env:COMPUTERNAME"
Write-Host "Found    : $($Results.Count)"
Write-Host ''

if ($Results.Count -eq 0) {

    Write-Host 'MTS Link products not found.'
}
else {

    $Results |
        Select-Object `
            Action,
            Scope,
            DisplayName,
            DisplayVersion,
            RegistryView,
            UserSID,
            EffectiveCommandSource,
            EffectiveUninstallCommand |
        Format-Table -AutoSize |
        Out-String -Width 500 |
        Write-Host


    Write-Host ''
    Write-Host '----------- REGISTERED UNINSTALL COMMANDS -----------'

    foreach ($item in $Results) {

        Write-Host ''
        Write-Host "[$($item.Action)] $($item.DisplayName) $($item.DisplayVersion)"
        Write-Host "Scope   : $($item.Scope)"
        Write-Host "UserSID : $($item.UserSID)"
        Write-Host "Registry: $($item.RegistryPath)"
        Write-Host "UninstallString      : $($item.UninstallString)"
        Write-Host "QuietUninstallString : $($item.QuietUninstallString)"
        Write-Host "Effective            : $($item.EffectiveUninstallCommand)"

        if ($item.DerivedMsiSilentCommand) {
            Write-Host "Derived MSI - NOT RUN : $($item.DerivedMsiSilentCommand)"
        }
    }
}


Write-Host ''
Write-Host "CSV : $csvPath"
Write-Host "JSON: $jsonPath"
Write-Host "TXT : $txtPath"

if ($Warnings.Count -gt 0) {
    Write-Host ''
    Write-Host "Warnings: $($Warnings.Count)"

    foreach ($warning in $Warnings) {
        Write-Warning $warning
    }
}

# Всегда 0: это пока аудит, не remediation.
exit 0