Tracking Microsoft Secure Score changes

Microsoft Secure Score can be a good starting point in assessing organizational security posture. Improvement actions are added to the score regularly (link) and points achieved are updated dynamically.

For me, Secure score is a mesurement of hard work represented in little percentage points. Every little point is a reward which can be taken back by Microsoft when changes happen in the current security state (let it be the result of an action [ie. someone enabled the printer spooler on a domain controller] – or inactvity [ie. a domain admin account became “dormant”]). Whatever is the reason of the score degradation, I want to be alerted, because I don’t want to check this chart on a daily basis. Unfortunately, I didn’t find any ready-to-use solution, so I’m sharing my findings.

TL;DR
Get-MgSecuritySecureScore Graph PowerShell cmdlet can be used to fetch 90 days of score data
-The basic idea is to compare the actual scores with yesterday’s scores and report on differences
-When new controlScores (~recommendations) arrive, send separate alert
-The script I share is a PowerShell script with certificate auth, but no Graph PowerShell cmdlets are used just native REST API calls (sorry, I still have issues with Graph PS while native approach is consistent). Using app auth with certificate, the script can be scheduled to run on a daily basis (I don’t recommend a more frequent schedule as there are temporary score changes which are mostly self-remediating)

Prerequisites
We will need an app registration with Microsoft Graph/SecurityEvents.Read.All Application permission (don’t forget the admin consent):

App registration with SecurityEvents.Read.All permission

On the server on which you are planning to schedule the script, create a new certificate. Example PowerShell command*:

New-SelfSignedCertificate -FriendlyName "F12 - Secure score monitor" -NotAfter (Get-date).AddYears(2) -Subject "F12 - Secure score monitor" -CertStoreLocation Cert:\LocalMachine\My -Provider “Microsoft Enhanced RSA and AES Cryptographic Provider” -KeyExportPolicy NonExportable

Don’t forget to grant read access to the private key for the account which will run the schedule. Right click on the certificate – All Tasks – Manage Private Keys…

I prefer to use “Network Service” for these tasks because limited permissions are needed

Export the certificate’s public key and upload it to the app registration’s certificates:

Let’s move on to the script.

The script

Some variables and actions need to be modified, like $tenantID, $appID and $certThumbprint in the first lines. Also, the notification part (Send-MailMessage lines) needs to be customized to your needs.
The script itself can be breaken down as follows:
– authenticate to Graph using certificate (the auth function is from MSEndpointMgr.com)
– the following to lines query the Secure Score data for today and yesterday:
$url = 'https://graph.microsoft.com/beta/security/securescores?$top=2'
$webResponse = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop

– some HTML style for readable emails
– compare today’s and yesterday’s controlscores – alert when there are new / deprecated recommendations
– compare today’s scores with yesterday’s scores – alert when changes are detected

Here it is:

$tenantId = '<your tenant ID>'
$appID = '<application ID with SecurityEvents.Read.All admin consented permission>'
$certThumbprint = '<thumbprint of certificate used to connect>'
$resourceAppIdUri = 'https://graph.microsoft.com'

#region Auth
$cert = gci Cert:\LocalMachine\my\$certThumbprint
$cert64Hash = [System.Convert]::ToBase64String($cert.GetCertHash())
function Get-Token {
    #https://msendpointmgr.com/2023/03/11/certificate-based-authentication-aad/
    #create JWT timestamp for expiration 
    $startDate = (Get-Date "1970-01-01T00:00:00Z" ).ToUniversalTime()  
    $jwtExpireTimeSpan = (New-TimeSpan -Start $startDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds  
    $jwtExpiration = [math]::Round($jwtExpireTimeSpan, 0)  
  
    #create JWT validity start timestamp  
    $notBeforeExpireTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds  
    $notBefore = [math]::Round($notBeforeExpireTimeSpan, 0)  
  
    #create JWT header  
    $jwtHeader = @{  
        alg = "RS256"  
        typ = "JWT"  
        x5t = $cert64Hash -replace '\+', '-' -replace '/', '_' -replace '='  
    }
    #create JWT payload  
    $jwtPayLoad = @{  
        aud = "https://login.microsoftonline.com/$TenantId/oauth2/token"  
        exp = $jwtExpiration   
        iss = $appID  
        jti = [guid]::NewGuid()   
        nbf = $notBefore  
        sub = $appID  
    }  
  
    #convert header and payload to base64  
    $jwtHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($jwtHeader | ConvertTo-Json))  
    $encodedHeader = [System.Convert]::ToBase64String($jwtHeaderToByte)  
  
    $jwtPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($jwtPayLoad | ConvertTo-Json))  
    $encodedPayload = [System.Convert]::ToBase64String($jwtPayLoadToByte)  
  
    #join header and Payload with "." to create a valid (unsigned) JWT  
    $jwt = $encodedHeader + "." + $encodedPayload  
  
    #get the private key object of your certificate  
    $privateKey = ([System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAprivateKey($cert))  
  
    #define RSA signature and hashing algorithm  
    $rsaPadding = [Security.Cryptography.RSASignaturePadding]::Pkcs1  
    $hashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256  
  
    #create a signature of the JWT  
    $signature = [Convert]::ToBase64String(  
        $privateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($jwt), $hashAlgorithm, $rsaPadding)  
    ) -replace '\+', '-' -replace '/', '_' -replace '='  
  
    #join the signature to the JWT with "."  
    $jwt = $jwt + "." + $signature  
  
    #create a hash with body parameters  
    $body = @{  
        client_id             = $appID
        resource              = $resourceAppIdUri
        client_assertion      = $jwt  
        client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"  
        scope                 = $scope  
        grant_type            = "client_credentials"  
  
    } 
    $url = "https://login.microsoft.com/$TenantId/oauth2/token"  
  
    #use the self-generated JWT as Authorization  
    $header = @{  
        Authorization = "Bearer $jwt"  
    }  
  
    #splat the parameters for Invoke-Restmethod for cleaner code  
    $postSplat = @{  
        ContentType = 'application/x-www-form-urlencoded'  
        Method      = 'POST'  
        Body        = $body  
        Uri         = $url  
        Headers     = $header  
    }  
  
    $request = Invoke-RestMethod @postSplat  

    #view access_token  
    $request
}
$accessToken = (Get-Token).access_token

 $headers = @{ 
    'Content-Type' = 'application/json'
    'Accept' = 'application/json'
    'Authorization' = "Bearer $accessToken" 
    }
#region end

$url = 'https://graph.microsoft.com/beta/security/securescores?$top=2'
$webResponse = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop

#HTML Style for table reports
$Style = @'
<style>
table{
border-collapse: collapse;
border-width: 2px;
border-style: solid;
border-color: grey;
color: black;
margin-bottom: 10px;
text-align: left;
}
th {
    background-color: #0000ff;
    color: white;
    border: 1px solid black;
    margin: 10px;
}
td {
    border: 1px solid black;
    margin: 10px;
}
</style>
'@


$controlScoreChanges = Compare-Object ($webResponse.value[0].controlScores.controlname) -DifferenceObject ($webResponse.value[1].controlScores.controlname) 
$report_controlScoreChanges = if ($controlScoreChanges){
    foreach ($control in $controlScoreChanges){
        [pscustomobject]@{
        State = switch ($control.sideindicator){"<=" {"New"} "=>" {"Removed"}}
        Category = $webresponse.value[0].controlScores.where({$_.controlname -eq ($control.inputobject)}).controlCategory
        Name = $control.inputobject
        Description = $webresponse.value[0].controlScores.where({$_.controlname -eq ($control.inputobject)}).description
        }
    }
    
}

if ($report_controlScoreChanges){
    [string]$body = $report_controlScoreChanges | ConvertTo-Html -Head $Style
    Send-MailMessage -To "<address>" -From "<address>" -Subject "Secure Score control changes detected" -Body $body -SmtpServer "<SMTP server address>" -Port 25 -BodyAsHtml

}

$ErrorActionPreference= 'silentlycontinue'
$report_scoreChanges = foreach ($controlscore in $webResponse.value[0].controlscores){
  if ( Compare-Object $controlscore.score -DifferenceObject ($webResponse.value[1].controlScores.where({$_.controlname -eq ($controlscore.controlname)}).score)){
        [pscustomobject]@{
            date = $controlscore.lastSynced
            controlCategory = $controlscore.controlCategory
            controlName = $controlscore.controlName
            scoreChange = ($controlscore.score) - (($webResponse.value[1].controlScores.where({$_.controlname -eq ($controlscore.controlname)})).score)
            description = $controlscore.description
            }
        }
    }

if ($report_ScoreChanges){
    [string]$body = $report_ScoreChanges | ConvertTo-Html -Head $Style
    Send-MailMessage -To "<address>" -From "<address>" -Subject "Secure Score changes detected" -Body $body -SmtpServer "<SMTP server address>" -Port 25 -BodyAsHtml

}

Some example results:

New recommendations (Defender for Identity fresh install -> new MDI recommendations)
Score changes by recommendation

Fun fact:
The Defender portal section where these score changes are displayed actually uses a “scoreImpactChangeLogs” node for these changes, but unfortunately I didn’t find a way to query this secureScoresV2 endpoint:

https://security.microsoft.com/apiproxy/mtp/secureScore/security/secureScoresV2?$top=400

I hope it means that these informations will be available via Graph so that no calculations will be needed to detect score changes.

Leave a Reply