Zum Inhalt
Kaffeeundcode
Skriptbibliothek
PowerShell 06.10.2026

PowerShell

Test IntuneWin32AppDetection

355_Test-IntuneWin32AppDetection.ps1

<#
.SYNOPSIS
    Prueft deklarative Datei-, Versions-, Registry- und MSI-Erkennung auf dem Zielgeraet.
.DESCRIPTION
    <!-- library-status:start -->
    Prüfstatus: Ungeprüft
    Windows- und Tenant-Abnahme ausstehend; keine pauschale Produktionsfreigabe.

    <!-- library-status:end -->

    RulePath enthaelt ein JSON-Array aus type (file, fileVersion, registry, msi), path und optional minimumVersion,
    name, expectedValue, productCode sowie registryView (Registry64 oder Registry32).
    Es wird kein beliebiger Skriptinhalt ausgefuehrt. IntuneOutput liefert nur bei vollstaendig passender Erkennung Ausgabe und Exit 0.
    Exit 1 bedeutet nicht erkannt; Exit 2 bedeutet nicht pruefbar. Ohne IntuneOutput werden Ergebnisobjekte ausgegeben.
.EXAMPLE
    ./17_Intune_Workflows/355_Test-IntuneWin32AppDetection.ps1 -RulePath './examples/detection-file.json'
#>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$RulePath, [switch]$IntuneOutput, [string]$OutputPath)
# kc-bundle:graph:start sha256=168d7f232db5d14cf1be94255b5d535f3739213158e781a4214040a070571864
# Eingebettete Hilfslogik aus Common/IntuneLibrary.psm1; durch tools/bundle-script-dependencies.mjs gepflegt.
New-Module -Name IntuneLibrary -ScriptBlock {
#requires -Version 5.1
Set-StrictMode -Version Latest

function Connect-IlGraph {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string[]]$Scopes, [string]$TenantId, [switch]$SkipConnect)
    if (-not (Get-Command Get-MgContext -ErrorAction SilentlyContinue)) {
        throw 'Microsoft.Graph.Authentication fehlt. Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
    }
    $context = Get-MgContext
    if (-not $SkipConnect -and (-not $context -or ($TenantId -and $context.TenantId -ne $TenantId))) {
        $arguments = @{ Scopes = $Scopes; ErrorAction = 'Stop'; NoWelcome = $true; ContextScope = 'Process' }
        if ($TenantId) { $arguments.TenantId = $TenantId }
        Connect-MgGraph @arguments | Out-Null
        $context = Get-MgContext
    }
    if (-not $context) { throw 'Keine aktive Graph-Sitzung.' }
    if ($TenantId -and $context.TenantId -ne $TenantId) { throw 'Die aktive Graph-Sitzung gehoert zu einem anderen Tenant.' }
    if ($context.AuthType -eq 'Delegated') {
        $missing = @($Scopes | Where-Object { $_ -notin $context.Scopes })
        if ($missing.Count) { throw "Der Sitzung fehlen angeforderte Scopes: $($missing -join ', '). Neu mit diesen Scopes anmelden." }
    }
}

function Assert-IlGraphUri {
    param([Parameter(Mandatory)][string]$Uri)
    $parsed = [uri]$Uri
    if (-not $parsed.IsAbsoluteUri -or $parsed.Scheme -ne 'https' -or $parsed.Host -ne 'graph.microsoft.com' -or $parsed.UserInfo -or $parsed.Port -ne 443) {
        throw 'Nur HTTPS-Anfragen an graph.microsoft.com sind erlaubt (Global Cloud).'
    }
    if ($parsed.AbsolutePath -notmatch '^/(v1.0|beta)/') { throw 'Graph-API-Version fehlt.' }
}

function Invoke-IlGraph {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Uri,
        [ValidateSet('GET','POST','PATCH','DELETE')][string]$Method = 'GET',
        [object]$Body,
        [ValidateRange(0,8)][int]$MaxRetries = 4
    )
    Assert-IlGraphUri $Uri
    for ($attempt = 0; ; $attempt++) {
        try {
            $arguments = @{ Uri = $Uri; Method = $Method; OutputType = 'PSObject'; ErrorAction = 'Stop' }
            if ($PSBoundParameters.ContainsKey('Body')) {
                $arguments.Body = ConvertTo-Json -InputObject $Body -Depth 100 -Compress
                $arguments.ContentType = 'application/json'
            }
            return Invoke-MgGraphRequest @arguments
        } catch {
            $status = 0
            $delay = [math]::Min(60, [math]::Pow(2, $attempt))
            if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
                $response = $_.Exception.Response
                if ($response.PSObject.Properties['StatusCode']) { $status = [int]$response.StatusCode }
                if ($response.PSObject.Properties['Headers'] -and $response.Headers) {
                    try {
                        $retryAfter = $response.Headers.RetryAfter
                        if ($retryAfter.Delta) { $delay = [math]::Ceiling($retryAfter.Delta.TotalSeconds) }
                        elseif ($retryAfter.Date) { $delay = [math]::Ceiling(($retryAfter.Date - [DateTimeOffset]::UtcNow).TotalSeconds) }
                    } catch { Write-Verbose 'Retry-After nicht lesbar; exponentieller Backoff.' }
                }
            }
            # Mutationen niemals automatisch wiederholen: ihre Annahme kann unklar sein.
            if ($Method -ne 'GET' -or $status -notin @(429,503,504) -or $attempt -ge $MaxRetries) { throw }
            if ($delay -gt 300) { throw 'Server fordert mehr als 300 Sekunden Wartezeit; Lauf spaeter erneut starten.' }
            Start-Sleep -Seconds ([math]::Max(1,$delay))
        }
    }
}

function Get-IlGraphCollection {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Uri, [ValidateRange(1,100000)][int]$MaxPages = 10000)
    $seen = @{}
    $rows = New-Object 'System.Collections.Generic.List[object]'
    while ($Uri) {
        if ($seen.ContainsKey($Uri)) { throw 'Wiederholter Graph-nextLink; unvollstaendige Abfrage verworfen.' }
        if ($seen.Count -ge $MaxPages) { throw 'Seitenlimit erreicht; unvollstaendige Abfrage verworfen.' }
        $seen[$Uri] = $true
        $page = Invoke-IlGraph -Uri $Uri
        if (-not $page -or -not $page.PSObject.Properties['value']) { throw "Keine Graph-Collection: $Uri" }
        foreach ($row in @($page.value)) { if ($null -ne $row) { $rows.Add($row) } }
        $Uri = if ($page.PSObject.Properties['@odata.nextLink']) { [string]$page.'@odata.nextLink' } else { $null }
    }
    return $rows.ToArray()
}

function ConvertTo-IlSegment {
    param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Value)
    return [uri]::EscapeDataString($Value)
}

function Resolve-IlDevice {
    [CmdletBinding(DefaultParameterSetName='Name')]
    param(
        [Parameter(Mandatory,ParameterSetName='Id')][string]$DeviceId,
        [Parameter(Mandatory,ParameterSetName='Name')][string]$DeviceName,
        [Parameter(Mandatory,ParameterSetName='Serial')][string]$SerialNumber
    )
    $base = 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices'
    if ($DeviceId) { return Invoke-IlGraph -Uri ($base + '/' + (ConvertTo-IlSegment $DeviceId)) }
    $field = if ($PSCmdlet.ParameterSetName -eq 'Serial') { 'serialNumber' } else { 'deviceName' }
    $value = if ($SerialNumber) { $SerialNumber } else { $DeviceName }
    $filter = [uri]::EscapeDataString("$field eq '$($value.Replace("'","''"))'")
    $devices = @(Get-IlGraphCollection -Uri ($base + '?$filter=' + $filter))
    if ($devices.Count -ne 1) { throw "$($devices.Count) Geraete gefunden. Eine eindeutige DeviceId verwenden." }
    return $devices[0]
}

function Get-IlProperty {
    param([AllowNull()][object]$Object, [Parameter(Mandatory)][string]$Name, [object]$Default = $null)
    if ($null -eq $Object) { return $Default }
    if ($Object -is [System.Collections.IDictionary]) {
        if ($Object.Contains($Name)) { return $Object[$Name] }
    } elseif ($Object.PSObject.Properties[$Name]) { return $Object.$Name }
    return $Default
}

function New-IlFinding {
    param([string]$Check, [ValidateSet('OK','Auffaellig','Nicht anwendbar','Nicht pruefbar')][string]$Status,
          [string]$ObjectId, [string]$Detail, [object]$Data)
    [pscustomobject][ordered]@{ Check=$Check; Status=$Status; ObjectId=$ObjectId; Detail=$Detail; Data=$Data }
}

function Export-IlResult {
    [CmdletBinding()]
    param([AllowNull()][object]$Data, [string]$OutputPath)
    if ($OutputPath) {
        $parent = Split-Path $OutputPath -Parent
        if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
        if ([IO.Path]::GetExtension($OutputPath) -eq '.csv') {
            @($Data) | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
        } else {
            ConvertTo-Json -InputObject @($Data) -Depth 100 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 -ErrorAction Stop
        }
    }
    return $Data
}

Export-ModuleMember -Function Connect-IlGraph,Invoke-IlGraph,Get-IlGraphCollection,ConvertTo-IlSegment,Resolve-IlDevice,Get-IlProperty,New-IlFinding,Export-IlResult

} | Import-Module -Scope Local -Force
# kc-bundle:graph:end
$parsedRules = Get-Content -LiteralPath $RulePath -Raw -ErrorAction Stop | ConvertFrom-Json
$rules = @($parsedRules)
if (-not $rules.Count) { throw 'Mindestens eine Erkennungsregel erforderlich.' }
$result = foreach ($rule in $rules) {
    $handle = $null; $baseKey = $null
    try {
        $type = [string](Get-IlProperty $rule 'type')
        $matched = $false
        $detail = ''
        switch ($type) {
            { $_ -in @('file','fileVersion') } {
                $file = [Environment]::ExpandEnvironmentVariables([string]$rule.path)
                if (-not [IO.Path]::IsPathRooted($file)) { throw 'Erkennungspfad muss absolut sein.' }
                $matched = Test-Path -LiteralPath $file -PathType Leaf
                if ($matched -and $type -eq 'fileVersion') {
                    $actual = (Get-Item -LiteralPath $file -ErrorAction Stop).VersionInfo.FileVersion
                    $matched = [version]$actual -ge [version]$rule.minimumVersion
                    $detail = "Gefunden: $actual; Minimum: $($rule.minimumVersion)"
                }
                if (-not $detail) { $detail = $file }
            }
            { $_ -in @('registry','msi') } {
                if ($env:OS -ne 'Windows_NT') { throw 'Registry-Erkennung erfordert Windows.' }
                $view = [string](Get-IlProperty $rule 'registryView' 'Registry64')
                if ($view -notin @('Registry64','Registry32')) { throw 'registryView muss Registry64 oder Registry32 sein.' }
                $registryPath = if ($type -eq 'msi') {
                    if ([string]$rule.productCode -notmatch '^{[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}}$') { throw 'Ungueltiger MSI-ProductCode.' }
                    "SOFTWAREMicrosoftWindowsCurrentVersionUninstall$($rule.productCode)"
                } else { [string]$rule.path }
                if ($registryPath -match '^HKLM:\') { $registryPath = $registryPath.Substring(6) }
                if ($registryPath -match '^(HKCU|HKEY_)') { throw 'Nur HKLM-Unterpfade werden unterstuetzt.' }
                $baseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine,[Microsoft.Win32.RegistryView]::$view)
                $handle = $baseKey.OpenSubKey($registryPath,$false)
                $matched = $null -ne $handle
                if ($matched -and $type -eq 'msi' -and (Get-IlProperty $rule 'minimumVersion')) {
                    $actual = [string]$handle.GetValue('DisplayVersion')
                    $matched = [version]$actual -ge [version]$rule.minimumVersion
                } elseif ($matched -and $type -eq 'registry' -and $rule.PSObject.Properties['name']) {
                    $matched = [string]$rule.name -in $handle.GetValueNames()
                    if ($matched -and $rule.PSObject.Properties['expectedValue']) {
                        $matched = [string]$handle.GetValue([string]$rule.name) -ceq [string]$rule.expectedValue
                    }
                }
                $detail = "$view : HKLM$registryPath"
            }
            default { throw "Unbekannter Regeltyp: $type" }
        }
        [pscustomobject]@{RuleType=$type; Status=if ($matched) {'Detected'} else {'NotDetected'}; Detail=$detail}
    } catch { [pscustomobject]@{RuleType=(Get-IlProperty $rule 'type'); Status='NotEvaluated'; Detail=$_.Exception.Message} }
    finally { if ($handle) {$handle.Dispose()}; if ($baseKey) {$baseKey.Dispose()} }
}
if ($IntuneOutput) {
    if (@($result | Where-Object Status -eq 'NotEvaluated').Count) { exit 2 }
    if (@($result | Where-Object Status -ne 'Detected').Count) { exit 1 }
    Write-Output 'Detected'; exit 0
}
Export-IlResult -Data @($result) -OutputPath $OutputPath