completers/sigcheck_completer/sigcheck_completer.ps1

# sigcheck tab completion for PowerShell
# Native completer for Sigcheck with mode-aware switch/value and path completion.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name SigcheckCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:SigcheckCompletionCatalog = @{
        Switches = @(
            @{ Token = '-a'; Description = 'Show extended version information.'; TakesValue = $false }
            @{ Token = '-accepteula'; Description = 'Silently accept the Sigcheck EULA.'; TakesValue = $false }
            @{ Token = '-c'; Description = 'CSV output with comma delimiter.'; TakesValue = $false }
            @{ Token = '-ct'; Description = 'CSV output with tab delimiter.'; TakesValue = $false }
            @{ Token = '-d'; Description = 'Dump contents of a catalog file.'; TakesValue = $false }
            @{ Token = '-e'; Description = 'Scan executable images only.'; TakesValue = $false }
            @{ Token = '-f'; Description = 'Look for signatures in the specified catalog file.'; TakesValue = $true; ValueKind = 'CatalogFile' }
            @{ Token = '-h'; Description = 'Show file hashes.'; TakesValue = $false }
            @{ Token = '-i'; Description = 'Show catalog name and signing chain.'; TakesValue = $false }
            @{ Token = '-l'; Description = 'Traverse symbolic links and junctions.'; TakesValue = $false }
            @{ Token = '-m'; Description = 'Dump manifest.'; TakesValue = $false }
            @{ Token = '-n'; Description = 'Only show file version number.'; TakesValue = $false }
            @{ Token = '-o'; Description = 'Query VirusTotal using a previously captured CSV file.'; TakesValue = $false }
            @{ Token = '-p'; Description = 'Verify signatures against the specified policy GUID or policy file.'; TakesValue = $true; ValueKind = 'Policy' }
            @{ Token = '-q'; Description = 'Quiet - suppress per-file output detail.'; TakesValue = $false }
            @{ Token = '-r'; Description = 'Disable certificate revocation checking.'; TakesValue = $false }
            @{ Token = '-s'; Description = 'Recurse subdirectories.'; TakesValue = $false }
            @{ Token = '-t'; Description = 'Dump machine certificate stores.'; TakesValue = $false }
            @{ Token = '-tu'; Description = 'Dump user certificate stores.'; TakesValue = $false }
            @{ Token = '-tv'; Description = 'Dump machine certificate stores and validate against Microsoft roots.'; TakesValue = $false }
            @{ Token = '-tuv'; Description = 'Dump user certificate stores and validate against Microsoft roots.'; TakesValue = $false }
            @{ Token = '-u'; Description = 'Show unsigned or suspicious files.'; TakesValue = $false }
            @{ Token = '-v'; Description = 'Query VirusTotal by file hash.'; TakesValue = $false }
            @{ Token = '-vr'; Description = 'Query VirusTotal and open reports for positives.'; TakesValue = $false }
            @{ Token = '-vs'; Description = 'Query VirusTotal and submit unknown files.'; TakesValue = $false }
            @{ Token = '-vrs'; Description = 'Query VirusTotal, submit unknown files, and open positive reports.'; TakesValue = $false }
            @{ Token = '-vt'; Description = 'Accept VirusTotal terms non-interactively.'; TakesValue = $false }
            @{ Token = '-w'; Description = 'Write output to the specified file.'; TakesValue = $true; ValueKind = 'OutputFile' }
            @{ Token = '-nobanner'; Description = 'Do not display the startup banner.'; TakesValue = $false }
            @{ Token = '-?'; Description = 'Show Sigcheck help.'; TakesValue = $false }
            @{ Token = '/?'; Description = 'Show Sigcheck help.'; TakesValue = $false }
        )
        MachineStoreNames   = @()
        UserStoreNames      = @()
        StoreNamesUpdated   = $null
        StoreNamesTtl       = 60
    }
}

function New-SigcheckCompletionResult {
    param(
        [string]$CompletionText,
        [string]$ResultType,
        [string]$ToolTip,
        [string]$ListItemText
    )

    if ([string]::IsNullOrWhiteSpace($ListItemText)) {
        $ListItemText = $CompletionText
    }

    if ([string]::IsNullOrWhiteSpace($ToolTip)) {
        $ToolTip = $CompletionText
    }

    [System.Management.Automation.CompletionResult]::new(
        $CompletionText,
        $ListItemText,
        $ResultType,
        $ToolTip
    )
}

function Get-SigcheckQuoteChar {
    param([string]$Value)

    # The quote the user opened the word with ('' when the word is bare), so
    # completions re-emit the same quoting style.
    if (-not [string]::IsNullOrEmpty($Value) -and ($Value[0] -eq [char]39 -or $Value[0] -eq [char]34)) {
        return [string]$Value[0]
    }

    ''
}

function Remove-SigcheckOuterQuotes {
    param([string]$Value)

    if ([string]::IsNullOrEmpty($Value)) {
        return ''
    }

    $quoteChar = Get-SigcheckQuoteChar -Value $Value
    if ($quoteChar -eq "'") {
        # An odd run of trailing quotes ends with the closing quote; '' inside
        # a single-quoted string is one literal quote.
        $inner = $Value.Substring(1)
        $trailing = $inner.Length - $inner.TrimEnd([char]39).Length
        if ($trailing % 2 -eq 1) {
            $inner = $inner.Substring(0, $inner.Length - 1)
        }

        return $inner.Replace("''", "'")
    }

    if ($quoteChar -eq '"') {
        $inner = $Value.Substring(1)
        if ($inner.EndsWith('"') -and -not $inner.EndsWith('`"')) {
            $inner = $inner.Substring(0, $inner.Length - 1)
        }

        return $inner -replace '`(.)', '$1'
    }

    $Value
}

function ConvertTo-SigcheckQuotedValue {
    param(
        [string]$Value,
        [string]$QuoteChar = ''
    )

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return $Value
    }

    # Whitespace or an argument-mode metacharacter (braces start a script
    # block, so CatRoot\{GUID}\ must be quoted too) needs quoting; a bare
    # word without one stays bare unless the user already opened a quote.
    if ([string]::IsNullOrEmpty($QuoteChar)) {
        if ($Value -notmatch '[\s{}();,|&<>''"`$\u2018-\u201E]' -and $Value -notmatch '^[@#]') {
            return $Value
        }

        $QuoteChar = "'"
    }

    if ($QuoteChar -eq '"') {
        return '"' + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + '"'
    }

    "'" + ($Value -replace '([''\u2018-\u201B])', '$1$1') + "'"
}

function Get-SigcheckTokenState {
    param(
        [string]$Line,
        [int]$CursorPosition
    )

    if ($null -eq $Line) {
        $Line = ''
    }

    $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length)
    $prefix = $Line.Substring(0, $safeCursor)
    $tokens = New-Object System.Collections.Generic.List[string]
    $builder = New-Object System.Text.StringBuilder
    $quoteChar = [char]0

    foreach ($character in $prefix.ToCharArray()) {
        if (($character -eq [char]34) -or ($character -eq [char]39)) {
            if ($quoteChar -eq [char]0) {
                $quoteChar = $character
            } elseif ($quoteChar -eq $character) {
                $quoteChar = [char]0
            }

            [void]$builder.Append($character)
            continue
        }

        if ([char]::IsWhiteSpace($character) -and $quoteChar -eq [char]0) {
            if ($builder.Length -gt 0) {
                $tokens.Add($builder.ToString())
                [void]$builder.Clear()
            }

            continue
        }

        [void]$builder.Append($character)
    }

    $hasTrailingSpace = $prefix -match '\s$'
    if ($builder.Length -gt 0) {
        $tokens.Add($builder.ToString())
    }

    if ($hasTrailingSpace) {
        return [pscustomobject]@{
            TokensBeforeCurrent = @($tokens)
            CurrentToken        = ''
        }
    }

    if ($tokens.Count -gt 0) {
        return [pscustomobject]@{
            TokensBeforeCurrent = @($tokens | Select-Object -First ($tokens.Count - 1))
            CurrentToken        = $tokens[$tokens.Count - 1]
        }
    }

    [pscustomobject]@{
        TokensBeforeCurrent = @()
        CurrentToken        = ''
    }
}

function Get-SigcheckArgumentsFromTokenState {
    param([pscustomobject]$TokenState)

    [pscustomobject]@{
        ArgumentsBeforeCurrent = @($TokenState.TokensBeforeCurrent | Select-Object -Skip 1)
        CurrentArgument        = $TokenState.CurrentToken
    }
}

function Get-SigcheckUniqueCompletions {
    param([object[]]$Results)

    $seen = @{}
    $unique = New-Object System.Collections.Generic.List[object]
    foreach ($result in $Results) {
        if ($null -eq $result) {
            continue
        }

        if ($seen.ContainsKey($result.CompletionText)) {
            continue
        }

        $seen[$result.CompletionText] = $true
        [void]$unique.Add($result)
    }

    @($unique.ToArray())
}

function Get-SigcheckPathCompletions {
    param(
        [string]$CurrentWord,
        [string]$ToolTip,
        [string]$Placeholder = '<path>',
        [string[]]$Extension = @(),
        [string[]]$Seed = @()
    )

    $typedValue = Remove-SigcheckOuterQuotes -Value $CurrentWord
    $quoteChar = Get-SigcheckQuoteChar -Value $CurrentWord
    $results = New-Object System.Collections.Generic.List[object]

    $parentPath = '.'
    $leaf = ''
    if (-not [string]::IsNullOrWhiteSpace($typedValue)) {
        if ($typedValue.EndsWith('\') -or $typedValue.EndsWith('/')) {
            $parentPath = $typedValue
        } else {
            $candidateParent = Split-Path -Path $typedValue -Parent
            if ([string]::IsNullOrWhiteSpace($candidateParent)) {
                $leaf = $typedValue
            } else {
                $parentPath = $candidateParent
                $leaf = Split-Path -Path $typedValue -Leaf
            }
        }
    }

    $items = @(Get-ChildItem -LiteralPath $parentPath -ErrorAction Ignore)

    $seenPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($seedPath in $Seed) {
        if ([string]::IsNullOrWhiteSpace($typedValue) -or $seedPath.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) {
            $seedText = ConvertTo-SigcheckQuotedValue -Value $seedPath -QuoteChar $quoteChar
            if ($seenPaths.Add($seedText)) {
                [void]$results.Add((New-SigcheckCompletionResult -CompletionText $seedText -ListItemText $seedPath -ResultType 'ParameterValue' -ToolTip $ToolTip))
            }
        }
    }

    foreach ($item in $items) {
        if (-not [string]::IsNullOrWhiteSpace($leaf) -and
            -not $item.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        # Keep every directory so the tree stays navigable, but keep only the
        # file kinds the option actually accepts.
        if ($Extension.Count -gt 0 -and -not $item.PSIsContainer -and $item.Extension -notin $Extension) {
            continue
        }

        $candidate = if ($parentPath -eq '.') { $item.Name } else { Join-Path -Path $parentPath -ChildPath $item.Name }
        if ($item.PSIsContainer) {
            $candidate += '\'
        }

        $completionText = ConvertTo-SigcheckQuotedValue -Value $candidate -QuoteChar $quoteChar
        if (-not $seenPaths.Add($completionText)) {
            continue
        }

        [void]$results.Add((New-SigcheckCompletionResult -CompletionText $completionText -ListItemText $completionText -ResultType 'ParameterValue' -ToolTip $ToolTip))
    }

    if ($results.Count -eq 0) {
        if ([string]::IsNullOrWhiteSpace($CurrentWord)) {
            [void]$results.Add((New-SigcheckCompletionResult -CompletionText $Placeholder -ListItemText $Placeholder -ResultType 'ParameterValue' -ToolTip $ToolTip))
        } else {
            [void]$results.Add((New-SigcheckCompletionResult -CompletionText $CurrentWord -ListItemText $CurrentWord -ResultType 'ParameterValue' -ToolTip $ToolTip))
        }
    }

    @($results.ToArray())
}

function Get-SigcheckCatalogRoot {
    $root = Join-Path -Path $env:SystemRoot -ChildPath 'System32\CatRoot'
    if (-not (Test-Path -LiteralPath $root -PathType Container)) {
        return @()
    }

    @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction Ignore |
        Sort-Object -Property Name |
        ForEach-Object { $_.FullName + '\' })
}

function Update-SigcheckStoreNames {
    $lastUpdated = $script:SigcheckCompletionCatalog.StoreNamesUpdated
    if ($null -ne $lastUpdated -and
        ($script:SigcheckCompletionCatalog.MachineStoreNames.Count -gt 0 -or $script:SigcheckCompletionCatalog.UserStoreNames.Count -gt 0) -and
        ((Get-Date) - $lastUpdated).TotalSeconds -lt $script:SigcheckCompletionCatalog.StoreNamesTtl) {
        return
    }

    try {
        $script:SigcheckCompletionCatalog.MachineStoreNames = @(
            Get-ChildItem -Path Cert:\LocalMachine -ErrorAction Stop |
                Select-Object -ExpandProperty PSChildName |
                Sort-Object -Unique
        )
    } catch {
        $script:SigcheckCompletionCatalog.MachineStoreNames = @('Root', 'CA', 'My', 'TrustedPublisher')
    }

    try {
        $script:SigcheckCompletionCatalog.UserStoreNames = @(
            Get-ChildItem -Path Cert:\CurrentUser -ErrorAction Stop |
                Select-Object -ExpandProperty PSChildName |
                Sort-Object -Unique
        )
    } catch {
        $script:SigcheckCompletionCatalog.UserStoreNames = @('Root', 'CA', 'My', 'TrustedPublisher')
    }

    $script:SigcheckCompletionCatalog.StoreNamesUpdated = Get-Date
}

function Get-SigcheckCommandState {
    param([string[]]$ArgumentsBeforeCurrent)

    $usedTokens = @{}
    $valueContext = $null
    $mode = 'scan'
    $storeMode = $null
    $positionals = New-Object System.Collections.Generic.List[string]

    for ($index = 0; $index -lt $ArgumentsBeforeCurrent.Count; $index++) {
        $token = $ArgumentsBeforeCurrent[$index]
        if ([string]::IsNullOrWhiteSpace($token)) {
            continue
        }

        # Sysinternals tools accept /x for every -x switch; normalise so mode
        # detection and the used-token bookkeeping see one spelling.
        $lookup = $token.ToLowerInvariant()
        if ($lookup.Length -gt 1 -and $lookup.StartsWith('/')) {
            $lookup = '-' + $lookup.Substring(1)
        }

        $usedTokens[$lookup] = $true

        switch ($lookup) {
            '-d' {
                $mode = 'catalog'
                continue
            }
            '-o' {
                $mode = 'offline'
                continue
            }
            '-t' {
                $mode = 'store'
                $storeMode = 'machine'
                continue
            }
            '-tu' {
                $mode = 'store'
                $storeMode = 'user'
                continue
            }
            '-tv' {
                $mode = 'store'
                $storeMode = 'machine'
                continue
            }
            '-tuv' {
                $mode = 'store'
                $storeMode = 'user'
                continue
            }
            '-f' {
                if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) {
                    $valueContext = 'CatalogFile'
                    break
                }

                $index++
                continue
            }
            '-p' {
                if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) {
                    $valueContext = 'Policy'
                    break
                }

                $index++
                continue
            }
            '-w' {
                if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) {
                    $valueContext = 'OutputFile'
                    break
                }

                $index++
                continue
            }
            default {
                if ($lookup.StartsWith('-') -or $lookup.StartsWith('/')) {
                    continue
                }

                $positionals.Add($token)
            }
        }
    }

    [pscustomobject]@{
        UsedTokens   = $usedTokens
        ValueContext = $valueContext
        Mode         = $mode
        StoreMode    = $storeMode
        Positionals  = @($positionals.ToArray())
    }
}

function Complete-Sigcheck {
    param(
        [string]$WordToComplete,
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    $line = if ($CommandAst.Extent -and $null -ne $CommandAst.Extent.Text) { $CommandAst.Extent.Text } else { $CommandAst.ToString() }
    # $CursorPosition indexes the whole input line; $line is command-relative.
    $relativeCursor = $CursorPosition - $CommandAst.Extent.StartOffset
    if ($relativeCursor -gt $line.Length) {
        $line = $line.PadRight($relativeCursor)
    }
    $relativeCursor = [Math]::Min([Math]::Max($relativeCursor, 0), $line.Length)
    $tokenState = Get-SigcheckTokenState -Line $line -CursorPosition $relativeCursor
    $argumentsState = Get-SigcheckArgumentsFromTokenState -TokenState $tokenState
    $state = Get-SigcheckCommandState -ArgumentsBeforeCurrent $argumentsState.ArgumentsBeforeCurrent
    $currentWord = $argumentsState.CurrentArgument

    switch ($state.ValueContext) {
        'CatalogFile' {
            return Get-SigcheckPathCompletions -CurrentWord $currentWord -ToolTip 'Catalog file path.' -Placeholder '<catalog-file>' -Extension @('.cat', '.cab') -Seed @(Get-SigcheckCatalogRoot)
        }
        'OutputFile' { return Get-SigcheckPathCompletions -CurrentWord $currentWord -ToolTip 'Output file path.' -Placeholder '<output-file>' }
        'Policy' {
            $typedValue = Remove-SigcheckOuterQuotes -Value $currentWord
            $results = New-Object System.Collections.Generic.List[object]
            foreach ($sample in @('{00000000-0000-0000-0000-000000000000}', '<policy-guid-or-path>')) {
                if (-not [string]::IsNullOrWhiteSpace($typedValue) -and
                    -not $sample.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) {
                    continue
                }

                # A bare {GUID} would parse as a script block, so it goes through the
                # quoting helper; the <...> placeholder stays as typed.
                $sampleText = if ($sample.StartsWith('<')) { $sample } else { ConvertTo-SigcheckQuotedValue -Value $sample -QuoteChar (Get-SigcheckQuoteChar -Value $currentWord) }
                [void]$results.Add((New-SigcheckCompletionResult -CompletionText $sampleText -ListItemText $sample -ResultType 'ParameterValue' -ToolTip 'Policy GUID or policy file path.'))
            }

            $pathResults = Get-SigcheckPathCompletions -CurrentWord $currentWord -ToolTip 'Policy file path.' -Placeholder '<policy-file>'
            foreach ($item in $pathResults) {
                [void]$results.Add($item)
            }

            return Get-SigcheckUniqueCompletions -Results @($results.ToArray())
        }
    }

    $results = New-Object System.Collections.Generic.List[object]

    if (-not $currentWord.StartsWith('-') -and -not $currentWord.StartsWith('/')) {
        switch ($state.Mode) {
            'store' {
                Update-SigcheckStoreNames
                $typedValue = Remove-SigcheckOuterQuotes -Value $currentWord
                $storeNames = if ($state.StoreMode -eq 'user') { $script:SigcheckCompletionCatalog.UserStoreNames } else { $script:SigcheckCompletionCatalog.MachineStoreNames }
                $quoteChar = Get-SigcheckQuoteChar -Value $currentWord
                foreach ($storeName in @('*') + $storeNames + @('<store-name>')) {
                    if (-not [string]::IsNullOrWhiteSpace($typedValue) -and
                        -not $storeName.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) {
                        continue
                    }

                    # Store names such as 'AAD Token Issuer' contain spaces.
                    $storeText = if ($storeName -eq '*' -or $storeName.StartsWith('<')) { $storeName } else { ConvertTo-SigcheckQuotedValue -Value $storeName -QuoteChar $quoteChar }
                    [void]$results.Add((New-SigcheckCompletionResult -CompletionText $storeText -ListItemText $storeName -ResultType 'ParameterValue' -ToolTip 'Certificate store name or * for all stores.'))
                }
            }
            'offline' {
                foreach ($item in @(Get-SigcheckPathCompletions -CurrentWord $currentWord -ToolTip 'CSV file previously captured by Sigcheck -h.' -Placeholder '<sigcheck-csv-file>' -Extension @('.csv'))) {
                    [void]$results.Add($item)
                }
            }
            'catalog' {
                foreach ($item in @(Get-SigcheckPathCompletions -CurrentWord $currentWord -ToolTip 'Catalog file or directory to inspect.' -Placeholder '<catalog-file-or-directory>' -Extension @('.cat', '.cab'))) {
                    [void]$results.Add($item)
                }
            }
            default {
                foreach ($item in @(Get-SigcheckPathCompletions -CurrentWord $currentWord -ToolTip 'File or directory to inspect.' -Placeholder '<file-or-directory>')) {
                    [void]$results.Add($item)
                }
            }
        }
    }

    $wantsSwitches = [string]::IsNullOrEmpty($currentWord) -or $currentWord.StartsWith('-') -or $currentWord.StartsWith('/')
    if ($wantsSwitches) {
        # Sysinternals accepts /x for every -x switch, so a '/'-prefixed word
        # completes the whole catalog in its slash spelling.
        $useSlash = $currentWord.StartsWith('/')
        foreach ($switchSpec in $script:SigcheckCompletionCatalog.Switches) {
            $canonical = $switchSpec.Token.ToLowerInvariant()
            if ($canonical.StartsWith('/')) { $canonical = '-' + $canonical.Substring(1) }

            $token = $switchSpec.Token
            if ($useSlash) {
                $token = '/' + $token.Substring(1)
            } elseif ($token.StartsWith('/')) {
                continue
            }

            if (-not $token.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) {
                continue
            }

            if ($state.UsedTokens.ContainsKey($canonical) -and
                $canonical -notin @('-v', '-vr', '-vs', '-vrs', '-t', '-tu', '-tv', '-tuv', '-?')) {
                continue
            }

            if (($canonical -in @('-c', '-ct')) -and
                ($state.UsedTokens.ContainsKey('-c') -or $state.UsedTokens.ContainsKey('-ct')) -and
                -not $state.UsedTokens.ContainsKey($canonical)) {
                continue
            }

            [void]$results.Add((New-SigcheckCompletionResult -CompletionText $token -ListItemText $token -ResultType 'ParameterName' -ToolTip $switchSpec.Description))
        }
    }

    Get-SigcheckUniqueCompletions -Results @($results.ToArray())
}

Register-ArgumentCompleter -Native -CommandName @('sigcheck', 'sigcheck.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)

    Complete-Sigcheck -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition
}