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


#requires -version 5.1

<#
    MTS Link Audit v2
    НИЧЕГО НЕ УДАЛЯЕТ.

    Ищет зарегистрированные uninstall-команды:
      - HKLM 64-bit
      - HKLM 32-bit
      - пользовательские HKU
      - NTUSER.DAT незалогиненных пользователей

    MTS Link Meetings 1.6.0.0 = KEEP
    остальные MTS Link          = REMOVE_LATER
#>

[CmdletBinding()]
param()

$ErrorActionPreference = 'Continue'

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

$OutputDirectory = 'C:\ProgramData\MTSLink-Audit'

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

# MTS Link / MTSLink / МТС Линк
$MtsNameRegex = '(?i)^(MTS\s*Link|МТС\s*Линк)'

$Timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'

# ============================================================
# Папку создаём СРАЗУ
# ============================================================

try {
    [System.IO.Directory]::CreateDirectory($OutputDirectory) | Out-Null
}
catch {
    Write-Output "FATAL: Cannot create $OutputDirectory"
    Write-Output $_.Exception.Message
    exit 1
}

$LogPath  = Join-Path $OutputDirectory "MTSLink_Audit_$Timestamp.log"
$CsvPath  = Join-Path $OutputDirectory "MTSLink_Audit_$Timestamp.csv"
$TxtPath  = Join-Path $OutputDirectory "MTSLink_Audit_$Timestamp.txt"

$Results  = New-Object System.Collections.ArrayList
$Warnings = New-Object System.Collections.ArrayList


function Write-Log {
    param(
        [string]$Message
    )

    $line = '{0}  {1}' -f (
        Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    ), $Message

    try {
        Add-Content `
            -LiteralPath $LogPath `
            -Value $line `
            -Encoding UTF8 `
            -ErrorAction SilentlyContinue
    }
    catch {
    }

    Write-Output $Message
}


function Add-WarningLog {
    param(
        [string]$Message
    )

    [void]$Warnings.Add($Message)
    Write-Log "WARNING: $Message"
}


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-MsiProductCode {
    param(
        [string]$KeyName,
        [string]$Text
    )

    if ($KeyName -match '^\{[0-9A-Fa-f-]{36}\}$') {
        return $KeyName
    }

    if ($Text -match '\{[0-9A-Fa-f-]{36}\}') {
        return $Matches[0]
    }

    return $null
}


function Invoke-RegExe {
    param(
        [string]$Arguments
    )

    $result = [ordered]@{
        ExitCode = -1
        StdOut   = ''
        StdErr   = ''
    }

    try {
        $psi = New-Object System.Diagnostics.ProcessStartInfo

        $psi.FileName =
            "$env:SystemRoot\System32\reg.exe"

        $psi.Arguments              = $Arguments
        $psi.UseShellExecute        = $false
        $psi.CreateNoWindow         = $true
        $psi.RedirectStandardOutput = $true
        $psi.RedirectStandardError  = $true

        $process = New-Object System.Diagnostics.Process
        $process.StartInfo = $psi

        [void]$process.Start()

        $stdout = $process.StandardOutput.ReadToEnd()
        $stderr = $process.StandardError.ReadToEnd()

        $process.WaitForExit()

        $result.ExitCode = $process.ExitCode
        $result.StdOut   = $stdout.Trim()
        $result.StdErr   = $stderr.Trim()

        $process.Dispose()
    }
    catch {
        $result.StdErr = $_.Exception.Message
    }

    return [PSCustomObject]$result
}


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

    $base = $null
    $key  = $null

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

        $key = $base.OpenSubKey($SID)

        if ($null -ne $key) {
            return $true
        }

        return $false
    }
    catch {
        return $false
    }
    finally {

        if ($key) {
            $key.Dispose()
        }

        if ($base) {
            $base.Dispose()
        }
    }
}


function Read-UninstallRoot {

    param(
        [Microsoft.Win32.RegistryHive]$Hive,
        [Microsoft.Win32.RegistryView]$View,

        [string]$RegistryPath,

        [string]$DisplayRegistryPath,

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

        [string]$SID,

        [string]$ProfilePath
    )

    $base = $null
    $root = $null

    try {

        $base = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
            $Hive,
            $View
        )

        $root = $base.OpenSubKey($RegistryPath)

        if ($null -eq $root) {
            return
        }

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

            $app = $null

            try {

                $app = $root.OpenSubKey($subKeyName)

                if ($null -eq $app) {
                    continue
                }

                $displayName =
                    [string](Get-RegValue $app 'DisplayName')

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

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

                $displayVersion =
                    [string](Get-RegValue $app 'DisplayVersion')

                $publisher =
                    [string](Get-RegValue $app 'Publisher')

                $installLocation =
                    [string](Get-RegValue $app 'InstallLocation')

                $uninstallString =
                    [string](Get-RegValue $app 'UninstallString')

                $quietUninstallString =
                    [string](Get-RegValue $app 'QuietUninstallString')

                $modifyPath =
                    [string](Get-RegValue $app 'ModifyPath')

                $windowsInstaller =
                    Get-RegValue $app 'WindowsInstaller'

                $allCommandText =
                    "$uninstallString $quietUninstallString $modifyPath"

                $productCode =
                    Get-MsiProductCode `
                        -KeyName $subKeyName `
                        -Text $allCommandText


                # --------------------------------------------
                # Какую зарегистрированную команду считать
                # основной для будущего удаления
                # --------------------------------------------

                $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'
                }


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


                $item = [PSCustomObject]@{

                    Action                   = $action
                    Scope                    = $Scope

                    UserSID                  = $SID
                    ProfilePath              = $ProfilePath

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

                    RegistryView             = $viewText
                    RegistryPath             =
                        "$DisplayRegistryPath\$subKeyName"

                    RegistryKeyName          = $subKeyName

                    InstallLocation          = $installLocation

                    UninstallString          = $uninstallString
                    QuietUninstallString     = $quietUninstallString

                    EffectiveCommandSource   = $commandSource
                    EffectiveUninstallCommand =
                        $effectiveCommand

                    WindowsInstaller         = $windowsInstaller
                    MsiProductCode           = $productCode

                    DerivedMsiSilentCommand  =
                        $derivedMsiCommand
                }

                [void]$Results.Add($item)

                Write-Log (
                    "FOUND: [{0}] [{1}] {2} {3}" -f
                    $action,
                    $Scope,
                    $displayName,
                    $displayVersion
                )
            }
            catch {

                Add-WarningLog (
                    "Cannot read entry {0}\{1}: {2}" -f
                    $DisplayRegistryPath,
                    $subKeyName,
                    $_.Exception.Message
                )
            }
            finally {

                if ($app) {
                    $app.Dispose()
                }
            }
        }
    }
    catch {

        Add-WarningLog (
            "Cannot scan registry path {0}: {1}" -f
            $DisplayRegistryPath,
            $_.Exception.Message
        )
    }
    finally {

        if ($root) {
            $root.Dispose()
        }

        if ($base) {
            $base.Dispose()
        }
    }
}


function Scan-UserHive {

    param(
        [string]$MountedName,
        [string]$RealSID,
        [string]$ProfilePath
    )

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

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

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

        Read-UninstallRoot `
            -Hive ([Microsoft.Win32.RegistryHive]::Users) `
            -View $view `
            -RegistryPath $relativePath `
            -DisplayRegistryPath $displayPath `
            -Scope 'User' `
            -SID $RealSID `
            -ProfilePath $ProfilePath
    }
}


# ============================================================
# START
# ============================================================

Write-Log '============================================================'
Write-Log 'MTS Link audit started'
Write-Log "Computer: $env:COMPUTERNAME"
Write-Log "Running as: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Write-Log "Output: $OutputDirectory"
Write-Log '============================================================'


# ============================================================
# 1. HKLM
# ============================================================

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

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

    Read-UninstallRoot `
        -Hive ([Microsoft.Win32.RegistryHive]::LocalMachine) `
        -View $view `
        -RegistryPath 'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' `
        -DisplayRegistryPath 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' `
        -Scope 'Machine' `
        -SID $null `
        -ProfilePath $null
}


# ============================================================
# 2. Получаем реальные пользовательские профили
# ============================================================

Write-Log 'Enumerating user profiles...'

$Profiles = @()

$profileBase = $null
$profileList = $null

try {

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

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

    if ($null -eq $profileList) {
        throw 'ProfileList registry key not found'
    }


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

        # Системные учётки
        if ($sid -in @(
            'S-1-5-18',
            'S-1-5-19',
            'S-1-5-20'
        )) {
            continue
        }

        $profileKey = $null

        try {

            $profileKey = $profileList.OpenSubKey($sid)

            if ($null -eq $profileKey) {
                continue
            }

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

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

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


            # --------------------------------------------
            # Не сканируем системные ServiceProfiles
            # --------------------------------------------

            if (
                $profilePath.StartsWith(
                    $env:SystemRoot,
                    [System.StringComparison]::OrdinalIgnoreCase
                )
            ) {
                Write-Log "Skipping system profile: $profilePath"
                continue
            }


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


            # Если hive уже загружен, NTUSER.DAT
            # может быть занят — это нормально.
            if (
                -not (Test-HiveLoaded $sid) -and
                -not (Test-Path -LiteralPath $ntUserDat)
            ) {
                Write-Log "Skipping profile without NTUSER.DAT: $profilePath"
                continue
            }


            $Profiles += [PSCustomObject]@{
                SID         = $sid
                ProfilePath = $profilePath
                NTUserDat   = $ntUserDat
            }
        }
        catch {

            Add-WarningLog (
                "Profile $sid enumeration failed: " +
                $_.Exception.Message
            )
        }
        finally {

            if ($profileKey) {
                $profileKey.Dispose()
            }
        }
    }
}
catch {

    Add-WarningLog (
        "Profile enumeration failed: " +
        $_.Exception.Message
    )
}
finally {

    if ($profileList) {
        $profileList.Dispose()
    }

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


# ============================================================
# 3. Сканируем пользовательские hive
# ============================================================

$counter = 0

foreach ($profile in $Profiles) {

    $counter++

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

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


    # --------------------------------------------------------
    # Hive уже загружен
    # --------------------------------------------------------

    if (Test-HiveLoaded $sid) {

        Write-Log "Hive already loaded: HKEY_USERS\$sid"

        try {

            Scan-UserHive `
                -MountedName $sid `
                -RealSID $sid `
                -ProfilePath $profilePath
        }
        catch {

            Add-WarningLog (
                "Loaded hive scan failed for $sid : " +
                $_.Exception.Message
            )
        }

        continue
    }


    # --------------------------------------------------------
    # Hive не загружен
    # --------------------------------------------------------

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

    $loadedByScript = $false

    try {

        Write-Log "Loading offline hive: $ntUserDat"

        $loadResult =
            Invoke-RegExe (
                'load "HKU\{0}" "{1}"' -f
                $mountName,
                $ntUserDat
            )


        if ($loadResult.ExitCode -ne 0) {

            Add-WarningLog (
                "reg.exe LOAD failed for $sid. " +
                "ExitCode=$($loadResult.ExitCode); " +
                "Error=$($loadResult.StdErr)"
            )

            continue
        }


        $loadedByScript = $true

        Write-Log "Offline hive loaded as HKU\$mountName"


        Scan-UserHive `
            -MountedName $mountName `
            -RealSID $sid `
            -ProfilePath $profilePath
    }
    catch {

        Add-WarningLog (
            "Offline hive scan failed for $sid : " +
            $_.Exception.Message
        )
    }
    finally {

        if ($loadedByScript) {

            [GC]::Collect()
            [GC]::WaitForPendingFinalizers()

            Start-Sleep -Milliseconds 200

            $unloadResult =
                Invoke-RegExe (
                    'unload "HKU\{0}"' -f
                    $mountName
                )

            if ($unloadResult.ExitCode -eq 0) {

                Write-Log "Hive unloaded: HKU\$mountName"
            }
            else {

                Add-WarningLog (
                    "reg.exe UNLOAD failed for HKU\$mountName. " +
                    "ExitCode=$($unloadResult.ExitCode); " +
                    "Error=$($unloadResult.StdErr)"
                )
            }
        }
    }
}


# ============================================================
# 4. Убираем точные дубли
# ============================================================

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


# ============================================================
# 5. CSV
# ============================================================

try {

    $FinalResults |
        Export-Csv `
            -LiteralPath $CsvPath `
            -NoTypeInformation `
            -Encoding UTF8 `
            -Delimiter ';'

    Write-Log "CSV saved: $CsvPath"
}
catch {

    Add-WarningLog (
        "CSV save failed: " +
        $_.Exception.Message
    )
}


# ============================================================
# 6. TXT
# ============================================================

try {

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

    $text.Add("ComputerName : $env:COMPUTERNAME")
    $text.Add(
        "AuditTime    : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
    )
    $text.Add("Found        : $($FinalResults.Count)")
    $text.Add("Warnings     : $($Warnings.Count)")
    $text.Add('')


    foreach ($item in $FinalResults) {

        $text.Add(
            '============================================================'
        )

        $text.Add("Action      : $($item.Action)")
        $text.Add("Name        : $($item.DisplayName)")
        $text.Add("Version     : $($item.DisplayVersion)")
        $text.Add("Scope       : $($item.Scope)")
        $text.Add("UserSID     : $($item.UserSID)")
        $text.Add("Profile     : $($item.ProfilePath)")
        $text.Add("RegView     : $($item.RegistryView)")
        $text.Add("Registry    : $($item.RegistryPath)")
        $text.Add("")

        $text.Add('UninstallString:')
        $text.Add("  $($item.UninstallString)")
        $text.Add("")

        $text.Add('QuietUninstallString:')
        $text.Add("  $($item.QuietUninstallString)")
        $text.Add("")

        $text.Add(
            "Effective [$($item.EffectiveCommandSource)]:"
        )

        $text.Add(
            "  $($item.EffectiveUninstallCommand)"
        )


        if ($item.DerivedMsiSilentCommand) {

            $text.Add('')
            $text.Add(
                'Derived MSI command - INFORMATION ONLY, NOT EXECUTED:'
            )

            $text.Add(
                "  $($item.DerivedMsiSilentCommand)"
            )
        }

        $text.Add('')
    }


    if ($Warnings.Count -gt 0) {

        $text.Add('')
        $text.Add(
            '==================== WARNINGS ===================='
        )

        foreach ($warning in $Warnings) {
            $text.Add($warning)
        }
    }


    $text |
        Set-Content `
            -LiteralPath $TxtPath `
            -Encoding UTF8

    Write-Log "TXT saved: $TxtPath"
}
catch {

    Add-WarningLog (
        "TXT save failed: " +
        $_.Exception.Message
    )
}


# ============================================================
# 7. SCCM output
# ============================================================

Write-Log '============================================================'
Write-Log "AUDIT COMPLETE"
Write-Log "Found: $($FinalResults.Count)"
Write-Log "Warnings: $($Warnings.Count)"
Write-Log "Folder: $OutputDirectory"
Write-Log '============================================================'


foreach ($item in $FinalResults) {

    Write-Output (
        "{0} | {1} | {2} | {3} | {4}" -f
        $item.Action,
        $item.Scope,
        $item.DisplayName,
        $item.DisplayVersion,
        $item.EffectiveUninstallCommand
    )
}


# Аудит не считаем Failed из-за одного проблемного профиля
exit 0