public/xspm/Test-MtXspmPublicRemotelyExploitableHighExposureDevices.ps1

<#
.SYNOPSIS
    Test to find public exposed devices with remotely exploitable, highly likely to be exploited, high or critical severity CVE's

.DESCRIPTION
    Test to find devices that comply to the following:
    - Incoming connections from public IP addresses in the last 7 days (internet exposed)
    - High or Critical severity CVE's
    - CVE's must have known exploits
    - CVE's are remotely exploitable over the network
    - No user interaction required to exploit CVE's
    - EPSS score of CVE must be above 10% (likelihood of exploitation)

.OUTPUTS
    [bool] - Returns $true if no devices are found, $false if any are found, $null if skipped or prerequisites not met.

.EXAMPLE
    Test-MtXspmPublicRemotelyExploitableHighExposureDevices

.LINK
    https://maester.dev/docs/commands/Test-MtXspmPublicRemotelyExploitableHighExposureDevices
#>


function Test-MtXspmPublicRemotelyExploitableHighExposureDevices {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'This test checks for devices with remotely exploitable, highly likely to be exploited, high or critical severity CVEs')]
    [OutputType([bool])]
    param()

    Write-Verbose "Get raw data from Exposure Management..."
    $Query = @"
        // Flag remotely exploitable, no user interaction, CVE's with a EPSS score above a certain threshold (likelihood of exploitation)
        // For devices with incoming public connections
        // See efficiency research on https://www.first.org/epss/model
        let epss_threshold = 0.1;
        let exploit_statusses = dynamic(['ExploitIsPublic','ExploitIsInKit','ExploitIsVerified']);
        // Xspm base query we materialize since we need these results multiple times
        let xspm_base = materialize (
            ExposureGraphNodes
            // Get device nodes with their inventory ID
            | mv-expand EntityIds
            | where EntityIds.type == 'DeviceInventoryId'
            // Get first important properties
            | extend DeviceId = tostring(parse_json(EntityIds)['id']),
                ExposureScore = tostring(parse_json(NodeProperties)['rawData']['exposureScore']),
                HasHighOrCriticalCve = tostring(parse_json(NodeProperties)['rawData']['highRiskVulnerabilityInsights']['hasHighOrCritical'])
            // Focus on devices with high exposure
            | where ExposureScore == 'High'
            // Get vulnerability exploit information
            | extend RceExploitLevels = parse_json(NodeProperties)['rawData']['highRiskVulnerabilityInsights']['vulnerableToRemoteCodeExecution']['explotabilityLevels']
            | extend PrivEscExploitLevels = parse_json(NodeProperties)['rawData']['highRiskVulnerabilityInsights']['vulnerableToPrivilegeEscalation']['explotabilityLevels']
            // Focus on devices where cve has known exploits
            | where RceExploitLevels has_any (exploit_statusses) or PrivEscExploitLevels has_any (exploit_statusses)
            // Focus on devices that are public exposed
            | join kind=inner (
                DeviceNetworkEvents
                | where TimeGenerated > ago(7d)
                | where ActionType contains 'InboundConnection'
                | where RemoteIPType == 'Public'
                // Exclude MacOS Rapportd and ControlCenter
                | where InitiatingProcessFileName != 'rapportd' and InitiatingProcessFileName != 'controlcenter'
                | distinct DeviceName, DeviceId, LocalPort, InitiatingProcessFolderPath, InitiatingProcessVersionInfoProductName, InitiatingProcessFileName
            ) on `$left.DeviceId == `$right.DeviceId
            // Save open ports by Device ID
            | summarize PublicOpenPortList = make_set(LocalPort) by DeviceId
        );
        // Save flagged device IDs in list to limit results of CVE's we need to search later
        let flagged_devices = toscalar(
            xspm_base
            | summarize make_set(DeviceId)
        );
        // CVE base query we materialize since we need these results multiple times
        let cve_base = materialize (
            DeviceTvmSoftwareVulnerabilities
            | where VulnerabilitySeverityLevel in ('High', 'Critical')
            | where DeviceId in ( flagged_devices )
        );
        // Save flagged CVE IDs in list to limit results of CVE database we need to search later
        let flagged_cves = toscalar(
            cve_base
            | summarize make_set(CveId)
        );
        // Query the CVE's of the flagged devices
        cve_base
        // Enrich the CVE data with their EPSS and CVSS Score
        | join kind=inner (
            DeviceTvmSoftwareVulnerabilitiesKB
            // Focus on flagged CVE's
            | where CveId in ( flagged_cves )
            // Focus on CVE's tagged with Attack Vector being over the Network
            // 'Vulnerabilities with this rating are remotely exploitable, from one or more hops away, up to and including remote exploitation over the Internet.'
            // 'Does not require user interaction'
            | where CvssVector contains '/AV:N' and CvssVector contains '/UI:N'
            // Focus on CVE's where an exploit is available
            | where IsExploitAvailable != 0
            | distinct CveId, EpssScore, CvssScore, CvssVector, IsExploitAvailable, AffectedSoftwareList=tostring(AffectedSoftware)
        ) on CveId
        // Continue with only relevant data
        | project DeviceId, DeviceName, OSPlatform, OSVersion, OSArchitecture, SoftwareName, SoftwareVendor, SoftwareVersion, CveId, VulnerabilitySeverityLevel, EpssScore, CvssScore, CvssVector, IsExploitAvailable, AffectedSoftwareList
        // Now flag CVE's with a EPSS score above a certain threshold
        // See efficiency research on https://www.first.org/epss/model
        | where EpssScore >= epss_threshold
        | summarize MaxEpssScore = max(EpssScore), MaxCvssScore = max(CvssScore), CveList = make_set(CveId) by DeviceId, DeviceName
        // Add xspm data again
        | join kind=inner xspm_base on DeviceId
        // Sort and remove data
        | extend CveCount = array_length(CveList)
        | sort by CveCount desc
        | project-away DeviceId1, DeviceId, CveCount
"@


    $Devices = Invoke-MtGraphSecurityQuery -Query $Query -Timespan "P1D"

    $Severity = "High"

    if ($return -or [string]::IsNullOrEmpty($Devices)) {
        $testResultMarkdown = "Well done. No public exposed devices with high or critical CVE's and high changes of exploitation were found."
    } else {
        $testResultMarkdown = "At least one public exposed device with high or critical CVE's and high changes of exploitation was found.`n`n%TestResult%"

        Write-Verbose "Found $($Devices.Count) public exposed devices with high or critical CVE's and high changes of exploitation."

        $result = "| DeviceName | MaxEpssScore | MaxCvssScore | CveList | PublicOpenPortList | `n"
        $result += "| --- | --- | --- | --- | --- |`n"
        foreach ($Device in $Devices) {
            $CveList = $($Device.CveList) -join ', '   # "user1, user2, user3"
            $PublicOpenPortList = $($Device.PublicOpenPortList) -join ', '   # "user1, user2, user3"
            $result += "| $($Device.DeviceName) | $($Device.MaxEpssScore) | $($Device.MaxCvssScore) | $($CveList) | $($PublicOpenPortList) |`n"
        }
    }
    $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
    Add-MtTestResultDetail -Result $testResultMarkdown -Severity $Severity
    $result = [string]::IsNullOrEmpty($Devices)
    return $result
}