Post

Automating vulnerability reports with Microsoft Defender – Part 2

Reviewing the OAuth 2.0 client credentials flow

In Part 1 of this series we brainstormed an idea for an app that would generate automated vulnerability reports using the Microsoft Defender for Endpoint API and email those recommendations directly to our end-users. We created an app registration in Azure AD, granted it the appropriate permissions to query the various Microsoft APIs, and finally scoped those application permissions so that our app could only send mail on behalf of a specific shared mailbox. With all of that supporting infrastructure sorted we can finally get started writing the script - but to do that we need to understand the OAuth 2.0 client credentials flow.

Microsoft’s identity platform provides a number of different ‘flows’ that applications can use to authenticate and authorise themselves when accessing resources (as a reminder, authentication is the process of proving that you are who you say you are, whereas authorisation is the act of granting an authenticated party permission to do something). Daemon apps or background services such as our script use the OAuth 2.0 client credentials grant flow, where the app uses credentials to authenticate under its own identity (i.e. the app registration) when accessing resources, instead of acting on behalf of a user.

In our case we are specifically interested in the latter half of the OAuth 2.0 client credentials flow, since we already provided our app with admin consent that authorises our app to use the API permissions we selected when we created the app registration in Part 1:

Essentially, our script will make a HTTP POST request to the /oauth2/token endpoint of the Microsoft identity platform. This request contains a string that identifies the resource we want access to (e.g. the Microsoft Defender for Endpoint API), and an application ID/client secret pair used to authenticate the script on behalf of our app registration. Microsoft’s identity platform will respond with an access token that our script will append to the header of subsequent HTTP requests in order to authenticate itself to the desired resource.

If any of this is confusing to you, I strongly recommend reviewing Microsoft’s comprehensive documentation as it does a much better job than I can in explaining these concepts in detail.

Writing the script

Let’s get started writing the script. One last word of caution before we begin - I am not a developer, so treat this code as a proof-of-concept only. There are a few deficiencies to the script (which I’ll explain later), and there will most certainly be cases where something could have been optimised or written more elegantly.

I’m going to be using raw PowerShell since it’ll make life easier if we won’t have to worry about importing dependencies when we deploy to Azure Automation, but you could also use Python if you prefer. If you were using this in production you’d probably want to use the Microsoft Authentication Library (MSAL) rather than manually craft HTTP requests by hand - I’ve simply done it this way to make it easier to illustrate the concept in an implementation-agnostic manner.

Anyway - as explained previously, our script needs to retrieve an access token from the Microsoft identity platform that grants us access to the Microsoft Defender for Endpoint API on behalf of our app registration. This is done using the Invoke-RestMethod cmdlet to create a HTTP POST request, specifying that we’re using the OAuth 2.0 client credentials grant flow and passing in the URI for the Microsoft Defender for Endpoint API alongside our app registration’s application ID and client secret.

I’ll be hard-coding these secrets for testing purposes, but eventually we’ll store them as variables in Azure Automation so that they get injected at runtime - just don’t forget and accidentally publish your code to GitHub before we do.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# App registration information
$tenantId = '<tenant_id>'
$appId = '<app_id>'
$appSecret = '<client_secret>'

# Fetch an access token for the Microsoft Defender for Endpoint API
$resourceAppIdUri = 'https://api.securitycenter.microsoft.com'
$oAuthUri = "https://login.microsoftonline.com/$tenantId/oauth2/token"
$authBody = [Ordered] @{
     resource = "$resourceAppIdUri"
     client_id = "$appId"
     client_secret = "$appSecret"
     grant_type = 'client_credentials'
}
$authResponse = Invoke-RestMethod -Method Post -Uri $oAuthUri -Body $authBody -ErrorAction Stop
$token = $authResponse.access_token
Write-Output "[+] Fetched access token for Microsoft Defender for Endpoint API"

Once we have an access token, we can include it in the Authorization header of a HTTP GET request to query the machines endpoint of the Microsoft Defender for Endpoint API and get a list of machines that have been onboarded to the service. We also use query parameters to specify that we just want a list of Microsoft Defender device IDs and their corresponding Azure AD device IDs, and that we’re only interested in active Windows 10 and macOS machines.

1
2
3
4
5
6
7
8
9
10
11
12
13
# Get all Windows 10 and macOS machines
$url = "https://api.securitycenter.microsoft.com/api/machines?`$filter=osPlatform in('Windows10','macOS') and healthStatus eq 'Active'&`$select=id,aadDeviceId"
$headers = @{
    'Content-Type' = 'application/json'
    Accept = 'application/json'
    Authorization = "Bearer $token"
}
$response = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop
$machines =  $response.value

# Filter out machines that don't have a respective Azure AD device ID (Microsoft Defender for Endpoint API seems to be bugged...)
$machines = $machines | Where-Object {$null -ne $_.aadDeviceId}
Write-Output "[+] Retrieved list of Windows 10 and macOS devices"

One problem I’ve noticed is that a small subset of macOS devices in my Microsoft Defender for Endpoint tenant did not have a corresponding Azure AD device ID, despite having all been deployed in the same manner via Microsoft Intune. This issue did not affect any Windows 10 devices. I’ve opted to filter out any machine lacking an Azure AD device ID in the interim until I can figure out the root cause.

Now that we have a list of active workstations from the service, we can query the recommendations endpoint to get the security recommendations pertaining to installed software on each machine (as indicated by the remediationType), and filter out the machines that don’t have any recommendations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Get the security recommendations pertaining to installed software on each machine
foreach($machine in $machines){
    $url = "https://api.securitycenter.microsoft.com/api/machines/$($machine.id)/recommendations?`$filter=remediationType in('Update','Upgrade','Uninstall')"
    $headers = @{
        'Content-Type' = 'application/json'
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }
    $response = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop
    $recommendations = $response.value

    # Add the recommendations to the machine object
    $machine | Add-Member -MemberType NoteProperty -Name recommendations -value $recommendations.recommendationName
}

# Filter out machines that don't have any recommendations
$machines = $machines | Where-Object {$null -ne $_.recommendations}
Write-Output "[+] Retrieved security recommendations"

At this point the $machines object will look something like this:

This is a good start, but as you may have been able to tell from the documentation, the Microsoft Defender for Endpoint API doesn’t provide us with the information we need regarding device ownership to determine where to send our notification emails. We’ll need to correlate the Azure AD device ID with a list of managed devices from the Microsoft Graph API in order to enrich these objects with user information.

As before, we need to fetch an access token for the Microsoft Graph API before we can query it for information. Unfortunately, our current access token for Microsoft Defender is not valid for this API because Microsoft Graph is another resource entirely. Once we have an appropriate access token we can query the deviceManagement endpoint to get a list of all devices managed by Microsoft Intune, including properties like the owner’s display name and user principal name, which I’m assuming is also their email address for the sake of this article.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Fetch an access token for the Microsoft Graph API
$resourceAppIdUri = 'https://graph.microsoft.com/.default'
$oAuthUri = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
$authBody = [Ordered] @{
     scope = "$resourceAppIdUri"
     client_id = "$appId"
     client_secret = "$appSecret"
     grant_type = 'client_credentials'
}
$authResponse = Invoke-RestMethod -Method Post -Uri $oAuthUri -Body $authBody -ErrorAction Stop
$token = $authResponse.access_token
Write-Output "[+] Fetched access token for Microsoft Graph API"

# Get all devices managed by Microsoft Intune
$url = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$select=azureADDeviceId,deviceName,operatingSystem,userPrincipalName,userDisplayName"
$headers = @{
    'Content-Type' = 'application/json'
    Accept = 'application/json'
    Authorization = "Bearer $token"
}
$response = Invoke-RestMethod -Method Get -Uri $url -Headers $headers -ErrorAction Stop
$devices =  $response.value

We now iterate through the vulnerable machines from Microsoft Defender and update the $machine objects with the more detailed device information we retrieved from Microsoft Graph. We also the group the list of machines by user with the Group-Object cmdlet, since it’s possible that users may have multiple devices enrolled, and we only want to send them a single notification rather than one email per device.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Iterate through the vulnerable machines from Microsoft Defender and update them with more detailed properties from Microsoft Graph
foreach($machine in $machines){
    foreach($device in $devices){
        if($machine.aadDeviceId -eq $device.azureADDeviceId){
            $machine | Add-Member -MemberType NoteProperty -Name deviceName $device.deviceName -Force
            $machine | Add-Member -MemberType NoteProperty -Name operatingSystem $device.operatingSystem -Force
            $machine | Add-Member -MemberType NoteProperty -Name userPrincipalName $device.userPrincipalName -Force
            $machine | Add-Member -MemberType NoteProperty -Name userDisplayName $device.userDisplayName -Force
        }
    }
}

# Group all machines and their respective recommendations by each individual user
$groupedMachines = $machines | Group-Object -Property userPrincipalName | Where-Object {$_.Name -ne ''}
Write-Output "[+] Updated devices with ownership properties from Microsoft Intune"

Lastly, we craft a report notifying each user about the vulnerabilities present on their devices, and use the sendMail endpoint to send the report as an email from the shared mailbox we created in Part 1. Make sure you update the <shared_mailbox> value I hard-coded in the API call with the address of your mailbox accordingly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# Send an email from the security mailbox notifying each user about the vulnerabilities present on their devices
$date = Get-Date -Format "dd/MM/yy"
foreach($user in $groupedMachines){
    # Fetch the user's details
    $userMachines = $user.Group
    $userFullName = $userMachines.userDisplayName
    $userFirstName = ($userFullName -Split ' ')[0]
    $userEmail = $user.Name

    # Craft the email
    $preamble = "Hi $userFirstName,`n`nWe have detected one or more security vulnerabilities present in the software installed on your device(s). "
    $preamble += "Please ensure you follow the recommendations listed below to remediate these vulnerabilities:`n"
    $report = ''
    foreach($userMachine in $userMachines){
        $report += "`n$($userMachine.deviceName) ($($userMachine.operatingSystem))`n"
        foreach($recommendation in $userMachine.recommendations){
            $report += "   - $recommendation`n"
        }
    }
    $signature = "`nRegards,`n"
    $signature += "Security"
    $content = $preamble + $report + $signature

    # Send the email
    $url = "https://graph.microsoft.com/v1.0/users/<shared_mailbox>/sendMail"
    $headers = @{
        'Content-Type' = 'application/json'
        Accept = 'application/json'
        Authorization = "Bearer $token"
    }
    $body = @{
        'message' = @{
            'subject' = "Endpoint Vulnerability Report - $userFullName ($date)"
            'body' = @{
                'contentType' = 'Text'
                'content' = "$content"
            }
            'toRecipients' = @(
                @{'emailAddress' = @{
                    'address' = "$userEmail"
                }
            })
        }
        'saveToSentItems' = 'true'
    } | ConvertTo-Json -Depth 4
    $response = Invoke-RestMethod -Method Post -Uri $url -Headers $headers -Body $body -ErrorAction Stop
    Write-Output "[+] Sent endpoint vulnerability report to $userFullName ($userEmail)"
}

Phew! We’re finally done writing the script. If you give it a run, your users will end up getting an email that looks something like this:

Let’s now deploy the finished script to Azure Automation so we can remove those pesky hard-coded credentials and configure it to run on a recurring schedule without user intervention.

Deploying the script to Azure Automation

Azure Automation is a cloud-based service comprised of three core domains - Process Automation, Configuration Management and Update Management. We’re specifically interested in the Process Automation component, which as Microsoft indicates “allows you to automate frequent, time-consuming, and error-prone cloud management tasks”. This is perfect for our use case. Essentially, we have the ability to publish our PowerShell script as a ‘runbook’ that is hosted in Azure and gets executed as a ‘job’ on a recurring schedule, without us having to manually run the script ourselves or host it on an actual server in a potentially insecure manner. We also have the ability to store our application’s secrets as encrypted variables that get injected into the script at runtime, rather than hard-code them into the script itself (which is always a bad idea).

Follow the instructions in Microsoft’s documentation to create an Azure Automation account in your Azure subscription and create a PowerShell runbook using the script we wrote earlier. Since this post is already getting pretty long I won’t go into much detail on the process here, but it should be pretty self-explanatory. You should end up with an unpublished PowerShell runbook in that looks something like this:

Next, save the runbook and follow the instructions in Microsoft’s documentation to create an encrypted variable like so:

You can now update your runbook to inject the value of the encrypted variables into your script at runtime using the Get-AutomationVariable cmdlet:

1
Get-AutomationVariable -Name <variable_name>

Lastly, let’s publish our runbook and link it to a recurring schedule that will initiate a job at 10 AM on every second Wednesday of the month (i.e. the day after Patch Tuesday):

That’s all there is to it! You might want to test the runbook first to make sure everything works as expected, but otherwise we’re all set - our end-users will now receive automated vulnerability reports sent directly to their inboxes every month with a concise list of remediation actions to act upon, all without any additional administrative overhead on the IT team.

Conclusion

While this PowerShell script works perfectly as a proof-of-concept, there are deficiencies that would make it unsuitable for production use in larger environments. Here’s a few examples that come to mind:

  • It can’t handle paginated API responses, which means we’d potentially miss a large subset of machines and/or recommendations if the response from an API call happened to span multiple pages.
  • It makes a brand new API call to the recommendations endpoint for every single machine. This is fine for smaller tenants, but in larger environments it would be very inefficient and we have no way to handle rate limiting if it occurred. Perhaps we could use the OData $expand parameter to query a list of machines and their associated recommendations in a single API call?

I’ll leave these improvements as an exercise for the reader. Thanks for reading if you made it this far!

This post is licensed under CC BY 4.0 by the author.