Building a Multi-Tenant Detections/Health Dashboard with Sophos Central API’s

Disclaimer: This information is provided as-is for the benefit of the Community. Please contact Sophos Professional Services if you require assistance with your specific environment.

Also note that by using or accessing the Software below, you agree to be bound by the terms of the Sophos End User License Agreement

For the Health Dashboard see the post below this one...


Introduction:

While talking to partners and customers using the Enterprise Dashboard, I regularly ask for an option to easily check which tenant has how many detections without the need of going into the tenants individually.

With the introduction of the recently released Detection API for Sophos Central building a detections-based report or dashboard became an option.

Initially I started working on extracting the data from Sophos Central using the API. Instead of starting from scratch I used the framework/samples provided during the Sophos Central API Academy 2022 as basis. This allowed me to build a PowerShell script which exports detection counters to a CSV-file. This CSV-file can serve as a basis for any report you want to create yourself.

Next up was the dashboard. Because I wanted to make sure that the dashboard would work in combination with any webserver (and not have any complex requirements like a database server), I decided to use the CSV-file and that the complete logic for showing the dashboard should be handled by the browser itself. The webpage I created therefore regularly checks if the CSV-file was updated and then refreshes the contents of the page automatically.

In the next section you will find the used components and a short guide on how to set up your system. By following these steps, you will end with a dashboard which looks like the screenshot at the beginning of this post.

Steps:

The steps below should guide you through setting up your own dashboard. You yourself are responsible for setting up the webserver on which the dashboard will be hosted, I will therefore also not provide instructions on how to set up the webserver, define possible access controls, etc.

  1. Create a Service Principal with the Service Principal ReadOnly role (other roles can be used as well but the ReadOnly role has the least privileges). How this is done is described in Gettings Started as a Partner respectively Getting Started as an Organization.

  2. Create a html file from the code block below in a directory served by your webserver. Due to security implications we cannot use the "script" html-tag when posting code, you therefore have to replace all "s#c#r#i#p#t" entries within the file "script" when you create your html-file.

    <!DOCTYPE html>
    <html>
    <head>
    <style>
    * { box-sizing: border-box; }
    body { font-family: Arial; margin: 0; }
    .header { padding: 10px; text-align: center; background: #0000FF; color: white; }
    h2 { margin: 0px; padding: 10px; background: #FFFFFF;}
    h2.center { text-align: center; }
    
    /* Flex Container */
    .container { display: flex; background-color: DodgerBlue; }
    .container-l { flex: 20%; flex-basis: 250px; background-color: #f1f1f1; padding: 10px; }
    .container-r { flex: 80%; background-color: #f1f1f1; padding: 10px; }
    
    /* Summary Container */
    .summary { display: flex; flex-wrap: wrap; justify-content: center; text-align: center; background-color: white; padding 20px; }
    #sumCri { border-radius: 10px; background: #d63d00; margin: 10px; padding: 16px; width: 200px; height: 80px; text-align: center; color: white;}
    #sumHig { border-radius: 10px; background: #ec6500; margin: 10px; padding: 16px; width: 200px; height: 80px; text-align: center;}
    #sumMed { border-radius: 10px; background: #ff8f00; margin: 10px; padding: 16px; width: 200px; height: 80px; text-align: center;}
    #sumLow { border-radius: 10px; background: #696a6b; margin: 10px; padding: 16px; width: 200px; height: 80px; text-align: center; color: white;}
    #sumInf { border-radius: 10px; background: #dadce0; margin: 10px; padding: 16px; width: 200px; height: 80px; text-align: center;}
    #sumTot { border-radius: 10px; background: #F0F2F4; margin: 10px; padding: 16px; width: 200px; height: 80px; text-align: center;}
    #sumSep { border-radius: 10px; width: 200px; height: 10px; text-align: center;}
    #sumVal { font-size: 25px; font-weight: bold; }
    
    /* Details Container */
    .details { background-color: white; }
    table { border-collapse: collapse; width: 100%; }
    th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
    th.number, td.number { width: 7%; text-align: right; }
    
    /* detection colors */
    .critical { background: #d63d0080 !important; }
    .high { background: #ec650080; }
    .medium { background: #ff8f0080; }
    .low { background: #696a6b80; }
    .info { background: #dadce080; }
    .none { background: #FFFFFF; color: #b3afaf; }
    
    /* Footer */
    .footer { padding: 3px; text-align: center; background: #ddd; }
    
    /* Automatically adjust for small screens */
    @media screen and (max-width: 900px) { .container { flex-direction: column; }}
    
    
    </style>
    </head>
    <s#c#r#i#p#t src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></s#c#r#i#p#t>
    <s#c#r#i#p#t src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/4.1.2/papaparse.js"></s#c#r#i#p#t>
    <body>
    <s#c#r#i#p#t>
    function updatePage(dataTable) {
        var tbody = '<tr><th class="name">Tenant Name</th><th class="number">Critical</th><th class="number">High</th><th class="number">Medium</th><th class="number">Low</th><th class="number">Info</th><th class="number">Total</th></tr>';
    	var sumT = sumC = sumH = sumM = sumL = sumI = 0;
    
    	// build table and totals
    	for (var i = 0; i < dataTable.length; i++) {
    		var row = "";
    		if(dataTable[i].hasOwnProperty('Name')) {
    
    			if(dataTable[i].Detections_Critical !== 0) {
    				row = '<tr class="critical">';
    			} else if(dataTable[i].Detections_High !== 0) {
    				row = '<tr class="high">';
    			} else if(dataTable[i].Detections_Medium !== 0) {
    				row = '<tr class="medium">';
    			} else if(dataTable[i].Detections_Low !== 0) {
    				row = '<tr class="low">';
    			} else if(dataTable[i].Detections_Info !== 0) {
    				row = '<tr class="info">';
    			} else {
    				row = '<tr class="none">';
    			}
    
    			row += '<td class="name">' + dataTable[i].Name + "</td>";
    			row += '<td class="number numC">' + dataTable[i].Detections_Critical + "</td>";
    			row += '<td class="number numH">' + dataTable[i].Detections_High + "</td>";
    			row += '<td class="number numM">' + dataTable[i].Detections_Medium + "</td>";
    			row += '<td class="number numL">' + dataTable[i].Detections_Low + "</td>";
    			row += '<td class="number numI">' + dataTable[i].Detections_Info + "</td>";
    			row += '<td class="number">' + dataTable[i].Detections_Total + "</td>";
    			tbody += row + "</tr>";
    			
    			sumT += parseInt(dataTable[i].Detections_Total);
    			sumC += parseInt(dataTable[i].Detections_Critical);
    			sumH += parseInt(dataTable[i].Detections_High);
    			sumM += parseInt(dataTable[i].Detections_Medium);
    			sumL += parseInt(dataTable[i].Detections_Low);
    			sumI += parseInt(dataTable[i].Detections_Info);
    		}
    	}
    
    	// update webpage
    	$("output").html(
    	  '<table class="table"><tbody>' + tbody + "</tbody></table>"
    	);
    	
    	document.getElementById("sumCVal").innerHTML=sumC;
    	document.getElementById("sumHVal").innerHTML=sumH;
    	document.getElementById("sumMVal").innerHTML=sumM;
    	document.getElementById("sumLVal").innerHTML=sumL;
    	document.getElementById("sumIVal").innerHTML=sumI;
    	document.getElementById("sumTVal").innerHTML=sumT;
    }
    
    function parseDetections(url, callBack) {
        Papa.parse(url+"?_="+ (new Date).getTime(), {
            download: true,
            dynamicTyping: true,
    	    header: true,
            complete: function(results) {
    		    // console.log(results);
                callBack(results.data);
            }
        });
    }
    
    function updateDate(lastUpdated) {
    	const formattedDate = lastUpdated.toLocaleString('en-US', { timeZoneName: 'short' });
    	document.getElementById("lastUpdated").innerHTML="TENANT DETAILS from " + formattedDate;
    }
    
    function fetchLastModified(url, callback) {
        fetch(url, {method: "HEAD"})
            .then(r => {callback(new Date(r.headers.get('Last-Modified')))});
    }
    
    fetchLastModified("detections.csv", updateDate);
    parseDetections("detections.csv", updatePage);
    
    setInterval(function(){
    	parseDetections("detections.csv", updatePage);
    	fetchLastModified("detections.csv", updateDate);
    }, 30000); //refresh every 30 seconds
    	
    </s#c#r#i#p#t>
    
    
    <!-- Header -->
    <div class="header">
      <h1>Multi-Tenant Detections Dashboard</h1>
    </div>
    
    <!-- The flexible grid (content) -->
    <div class="container">
    	<div class="container-l">
    		<h2 class="center">SUMMARY</h2>
    		<div class="summary">
    			<div id="sumCri"><div id="sumVal"><div id="sumCVal">0</div></div>Critical</div><br>
    			<div id="sumHig"><div id="sumVal"><div id="sumHVal">0</div></div>High</div><br>
    			<div id="sumMed"><div id="sumVal"><div id="sumMVal">0</div></div>Medium</div><br><br>
    			<div id="sumLow"><div id="sumVal"><div id="sumLVal">0</div></div>Low</div><br>
    			<div id="sumInf"><div id="sumVal"><div id="sumIVal">0</div></div>Info</div><br>
    			<div id="sumTot"><div id="sumVal"><div id="sumTVal">0</div></div>Total</div><br>
    		</div>
    	</div>
    	<div class="container-r">
    		<h2><div id="lastUpdated">TENANT DETAILS</div></h2>
    		<div class="details">
    			<output>make sure that detections.csv is stored in the same directory as this page...</output>
    		</div>
    	</div>
    </div>
    
    <!-- Footer -->
    <div class="footer">
      <h4>Powered by the Detections API of Sophos Central, for more info see: <a href="https://developer.sophos.com/detections" target="_blank">developer.sophos.com/detections</a></h4>
    </div>
    
    </body>
    </html>

    Note: Do not place and run the PowerShell script in this directory!

  3. Create a directory in which you store the Get-Central-Detections.ps1 from the following code block:

    param ([switch] $SaveCredentials, [switch] $Export, [string] $Path = "." )
    <#
    	Description: Gather detection counts for all tenants
    	Parameters: -SaveCredentials -> will store then entered credentials locally on the PC, this is needed once
    				-Export -> Export the results to "detections.csv" in the current directory
    				-FileName -> Specify another file to export to (you can include the path)
    #>
    
    # Error Parser for Web Request
    function ParseWebError($WebError) {
    	if ($PSVersionTable.PSVersion.Major -lt 6) {
    		$resultStream = New-Object System.IO.StreamReader($WebError.Exception.Response.GetResponseStream())
    		$resultBody = $resultStream.readToEnd()  | ConvertFrom-Json
    		return $resultBody.message
    	} else {
    		$resultBody = $WebError.ErrorDetails | ConvertFrom-Json
    		return $resultBody.message
    	}
    }
    
    # Setup datatable to store detection counts
    $DetectionsList = New-Object System.Data.Datatable
    [void]$DetectionsList.Columns.Add("ID")
    [void]$DetectionsList.Columns.Add("Detections_Critical")
    [void]$DetectionsList.Columns.Add("Detections_High")
    [void]$DetectionsList.Columns.Add("Detections_Medium")
    [void]$DetectionsList.Columns.Add("Detections_Low")
    [void]$DetectionsList.Columns.Add("Detections_Info")
    [void]$DetectionsList.Columns.Add("Detections_Total")
    [void]$DetectionsList.Columns.Add("Name")
    
    Clear-Host
    Write-Output "==============================================================================="
    Write-Output "Sophos API - Get XDR Detection Counts for the last 24 hours"
    Write-Output "==============================================================================="
    
    # Define the filename and path for the credential file
    $CredentialFile = (Get-Item $PSCommandPath ).DirectoryName+"\"+(Get-Item $PSCommandPath ).BaseName+".json"
    
    # Check if Central API Credentials have been stored, if not then prompt the user to enter the credentials
    if (((Test-Path $CredentialFile) -eq $false) -or $SaveCredentials){
    	# Prompt for Credentials
    	$clientId = Read-Host "Please Enter your Client ID"
    	$clientSecret = Read-Host "Please Enter your Client Secret" -AsSecureString 
    } else { 
    	# Read Credentials from JSON File
    	$credentials = Get-Content $CredentialFile | ConvertFrom-Json
    	$clientId = $credentials[0]
    	$clientSecret = $credentials[1] | ConvertTo-SecureString
    }
    
    # We are making use of the PSCredentials object to store the API credentials
    # The Client Secret will be encrypted for the user excuting the script
    # When scheduling execution of the script remember to use the same user context
    
    $SecureCredentials = New-Object System.Management.Automation.PSCredential -ArgumentList $clientId , $clientSecret
    
    # SOPHOS OAuth URL
    $TokenURI = "https://id.sophos.com/api/v2/oauth2/token"
    
    # TokenRequestBody for oAuth2
    $TokenRequestBody = @{
    	"grant_type" = "client_credentials";
    	"client_id" = $SecureCredentials.GetNetworkCredential().Username;
    	"client_secret" = $SecureCredentials.GetNetworkCredential().Password;
    	"scope" = "token";
    }
    $TokenRequestHeaders = @{
    	"content-type" = "application/x-www-form-urlencoded";
    }
    
    # Set TLS Version
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    
    # Post Request to SOPHOS for OAuth2 token
    try {
    	$APIAuthResult = (Invoke-RestMethod -Method Post -Uri $TokenURI -Body $TokenRequestBody -Headers $TokenRequestHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    	if ($SaveCredentials) {
    		$clientSecret = $clientSecret | ConvertFrom-SecureString
    		ConvertTo-Json $ClientID, $ClientSecret | Out-File $CredentialFile -Force
    	}
    } catch {
    	# If there's an error requesting the token, say so, display the error, and break:
    	Write-Output "" 
    	Write-Output "AUTHENTICATION FAILED - Unable to retreive SOPHOS API Authentication Token"
    	Write-Output "Please verify the credentials used!" 
    	Write-Output "" 
    	Write-Output "If you are working with saved credentials then you can reset them by calling"
    	Write-Output "this script with the -SaveCredentials parameter"
    	Write-Output "" 
    	Read-Host -Prompt "Press ENTER to continue..."
    	Break
    }
    
    # Set the Token for use later on:
    $Token = $APIAuthResult.access_token
    
    # SOPHOS Whoami URI:
    $WhoamiURI = "https://api.central.sophos.com/whoami/v1"
    
    # SOPHOS Whoami Headers:
    $WhoamiRequestHeaders = @{
    	"Content-Type" = "application/json";
    	"Authorization" = "Bearer $Token";
    }
    
    # Post Request to SOPHOS for Whoami Details:
    $WhoamiResult = (Invoke-RestMethod -Method Get -Uri $WhoamiURI -Headers $WhoamiRequestHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    
    # Save Response details
    $WhoamiID = $WhoamiResult.id
    $WhoamiType = $WhoamiResult.idType	
    
    # Check if we are using partner cerdentials
    if (-not (($WhoamiType -eq "partner") -or ($WhoamiType -eq "organization"))) {
    	Write-Output "Aborting script - idType does not match partner or organization!"
    	Break
    }
    
    # SOPHOS Partner/Organization API Headers:
    if ($WhoamiType -eq "partner") {
    	$GetTenantsHeaders = @{
    		"Authorization" = "Bearer $Token";
    		"X-Partner-ID" = "$WhoamiID";
    	}
    } else {
    	$GetTenantsHeaders = @{
    		"Authorization" = "Bearer $Token";
    		"X-Organization-ID" = "$WhoamiID";
    	}
    }
    
    # Get all Tenants
    Write-Host ("Checking:")
    $GetTenantsPage = 1
    do {
    
    	if ($WhoamiType -eq "partner") {
    		$GetTenants = (Invoke-RestMethod -Method Get -Uri "https://api.central.sophos.com/partner/v1/tenants?pageTotal=true&pageSize=100&page=$GetTenantsPage" -Headers $GetTenantsHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    	} else {
    		$GetTenants = (Invoke-RestMethod -Method Get -Uri "https://api.central.sophos.com/organization/v1/tenants?pageTotal=true&pageSize=100&page=$GetTenantsPage" -Headers $GetTenantsHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    	}
    
    	foreach ($Tenant in $GetTenants.items) {
    
    		# Codepage stuff to ensure that powershell displays those nasty Umlauts and other special characters correctly
    		$ShowAs = $Tenant.showAs
    		$ShowAs = [System.Text.Encoding]::GetEncoding(28591).GetBytes($ShowAs)
    		$ShowAs = [System.Text.Encoding]::UTF8.GetString($ShowAs)
    
    		Write-Host ("+- $($ShowAs)... $(" " * 75)".Substring(0,75)) 
    
    		$TenantID = $Tenant.id
    		$TenantDataRegion = $Tenant.apiHost
    
    		# SOPHOS Endpoint API Headers:
    		$TenantHeaders = @{
    			"Authorization" = "Bearer $Token";
    			"X-Tenant-ID" = "$TenantID";
    			"Content-Type" = "application/json";
    		}
    
    		$Detections_Total = 0
    		$Detections_Critical = 0
    		$Detections_High = 0
    		$Detections_Medium = 0
    		$Detections_Low = 0
    		$Detections_Info = 0
    
    		# Check Protection Status using the Health Check API
    		if (-not $null -eq $TenantDataRegion) {
    		try {
    			$DetectionsCounts = (Invoke-RestMethod -Method Get -Uri $TenantDataRegion"/detections/v1/queries/detections/counts?resolution=hour" -Headers $TenantHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)   
    
    			foreach ($DetectionsCount in $DetectionsCounts.resolutionDetectionCounts) {
    				$Detections_Total += $DetectionsCount.totalCount
    				$Detections_Critical += $DetectionsCount.countBySeverity.critical
    				$Detections_High += $DetectionsCount.countBySeverity.high
    				$Detections_Medium += $DetectionsCount.countBySeverity.medium
    				$Detections_Low += $DetectionsCount.countBySeverity.low
    				$Detections_Info += $DetectionsCount.countBySeverity.info
    			}
    
    			[void]$DetectionsList.Rows.Add($Tenant.id, $Detections_Critical, $Detections_High, $Detections_Medium, $Detections_Low, $Detections_Info, $Detections_Total, $ShowAs)
    			Write-Host ("   --> Total Detections: $($Detections_Total)")
    		} catch {
    			# Something went wrong, get error details...
    			$WebError = ParseWebError($_)
    			Write-Host "   --> $($WebError)"
    		}
    	} else {
    		Write-Host ("   --> Account not activated")
    	}
    		Start-Sleep -Milliseconds 50 # Slow down processing to prevent hitting the API rate limit
    	}
    	$GetTenantsPage++
    
    } while ($GetTenantsPage -le $GetTenants.pages.total)
    
    
    Write-Output "==============================================================================="
    Write-Output "Check completed!"
    Write-Output "==============================================================================="
    
    if ($Export) {
    	Write-Host ""
    	Write-Host "The detections were save to the following file:"
    	Write-Host "$($Path)\detections.csv"
    	$detectionsList.Select("", "Detections_critical DESC, Detections_High DESC, Detections_Medium DESC, Detections_Low DESC, Detections_Info DESC, Name ASC") | Export-Csv $path"\detections.csv" -Encoding UTF8 -NoTypeInformation
    } else {
    	Write-Host "Results:"    
    	$detectionsList.Select("", "Detections_critical DESC, Detections_High DESC, Detections_Medium DESC, Detections_Low DESC, Detections_Info DESC, Name ASC") | Format-Table
    }
    


  4. Now that everything is prepared, we are going to run our PowerShell script for the first time with the parameter -SaveCredentials. This parameter stores the credentials of our Service Principal in a JSON-file, we do this so that we do not have to enter these credentials every time we run the script, for example:

    C:\Detections\Get-Central-Detections.ps1 -SaveCredentials

    This will scan all tenants linked to your partner or organization account, during the scan it will display the tenant name and whether the number of detections could be retrieved successfully. Please note that a tenant must have a XDR or MDR license!

  5. From this point on you should call the PowerShell script without the -SaveCredentials parameter (unless your credentials are no longer valid, and you need to update them).

  6. For the next step we need to run the PowerShell script with the following parameters:
    -Export -Path <path where to store the detections.csv file> for example:

    C:\Detections\Get-Central-Detections.ps1 -Export C:\Detections\wwwroot

    If you now check the webpage in your browser, just remember that the detections.csv file need to be stored in the same directory, then you should see the dashboard populated with the data from the CSV-File.

  7. Now that we have verified that everything works the only thing left is to schedule the PowerShell script with the -Export parameter on a regular basis. Under Windows you could for example use the Windows Task Scheduler.


I hope that you found the information in this recommended read post useful. If you did then please klick the like button on the right.

The PowerShell script included in this post was used with PowerShell 5.1 on Windows and PowerShell 7.4 on Linux.



Updated title to include the Health Dashboard
[edited by: Marcel at 12:15 PM (GMT -7) on 24 Jun 2024]
  • After completing the Multi-Tenant Detections Dashboard I was asked by a colleague if it is possible to do something similar for but then as Multi-Tenant Health Dashboard. To no surprise that was rather easily achieved by following a similar approach as I used with the Multi-Tenant Detections Dashboard. 

    You can therefore simply follow the instructions from the Multi-Tenant Detections Dashboard but instead of using the code listed above you will have to use:

    For step 2 (do not forget to replace all instances of s#c#r#i#p#t with script):

    <!DOCTYPE html>
    <html>
    <head>
    <style>
    * { box-sizing: border-box; }
    body { font-family: Arial; margin: 0; }
    .header { padding: 10px; text-align: center; background: #0000FF; color: white; }
    h2 { margin: 0px; padding: 10px; background: #FFFFFF;}
    h2.center { text-align: center; }
    h3 { margin: 0px; }
    
    /* Flex Container */
    .container { display: flex; flex-wrap: wrap;   justify-content: center; background-color: #ffffff; }
    .container-h { width: 600px; height: 170px; background-color: #dddddd;; padding: 5px; margin: 0.5rem;}
    
    /* Details Container */
    .details { display: grid; grid-template-columns: 23% 36% 36%; gap: 10px; background-color: #ffffff; padding: 1px;  margin: 0px; }
    .tenant { padding: 10px; text-align: center; background: #dddddd; color: #000000; }
    
    /* Progress Circle */
    .circle_normal { grid-row: 1 / span 2; position: relative; width: 100px; height: 100px; margin: 0.5rem; border-radius: 50%; background: conic-gradient(#FFA500 var(--percentage, 0), #00FF00 0); overflow: hidden; }
    .circle_snooze { grid-row: 1 / span 2; position: relative; width: 100px; height: 100px; margin: 0.5rem; border-radius: 50%; background: conic-gradient(#a7a7a5 var(--percentage, 0), #00FF00 0); overflow: hidden; }
    .circle_inner { display: flex; justify-content: center; align-items: center; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 65px; height: 65px; background: #FFF; border-radius: 50%; font-size: 1.5em; font-weight: bold;; color: rgba(0, 0, 0, 0.75); }
    
    /* Progress Bar */
    .progress_boxt {height: 35px; margin-top: 10px}
    .progress_boxb {height: 35px; }
    .progress_normal { height: 10px; border-radius: 10px; background: linear-gradient(to left, #FFA500 var(--percentage, 0), #00FF00 0); }
    .progress_snooze { height: 10px; border-radius: 10px; background: linear-gradient(to left, #a7a7a5 var(--percentage, 0), #00FF00 0); }
    
    /* Footer */
    .footer { padding: 3px; text-align: center; background: #ddd; }
    
    /* Automatically adjust for small screens */
    @media screen and (max-width: 900px) { .container { flex-direction: column; }}
    
    
    </style>
    </head>
    <s#c#r#i#p#t src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></s#c#r#i#p#t>
    <s#c#r#i#p#t src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/4.1.2/papaparse.js"></s#c#r#i#p#t>
    <body>
    <s#c#r#i#p#t>
    function updatePage(dataTable) {
    
    	health = '<div class="container">'
    
    	// build health widget for each customer
    	for (var i = 0; i < dataTable.length; i++) {
    		var widget = '';
    		if(dataTable[i].hasOwnProperty('Name')) {
    
    			widget += '<div class="container-h"><div class="tenant"><h3>' + dataTable[i].Name + '</h3></div><div class="details">';
    			
    			if(dataTable[i].HOvSn == "False") {					
    				widget += '<div class="circle_normal" style="--percentage:' + (100 - dataTable[i].HOvSc) + '%;"><div class="circle_inner">' + dataTable[i].HOvSc + '</div></div>';
    			} else {
    				widget += '<div class="circle_snooze" style="--percentage:' + (100 - dataTable[i].HOvSc) + '%;"><div class="circle_inner"> ' + dataTable[i].HOvSc + '</div></div>';
    			}
    			
    			if(dataTable[i].HPrSn == "False") {					
    				widget += '<div class="progress_boxt">Protection Installed<br><div class="progress_normal" style="--percentage:' + (100 - dataTable[i].HPrSc) + '%;"></div></div>';
    			} else {
    				widget += '<div class="progress_boxt">Protection Installed<br><div class="progress_snooze" style="--percentage:' + (100 - dataTable[i].HPrSc) + '%;"></div></div>';
    			}
    
    						
    			if(dataTable[i].HTaSn == "False") {					
    				widget += '<div class="progress_boxt">Tamper Protection<br><div class="progress_normal" style="--percentage:' + (100 - dataTable[i].HTaSc) + '%;"></div></div>';
    			} else {
    				widget += '<div class="progress_boxt">Tamper Protection<br><div class="progress_snooze" style="--percentage:' + (100 - dataTable[i].HTaSc) + '%;"></div></div>';
    			}	
    
    			if(dataTable[i].HPoSn == "False") {					
    				widget += '<div class="progress_boxb">Policies<br><div class="progress_normal" style="--percentage:' + (100 - dataTable[i].HPoSc) + '%;"></div></div>';
    			} else {
    				widget += '<div class="progress_boxb">Policies<br><div class="progress_snooze" style="--percentage:' + (100 - dataTable[i].HPoSc) + '%;"></div></div>';
    			}	
    
    			if(dataTable[i].HExSn == "False") {					
    				widget += '<div class="progress_boxb">Exclusions<br><div class="progress_normal" style="--percentage:' + (100 - dataTable[i].HExSc) + '%;"></div></div>';
    			} else {
    				widget += '<div class="progress_boxb">Exclusions<br><div class="progress_snooze" style="--percentage:' + (100 - dataTable[i].HExSc) + '%;"></div></div>';
    			}	
    
    			widget += '</div></div>'
    			health += widget;
    		}
    	}
    	health += '</div>'
    
    	// update webpage
    	$("output").html(health);
    }
    
    function parseHealth(url, callBack) {
    	var lastUpdated = null;
    	fetch(url, {method: "HEAD"}).then(r => {
    		if(r.status === 403) { return; };
        	lastUpdated = r.headers.get('Last-Modified');
    		const formattedDate = lastUpdated.toLocaleString('en-US', { timeZoneName: 'short' });
    		document.getElementById("lastUpdated").innerHTML="TENANT DETAILS from " + formattedDate;
    
    		Papa.parse(url+"?_="+ (new Date).getTime(), {
    			download: true,
    			dynamicTyping: true,
    			header: true,
    			complete: function(results) {
    				// console.log(results);
    				callBack(results.data);
    			}
    		});
    	})
    }
    
    parseHealth("healthscores.csv", updatePage);
    
    setInterval(function(){
    	parseHealth("healthscores.csv", updatePage);
    }, 1800000); //refresh every 30 minutes
    	
    </s#c#r#i#p#t>
    
    <!-- Header -->
    <div class="header">
      <h1>Multi-Tenant Health Dashboard</h1>
    </div>
    <h2><div id="lastUpdated">TENANT DETAILS</div></h2>
    
    <!-- The flexible grid (content) -->
    <output>make sure that healthscores.csv is stored in the same directory as this page...</output>
    
    
    <!-- Footer -->
    <div class="footer">
      <h4>Powered by the Account Health Check API of Sophos Central, for more info see: <a href="https://developer.sophos.com/account-health-check" target="_blank">developer.sophos.com/account-health-check</a></h4>
    </div>
    
    </body>
    </html>
      

    And for Step 3 we use:

    param ([switch] $SaveCredentials, [switch] $Export, [string] $Path = "." )
    <#
    	Description: Gather health scores for all tenants
    	Parameters: -SaveCredentials -> will store then entered credentials locally on the PC, this is needed once
    				-Export -> Export the results to "healtscores.csv" 
    				-Path -> Specify another directory to export to
    #>
    
    # Error Parser for Web Request
    function ParseWebError($WebError) {
    	if ($PSVersionTable.PSVersion.Major -lt 6) {
    		$resultStream = New-Object System.IO.StreamReader($WebError.Exception.Response.GetResponseStream())
    		$resultBody = $resultStream.readToEnd()  | ConvertFrom-Json
    	} else {
    		$resultBody = $WebError.ErrorDetails | ConvertFrom-Json
    	}
    
    	if ($null -ne $resultBody.message) {
    		return $resultBody.message
    	} else {
    		return $WebError.Exception.Response.StatusCode
    	}
    }
    
    # Function to extract the lowest score and linked snoozed status from an array
    function ParseScores($Values) {
    
    	$Score = 100
    	$Snoozed = $False
    
    	Foreach ($Value in $Values) {
    		if ($null -ne $Value) {
    			if ($Score -gt $Value.Score) {
    				$Score = $Value.Score
    				$Snoozed = $Value.Snoozed
    			} elseif (($Score -eq $Value.Score) -and ($ture -eq $Value.Snoozed)) {
    				$Snoozed = $true
    			}
    		}
    	}
    	return @{"Score" = $Score; "Snoozed" = $Snoozed}
    }
    
    # Setup datatable to store health scores
    $HealthList = New-Object System.Data.Datatable
    [void]$HealthList.Columns.Add("ID")
    [void]$HealthList.Columns.Add("HOvSc",[int])
    [void]$HealthList.Columns.Add("HOvSn",[boolean])
    [void]$HealthList.Columns.Add("HPrSc",[int])
    [void]$HealthList.Columns.Add("HPrSn",[boolean])
    [void]$HealthList.Columns.Add("HTaSc",[int])
    [void]$HealthList.Columns.Add("HTaSn",[boolean])
    [void]$HealthList.Columns.Add("HPoSc",[int])
    [void]$HealthList.Columns.Add("HPoSn",[boolean])
    [void]$HealthList.Columns.Add("HExSc",[int])
    [void]$HealthList.Columns.Add("HExSn",[boolean])
    [void]$HealthList.Columns.Add("Name")
    $HealthView = New-Object System.Data.DataView($HealthList)
    
    Clear-Host
    Write-Output "==============================================================================="
    Write-Output "Sophos API - Get health scoures for all tenants"
    Write-Output "==============================================================================="
    
    # Define the filename and path for the credential file
    $CredentialFile = (Get-Item $PSCommandPath ).DirectoryName+"\"+(Get-Item $PSCommandPath ).BaseName+".json"
    
    # Check if Central API Credentials have been stored, if not then prompt the user to enter the credentials
    if (((Test-Path $CredentialFile) -eq $false) -or $SaveCredentials){
    	# Prompt for Credentials
    	$clientId = Read-Host "Please Enter your Client ID"
    	$clientSecret = Read-Host "Please Enter your Client Secret" -AsSecureString 
    } else { 
    	# Read Credentials from JSON File
    	$credentials = Get-Content $CredentialFile | ConvertFrom-Json
    	$clientId = $credentials[0]
    	$clientSecret = $credentials[1] | ConvertTo-SecureString
    }
    
    # We are making use of the PSCredentials object to store the API credentials
    # The Client Secret will be encrypted for the user excuting the script
    # When scheduling execution of the script remember to use the same user context
    
    $SecureCredentials = New-Object System.Management.Automation.PSCredential -ArgumentList $clientId , $clientSecret
    
    # SOPHOS OAuth URL
    $TokenURI = "https://id.sophos.com/api/v2/oauth2/token"
    
    # TokenRequestBody for oAuth2
    $TokenRequestBody = @{
    	"grant_type" = "client_credentials";
    	"client_id" = $SecureCredentials.GetNetworkCredential().Username;
    	"client_secret" = $SecureCredentials.GetNetworkCredential().Password;
    	"scope" = "token";
    }
    $TokenRequestHeaders = @{
    	"content-type" = "application/x-www-form-urlencoded";
    }
    
    # Set TLS Version
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    
    # Post Request to SOPHOS for OAuth2 token
    try {
    	$APIAuthResult = (Invoke-RestMethod -Method Post -Uri $TokenURI -Body $TokenRequestBody -Headers $TokenRequestHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    	if ($SaveCredentials) {
    		$clientSecret = $clientSecret | ConvertFrom-SecureString
    		ConvertTo-Json $ClientID, $ClientSecret | Out-File $CredentialFile -Force
    	}
    } catch {
    	# If there's an error requesting the token, say so, display the error, and break:
    	Write-Output "" 
    	Write-Output "AUTHENTICATION FAILED - Unable to retreive SOPHOS API Authentication Token"
    	Write-Output "Please verify the credentials used!" 
    	Write-Output "" 
    	Write-Output "If you are working with saved credentials then you can reset them by calling"
    	Write-Output "this script with the -SaveCredentials parameter"
    	Write-Output "" 
    	Read-Host -Prompt "Press ENTER to continue..."
    	Break
    }
    
    # Set the Token for use later on:
    $Token = $APIAuthResult.access_token
    
    # SOPHOS Whoami URI:
    $WhoamiURI = "https://api.central.sophos.com/whoami/v1"
    
    # SOPHOS Whoami Headers:
    $WhoamiRequestHeaders = @{
    	"Content-Type" = "application/json";
    	"Authorization" = "Bearer $Token";
    }
    
    # Post Request to SOPHOS for Whoami Details:
    $WhoamiResult = (Invoke-RestMethod -Method Get -Uri $WhoamiURI -Headers $WhoamiRequestHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    
    # Save Response details
    $WhoamiID = $WhoamiResult.id
    $WhoamiType = $WhoamiResult.idType	
    
    # Check if we are using partner/organization credentials
    if (-not (($WhoamiType -eq "partner") -or ($WhoamiType -eq "organization"))) {
    	Write-Output "Aborting script - idType does not match partner or organization!"
    	Break
    }
    
    # SOPHOS Partner/Organization API Headers:
    if ($WhoamiType -eq "partner") {
    	$GetTenantsHeaders = @{
    		"Authorization" = "Bearer $Token";
    		"X-Partner-ID" = "$WhoamiID";
    	}
    } else {
    	$GetTenantsHeaders = @{
    		"Authorization" = "Bearer $Token";
    		"X-Organization-ID" = "$WhoamiID";
    	}
    }
    
    # Get all Tenants
    Write-Host ("Checking:")
    $GetTenantsPage = 1
    do {
    
    	if ($WhoamiType -eq "partner") {
    		$GetTenants = (Invoke-RestMethod -Method Get -Uri "https://api.central.sophos.com/partner/v1/tenants?pageTotal=true&pageSize=100&page=$GetTenantsPage" -Headers $GetTenantsHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    	} else {
    		$GetTenants = (Invoke-RestMethod -Method Get -Uri "https://api.central.sophos.com/organization/v1/tenants?pageTotal=true&pageSize=100&page=$GetTenantsPage" -Headers $GetTenantsHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)
    	}
    
    	foreach ($Tenant in $GetTenants.items) {
    
    		# Codepage stuff to ensure that powershell displays those nasty Umlauts and other special characters correctly
    		$ShowAs = $Tenant.showAs
    		$ShowAs = [System.Text.Encoding]::GetEncoding(28591).GetBytes($ShowAs)
    		$ShowAs = [System.Text.Encoding]::UTF8.GetString($ShowAs)
    
    		Write-Host ("+- $($ShowAs)... $(" " * 75)".Substring(0,75)) 
    
    		$TenantID = $Tenant.id
    		$TenantDataRegion = $Tenant.apiHost
    
    		# SOPHOS Endpoint API Headers:
    		$TenantHeaders = @{
    			"Authorization" = "Bearer $Token";
    			"X-Tenant-ID" = "$TenantID";
    			"Content-Type" = "application/json";
    		}
    
    		# Check Protection Status using the Health Check API
    		if (-not $null -eq $TenantDataRegion) {
    		try {
    			$HealthCheck = (Invoke-RestMethod -Method Get -Uri $TenantDataRegion"/account-health-check/v1/health-check" -Headers $TenantHeaders -ErrorAction SilentlyContinue -ErrorVariable ScriptError)   
    			$HPR = ParseScores ($HealthCheck.Endpoint.Protection.Computer,$HealthCheck.Endpoint.Protection.Server)
    			$HTA = ParseScores ($HealthCheck.Endpoint.TamperProtection.Computer, $HealthCheck.Endpoint.TamperProtection.Server, $HealthCheck.Endpoint.TamperProtection.GlobalDetail)
    			$HPO = ParseScores ($HealthCheck.Endpoint.Policy.Computer."threat-protection", $HealthCheck.Endpoint.Policy.Server."server-threat-protection")
    			$HEX = ParseScores ($HealthCheck.Endpoint.Exclusions.Global, $HealthCheck.Endpoint.Exclusions.Policy.Computer, $HealthCheck.Endpoint.Exclusions.Policy.Server)
    			$HOV = ParseScores ($HPR, $HTA, $HPO, $HEX)
    			[void]$HealthList.Rows.Add($Tenant.id, $HOV.Score, $HOV.Snoozed, $HPR.Score, $HPR.Snoozed, $HTA.Score, $HTA.Snoozed, $HPO.Score, $HPO.Snoozed, $HEX.Score, $HEX.Snoozed, $ShowAs)
    			Write-Host ("   --> Health Score: $($HOV.Score) - Snoozed: $($HOV.Snoozed)")
    		} catch {
    			# Something went wrong, get error details...
    			$WebError = ParseWebError($_)
    			Write-Host "   --> $($WebError)"
    		}
    	} else {
    		Write-Host ("   --> Account not activated")
    	}
    		Start-Sleep -Milliseconds 50 # Slow down processing to prevent hitting the API rate limit
    	}
    	$GetTenantsPage++
    
    } while ($GetTenantsPage -le $GetTenants.pages.total)
    
    Write-Output "==============================================================================="
    Write-Output "Check completed!"
    Write-Output "==============================================================================="
    
    if ($Export) {
    	Write-Host ""
    	Write-Host "The health scores were saved to the following file:"
    	Write-Host "$($Path)\healthscores.csv"
    	$HealthList.Select("", "HOvSc ASC, Name ASC") | Export-Csv $Path"\healthscores.csv" -Encoding UTF8 -NoTypeInformation
    } else {
    	Write-Host "Results:"    
    	$HealthList.Select("", "HOvSc ASC, Name ASC") | Format-Table Name,HOvSc,HOvSn,HPrSc,HTaSc,HPoSc,HExSc
    }
    
     

    Obviously you should use a different name when storing this, I personally used Get-Central-Healthscores.ps1.

    The other instructions all stay the same.  

  • Hi Marcel,

    thank you, great post!

    when using the Get-Central-Detections.ps1 we get the following error on customers that have XDR/MDR enabled:

    --> An unexpected error occurred while processing the request

    Any ideas how to solve this?

    Greetings

  • Hi PiPy,

    unfortunately the API call I am using (detections/counts) broke down and has since then only been returning 500 Internal Server Error. This issue is currently being investigated by engineering, which means that we will not be able to use the Detections Dashboard until engineering solves the underlying issue.

    Technically it would be possible to switch to one of the other detections API calls but these are rather "expensive", because I then would have to perform multiple API calls instead of a single on per tenant. Especially keeping the rate limits in mind.

    Best regards,

    Marcel