Private/Kinds/BrowserExtension.ps1

# The BrowserExtension Kind: whether an application's browser extension is installed and switched on, in each browser and profile.
#
# A password manager, a telephony client, a signing tool: the part of it the User works
# with is an extension in the browser, and "it does not fill in my passwords" is a
# ticket about that extension - not installed in the profile the User works in, switched
# off, or cut off from the desktop application it talks to. A Run said nothing of any of
# it. Observed 2026-10-13 on a workstation: a password manager's extension in Chrome and
# in Edge, each in the one profile, in two different versions, none in either of two
# Firefox profiles; and its link to the desktop application registered for all three.
#
# What is read, for the User of the Run:
#
# - Chrome and Edge: for every profile, whether the folder of the extension is there
# and which version it holds, and from the profile's preferences whether it is
# switched off. Of the preferences only the entry of this one extension is read.
# - Firefox: for every profile, the entry of the extension in extensions.json.
# - The link to the desktop application ("native messaging"), where a Check Definition
# names one: whether it is registered for each browser, and whether what it points
# at is there.
#
# Nothing of what an extension stores is read, and nothing a User did in a browser.
# Readable without admin rights.

function Get-BrowserExtensionSetting {
    # One field of an entry that arrives as ConvertFrom-Json made it or as a hashtable. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Entry, [Parameter(Mandatory)][string]$Name)

    if ($null -eq $Entry) { return $null }
    if ($Entry -is [System.Collections.IDictionary]) { if ($Entry.Contains($Name)) { return $Entry[$Name] } else { return $null } }
    $property = $Entry.PSObject.Properties[$Name]
    if ($property) { return $property.Value }
    $null
}

function Select-BrowserExtensionVersion {
    <#
    .SYNOPSIS
        The newest of the versions an extension's folder holds: "2026.9.2_0" beside
        "2026.8.0_0" is 2026.9.2. Pure.
    .DESCRIPTION
        A browser keeps the version before beside the new one until it has restarted, so
        two folders are no fault. The part behind the underscore counts installs of one
        version and is no part of it.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Name)

    $read = @($Name | Where-Object { $_ } | ForEach-Object {
        $text = ("$_" -split '_')[0]
        $parsed = $null
        if (-not [version]::TryParse($text, [ref]$parsed)) { $parsed = [version]'0.0' }
        [pscustomobject]@{ Text = $text; Version = $parsed }
    } | Sort-Object Version -Descending)
    if ($read.Count) { return $read[0].Text }
    ''
}

function ConvertFrom-BrowserExtensionPreference {
    <#
    .SYNOPSIS
        Whether a Chromium profile's preferences say an extension is switched on: $true,
        $false, or $null where they do not name it. Pure.
    .DESCRIPTION
        Read by the rule the Browser Kind reads every extension by: Test-BrowserExtensionOn.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Preference, [Parameter(Mandatory)][string]$Id)

    $entries = Get-BrowserPreferenceSetting -Document $Preference
    if (-not $entries.ContainsKey($Id)) { return $null }
    Test-BrowserExtensionOn -Entry $entries[$Id]
}

function Get-BrowserExtensionChromium {
    <#
    .SYNOPSIS
        One row for every profile of a Chromium browser: whether the extension is there,
        its version, and whether it is switched on.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Browser, [AllowEmptyString()][string]$Root = '', [AllowNull()][AllowEmptyString()][string]$Id)

    if (-not $Id) { return }
    # Which profiles there are is the one list both browser Kinds share. See BrowserProfile.ps1.
    foreach ($found in @(Get-BrowserProfile -Browser $Browser -Root $Root)) {
        $folder    = Join-Path $found.Folder ('Extensions\{0}' -f $Id)
        $installed = [bool](Test-Path -LiteralPath $folder -PathType Container)
        $version   = ''
        $enabled   = $null
        if ($installed) {
            $version = Select-BrowserExtensionVersion -Name @(Get-ChildItem -LiteralPath $folder -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name })
            foreach ($file in 'Secure Preferences', 'Preferences') {
                $path = Join-Path $found.Folder $file
                if ($null -ne $enabled -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
                try { $enabled = ConvertFrom-BrowserExtensionPreference -Preference (Read-AppLogFile -Path $path | ConvertFrom-Json) -Id $Id } catch { $enabled = $null }
            }
        }
        [pscustomobject]@{ Browser = $Browser; Profile = $found.Profile; Installed = $installed; Version = $version; Enabled = $enabled }
    }
}

function Get-BrowserExtensionFirefox {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowEmptyString()][string]$Root = '', [AllowNull()][AllowEmptyString()][string]$Id)

    if (-not $Id) { return }
    foreach ($found in @(Get-BrowserProfile -Browser 'Firefox' -Root $Root)) {
        $file = Join-Path $found.Folder 'extensions.json'
        if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { continue }
        # The name the User sees, not the folder's "abcd1234." before it.
        $name = $found.Profile
        $addon = $null
        try { $addon = @((Read-AppLogFile -Path $file | ConvertFrom-Json).addons | Where-Object { "$($_.id)" -eq $Id })[0] } catch { $addon = $null }
        if (-not $addon) { [pscustomobject]@{ Browser = 'Firefox'; Profile = $name; Installed = $false; Version = ''; Enabled = $null }; continue }
        [pscustomobject]@{
            Browser = 'Firefox'; Profile = $name; Installed = $true; Version = "$($addon.version)"
            Enabled = Test-BrowserFirefoxExtensionOn -Addon $addon
        }
    }
}

function Get-BrowserExtensionHost {
    <#
    .SYNOPSIS
        Whether the link from the extension to the desktop application is registered for
        a browser, and whether the description it names and the program in it are there.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([Parameter(Mandatory)][string]$Browser, [Parameter(Mandatory)][string]$Key, [Parameter(Mandatory)][string]$Name)

    $manifest = ''
    foreach ($hive in 'HKCU:', 'HKLM:') {
        if ($manifest) { continue }
        $values = Get-ItemProperty -LiteralPath (Join-Path $hive (Join-Path $Key $Name)) -ErrorAction SilentlyContinue
        if ($values) { $manifest = "$($values.'(default)')".Trim() }
    }
    $manifestExists = [bool]($manifest -and (Test-Path -LiteralPath $manifest -PathType Leaf))
    $target = $null
    if ($manifestExists) {
        try {
            $path = "$((Read-AppLogFile -Path $manifest | ConvertFrom-Json).path)".Trim()
            if ($path) {
                if (-not [IO.Path]::IsPathRooted($path)) { $path = Join-Path (Split-Path -Path $manifest -Parent) $path }
                $target = [bool](Test-Path -LiteralPath $path -PathType Leaf)
            }
        }
        catch { $target = $null }
    }
    [pscustomobject]@{ Browser = $Browser; Registered = [bool]$manifest; ManifestExists = $manifestExists; TargetExists = $target }
}

function Get-BrowserExtensionData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $ids  = Get-Parameter $Parameters 'Ids' $null
    $name = "$(Get-Parameter $Parameters 'NativeHost' '')".Trim()

    # The profiles of the User of the Run, from the list the Browser Kind reads too.
    $rows = @(
        Get-BrowserExtensionChromium -Browser 'Chrome' -Id "$(Get-BrowserExtensionSetting $ids 'Chrome')"
        Get-BrowserExtensionChromium -Browser 'Edge'   -Id "$(Get-BrowserExtensionSetting $ids 'Edge')"
        Get-BrowserExtensionFirefox  -Id "$(Get-BrowserExtensionSetting $ids 'Firefox')"
    )

    $hosts = @()
    if ($name) {
        $hosts = @(
            Get-BrowserExtensionHost -Browser 'Chrome'  -Key 'Software\Google\Chrome\NativeMessagingHosts' -Name $name
            Get-BrowserExtensionHost -Browser 'Edge'    -Key 'Software\Microsoft\Edge\NativeMessagingHosts' -Name $name
            Get-BrowserExtensionHost -Browser 'Firefox' -Key 'Software\Mozilla\NativeMessagingHosts' -Name $name
        )
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.Data.BrowserExtension'
        App        = "$(Get-Parameter $Parameters 'App' '')"
        Title      = "$(Get-Parameter $Parameters 'Title' '')"
        Profiles   = $rows
        HostName   = $name
        Hosts      = $hosts
    }
}

function Get-BrowserExtensionTitle {
    # What the extension is called in the Report. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Data)

    $title = "$(Get-DataProperty $Data 'Title')".Trim()
    if (-not $title) { $title = "$(Get-DataProperty $Data 'App')".Trim() }
    if (-not $title) { $title = Get-Text 'Value.BrowserExtension.Nameless' }
    $title
}

function New-BrowserExtensionInstallFinding {
    <#
    .SYNOPSIS
        Where the extension is installed and switched on: each browser and profile.
    .DESCRIPTION
        OK where it is on in at least one profile. Where it is nowhere, or off wherever
        it is, that is said at INFO: whether the User is meant to have it is not a thing
        a machine knows.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $title    = Get-BrowserExtensionTitle -Data $Data
    $check    = (Get-Text 'Check.BrowserExtension.Installed') -f $title
    $profiles = @((Get-DataCollection $Data 'Profiles') | Where-Object { $_ })
    if (-not $profiles.Count) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Value (Get-Text 'Value.BrowserExtension.NoBrowser') -Hint (Get-Text 'Hint.BrowserExtension.NoBrowser')
    }

    $items = @($profiles | ForEach-Object {
        $where = (Get-Text 'Value.BrowserExtension.Where') -f "$(Get-DataProperty $_ 'Browser')", "$(Get-DataProperty $_ 'Profile')"
        if (-not [bool](Get-DataProperty $_ 'Installed')) { return (Get-Text 'Value.BrowserExtension.Absent') -f $where }
        $enabled = Get-DataProperty $_ 'Enabled'
        $state = Get-Text 'Value.BrowserExtension.StateUnknown'
        if ($enabled -eq $true)      { $state = Get-Text 'Value.BrowserExtension.On' }
        elseif ($enabled -eq $false) { $state = Get-Text 'Value.BrowserExtension.Off' }
        (Get-Text 'Value.BrowserExtension.Present') -f $where, "$(Get-DataProperty $_ 'Version')", $state
    })
    $value = (Get-Text 'Value.BrowserExtension.Profiles') -f ($items -join '; ')
    $reference = @(New-Reference -Section 'Title.BrowserExtension.Profiles')

    $installed = @($profiles | Where-Object { [bool](Get-DataProperty $_ 'Installed') })
    $on        = @($installed | Where-Object { (Get-DataProperty $_ 'Enabled') -ne $false })
    if (-not $installed.Count) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Value ('{0} | {1}' -f (Get-Text 'Value.BrowserExtension.Nowhere'), $value) `
            -Meaning (Get-Text 'Meaning.BrowserExtension.Nowhere') -Hint (Get-Text 'Hint.BrowserExtension.Nowhere') -Reference $reference
    }
    if (-not $on.Count) {
        return New-Finding -Category Apps -Check $check -Severity INFO -Value ('{0} | {1}' -f (Get-Text 'Value.BrowserExtension.OffEverywhere'), $value) `
            -Meaning (Get-Text 'Meaning.BrowserExtension.Off') -Hint (Get-Text 'Hint.BrowserExtension.Off') -Reference $reference
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value $value -Reference $reference
}

function New-BrowserExtensionHostFinding {
    <#
    .SYNOPSIS
        Whether the link from the extension to the desktop application is in order, for
        each browser. Nothing where a Check Definition names no such link.
    .DESCRIPTION
        A WARN where a link is registered and what it points at is gone: that is what an
        uninstall or a moved installation leaves, and the extension then fails without
        saying why. A link that is not registered is one nobody switched on, and no fault.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    if (-not "$(Get-DataProperty $Data 'HostName')".Trim()) { return }
    $title = Get-BrowserExtensionTitle -Data $Data
    $check = (Get-Text 'Check.BrowserExtension.Host') -f $title
    $hosts = @((Get-DataCollection $Data 'Hosts') | Where-Object { $_ })
    $registered = @($hosts | Where-Object { [bool](Get-DataProperty $_ 'Registered') })
    if (-not $registered.Count) {
        return New-Finding -Category Apps -Check $check -Severity OK -Value (Get-Text 'Value.BrowserExtension.HostNone')
    }

    $broken = @($registered | Where-Object { -not [bool](Get-DataProperty $_ 'ManifestExists') -or (Get-DataProperty $_ 'TargetExists') -eq $false })
    $items = @($registered | ForEach-Object {
        $state = Get-Text 'Value.BrowserExtension.HostFine'
        if (-not [bool](Get-DataProperty $_ 'ManifestExists'))        { $state = Get-Text 'Value.BrowserExtension.HostNoManifest' }
        elseif ((Get-DataProperty $_ 'TargetExists') -eq $false)      { $state = Get-Text 'Value.BrowserExtension.HostNoTarget' }
        (Get-Text 'Value.BrowserExtension.HostItem') -f "$(Get-DataProperty $_ 'Browser')", $state
    })
    $value = (Get-Text 'Value.BrowserExtension.Hosts') -f ($items -join '; ')
    if ($broken.Count) {
        return New-Finding -Category Apps -Check $check -Severity WARN -Value $value `
            -Meaning (Get-Text 'Meaning.BrowserExtension.HostBroken') -Hint ((Get-Text 'Hint.BrowserExtension.HostBroken') -f $title)
    }
    New-Finding -Category Apps -Check $check -Severity OK -Value $value
}

function ConvertTo-BrowserExtensionFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    New-BrowserExtensionInstallFinding -Data $Data
    New-BrowserExtensionHostFinding -Data $Data
}

function ConvertTo-BrowserExtensionSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $profiles = @((Get-DataCollection $Data 'Profiles') | Where-Object { $_ })
    if (-not $profiles.Count) { return }
    $linked = @{}
    foreach ($one in @((Get-DataCollection $Data 'Hosts') | Where-Object { $_ })) { $linked["$(Get-DataProperty $one 'Browser')"] = $one }
    $named = [bool]"$(Get-DataProperty $Data 'HostName')".Trim()
    New-Section -Title (Get-Text 'Title.BrowserExtension.Profiles') -Help (Get-Text 'Help.BrowserExtension.Profiles') -Row @(
        $profiles | ForEach-Object {
            $installed = [bool](Get-DataProperty $_ 'Installed')
            $row = [ordered]@{}
            $row[(Get-Text 'Column.BrowserExtension.Browser')]   = "$(Get-DataProperty $_ 'Browser')"
            $row[(Get-Text 'Column.BrowserExtension.Profile')]   = "$(Get-DataProperty $_ 'Profile')"
            $row[(Get-Text 'Column.BrowserExtension.Installed')] = Get-TriStateText $installed
            $row[(Get-Text 'Column.BrowserExtension.Version')]   = "$(Get-DataProperty $_ 'Version')"
            $row[(Get-Text 'Column.BrowserExtension.Enabled')]   = $(if ($installed) { Get-TriStateText (Get-DataProperty $_ 'Enabled') } else { '' })
            if ($named) {
                $link = $linked["$(Get-DataProperty $_ 'Browser')"]
                $row[(Get-Text 'Column.BrowserExtension.Host')] = Get-TriStateText $(if ($link) { [bool](Get-DataProperty $link 'Registered') } else { $null })
            }
            [pscustomobject]$row
        }
    )
}