Private/Invoke-FleetDMRequest.ps1

function Test-FleetSecretLogName {
    <#
    .SYNOPSIS
        Tests whether a log field name carries a credential.
    #>

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

    if ([string]::IsNullOrEmpty($Name)) {
        return $false
    }

    return [bool]($Name -match '^(?i)(?:current_password|new_password|api_token|authorization|password|token|secret)$')
}

function Get-FleetRedactedUriText {
    <#
    .SYNOPSIS
        Masks secret query values in text used only for logging.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$Text
    )

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

    return [regex]::Replace(
        $Text,
        '(?i)([?&](?:current_password|new_password|api_token|authorization|password|token|secret)=)[^&\s#"]*',
        '${1}[REDACTED]'
    )
}

function Format-FleetUnsafeSecretLiteral {
    <#
    .SYNOPSIS
        Drops the remainder of a log string at an unparsable secret assignment.
 
    .DESCRIPTION
        PowerShell display text such as @{password=prefix;sensitive-tail} does not
        mark where a value ends. Semicolons, braces, and newlines can belong to
        the secret, so the rest of that literal is suppressed.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]
        [string]$Text
    )

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

    # Query strings are masked separately. A preceding ? or & is not a PowerShell literal.
    $match = [regex]::Match(
        $Text,
        '(?i)(?<![A-Za-z0-9_?&-])(?:current_password|new_password|api_token|authorization|password|token|secret)\s*='
    )
    if (-not $match.Success) {
        return $Text
    }

    $start = $Text.LastIndexOf('@{', $match.Index)
    if ($start -lt 0) {
        $start = $match.Index
    }

    return $Text.Substring(0, $start) + '[REDACTED]'
}

function ConvertTo-FleetLogJson {
    <#
    .SYNOPSIS
        Serializes a verbose-log value without a depth cutoff.
 
    .DESCRIPTION
        ConvertTo-Json stops at a fixed depth and turns the next PSCustomObject
        into text such as @{password=secret}. Property redaction does not match
        that text. Dictionaries, lists, and note properties are walked for the
        log only. The HTTP body is serialized separately.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        [object]$InputObject,

        [AllowEmptyCollection()]
        [System.Collections.Generic.List[object]]$Seen
    )

    if ($null -eq $InputObject) {
        return 'null'
    }

    # -InputObject avoids enumerating strings and hashtables into ConvertTo-Json.
    if ($InputObject -is [string] -or $InputObject -is [ValueType]) {
        return (ConvertTo-Json -InputObject $InputObject -Compress)
    }

    if ($null -eq $Seen) {
        $Seen = [System.Collections.Generic.List[object]]::new()
    }
    else {
        foreach ($item in @($Seen)) {
            if ([object]::ReferenceEquals($item, $InputObject)) {
                return '"[Circular]"'
            }
        }
    }

    $Seen.Add($InputObject) | Out-Null

    if ($InputObject -is [System.Collections.IDictionary]) {
        $pairs = foreach ($key in @($InputObject.Keys)) {
            $keyJson = ConvertTo-Json -InputObject $key -Compress
            if (Test-FleetSecretLogName -Name ([string]$key)) {
                '{0}:"[REDACTED]"' -f $keyJson
            }
            else {
                '{0}:{1}' -f $keyJson, (ConvertTo-FleetLogJson -InputObject $InputObject[$key] -Seen $Seen)
            }
        }
        return '{' + ($pairs -join ',') + '}'
    }

    if ($InputObject -is [System.Collections.IList]) {
        $items = foreach ($item in @($InputObject)) {
            ConvertTo-FleetLogJson -InputObject $item -Seen $Seen
        }
        return '[' + ($items -join ',') + ']'
    }

    $noteProperties = @($InputObject.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' })
    if ($noteProperties.Count -gt 0) {
        $keyProperty = $null
        $valueProperty = $null
        foreach ($property in $noteProperties) {
            if ($property.Name -eq 'Key') {
                $keyProperty = $property
            }
            elseif ($property.Name -eq 'Value') {
                $valueProperty = $property
            }
        }

        # Windows PowerShell 5.1 can log a hashtable entry as Key/Value.
        # The value may be an array or object, so replace it whole.
        if ($noteProperties.Count -eq 2 -and $keyProperty -and $valueProperty -and (Test-FleetSecretLogName -Name ([string]$keyProperty.Value))) {
            $keyJson = ConvertTo-Json -InputObject ([string]$keyProperty.Value) -Compress
            return "{`"Key`":$keyJson,`"Value`":`"[REDACTED]`"}"
        }

        $pairs = foreach ($property in $noteProperties) {
            $nameJson = ConvertTo-Json -InputObject $property.Name -Compress
            if (Test-FleetSecretLogName -Name $property.Name) {
                '{0}:"[REDACTED]"' -f $nameJson
            }
            else {
                '{0}:{1}' -f $nameJson, (ConvertTo-FleetLogJson -InputObject $property.Value -Seen $Seen)
            }
        }
        return '{' + ($pairs -join ',') + '}'
    }

    try {
        return (ConvertTo-Json -InputObject $InputObject -Compress -Depth 0 -WarningAction SilentlyContinue)
    }
    catch {
        return (ConvertTo-Json -InputObject $InputObject.GetType().FullName -Compress)
    }
}

function Get-FleetRedactedLogText {
    <#
    .SYNOPSIS
        Redacts passwords, token fields, and bearer tokens in verbose text.
 
    .DESCRIPTION
        Transcripts and CI capture the verbose stream. Secret keys are removed
        for any value type. The request itself still sends the original value.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$Text
    )

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

    if ($Text -isnot [string]) {
        $Text = ConvertTo-FleetLogJson -InputObject $Text
    }
    else {
        $trimmed = $Text.Trim()
        if ($trimmed.StartsWith('{') -or $trimmed.StartsWith('[')) {
            try {
                $parsed = $trimmed | ConvertFrom-Json -ErrorAction Stop
                $Text = ConvertTo-FleetLogJson -InputObject $parsed
            }
            catch {
                Write-Debug 'Verbose text was not JSON.'
            }
        }
    }

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

    $redacted = [regex]::Replace(
        $Text,
        '(?i)("(?:current_password|new_password|api_token|authorization|password|token|secret)"\s*:\s*")((?:\\.|[^"\\])*)(")',
        '${1}[REDACTED]${3}'
    )
    $redacted = [regex]::Replace(
        $redacted,
        '(?i)("Key"\s*:\s*"(?:current_password|new_password|api_token|authorization|password|token|secret)"\s*,\s*"Value"\s*:\s*")((?:\\.|[^"\\])*)(")',
        '${1}[REDACTED]${3}'
    )
    $redacted = Get-FleetRedactedUriText -Text $redacted
    $redacted = Format-FleetUnsafeSecretLiteral -Text $redacted
    return [regex]::Replace($redacted, '(?i)\bBearer\s+[^\s"\\}]+', 'Bearer [REDACTED]')
}

function Invoke-FleetDMRequest {
    <#
    .SYNOPSIS
        Internal function to make API requests to FleetDM
     
    .DESCRIPTION
        This is the core function that handles all API communication with FleetDM.
        It manages authentication, error handling, pagination, and response processing.
     
    .PARAMETER Endpoint
        The API endpoint path (without the base URI or /api/v1/fleet/ prefix)
     
    .PARAMETER Method
        The HTTP method to use (GET, POST, PUT, PATCH, DELETE)
     
    .PARAMETER Body
        Hashtable containing the request body data (will be converted to JSON)
     
    .PARAMETER QueryParameters
        Hashtable of query parameters to append to the URI
     
    .PARAMETER FollowPagination
        If specified, automatically follows pagination to retrieve all results
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Endpoint,
        
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method = 'GET',
        
        [hashtable]$Body,
        
        [hashtable]$QueryParameters,
        
        [switch]$FollowPagination
    )
    
    begin {
        # Check if connected
        if (-not $script:FleetDMConnection) {
            throw "Not connected to FleetDM. Please run Connect-FleetDM first."
        }
        
        # Remove leading slash from endpoint if present
        $Endpoint = $Endpoint.TrimStart('/')
        
        # Build the full URI
        $uri = "$($script:FleetDMConnection.BaseUri)/api/v1/fleet/$Endpoint"
        
        Write-Verbose "Preparing $Method request to: $uri"
    }
    
    process {
        Write-Verbose "Using headers: $(Get-FleetRedactedLogText -Text $script:FleetDMConnection.Headers)"
        # Add query parameters if provided
        if ($QueryParameters -and $QueryParameters.Count -gt 0) {
            $queryString = ConvertTo-FleetQueryString -QueryParameters $QueryParameters
            if ($queryString) {
                $separator = if ($uri.Contains('?')) { '&' } else { '?' }
                $uri = "$uri$separator$queryString"
            }

            Write-Verbose "Full URI with parameters: $(Get-FleetRedactedLogText -Text $uri)"
        }
        Write-Verbose "HTTP Method: $Method"
        # Prepare the request parameters
        $requestParams = @{
            Uri = $uri
            Method = $Method
            Headers = $script:FleetDMConnection.Headers
            ContentType = 'application/json'
            WebSession = $script:FleetDMWebSession
            ErrorAction = 'Stop'
        }
        Write-Verbose "Request parameters: $(Get-FleetRedactedLogText -Text $requestParams)"
        # Add body if provided. The wire body stays depth-limited JSON.
        # The verbose line redacts a separate serialization of the original object.
        if ($Body -and $Body.Count -gt 0) {
            $jsonBody = ConvertTo-FleetJson -InputObject $Body -Depth 10
            $requestParams['Body'] = $jsonBody
            Write-Verbose "Request body: $(Get-FleetRedactedLogText -Text $Body)"
        }
        Write-Verbose "Sending request to FleetDM API"
        try {
            if ($FollowPagination -and $Method -eq 'GET') {
                Write-Verbose "Pagination enabled. Fetching all results."
                $allResults = New-Object System.Collections.Generic.List[object]
                $page = 0
                $maxPages = 1000
                $baseUri = Get-FleetUriWithoutPage -Uri $uri
                $pageSize = 0
                $pageSizeMatch = [regex]::Match($baseUri, '(?:^|[?&])per_page=(\d+)')
                if ($pageSizeMatch.Success) {
                    $pageSize = [int]$pageSizeMatch.Groups[1].Value
                }
                $previousFirstId = $null

                while ($page -lt $maxPages) {
                    $separator = if ($baseUri.Contains('?')) { '&' } else { '?' }
                    $requestParams['Uri'] = "$baseUri${separator}page=$page"

                    Write-Verbose "Fetching page $page"

                    $response = Invoke-RestMethod @requestParams
                    $pageData = Get-FleetPaginatedData -Response $response -ReturnMetadata
                    $pageItems = @($pageData.Data | Where-Object { $null -ne $_ })

                    # An empty page means the cursor is exhausted. Continuing
                    # when has_next_results stays true would loop forever.
                    if ($pageItems.Count -eq 0) {
                        break
                    }

                    $metadata = $pageData.Metadata
                    $knowsNextPage = $metadata -and (
                        ($metadata -is [System.Collections.IDictionary] -and $metadata.ContainsKey('has_next_results')) -or
                        ($metadata.PSObject.Properties.Name -contains 'has_next_results')
                    )

                    $firstId = $pageItems[0].id
                    if (-not $knowsNextPage -and $pageSize -gt 0 -and $pageItems.Count -ge $pageSize) {
                        # A repeated full page is the same cursor. Do not append it.
                        if ($null -ne $previousFirstId -and $null -ne $firstId -and $firstId -eq $previousFirstId) {
                            break
                        }
                    }

                    foreach ($item in $pageItems) {
                        $allResults.Add($item)
                    }

                    if ($knowsNextPage) {
                        if (-not $pageData.HasMore) {
                            break
                        }
                    }
                    else {
                        # List fleets and some other routes omit meta.has_next_results.
                        # Keep reading while a page is full.
                        if ($pageSize -le 0 -or $pageItems.Count -lt $pageSize) {
                            break
                        }
                        $previousFirstId = $firstId
                    }

                    $page++
                }

                if ($page -ge $maxPages) {
                    Write-Warning "Stopped pagination after $maxPages pages."
                }

                Write-Verbose "Retrieved $($allResults.Count) total items across $($page + 1) pages"

                # List[object] cannot be unwrapped with @() on PowerShell 7.
                return @($allResults.ToArray())
            }
            else {
                Write-Verbose "No pagination requested. Sending single request."
                # Single request without pagination
                $response = Invoke-RestMethod @requestParams
                
                Write-Verbose "Request completed successfully"
                
                return $response
            }
        }
        catch {
            # Handle errors
            Resolve-FleetDMApiError -ErrorRecord $_
        }
    }
}

function Resolve-FleetDMApiError {
    <#
    .SYNOPSIS
        Internal function to handle FleetDM API errors
     
    .DESCRIPTION
        Processes error responses from the FleetDM API and throws appropriate exceptions
     
    .PARAMETER ErrorRecord
        The error record from the failed API call
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )
    
    $exception = $ErrorRecord.Exception
    $response = $exception.Response

    if ($response) {
        $statusCode = [int]$response.StatusCode
        $statusDescription = $response.StatusDescription

        # Try to parse the error response body
        $errorMessage = $statusDescription
        $errorDetails = $null
        $responseBody = Get-FleetHttpErrorBody -ErrorRecord $ErrorRecord

        if ($responseBody) {
            try {
                $errorContent = $responseBody | ConvertFrom-Json

                if ($errorContent.message) {
                    $errorMessage = $errorContent.message
                }

                if ($errorContent.errors) {
                    $errorDetails = $errorContent.errors
                }

                if ($errorContent.uuid) {
                    Write-Verbose "Error UUID: $($errorContent.uuid)"
                }
            }
            catch {
                Write-Verbose "Could not parse error response body"
                if (-not $errorMessage) {
                    $errorMessage = $responseBody
                }
            }
        }
        
        # Create error message based on status code
        $premiumText = "$errorMessage $($errorDetails | Out-String)"
        $isPremiumStatus = $statusCode -eq 402 -or (
            $statusCode -eq 403 -and $premiumText -match 'premium|license'
        )
        if ($isPremiumStatus) {
            $detail = if ($errorMessage) { $errorMessage } else { 'Requires Fleet Premium license' }
            throw "This feature requires Fleet Premium. $detail"
        }

        switch ($statusCode) {
            400 {
                # Check for specific error reasons in the details
                $isPremiumError = $false
                if ($errorDetails) {
                    foreach ($detail in $errorDetails) {
                        if ($detail.reason -like "*premium license*") {
                            $isPremiumError = $true
                            break
                        }
                    }
                }
                
                if ($isPremiumError) {
                    throw "This feature requires a Fleet Premium license"
                }
                else {
                    $fullMessage = "Bad Request: $errorMessage"
                    if ($errorDetails) {
                        $fullMessage += "`nDetails: $($errorDetails | ConvertTo-Json -Compress)"
                    }
                    throw $fullMessage
                }
            }
            401 {
                throw "Authentication failed. Your session may have expired. Please run Connect-FleetDM again."
            }
            403 {
                throw "Access denied. You don't have permission to perform this operation. Error: $errorMessage"
            }
            404 {
                throw "Resource not found. The requested item does not exist. Error: $errorMessage"
            }
            408 {
                throw "Request timeout. The operation took too long to complete. Error: $errorMessage"
            }
            429 {
                # Rate limiting
                $retryAfter = $null
                
                if ($errorMessage -match 'retry after:\s*(\d+)s') {
                    $retryAfter = $matches[1]
                }
                
                if ($retryAfter) {
                    Write-Warning "Rate limit exceeded. Please wait $retryAfter seconds before retrying."
                    throw "Rate limit exceeded. Retry after $retryAfter seconds."
                }
                else {
                    throw "Rate limit exceeded. Please wait before retrying."
                }
            }
            500 {
                throw "Internal server error. FleetDM encountered an error. Error: $errorMessage"
            }
            502 {
                throw "Bad gateway. FleetDM service may be temporarily unavailable."
            }
            503 {
                throw "Service unavailable. FleetDM service is temporarily unavailable."
            }
            default {
                throw "FleetDM API error (HTTP $statusCode): $errorMessage"
            }
        }
    }
    else {
        # Non-HTTP error (network, timeout, etc.)
        throw "Failed to communicate with FleetDM: $($exception.Message)"
    }
}

function Get-FleetHttpErrorBody {
    <#
    .SYNOPSIS
        Reads the response body from a failed Fleet HTTP call.
 
    .DESCRIPTION
        PowerShell 7 exposes the body on ErrorRecord.ErrorDetails and the
        response as HttpResponseMessage. Windows PowerShell 5.1 exposes an
        HttpWebResponse stream. Both shapes are read here.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
        return [string]$ErrorRecord.ErrorDetails.Message
    }

    $response = $ErrorRecord.Exception.Response
    if (-not $response) {
        return $null
    }

    if ($response.PSObject.Methods.Name -contains 'GetResponseStream') {
        try {
            $stream = $response.GetResponseStream()
            if ($stream -and $stream.CanSeek) {
                $stream.Position = 0
            }
            $reader = New-Object System.IO.StreamReader($stream)
            $body = $reader.ReadToEnd()
            $reader.Dispose()
            return $body
        }
        catch {
            Write-Verbose "Could not read HTTP response stream: $_"
        }
    }

    if ($response.Content -and $response.Content.PSObject.Methods.Name -contains 'ReadAsStringAsync') {
        try {
            return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
        }
        catch {
            Write-Verbose "Could not read HTTP response content: $_"
        }
    }

    return $null
}

function Get-FleetUriWithoutPage {
    <#
    .SYNOPSIS
        Returns a URI with any existing page query parameter removed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri
    )

    $question = $Uri.IndexOf('?')
    if ($question -lt 0) {
        return $Uri
    }

    $path = $Uri.Substring(0, $question)
    $kept = @($Uri.Substring($question + 1) -split '&' | Where-Object { $_ -and ($_ -split '=', 2)[0] -ne 'page' })
    if ($kept.Count -eq 0) {
        return $path
    }

    return ($path + '?' + ($kept -join '&'))
}