private/Invoke-VSARestMethod.ps1

function ConvertTo-ODataString {
    <#
    .SYNOPSIS
       Escapes special characters in OData filter strings to prevent injection attacks.
    .DESCRIPTION
       Escapes single quotes and other special characters in OData filter strings according to OData standards.
       This function prevents OData injection vulnerabilities by properly escaping user input.
    .PARAMETER FilterString
        The string to escape for use in OData filters.
    .EXAMPLE
       ConvertTo-ODataString -FilterString "O'Brien's Computer"
       Returns: O''Brien''s Computer
    .NOTES
        Version 1.0.0
        Security: This function implements OWASP recommendations for OData query security.
    #>

    [CmdletBinding()]
    param(
        [parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [string] $FilterString
    )
    
    # Escape single quotes by doubling them per OData specification
    $escaped = $FilterString -replace "'", "''"
    
    # Additional escaping for known problematic characters
    $escaped = $escaped -replace '\\', '\\\\'
    
    return $escaped
}

function Invoke-VSARestMethod {
    <#
    .SYNOPSIS
       Invokes VSA REST API methods with automatic retry for transient failures.
    .DESCRIPTION
       Executes various VSA REST API methods and returns the response or success status.
       Automatically retries on transient HTTP errors (502, 503, 504) with exponential backoff.
        
       SECURITY NOTE: This function implements OData injection prevention by automatically escaping
       user input in Filter parameters. Sort and Skip parameters are also validated.
    .PARAMETER VSAConnection
        Specifies the established VSAConnection.
    .PARAMETER URISuffix
        Specifies the URI suffix if it differs from the default.
    .PARAMETER Method
        Specifies the REST API Method (default is "GET").
    .PARAMETER Body
        Specifies the request body for methods that require it.
    .PARAMETER ContentType
        Specifies the content type of the request (default is "application/json").
    .PARAMETER MaxRetries
        Specifies the maximum number of retry attempts for transient HTTP errors (502, 503, 504).
        Default is 3. Valid range is 0-10.
        Uses exponential backoff: 1s, 2s, 4s, 8s, etc.
    .PARAMETER ExtendedOutput
        Specifies whether to return the Result field of the REST API response.
    .PARAMETER Filter
        Specifies REST API Filter. Special characters are automatically escaped to prevent injection attacks.
    .PARAMETER RecordsPerPage
        Specifies the number of records per page for paging (default is 100).
    .PARAMETER Skip
        Specifies the number of records to skip for paging (must be numeric).
    .PARAMETER Sort
        Specifies REST API Sorting. Only alphanumeric characters, spaces, hyphens, and commas are allowed.
    .EXAMPLE
       Invoke-VSARestMethod -VSAConnection $connection -URISuffix 'items' -Method 'GET'
    .EXAMPLE
       Invoke-VSARestMethod -VSAConnection $connection -URISuffix 'items' -Method 'POST' -Body $Body
    .EXAMPLE
       Invoke-VSARestMethod -VSAConnection $connection -URISuffix 'items' -MaxRetries 5
    .INPUTS
       Accepts piped VSAConnection.
    .OUTPUTS
       Varies based on the method invoked.
    .NOTES
        Version 1.0.0
        SECURITY: Implements OData injection prevention (version 0.1.5) and parameter validation.
        RELIABILITY: Automatic retry with exponential backoff for transient failures (v0.1.7).
    #>

    [CmdletBinding()]
    param (
        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [AllowNull()]
        [VSAConnection] $VSAConnection = $null,

        [parameter(DontShow, Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $URISuffix,

        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateSet("GET", "POST", "PUT", "DELETE", "PATCH")]
        [string] $Method = 'GET',

        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $Body,

        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $ContentType = "application/json",

        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [switch] $ExtendedOutput,

        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [string] $Filter,
        <#
        .PARAMETER Filter
            Specifies an OData filter expression for the REST API query.
            Syntax: Property Operator Value
             
            Operators:
              - eq : equals (Name eq 'MyAgent')
              - ne : not equals (Status ne 'Inactive')
              - lt : less than (CreatedDate lt 2024-01-01)
              - le : less than or equal
              - gt : greater than
              - ge : greater than or equal
              - startswith : prefix match (Name startswith 'prod')
              - endswith : suffix match
              - contains : substring match
              - and : logical AND
              - or : logical OR
             
            Examples:
              "Name eq 'MyAgent'"
              "Name startswith 'prod'"
              "Status ne 'Inactive'"
              "ComputerName eq 'PC' and Status eq 'Online'"
              "Name eq 'O''Brien'" (single quotes doubled for escaping)
             
            Special characters are automatically escaped to prevent OData injection.
        #>


        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateRange(1, 100)]
        [int] $RecordsPerPage = 100,
        <#
        .PARAMETER RecordsPerPage
            Specifies the number of records to retrieve per API request page.
            Valid Range: 1 to 100
            Default Value: 100
             
            Behavior:
              - Smaller values (10-20): More API calls, lower memory usage, better for large result sets
              - Larger values (80-100): Fewer API calls, higher memory usage, faster retrieval
              - Module automatically paces through all pages and merges results
             
            Note: For results > 25,000 records, automatic token renewal occurs.
        #>


        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()] 
        [string] $Skip,
        <#
        .PARAMETER Skip
            Specifies the number of records to skip for pagination.
             
            Format: Numeric string representing count
            Examples: '0', '100', '500', '1000'
            Default Value: '0' (starts from beginning)
             
            Usage Pattern with RecordsPerPage:
              - Skip=0, RecordsPerPage=100 : Records 1-100
              - Skip=100, RecordsPerPage=100 : Records 101-200
              - Skip=500, RecordsPerPage=50 : Records 501-550
             
            Note: Typically used internally for pagination; usually not needed in manual queries.
        #>


        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [AllowEmptyString()]
        [string] $Sort,
        <#
        .PARAMETER Sort
            Specifies the sort order for results using OData syntax.
             
            Format: One or more sort expressions, comma-separated
            Syntax: FieldName asc|desc
             
            Examples:
              "Name asc" (alphabetical A-Z)
              "CreatedDate desc" (newest first)
              "Priority asc, Name asc" (multi-field: by priority, then by name)
              "Status desc, LastCheck asc" (offline first, then by check time)
             
            Valid Characters: Letters, numbers, spaces, commas, hyphens
            Invalid characters are automatically removed.
             
            Common Fields: Name, Status, CreatedDate, ModifiedDate, Priority
        #>


        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [int] $MaxRecordsPerSession = 25000,

        [parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [ValidateRange(0, 10)]
        [int] $MaxRetries = 3
    )

    $VSAServerURI = [string]::Empty
    [bool]$IgnoreCertificateErrors = $false

    if ( $null -eq $VSAConnection ) {
        $VSAServerURI = [VSAConnection]::GetPersistentURI()
        $UsersToken = "Bearer $( [VSAConnection]::GetPersistentToken() )"
        $IgnoreCertificateErrors = [VSAConnection]::GetIgnoreCertErrors()
        Update-VSAConnection
    } else {
        $VSAServerURI = $VSAConnection.URI
        $UsersToken = "Bearer $($VSAConnection.Token)"
        $IgnoreCertificateErrors = $VSAConnection.IgnoreCertificateErrors
        Update-VSAConnection -VSAConnection $VSAConnection
    }

    # Escape Filter parameter to prevent OData injection attacks
    if (-not [string]::IsNullOrEmpty($Filter)) {
        $Filter = ConvertTo-ODataString -FilterString $Filter
    }

    # Escape Sort parameter to prevent injection (alphanumeric and common field separators only)
    # Expected format: 'FieldName asc' or 'FieldName1 asc, FieldName2 desc'
    if (-not [string]::IsNullOrEmpty($Sort)) {
        if ($Sort -notmatch '^\[\w\s,\-\]*$') {
            $sanitized = $Sort -replace '[^a-zA-Z0-9\s,\-]', ''
            Write-Warning "Sort parameter contained potentially dangerous characters.`n" +
                         "Original: '$Sort'`n" +
                         "Sanitized: '$sanitized'`n" +
                         "Expected format: 'FieldName asc' or 'Field1 asc, Field2 desc'"
            $Sort = $sanitized
        }
    }

    # Validate Skip parameter (must be numeric)
    # Expected format: '0', '100', '500' (number of records to skip)
    if (-not [string]::IsNullOrEmpty($Skip)) {
        if ($Skip -notmatch '^\d+$') {
            throw "Skip parameter validation failed.`n" +
                  "Received: '$Skip'`n" +
                  "Expected: Positive integer (e.g., '0', '100', '500')`n" +
                  "Represents: Number of records to skip in pagination."
        }
    }

    $baseUri = New-Object System.Uri -ArgumentList $VSAServerURI
    [string]$URI = [System.Uri]::new($baseUri, $URISuffix) | Select-Object -ExpandProperty AbsoluteUri

    [hashtable]$ApiSearchParams = @{
        '$filter' = $Filter
        '$orderby' = $Sort
        '$skip' = $Skip
    }

    foreach ($key in $ApiSearchParams.Keys.Clone()) {
        if ([string]::IsNullOrEmpty($ApiSearchParams[$key])) {
            $ApiSearchParams.Remove($key)
        }
    }

    if (($null -eq $ApiSearchParams) -or (0 -eq $ApiSearchParams.Count)) {
        $CombinedURI = $URI
    } else {
        $CombinedURI = "{0}?{1}" -f $URI, $(($ApiSearchParams.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '&')
    }

    [hashtable]$WebRequestParams = @{
        Uri = $CombinedURI
        Method = $Method
        AuthString = $UsersToken
        IgnoreCertificateErrors = $IgnoreCertificateErrors
        MaxRetries = $MaxRetries
    }

    if ($Body) {
        $WebRequestParams.Add('Body', $Body)
        $WebRequestParams.Add('ContentType', $ContentType)
    }

    if ($PSCmdlet.MyInvocation.BoundParameters['Debug']) {
        Write-Debug "$($MyInvocation.MyCommand)"
        Write-Debug "Invoke-VSARestMethod. Request details:"
        $WebRequestParams | ConvertTo-Json -Depth 3 | Out-String | Write-Debug
    }
    if ($PSCmdlet.MyInvocation.BoundParameters['Verbose']) {
        Write-Verbose "Invoke-VSARestMethod. Calling Get-RequestData on URI: $($WebRequestParams.Uri)"
    }
    
    try {
        $response = Get-RequestData @WebRequestParams
    } catch {
        $contextInfo = @(
            "Failed to retrieve data from VSA API"
            "URI Suffix: $URISuffix"
            "Filter: $(if ($Filter) { $Filter } else { 'None' })"
            "Sort: $(if ($Sort) { $Sort } else { 'None' })"
            "Skip: $(if ($Skip) { $Skip } else { '0' })"
            "Records Per Page: $RecordsPerPage"
            "Method: $Method"
            "Error: $($_.Exception.Message)"
        ) -join "`n"
        Write-Error $contextInfo
        throw
    }

    if ($PSCmdlet.MyInvocation.BoundParameters['Debug']) {
        Write-Debug "Invoke-VSARestMethod. Response:`n$($response | Out-String)"
    }

    [array]$result = $response | Select-Object -ExpandProperty Result

    if (-not [string]::IsNullOrEmpty("$($response.TotalRecords)")) {
        [int]$TotalRecords = $response.TotalRecords

        if ($PSCmdlet.MyInvocation.BoundParameters['Debug']) {
            Write-Debug "Invoke-VSARestMethod`nRecords: $TotalRecords"
        }
        if ($PSCmdlet.MyInvocation.BoundParameters['Verbose']) {
            Write-Verbose "Invoke-VSARestMethod`nRecords: $TotalRecords"
        }

        [int]$Pages = [math]::Ceiling($TotalRecords / $RecordsPerPage)
        $resultCollection = [System.Collections.ArrayList]@($result)

        #Workaroud For SaaS limitation

        [int]$RenewTimes = 1
        [int]$RenewThreshold = $MaxRecordsPerSession
        

        for ( $PageProcessed = 1; $PageProcessed -le $Pages; $PageProcessed++ ) {

            $ApiSearchParams['$skip'] = $RecordsPerPage * $PageProcessed
            $CombinedURI = "$URI`?$([array]($ApiSearchParams.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '&')"
            $WebRequestParams.Uri = $CombinedURI

            #Workaroud For SaaS limitation $MaxRecordsPerSession
            if ($ApiSearchParams['$skip'] -ge $RenewThreshold ) {

                if ($PSCmdlet.MyInvocation.BoundParameters['Debug']) {
                    Write-Debug "Fetching in progress... So far fetched $RenewThreshold records."
                }
                if ($PSCmdlet.MyInvocation.BoundParameters['Verbose']) {
                    Write-Verbose "Fetching in progress... So far fetched $RenewThreshold records."
                }

                if ($null -eq $VSAConnection) {

                    [VSAConnection]::UpdatePersistentSessionExpiration( $([datetime]::Now).AddMinutes(-1) )
                    Update-VSAConnection
                    $UsersToken = "Bearer $( [VSAConnection]::GetPersistentToken() )"
                } else {

                    $VSAConnection.UpdateSessionExpiration( $([datetime]::Now).AddMinutes(-1) )
                    Update-VSAConnection -VSAConnection $VSAConnection
                    $UsersToken = "Bearer $($VSAConnection.Token)"
                }

                $RenewTimes +=1
                $RenewThreshold = $MaxRecordsPerSession * $RenewTimes
            }

            $WebRequestParams.AuthString = $UsersToken

            [array]$temp = Get-RequestData @WebRequestParams | Select-Object -ExpandProperty Result

            if (0 -lt $temp.Count) { 
                $resultCollection.AddRange($temp) | Out-Null 
            }
        }
        $result = $resultCollection.ToArray()
    }

    if ($PSCmdlet.MyInvocation.BoundParameters['Debug']) {
        Write-Debug "Invoke-VSARestMethod. Result`n $($result | Out-String)"
    }
    
    if ($ExtendedOutput) {
        return $response
    } else {
        return $result
    }
}