PowerQualys.psm1

function Join-UriQuery { 
    <#
    .SYNOPSIS
    Provides ability to join two Url paths together including advanced querying
 
    .DESCRIPTION
    Provides ability to join two Url paths together including advanced querying which is useful for RestAPI/GraphApi calls
 
    .PARAMETER BaseUri
    Primary Url to merge
 
    .PARAMETER RelativeOrAbsoluteUri
    Additional path to merge with primary url (optional)
 
    .PARAMETER QueryParameter
    Parameters and their values in form of hashtable
 
    .PARAMETER EscapeUriString
    If set, will escape the url string
 
    .EXAMPLE
    Join-UriQuery -BaseUri 'https://evotec.xyz/' -RelativeOrAbsoluteUri '/wp-json/wp/v2/posts' -QueryParameter @{
        page = 1
        per_page = 20
        search = 'SearchString'
    }
 
    .EXAMPLE
    Join-UriQuery -BaseUri 'https://evotec.xyz/wp-json/wp/v2/posts' -QueryParameter @{
        page = 1
        per_page = 20
        search = 'SearchString'
    }
 
    .EXAMPLE
    Join-UriQuery -BaseUri 'https://evotec.xyz' -RelativeOrAbsoluteUri '/wp-json/wp/v2/posts'
 
    .NOTES
    General notes
    #>

    [alias('Join-UrlQuery')]
    [CmdletBinding()]
    param (
        [parameter(Mandatory)][uri] $BaseUri,
        [parameter(Mandatory = $false)][uri] $RelativeOrAbsoluteUri,
        [Parameter()][System.Collections.IDictionary] $QueryParameter,
        [alias('EscapeUrlString')][switch] $EscapeUriString
    )
    Begin {
        Add-Type -AssemblyName System.Web
    }
    Process {

        if ($BaseUri -and $RelativeOrAbsoluteUri) {
            $Url = Join-Uri -BaseUri $BaseUri -RelativeOrAbsoluteUri $RelativeOrAbsoluteUri
        }
        else {
            $Url = $BaseUri
        }

        if ($QueryParameter) {
            $Collection = [System.Web.HttpUtility]::ParseQueryString([String]::Empty)
            foreach ($key in $QueryParameter.Keys) {
                $Collection.Add($key, $QueryParameter.$key)
            }
        }

        $uriRequest = [System.UriBuilder] $Url
        if ($Collection) {
            $uriRequest.Query = $Collection.ToString()
        }
        if (-not $EscapeUriString) {
            $uriRequest.Uri.AbsoluteUri
        }
        else {
            [System.Uri]::EscapeUriString($uriRequest.Uri.AbsoluteUri)
        }
    }
}
function Join-Uri { 
    <#
    .SYNOPSIS
    Provides ability to join two Url paths together
 
    .DESCRIPTION
    Provides ability to join two Url paths together
 
    .PARAMETER BaseUri
    Primary Url to merge
 
    .PARAMETER RelativeOrAbsoluteUri
    Additional path to merge with primary url
 
    .EXAMPLE
    Join-Uri 'https://evotec.xyz/' '/wp-json/wp/v2/posts'
 
    .EXAMPLE
    Join-Uri 'https://evotec.xyz/' 'wp-json/wp/v2/posts'
 
    .EXAMPLE
    Join-Uri -BaseUri 'https://evotec.xyz/' -RelativeOrAbsoluteUri '/wp-json/wp/v2/posts'
 
    .EXAMPLE
    Join-Uri -BaseUri 'https://evotec.xyz/test/' -RelativeOrAbsoluteUri '/wp-json/wp/v2/posts'
 
    .NOTES
    General notes
    #>

    [alias('Join-Url')]
    [cmdletBinding()]
    param(
        [parameter(Mandatory)][uri] $BaseUri,
        [parameter(Mandatory)][uri] $RelativeOrAbsoluteUri
    )

    return ($BaseUri.OriginalString.TrimEnd('/') + "/" + $RelativeOrAbsoluteUri.OriginalString.TrimStart('/'))
}
function Get-QualysReportInformation {
    [CmdletBinding()]
    param(
        [int] $MaximumRecords
    )
    $Hosts = Get-QualysHostDetection -ShowIgs -QID '45027,45302,90924,91074,91328' -MaximumRecords $MaximumRecords

    $FilteredHosts = [ordered] @{}
    foreach ($Computer in $Hosts) {
        $Name = $Computer.DNS_DATA.HOSTNAME.'#cdata-section'
        $Computer.LAST_SCAN_DATETIME = [datetime]::ParseExact($Computer.LAST_SCAN_DATETIME, 'yyyy-MM-dd\THH:mm:ss\Z', $null)
        $Computer.LAST_VM_AUTH_SCANNED_DATE = [datetime]::ParseExact($Computer.LAST_VM_AUTH_SCANNED_DATE, 'yyyy-MM-dd\THH:mm:ss\Z', $null)
        $Computer.LAST_VM_SCANNED_DATE = [datetime]::ParseExact($Computer.LAST_VM_SCANNED_DATE, 'yyyy-MM-dd\THH:mm:ss\Z', $null)

        if (-not $FilteredHosts[$Name]) {
            $FilteredHosts[$Name] = $Computer
        }
        else {
            if ($Computer.LAST_SCAN_DATETIME -gt $FilteredHosts[$Name].LAST_SCAN_DATETIME) {
                $FilteredHosts[$Name] = $Computer
            }
        }
    }

    $Output = foreach ($Computer in $FilteredHosts.Values) {
        $CacheDetection = [ordered] @{}
        foreach ($Detection in $Computer.DETECTION_LIST.DETECTION) {
            $CacheDetection[$Detection.QID] = $Detection
        }

        $HostName = $Computer.DNS_DATA.HOSTNAME.'#cdata-section'
        $Domain = $Computer.DNS_DATA.DOMAIN.'#cdata-section'

        if ($CacheDetection['91074'].RESULTS.'#cdata-section') {
            $InstallationDate = $CacheDetection['91074'].RESULTS.'#cdata-section'.Replace("Microsoft Windows install date retrieved from the registry: ", "")
        }
        else {
            $InstallationDate = $null
        }
        if ($CacheDetection['91328'].RESULTS.'#cdata-section') {
            $HotFixes = ($CacheDetection['91328'].RESULTS.'#cdata-section' -replace "&apos;", "" -replace "HotfixID", "" -split "\n") | ForEach-Object { if ($_.Trim()) {
                    "KB$_" 
                } }
        }
        else {
            $HotFixes = $null
        }

        if ($CacheDetection['90924'].RESULTS.'#cdata-section') {
            $LastReboot = $CacheDetection['90924'].RESULTS.'#cdata-section'.Replace("Last Reboot Date and Time(yyyy/mm/dd hh:mm:ss): ", "")
        }
        else {
            $LastReboot = $null
        }

        if ($CacheDetection['45027'].RESULTS.'#cdata-section') {
            $Disabled = $CacheDetection['45027'].RESULTS.'#cdata-section'.Replace("Disabled User/Machine Accounts: ", "") | ForEach-Object { if ($_.Trim()) {
                    $_.Trim() -split " " 
                } }
        }
        else {
            $Disabled = $null
        }

        if ($CacheDetection['45302'].RESULTS.'#cdata-section') {
            $LocalAccountsBefore = $CacheDetection['45302'].RESULTS.'#cdata-section'
            $LocalAccounts = foreach ($Local in $LocalAccountsBefore -split "\n") {
                # Get everything before { }, and then split it on space
                $First = $Local -replace "\{.*", ""
                $First = $First -split " "
                # Get string between { }
                $Second = $Local -replace ".*\{(.*)\}.*", '$1'
                $Second = $Second -split ","

                $Name = $Second[1].Trim() -replace "Name=", ""

                $SplittedName = $Name.Split("\")
                $ObjectDomain = $SplittedName[0]
                $ObjectName = $SplittedName[1]


                $Type = $Second[0].Trim() -replace "siduse=", ""

                if ($ObjectDomain -eq $HostName) {
                    $IsLocal = $true
                    if ($Type -eq 'Group') {
                        $ObjectType = 'LocalGroup'
                    }
                    elseif ($Type -eq 'Computer') {
                        $ObjectType = 'LocalComputer'
                    }
                    elseif ($Type -eq 'User') {
                        $ObjectType = 'LocalUser'
                    }
                    else {
                        $ObjectType = $Type
                    }
                }
                else {
                    $IsLocal = $false
                    if ($Type -eq 'Group') {
                        $ObjectType = 'DomainGroup'
                    }
                    elseif ($Type -eq 'Computer') {
                        $ObjectType = 'DomainComputer'
                    }
                    elseif ($Type -eq 'User') {
                        $ObjectType = 'DomainUser'
                    }
                    else {
                        $ObjectType = $Type
                    }
                }


                [PSCustomObject] @{
                    HostName       = $HostName
                    Domain         = $Domain
                    GroupSID       = $First[0].Trim()
                    GroupName      = $First[1].Trim()
                    IsLocal        = $IsLocal
                    Type           = $ObjectType
                    ObjectDomain   = $ObjectDomain
                    ObjectName     = $ObjectName
                    ObjectFullName = $Name
                    SID            = $Second[2].Trim() -replace "SID=", ""
                }
            }
        }
        else {
            $LocalAccounts = $null
        }

        [PSCustomObject] @{
            Name             = $Computer.DNS.'#cdata-section'
            HostName         = $HostName
            Domain           = $Computer.DNS_DATA.DOMAIN.'#cdata-section'
            FQDN             = $Computer.DNS_DATA.FQDN.'#cdata-section'
            IP               = $Computer.IP
            ID               = $Computer.ID
            LAST_SCAN_DATE   = $Computer.LAST_SCAN_DATETIME
            OS               = $Computer.OS.'#cdata-section'
            TRACKING_METHOD  = $Computer.TRACKING_METHOD
            InstallationDate = $InstallationDate
            HotFixes         = $HotFixes
            LastReboot       = $LastReboot
            Disabled         = $Disabled
            LocalAccounts    = $LocalAccounts
        }
    }
    $Output
}
function Get-QualysReportLocalAdmins {
    [CmdletBinding()]
    param(
        [int] $MaximumRecords
    )
    $Hosts = Get-QualysHostDetection -ShowIgs -QID '45302' -MaximumRecords $MaximumRecords

    $FilteredHosts = [ordered] @{}
    foreach ($Computer in $Hosts) {
        $Name = $Computer.DNS_DATA.HOSTNAME.'#cdata-section'
        $Computer.LAST_SCAN_DATETIME = [datetime]::ParseExact($Computer.LAST_SCAN_DATETIME, 'yyyy-MM-dd\THH:mm:ss\Z', $null)
        $Computer.LAST_VM_AUTH_SCANNED_DATE = [datetime]::ParseExact($Computer.LAST_VM_AUTH_SCANNED_DATE, 'yyyy-MM-dd\THH:mm:ss\Z', $null)
        $Computer.LAST_VM_SCANNED_DATE = [datetime]::ParseExact($Computer.LAST_VM_SCANNED_DATE, 'yyyy-MM-dd\THH:mm:ss\Z', $null)

        if (-not $FilteredHosts[$Name]) {
            $FilteredHosts[$Name] = $Computer
        }
        else {
            if ($Computer.LAST_SCAN_DATETIME -gt $FilteredHosts[$Name].LAST_SCAN_DATETIME) {
                $FilteredHosts[$Name] = $Computer
            }
        }
    }

    $Output = foreach ($Computer in $FilteredHosts.Values) {
        $CacheDetection = [ordered] @{}
        foreach ($Detection in $Computer.DETECTION_LIST.DETECTION) {
            $CacheDetection[$Detection.QID] = $Detection
        }

        $HostName = $Computer.DNS_DATA.HOSTNAME.'#cdata-section'
        $Domain = $Computer.DNS_DATA.DOMAIN.'#cdata-section'

        # if ($CacheDetection['91074'].RESULTS.'#cdata-section') {
        # $InstallationDate = $CacheDetection['91074'].RESULTS.'#cdata-section'.Replace("Microsoft Windows install date retrieved from the registry: ", "")
        # } else {
        # $InstallationDate = $null
        # }
        # if ($CacheDetection['91328'].RESULTS.'#cdata-section') {
        # $HotFixes = ($CacheDetection['91328'].RESULTS.'#cdata-section' -replace "&apos;", "" -replace "HotfixID", "" -split "\n") | ForEach-Object { if ($_.Trim()) { "KB$_" } }
        # } else {
        # $HotFixes = $null
        # }

        # if ($CacheDetection['90924'].RESULTS.'#cdata-section') {
        # $LastReboot = $CacheDetection['90924'].RESULTS.'#cdata-section'.Replace("Last Reboot Date and Time(yyyy/mm/dd hh:mm:ss): ", "")
        # } else {
        # $LastReboot = $null
        # }

        # if ($CacheDetection['45027'].RESULTS.'#cdata-section') {
        # $Disabled = $CacheDetection['45027'].RESULTS.'#cdata-section'.Replace("Disabled User/Machine Accounts: ", "") | ForEach-Object { if ($_.Trim()) { $_.Trim() -split " " } }
        # } else {
        # $Disabled = $null
        # }

        if ($CacheDetection['45302'].RESULTS.'#cdata-section') {
            $LocalAccountsBefore = $CacheDetection['45302'].RESULTS.'#cdata-section'
            $LocalAccounts = foreach ($Local in $LocalAccountsBefore -split "\n") {
                # Get everything before { }, and then split it on space
                $First = $Local -replace "\{.*", ""
                $First = $First -split " "
                # Get string between { }
                $Second = $Local -replace ".*\{(.*)\}.*", '$1'
                $Second = $Second -split ","

                $Name = $Second[1].Trim() -replace "Name=", ""

                $SplittedName = $Name.Split("\")
                $ObjectDomain = $SplittedName[0]
                $ObjectName = $SplittedName[1]


                $Type = $Second[0].Trim() -replace "siduse=", ""

                if ($ObjectDomain -eq $HostName) {
                    $IsLocal = $true
                    if ($Type -eq 'Group') {
                        $ObjectType = 'LocalGroup'
                    }
                    elseif ($Type -eq 'Computer') {
                        $ObjectType = 'LocalComputer'
                    }
                    elseif ($Type -eq 'User') {
                        $ObjectType = 'LocalUser'
                    }
                    else {
                        $ObjectType = $Type
                    }
                }
                else {
                    $IsLocal = $false
                    if ($Type -eq 'Group') {
                        $ObjectType = 'DomainGroup'
                    }
                    elseif ($Type -eq 'Computer') {
                        $ObjectType = 'DomainComputer'
                    }
                    elseif ($Type -eq 'User') {
                        $ObjectType = 'DomainUser'
                    }
                    else {
                        $ObjectType = $Type
                    }
                }


                [PSCustomObject] @{
                    HostName       = $HostName
                    Domain         = $Domain
                    GroupSID       = $First[0].Trim()
                    GroupName      = $First[1].Trim()
                    IsLocal        = $IsLocal
                    Type           = $ObjectType
                    ObjectDomain   = $ObjectDomain
                    ObjectName     = $ObjectName
                    ObjectFullName = $Name
                    SID            = $Second[2].Trim() -replace "SID=", ""
                }
            }
            $LocalAccounts
        }

        # [PSCustomObject] @{
        # Name = $Computer.DNS.'#cdata-section'
        # HostName = $HostName
        # Domain = $Computer.DNS_DATA.DOMAIN.'#cdata-section'
        # FQDN = $Computer.DNS_DATA.FQDN.'#cdata-section'
        # IP = $Computer.IP
        # ID = $Computer.ID
        # LAST_SCAN_DATE = $Computer.LAST_SCAN_DATETIME
        # OS = $Computer.OS.'#cdata-section'
        # TRACKING_METHOD = $Computer.TRACKING_METHOD
        # InstallationDate = $InstallationDate
        # HotFixes = $HotFixes
        # LastReboot = $LastReboot
        # Disabled = $Disabled
        # LocalAccounts = $LocalAccounts
        # }
    }
    $Output
}


# $Data = foreach ($HostName in $Hosts) {
# if ($HostName.DNS_Data.HOSTNAME.'#cdata-section' -eq 'au51xvvp001') {
# $HostName
# }
# }
function Connect-Qualys {
    [CmdletBinding(DefaultParameterSetName = 'Credential')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'SecurePassword')]
        [Parameter(Mandatory, ParameterSetName = 'Password')]
        [Parameter(Mandatory, ParameterSetName = 'Credential')]
        [string] $Url,
        [Parameter(Mandatory, ParameterSetName = 'SecurePassword')]
        [Parameter(Mandatory, ParameterSetName = 'Password')][string] $Username,
        [Parameter(Mandatory, ParameterSetName = 'Password')][string] $Password,
        [Parameter(Mandatory, ParameterSetName = 'Credential')][pscredential] $Credential,

        [alias('SecurePassword')][Parameter(Mandatory, ParameterSetName = 'SecurePassword')][string] $EncryptedPassword
    )

    if ($EncryptedPassword) {
        try {
            $Password = $EncryptedPassword | ConvertTo-SecureString -ErrorAction Stop
        }
        catch {
            if ($ErrorActionPreference -eq 'Stop') {
                throw
            }
            Write-Warning -Message "Connect-Qualys - Unable to convert password to secure string. Error: $($_.Exception.Message)"
            return
        }
    }
    elseif ($Credential) {
        $UserName = $Credential.UserName
        $Password = $Credential.GetNetworkCredential().Password
    }

    $Script:PowerQualys = @{
        'Uri'           = $Url
        'Authorization' = 'Basic {0}' -f ([Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $UserName, $Password))))
    }
}
function Get-QualysData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet(
            'HostSummary',
            'LocalAdmins'
        )][string] $Type,
        [int] $MaximumRecords
    )

    if ($Type -eq 'HostSummary') {
        Get-QualysReportInformation -MaximumRecords $MaximumRecords
    }
    elseif ($Type -eq 'LocalAdmins') {
        Get-QualysReportLocalAdmins -MaximumRecords $MaximumRecords
    }
    else {
        throw 'Invalid Type'
    }
}
function Get-QualysGroup {
    [CmdletBinding()]
    param(
        [int] $MaximumRecords
    )

    if (-not $Script:PowerQualys) {
        if ($ErrorActionPreference -eq 'Stop') {
            throw 'You must first connect to Qualys using Connect-Qualys'
        }
        Write-Warning -Message 'Get-QualysGroup - You must first connect to Qualys using Connect-Qualys'
        return
    }

    $invokeQualysQuerySplat = @{
        RelativeUri = 'asset/group/'
        Method      = 'GET'
        Body        = [ordered] @{
            action           = 'list'
            truncation_limit = $MaximumRecords
        }
    }

    $Query = Invoke-QualysQuery @invokeQualysQuerySplat
    if ($Query.ASSET_GROUP_LIST_OUTPUT.RESPONSE.WARNING) {
        Write-Warning -Message "Get-QualysGroup - Please be aware: $($Query.ASSET_GROUP_LIST_OUTPUT.RESPONSE.WARNING)"
    }
    $Query.ASSET_GROUP_LIST_OUTPUT.RESPONSE.ASSET_GROUP_LIST.ASSET_GROUP
}
function Get-QualysHost {
    [CmdletBinding()]
    param(
        [ValidateSet('Basic', 'Basic/AGs', 'All', 'All/AGs', 'None')][string] $Details = 'All',
        [DateTime] $ScanDateBefore,
        [DateTime] $ScanDateAfter,
        [int] $MaximumRecords,
        [switch] $Native
    )
    # https://docs.qualys.com/en/vm/api/assets/index.htm#t=host_lists%2Fhost_list.htm

    if (-not $Script:PowerQualys) {
        if ($ErrorActionPreference -eq 'Stop') {
            throw 'You must first connect to Qualys using Connect-Qualys'
        }
        Write-Warning -Message 'Get-QualysHost - You must first connect to Qualys using Connect-Qualys'
        return
    }

    $invokeQualysQuerySplat = @{
        RelativeUri = 'asset/host/'
        Method      = 'GET'
        Body        = [ordered] @{
            action           = 'list'
            details          = $Details
            truncation_limit = $MaximumRecords
        }
    }
    if ($ScanDateBefore) {
        $invokeQualysQuerySplat.Body['vm_scan_date_before'] = $ScanDateBefore.ToString('yyyy-MM-dd')
    }
    if ($ScanDateAfter) {
        $invokeQualysQuerySplat.Body['vm_scan_date_after'] = $ScanDateAfter.ToString('yyyy-MM-dd')
    }

    $Query = Invoke-QualysQuery @invokeQualysQuerySplat
    if ($Query.HOST_LIST_OUTPUT.RESPONSE.WARNING) {
        Write-Warning -Message "Get-QualysHost - Please be aware: $($Query.HOST_LIST_OUTPUT.RESPONSE.WARNING)"
    }
    if ($Query.HOST_LIST_OUTPUT.RESPONSE.HOST_LIST.HOST) {
        if (-not $Native) {
            $Properties = $Query.HOST_LIST_OUTPUT.RESPONSE.HOST_LIST.HOST[0] | Get-Member -Type Properties
            $Query.HOST_LIST_OUTPUT.RESPONSE.HOST_LIST.HOST | Select-Object -Property $Properties.Name
        }
        else {
            $Query.HOST_LIST_OUTPUT.RESPONSE.HOST_LIST.HOST
        }
    }
}
function Get-QualysHostDetection {
    [CmdletBinding()]
    param(
        [int] $MaximumRecords,
        [string] $Ids,
        [string] $IdMin,
        [string] $IdMax,
        [string] $Ip,
        [switch] $Native,
        [ValidateSet('Confirmed', 'Potential')][string] $IncludeVulnerabilityType,
        [switch] $ShowAssetId,
        [string] $OSPattern,
        [alias('Qids')][string] $QID,
        [string] $Severities,
        [switch] $ShowIgs
    )
    # https://docs.qualys.com/en/vm/api/assets/index.htm#t=host_lists%2Fhost_list.htm

    if (-not $Script:PowerQualys) {
        if ($ErrorActionPreference -eq 'Stop') {
            throw 'You must first connect to Qualys using Connect-Qualys'
        }
        Write-Warning -Message 'Get-QualysHostDetection - You must first connect to Qualys using Connect-Qualys'
        return
    }

    $invokeQualysQuerySplat = @{
        RelativeUri = 'asset/host/vm/detection/'
        Method      = 'GET'
        Body        = [ordered] @{
            action           = 'list'
            #details = $Details
            truncation_limit = $MaximumRecords
        }
    }
    if ($Ids) {
        $invokeQualysQuerySplat.Body['ids'] = $Ids
    }
    if ($IdMin) {
        $invokeQualysQuerySplat.Body['id_min'] = $IdMin
    }
    if ($IdMax) {
        $invokeQualysQuerySplat.Body['id_max'] = $IdMax
    }
    if ($Ip) {
        $invokeQualysQuerySplat.Body['ips'] = $Ip
    }
    if ($ScanDateBefore) {
        $invokeQualysQuerySplat.Body['vm_scan_date_before'] = $ScanDateBefore.ToString('yyyy-MM-dd')
    }
    if ($ScanDateAfter) {
        $invokeQualysQuerySplat.Body['vm_scan_date_after'] = $ScanDateAfter.ToString('yyyy-MM-dd')
    }
    if ($IncludeVulnerabilityType) {
        $invokeQualysQuerySplat.Body['include_vuln_type'] = $IncludeVulnerabilityType.ToLower()
    }
    if ($null -ne $PSBoundParameters['ShowAssetId']) {
        $invokeQualysQuerySplat.Body['show_asset_id'] = if ($ShowAssetId.IsPresent) {
            1 
        }
        else {
            0 
        }
    }
    if ($OSPattern) {
        $invokeQualysQuerySplat.Body['os_pattern'] = $OSPattern
    }
    if ($QID) {
        $invokeQualysQuerySplat.Body['qids'] = $QID
    }
    if ($Severities) {
        $invokeQualysQuerySplat.Body['severities'] = $Severities
    }
    if ($null -ne $PSBoundParameters['ShowIgs']) {
        $invokeQualysQuerySplat.Body['show_igs'] = if ($ShowIgs.IsPresent) {
            1 
        }
        else {
            0 
        }
    }

    $Query = Invoke-QualysQuery @invokeQualysQuerySplat
    if ($Query.HOST_LIST_OUTPUT.RESPONSE.WARNING) {
        Write-Warning -Message "Get-QualysHostDetection - Please be aware: $($Query.HOST_LIST_OUTPUT.RESPONSE.WARNING)"
    }
    if ($Query.HOST_LIST_VM_DETECTION_OUTPUT.RESPONSE.HOST_LIST.HOST) {
        if (-not $Native) {
            $Properties = $Query.HOST_LIST_VM_DETECTION_OUTPUT.RESPONSE.HOST_LIST.HOST[0] | Get-Member -Type Properties
            $Query.HOST_LIST_VM_DETECTION_OUTPUT.RESPONSE.HOST_LIST.HOST | Select-Object -Property $Properties.Name
        }
        else {
            $Query.HOST_LIST_VM_DETECTION_OUTPUT.RESPONSE.HOST_LIST.HOST
        }
    }
}

<#
<SIMPLE_RETURN> <RESPONSE> <DATETIME>2024-06-07T07:58:23Z</DATETIME> <CODE>1901</CODE>
 <TEXT>Unrecognized parameter(s): show_asset_ids (action=list allows: echo_request, ips, ids, id_min, id_max, ag_ids, ag_titles,
  os_pattern, truncation_limit, show_tags, show_asset_id, show_results, use_tags, no_vm_scan_since, vm_scan_since,
  vm_processed_after, vm_processed_before, vm_scan_date_before, vm_scan_date_after, vm_auth_scan_date_before,
   vm_auth_scan_date_after, compliance_enabled, include_ignored, include_disabled, show_host_services, qids,
   show_igs, show_reopened_info, host_metadata, host_metadata_fields, show_cloud_tags, cloud_tag_fields, show_qds,
   qds_min, qds_max, show_qds_factors, severities, include_search_list_titles, exclude_search_list_titles,
    include_search_list_ids, exclude_search_list_ids, output_format, max_days_since_last_vm_scan,
     max_days_since_detection_updated, detection_last_tested_since_days, detection_last_tested_before_days, status, include_vuln_type,
     active_kernels_only, arf_kernel_filter, arf_service_filter, arf_config_filter, suppress_duplicated_data_from_csv,
     detection_updated_since, detection_updated_before, detection_processed_after, detection_processed_before,
     detection_last_tested_since, detection_last_tested_before, filter_superseded_qids)</TEXT> </RESPONSE> </SIMPLE_RETURN>
#>

function Get-QualysKB {
    [CmdletBinding()]
    param(
        [string] $Ids,
        [string] $IdMin,
        [string] $IdMax,
        [string] $CVE,
        [datetime] $LastModifiedAfter,
        [datetime] $LastModifiedBefore,
        [datetime] $PublishedAfter,
        [datetime] $PublishedBefore,
        [switch] $IsPatchable
        #[ValidateSet('basic', 'all', 'none')][string] $Details
    )

    if (-not $Script:PowerQualys) {
        if ($ErrorActionPreference -eq 'Stop') {
            throw 'You must first connect to Qualys using Connect-Qualys'
        }
        Write-Warning -Message 'Get-QualysKB - You must first connect to Qualys using Connect-Qualys'
        return
    }

    $SeverityLevels = [ordered] @{
        '1' = 'Minimal'
        '2' = 'Medium'
        '3' = 'Serious'
        '4' = 'Critical'
        '5' = 'Urgent'
    }

    $invokeQualysQuerySplat = @{
        RelativeUri = 'knowledge_base/vuln/'
        Method      = 'GET'
        Body        = [ordered] @{
            action = 'list'
            # Doesn't work for some reason
            #truncation_limit = $MaximumRecords
        }
    }
    if ($Ids) {
        $invokeQualysQuerySplat.Body['ids'] = $Ids
    }
    if ($null -ne $PSBoundParameters['IsPatchable']) {
        $invokeQualysQuerySplat.Body['is_patchable'] = $IsPatchable.IsPresent
    }
    if ($IdMin) {
        $invokeQualysQuerySplat.Body['id_min'] = $IdMin
    }
    if ($IdMax) {
        $invokeQualysQuerySplat.Body['id_max'] = $IdMax
    }
    if ($CVE) {
        $invokeQualysQuerySplat.Body['cve'] = $CVE
    }
    if ($LastModifiedAfter) {
        $invokeQualysQuerySplat.Body['last_modified_after'] = $LastModifiedAfter.ToString('yyyy-MM-dd')
    }
    if ($LastModifiedBefore) {
        $invokeQualysQuerySplat.Body['last_modified_before'] = $LastModifiedBefore.ToString('yyyy-MM-dd')
    }
    if ($PublishedAfter) {
        $invokeQualysQuerySplat.Body['published_after'] = $PublishedAfter.ToString('yyyy-MM-dd')
    }
    if ($PublishedBefore) {
        $invokeQualysQuerySplat.Body['published_before'] = $PublishedBefore.ToString('yyyy-MM-dd')
    }
    # if ($Details) {
    # $Conversion = @{
    # 'basic' = 'Basic'
    # 'all' = 'All'
    # 'none' = 'None'
    # }
    # $invokeQualysQuerySplat.Body['details'] = $Conversion[$Details]
    # }
    $invokeQualysQuerySplat.Body['details'] = 'All'

    $Query = Invoke-QualysQuery @invokeQualysQuerySplat
    if ($Query.KNOWLEDGE_BASE_VULN_LIST_OUTPUT.RESPONSE.WARNING) {
        Write-Warning -Message "Get-QualysKB - Please be aware: $($Query.KNOWLEDGE_BASE_VULN_LIST_OUTPUT.RESPONSE.WARNING)"
    }
    if ($Query.KNOWLEDGE_BASE_VULN_LIST_OUTPUT.RESPONSE.VULN_LIST) {
        foreach ($Vuln in $Query.KNOWLEDGE_BASE_VULN_LIST_OUTPUT.RESPONSE.VULN_LIST.vuln) {
            $Solution = $Vuln.SOLUTION.'#cdata-section'
            if ($Solution) {
                $Solution = $Solution.Replace("\", [System.Environment]::NewLine)
            }
            $Diagnosis = $Vuln.DIAGNOSIS.'#cdata-section'
            if ($Diagnosis) {
                $Diagnosis = $Diagnosis.Replace("\", [System.Environment]::NewLine)
            }
            $Consequence = $Vuln.CONSEQUENCE.'#cdata-section'
            if ($Consequence) {
                $Consequence = $Consequence.Replace("\", [System.Environment]::NewLine)
            }
            [PSCustomObject] @{
                QID                 = $Vuln.QID                                  #45002
                Type                = $Vuln.VULN_TYPE                            #Potential Vulnerability
                Severity            = $SeverityLevels[$Vuln.SEVERITY_LEVEL]                       #2
                SeverityLevel       = $Vuln.SEVERITY_LEVEL                            #Medium
                Title               = $Vuln.TITLE.'#cdata-section'                                #TITLE
                Category            = $Vuln.CATEGORY                             #Information gathering
                #WhenChanged = $Vuln.LAST_SERVICE_MODIFICATION_DATETIME #2021-11-23T09:43:19Z
                #WhenCreated = $Vuln.PUBLISHED_DATETIME #1999-01-01T08:00:00Z
                WhenCreated         = [datetime]::ParseExact($Vuln.PUBLISHED_DATETIME, 'yyyy-MM-dd\THH:mm:ss\Z', $null)
                WhenChanged         = [datetime]::ParseExact($Vuln.LAST_SERVICE_MODIFICATION_DATETIME, 'yyyy-MM-dd\THH:mm:ss\Z', $null)
                Patchable           = if ($Vuln.PATCHABLE -eq "0") {
                    $false 
                }
                elseif ($Vuln.Patchable -eq "1") {
                    $true 
                }
                else {
                    $Vuln.PATCHABLE 
                }
                Diagnosis           = $Diagnosis
                Consequence         = $Consequence
                Solution            = $Solution
                PciFlag             = if ($Vuln.PCI_FLAG -eq "0") {
                    $false 
                }
                elseif ($Vuln.PCI_FLAG -eq "1") {
                    $true 
                }
                else {
                    $Vuln.PCI_FLAG 
                }                          #1
                ThreatIntelligence  = $Vuln.THREAT_INTELLIGENCE.Threat_Intel.'#cdata-section'                  #THREAT_INTELLIGENCE
                DiscoveryRemote     = if ($Vuln.DISCOVERY.Remote -eq "1") {
                    $true 
                }
                elseif ($Vuln.DISCOVERY.Remote -eq "0") {
                    $false 
                }
                else {
                    $Vuln.DISCOVERY.Remote 
                }
                DiscoveryAdditional = $Vuln.DISCOVERY.ADDITIONAL_INFO
                DiscoveryAuthType   = $Vuln.DISCOVERY.AUTH_TYPE_LIST.AUTH_TYPE
                #DISCOVERY = $Vuln.DISCOVERY #.'#cdata-section' #DISCOVERY
                SoftwareList        = foreach ($Software in $Vuln.SOFTWARE_LIST.SOFTWARE) {
                    [PSCustomObject] @{
                        Product = $Software.PRODUCT.'#cdata-section'
                        Vendor  = $Software.VENDOR.'#cdata-section'
                    }
                }
            }
        }
    }
    else {
        Write-Warning -Message 'Get-QualysKB - No vulnerabilities found'
    }
}

<#
action=list allows: echo_request, ids, id_min, id_max, details, is_patchable, last_modified_after,
 last_modified_before, last_modified_by_user_after, last_modified_by_user_before, last_modified_by_service_after,
  last_modified_by_service_before, code_modified_after, code_modified_before, published_after, published_before,
   discovery_method, discovery_auth_types, show_qid_change_log, show_pci_reasons, show_disabled_flag, show_supported_modules_info, cv
#>



function Invoke-QualysQuery {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string] $RelativeUri,
        [string] $Method = 'GET',
        [System.Collections.IDictionary] $Body = [ordred] @{},
        [ValidateSet('list', 'edit', 'reset', 'custom')][string] $Action,
        [string] $MaximumRecords
    )

    if (-not $Script:PowerQualys) {
        if ($ErrorActionPreference -eq 'Stop') {
            throw 'You must first connect to Qualys using Connect-Qualys'
        }
        Write-Warning -Message 'Invoke-QualysQuery - You must first connect to Qualys using Connect-Qualys'
        return
    }

    $Settings = @{
        Headers     = @{
            'X-Requested-With' = 'PowerQualys PowerShell Module'
            'Authorization'    = $Script:PowerQualys.Authorization
        }
        Method      = $Method
        Body        = $Body
        ErrorAction = 'Stop'
        Verbose     = $false
    }

    $joinUriQuerySplat = @{
        BaseUri               = $Script:PowerQualys.Uri + "/api/2.0/fo/"
        RelativeOrAbsoluteUri = $RelativeUri
    }
    if ($QueryParameter) {
        $joinUriQuerySplat['QueryParameter'] = $QueryParameter
    }

    $Settings['Uri'] = Join-UriQuery @joinUriQuerySplat

    if ($Action) {
        $Settings['Body']['action'] = $Action.ToLower()
    }
    if ($MaximumRecords) {
        $Settings['Body']['truncation_limit'] = $MaximumRecords
    }

    Write-Verbose -Message "Invoke-QualysQuery - Settings used: $($Settings | Out-String)"
    Write-Verbose -Message "Invoke-QualysQuery - Url queried: $($Settings['Uri'])"
    try {
        Invoke-RestMethod @Settings
    }
    catch {
        if ($ErrorActionPreference -eq 'Stop') {
            throw $_
        }
        if ($_.ErrorDetails.Message) {
            $Details = ($_.ErrorDetails.Message -split "`n" | ForEach-Object { if ($_.Trim() -ne "") {
                        $_.Trim() 
                    } } | Select-Object -Skip 1) -join " "
            Write-Warning -Message "Invoke-QualysQuery - Error when querying ($Method): $Details"
        }
        else {
            Write-Warning -Message "Invoke-QualysQuery - Error when querying ($Method): $($_.Exception.Message)"
        }
    }
}


# Export functions and aliases as required
Export-ModuleMember -Function @('Connect-Qualys', 'Get-QualysData', 'Get-QualysGroup', 'Get-QualysHost', 'Get-QualysHostDetection', 'Get-QualysKB', 'Invoke-QualysQuery') -Alias @()
# SIG # Begin signature block
# MIItsQYJKoZIhvcNAQcCoIItojCCLZ4CAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCST18K5fzeZExJ
# zJTE2L19D/XWPKe4d63ozjxq9mkpWaCCJrQwggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggWQMIIDeKADAgECAhAFmxtXno4hMuI5B72nd3VcMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL/mkHNo3rvkXUo8MCIw
# aTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/zG6Q4FutWxpdtHauyefLK
# EdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZanMylNEQRBAu34LzB4Tm
# dDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7sWxq868nPzaw0QF+xembu
# d8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL2pNe3I6PgNq2kZhAkHnD
# eMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfbBHMqbpEBfCFM1LyuGwN1
# XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3JFxGj2T3wWmIdph2PVld
# QnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3cAORFJYm2mkQZK37AlLTS
# YW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqxYxhElRp2Yn72gLD76GSm
# M9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0viastkF13nqsX40/ybzT
# QRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aLT8LWRV+dIPyhHsXAj6Kx
# fgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD
# VR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwPTzANBgkq
# hkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNkaA9Wz3eucPn9mkqZucl4
# XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjSPMFDQK4dUPVS/JA7u5iZ
# aWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK7VB6fWIhCoDIc2bRoAVg
# X+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eBcg3AFDLvMFkuruBx8lbk
# apdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp5aPNoiBB19GcZNnqJqGL
# FNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msgdDDS4Dk0EIUhFQEI6FUy
# 3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vriRbgjU2wGb2dVf0a1TD9u
# KFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ79ARj6e/CVABRoIoqyc54
# zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5nLGbsQAe79APT0JsyQq8
# 7kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3i0objwG2J5VT6LaJbVu8
# aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0HEEcRrYc9B9F1vM/zZn4w
# ggauMIIElqADAgECAhAHNje3JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yMjAzMjMwMDAwMDBaFw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1
# c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqG
# SIb3DQEBAQUAA4ICDwAwggIKAoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbS
# g9GeTKJtoLDMg/la9hGhRBVCX6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9
# /UO0hNoR8XOxs+4rgISKIhjf69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXn
# HwZljZQp09nsad/ZkIdGAHvbREGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0
# VAshaG43IbtArF+y3kp9zvU5EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4f
# sbVYTXn+149zk6wsOeKlSNbwsDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40Nj
# gHt1biclkJg6OBGz9vae5jtb7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0
# QCirc0PO30qhHGs4xSnzyqqWc0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvv
# mz3+DrhkKvp1KCRB7UK/BZxmSVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T
# /jnA+bIwpUzX6ZhKWD7TA4j+s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk
# 42PgpuE+9sJ0sj8eCXbsq11GdeJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5r
# mQzSM7TNsQIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4E
# FgQUuhbZbU2FL3MpdpovdYxqII+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5n
# P+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcG
# CCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu
# Y29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8v
# Y3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNV
# HSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIB
# AH1ZjsCTtm+YqUQiAX5m1tghQuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxp
# wc8dB+k+YMjYC+VcW9dth/qEICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIl
# zpVpP0d3+3J0FNf/q0+KLHqrhc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQ
# cAp876i8dU+6WvepELJd6f8oVInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfe
# Kuv2nrF5mYGjVoarCkXJ38SNoOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+j
# Sbl3ZpHxcpzpSwJSpzd+k1OsOx0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJsh
# IUDQtxMkzdwdeDrknq3lNHGS1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6
# OOmc4d0j/R0o08f56PGYX/sr2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDw
# N7+YAN8gFk8n+2BnFqFmut1VwDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR
# 81fZvAT6gt4y3wSJ8ADNXcL50CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2
# VVQrH4D6wPIOK+XW+6kvRBVK5xMOHds3OBqhK/bt1nz8MIIGsDCCBJigAwIBAgIQ
# CK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQGEwJVUzEV
# MBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t
# MSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjEwNDI5MDAw
# MDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln
# aUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBT
# aWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIICIjANBgkqhkiG9w0BAQEF
# AAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1M4zrPYGXcMW7xIUmMJ+k
# jmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZwZHMgQM+TXAkZLON4gh9
# NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI8IrgnQnAZaf6mIBJNYc9
# URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGiTUyCEUhSaN4QvRRXXegY
# E2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLmysL0p6MDDnSlrzm2q2AS
# 4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3SvUQakhCBj7A7CdfHmzJa
# wv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tvk2E0XLyTRSiDNipmKF+w
# c86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+960IHnWmZcy740hQ83eR
# Gv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3sMJN2FKZbS110YU0/EpF2
# 3r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FKPkBHX8mBUHOFECMhWWCK
# ZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1Hs/q27IwyCQLMbDwMVhEC
# AwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFGg34Ou2
# O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9P
# MA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDAzB3BggrBgEFBQcB
# AQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggr
# BgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1
# c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGln
# aWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwHAYDVR0gBBUwEzAH
# BgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQADggIBADojRD2NCHbuj7w6
# mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L/Z6jfCbVN7w6XUhtldU/
# SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHVUHmImoqKwba9oUgYftzY
# gBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rdKOtfJqGVWEjVGv7XJz/9
# kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK6Wrxoj7bQ7gzyE84FJKZ
# 9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43Nb3Y3LIU/Gs4m6Ri+kAew
# Q3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4ZXDlx4b6cpwoG1iZnt5Lm
# Tl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvmoLr9Oj9FpsToFpFSi0HA
# SIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8y4+ICw2/O/TOHnuO77Xr
# y7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMMB0ug0wcCampAMEhLNKhR
# ILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+FSCH5Vzu0nAPthkX0tGFu
# v2jiJmCG6sivqf6UHedjGzqGVnhOMIIGwjCCBKqgAwIBAgIQBUSv85SdCDmmv9s/
# X+VhFjANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln
# aUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5
# NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTIzMDcxNDAwMDAwMFoXDTM0MTAx
# MzIzNTk1OVowSDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu
# MSAwHgYDVQQDExdEaWdpQ2VydCBUaW1lc3RhbXAgMjAyMzCCAiIwDQYJKoZIhvcN
# AQEBBQADggIPADCCAgoCggIBAKNTRYcdg45brD5UsyPgz5/X5dLnXaEOCdwvSKOX
# ejsqnGfcYhVYwamTEafNqrJq3RApih5iY2nTWJw1cb86l+uUUI8cIOrHmjsvlmbj
# aedp/lvD1isgHMGXlLSlUIHyz8sHpjBoyoNC2vx/CSSUpIIa2mq62DvKXd4ZGIX7
# ReoNYWyd/nFexAaaPPDFLnkPG2ZS48jWPl/aQ9OE9dDH9kgtXkV1lnX+3RChG4PB
# uOZSlbVH13gpOWvgeFmX40QrStWVzu8IF+qCZE3/I+PKhu60pCFkcOvV5aDaY7Mu
# 6QXuqvYk9R28mxyyt1/f8O52fTGZZUdVnUokL6wrl76f5P17cz4y7lI0+9S769Sg
# LDSb495uZBkHNwGRDxy1Uc2qTGaDiGhiu7xBG3gZbeTZD+BYQfvYsSzhUa+0rRUG
# FOpiCBPTaR58ZE2dD9/O0V6MqqtQFcmzyrzXxDtoRKOlO0L9c33u3Qr/eTQQfqZc
# ClhMAD6FaXXHg2TWdc2PEnZWpST618RrIbroHzSYLzrqawGw9/sqhux7UjipmAmh
# cbJsca8+uG+W1eEQE/5hRwqM/vC2x9XH3mwk8L9CgsqgcT2ckpMEtGlwJw1Pt7U2
# 0clfCKRwo+wK8REuZODLIivK8SgTIUlRfgZm0zu++uuRONhRB8qUt+JQofM604qD
# y0B7AgMBAAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAW
# BgNVHSUBAf8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg
# hkgBhv1sBwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8wHQYDVR0O
# BBYEFKW27xPn783QZKHVVqllMaPe1eNJMFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6
# Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEy
# NTZUaW1lU3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQGCCsGAQUF
# BzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKGTGh0dHA6
# Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZT
# SEEyNTZUaW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIBAIEa1t6g
# qbWYF7xwjU+KPGic2CX/yyzkzepdIpLsjCICqbjPgKjZ5+PF7SaCinEvGN1Ott5s
# 1+FgnCvt7T1IjrhrunxdvcJhN2hJd6PrkKoS1yeF844ektrCQDifXcigLiV4JZ0q
# BXqEKZi2V3mP2yZWK7Dzp703DNiYdk9WuVLCtp04qYHnbUFcjGnRuSvExnvPnPp4
# 4pMadqJpddNQ5EQSviANnqlE0PjlSXcIWiHFtM+YlRpUurm8wWkZus8W8oM3NG6w
# QSbd3lqXTzON1I13fXVFoaVYJmoDRd7ZULVQjK9WvUzF4UbFKNOt50MAcN7MmJ4Z
# iQPq1JE3701S88lgIcRWR+3aEUuMMsOI5ljitts++V+wQtaP4xeR0arAVeOGv6wn
# LEHQmjNKqDbUuXKWfpd5OEhfysLcPTLfddY2Z1qJ+Panx+VPNTwAvb6cKmx5Adza
# ROY63jg7B145WPR8czFVoIARyxQMfq68/qTreWWqaNYiyjvrmoI1VygWy2nyMpqy
# 0tg6uLFGhmu6F/3Ed2wVbK6rr3M66ElGt9V/zLY4wNjsHPW2obhDLN9OTH0eaHDA
# dwrUAuBcYLso/zjlUlrWrBciI0707NMX+1Br/wd3H3GXREHJuEbTbDJ8WC9nR2Xl
# G3O2mflrLAZG70Ee8PBf4NvZrZCARK+AEEGKMIIHXzCCBUegAwIBAgIQB8JSdCgU
# otar/iTqF+XdLjANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UE
# ChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQg
# Q29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMB4XDTIzMDQxNjAw
# MDAwMFoXDTI2MDcwNjIzNTk1OVowZzELMAkGA1UEBhMCUEwxEjAQBgNVBAcMCU1p
# a2/FgsOzdzEhMB8GA1UECgwYUHJ6ZW15c8WCYXcgS8WCeXMgRVZPVEVDMSEwHwYD
# VQQDDBhQcnplbXlzxYJhdyBLxYJ5cyBFVk9URUMwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQCUmgeXMQtIaKaSkKvbAt8GFZJ1ywOH8SwxlTus4McyrWmV
# OrRBVRQA8ApF9FaeobwmkZxvkxQTFLHKm+8knwomEUslca8CqSOI0YwELv5EwTVE
# h0C/Daehvxo6tkmNPF9/SP1KC3c0l1vO+M7vdNVGKQIQrhxq7EG0iezBZOAiukNd
# GVXRYOLn47V3qL5PwG/ou2alJ/vifIDad81qFb+QkUh02Jo24SMjWdKDytdrMXi0
# 235CN4RrW+8gjfRJ+fKKjgMImbuceCsi9Iv1a66bUc9anAemObT4mF5U/yQBgAuA
# o3+jVB8wiUd87kUQO0zJCF8vq2YrVOz8OJmMX8ggIsEEUZ3CZKD0hVc3dm7cWSAw
# 8/FNzGNPlAaIxzXX9qeD0EgaCLRkItA3t3eQW+IAXyS/9ZnnpFUoDvQGbK+Q4/bP
# 0ib98XLfQpxVGRu0cCV0Ng77DIkRF+IyR1PcwVAq+OzVU3vKeo25v/rntiXCmCxi
# W4oHYO28eSQ/eIAcnii+3uKDNZrI15P7VxDrkUIc6FtiSvOhwc3AzY+vEfivUkFK
# RqwvSSr4fCrrkk7z2Qe72Zwlw2EDRVHyy0fUVGO9QMuh6E3RwnJL96ip0alcmhKA
# BGoIqSW05nXdCUbkXmhPCTT5naQDuZ1UkAXbZPShKjbPwzdXP2b8I9nQ89VSgQID
# AQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYD
# VR0OBBYEFHrxaiVZuDJxxEk15bLoMuFI5233MA4GA1UdDwEB/wQEAwIHgDATBgNV
# HSUEDDAKBggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1odHRwOi8vY3Js
# My5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQw
# OTZTSEEzODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQuZGlnaWNlcnQu
# Y29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAy
# MUNBMS5jcmwwPgYDVR0gBDcwNTAzBgZngQwBBAEwKTAnBggrBgEFBQcCARYbaHR0
# cDovL3d3dy5kaWdpY2VydC5jb20vQ1BTMIGUBggrBgEFBQcBAQSBhzCBhDAkBggr
# BgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBo
# dHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2Rl
# U2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqG
# SIb3DQEBCwUAA4ICAQC3EeHXUPhpe31K2DL43Hfh6qkvBHyR1RlD9lVIklcRCR50
# ZHzoWs6EBlTFyohvkpclVCuRdQW33tS6vtKPOucpDDv4wsA+6zkJYI8fHouW6Tqa
# 1W47YSrc5AOShIcJ9+NpNbKNGih3doSlcio2mUKCX5I/ZrzJBkQpJ0kYha/pUST2
# CbE3JroJf2vQWGUiI+J3LdiPNHmhO1l+zaQkSxv0cVDETMfQGZKKRVESZ6Fg61b0
# djvQSx510MdbxtKMjvS3ZtAytqnQHk1ipP+Rg+M5lFHrSkUlnpGa+f3nuQhxDb7N
# 9E8hUVevxALTrFifg8zhslVRH5/Df/CxlMKXC7op30/AyQsOQxHW1uNx3tG1DMgi
# zpwBasrxh6wa7iaA+Lp07q1I92eLhrYbtw3xC2vNIGdMdN7nd76yMIjdYnAn7r38
# wwtaJ3KYD0QTl77EB8u/5cCs3ShZdDdyg4K7NoJl8iEHrbqtooAHOMLiJpiL2i9Y
# n8kQMB6/Q6RMO3IUPLuycB9o6DNiwQHf6Jt5oW7P09k5NxxBEmksxwNbmZvNQ65Z
# n3exUAKqG+x31Egz5IZ4U/jPzRalElEIpS0rgrVg8R8pEOhd95mEzp5WERKFyXhe
# 6nB6bSYHv8clLAV0iMku308rpfjMiQkqS3LLzfUJ5OHqtKKQNMLxz9z185UCszGC
# BlMwggZPAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ
# bmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBS
# U0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQB8JSdCgUotar/iTqF+XdLjANBglghkgB
# ZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJ
# AzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8G
# CSqGSIb3DQEJBDEiBCBw29vNiF4HhttqDwnjKXyM0WM2ed8bTziDLXS8kZwXcDAN
# BgkqhkiG9w0BAQEFAASCAgCMUdofVLWybi6Yw3bOf9NUBSKFZlk7NgaSYdZHBSmf
# /+lHpR5ssxmecdWyLPByvQMoRZPgZNavg79/MOEsHO5mpXa+YvEfF1EeYLpLS3ZD
# SDHt7ptuxTapjATnOaHk4XSvPuh6i8YSuKScFrhfepvHujFLlDVaiN7Wtff1oRzV
# 8TxPaWQlM45Emydgfk32289hUvwT2b721XnBWgRHdcmdR0EKsvaQ9we0btVxIYqb
# DgTQIGLUOqSgkgRwY/RqO61u12pMlaw4EIJ+mT7E78bZwa1AnmlxKHfShWn/qdHT
# uAOCG+SLelVFmeI7adgd++Pq7LNlvQPm87YIFiDLZnBzMPNQXTOUybpJnkLIe9e/
# Ugf9GpXDA9nlVL0V1wuLp2lhPIipHblG46P82iFPaX2srYCwxl4Q9qFevq8UcfVv
# 47JzRRWISxYOU/syB1zGn61/9ZkzLQNbJMO2lI5Qr/eEiHPc86Hjkh5R2VfXOGNa
# +rWO6aR/uGSs11QP0nX5cnJ3alfZIsKfRbyh/t74IIK+vm5If3g46V5QI7K0voqg
# oNtny090UNjiP6DgixZr9DYAOxHH/CzTUMGn+Fg+zmqCEaJ6iVoCTbc9ZQzpi64+
# gcI+pQDwbEG30zxrDELb7xwvQsk+iLmeA7U9d62NdMiN+98odo8Q+3F7NUnDiYJ6
# b6GCAyAwggMcBgkqhkiG9w0BCQYxggMNMIIDCQIBATB3MGMxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1
# c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0ECEAVEr/OUnQg5
# pr/bP1/lYRYwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcN
# AQcBMBwGCSqGSIb3DQEJBTEPFw0yNDA2MTIyMDUxMDhaMC8GCSqGSIb3DQEJBDEi
# BCBQG8FM+Ezu8rozypk1jgdyLVNybjz+LwKWjjvvhwqkkTANBgkqhkiG9w0BAQEF
# AASCAgAph7r94DxQAcjEhf/VEnhLJlG44dyEUiJh8z4nxQ9I7GXgO0x5gO09irGp
# H3lxm0VSxx0I70s+GqHolnBy4Bz+rzJFo3OgK4Q5XixrCIvPTY5iSyRBF7HT8f4U
# KmuqRL4V+HS5C4DaRfkgrqRjCL57Qg2HJqeyerbwAjLm34lkGSGNXZTWIKSGXgaI
# OwMluBIF5AV4JFeAXuGFBcXAAN8I4lTDU7CxeoWP+R2Uc273Vq/ahGmHL/AJoDRc
# 9aZryAyHuYGRlROmRaLZ00WwcInZjBz9J8RxvuwE+pw4W4eWE3hFiUwBhM3u404s
# 4aOKVLZe964GUqCDlNY03ipESP0SXxJ0ONVyyS/j8cgwBOT1bN5N/a6+siTfu7f1
# jdDS/XMCpZPywp1i3b4OPYfdofWeDeSOHBS4t+QGxCfhSvCe5D/4cl3k63eI1B9e
# jY3yovzfgqDKfpQ/AFzlH2qJ+YIpaaGjI32wNtd6iNJsrJ7SRlU4tjAuj/NN6Ovr
# 7qxmhF//SxHts9+ODbEOIWXbalHKIIIvveg7RWWdpCS6dsi+2NdWmXK6X7ENiemi
# G8+EJv9Bw4pbqrC9shJY2X76KShTFGY0O6BN6KhNXlC0Y9BblPBN4A/jDPeqc0+S
# m6PJdJALr4DZePgUl/oLVceuD8Il7aqVHJDQhEld3WRF69PGdQ==
# SIG # End signature block