GraphApiAuditEvents: The new Graph API Logs
The new GraphApiAuditEvents table in Advanced Hunting have been in Public Preview since July this year. These valuable logs give new insights into the activities that are performed using the Graph API in your tenant, which makes it a table you definitly want to explore in the upcoming weeks. The GraphApiAuditEvents table is the ‘free’ version of the MicrosoftGraphActivityLogs table that was available in Sentinel. The GraphApiAuditEvents enables more organizations to use these valuable logs without burning their budget.
The logs that security teams had to their disposal often only included create, update, and delete actions. The missing piece for blue teams was read activities, which often signal discovery or reconnaissance attempts. GraphApiAuditEvents fills this gap, giving your security teams visibility into those read operations and enabling stronger detections for discovery activity.
GraphApiAuditEvents vs MicrosoftGraphActivityLogs
While the majority of the events in both tables are the same, there are a couple of differences that should be highlighted. These differences should be taken into account before choosing either of the two options.
Table Schema
The schema of the two tables is different; the GraphAPIAuditEvents table has 19 columns and the MicrosoftGraphActivityLogs has 33 (some of them default LAW column). Significant fields that are missing from the GraphAPIAuditEvents:
- DeviceId of the device from which the Graph API call was made.
- SessionId is not included, limiting the options to map the Graph activities to the SignIn and Audit logs.
The main compontents are included in both tables, but the columns can have a different name. For example ApplicationId and AppId. In the MicrosoftGraphActivityLogs there were two fields to identify who executed the API call, the UserId and ServicePrincipalId, in the new GraphAPIAuditEvents table that has been concatanated into one column, the AccountObjectId.
For the complete schema of both tables, have a look at the documentation:
Events
The number of events ingested into both tables is very similar, as seen in the chart below. For some reason, the MicrosoftGraphActivityLogs ingestes 0.01% more logs on a daily basis, to this day, I am not sure what the exact reason for this is or if the MicrosoftGraphActivityLogs contain duplicate events, resulting in a sligtly higher number of daily ingested events.
union withsource=TableName GraphAPIAuditEvents, MicrosoftGraphActivityLogs
| where TimeGenerated between (startofday(ago(8d)) .. endofday(ago(1d)))
| summarize TotalEvents = count() by TableName
Ingestion Delay
The ingestion delay for both the GraphApiAuditEvents and the MicrosoftGraphActivityLogs is similar. In the test environment used the delay is on average around 5/6 minutes for both logs to arrive. The average time is less relevant when calculating ingestion delays, the percentiles are most important. 50% of the logs arrive for both logs within 3/4 minutes and 90% of the data arrives within 10/11 minutes.
The delay is the time difference between the Graph API call being executed and the action being visible in the logs.
union withsource=TableName GraphAPIAuditEvents, MicrosoftGraphActivityLogs
| extend IngestionTime = ingestion_time()
| extend IngestionDelay = datetime_diff('minute', IngestionTime, TimeGenerated)
| summarize Average = round(avg(IngestionDelay), 1), percentiles(IngestionDelay, 50, 75, 90, 95, 97, 99) by TableName
Retention
As the MicrosoftGraphActivityLogs are ingested into Sentinel using the Microsoft Entra ID connector the log retention and type can be configured on the Log Anlaytics Workspace level. GraphApiAuditEvents are using the default retention in Defender XDR, which is 30 days. At the moment of publishing this blog the GraphApiAuditEvents cannot be forwareded to Sentinel to extend its retention.
Cost
The MicrosoftGraphActivityLogs are priced as any other table in Sentinel, because the volume of logs is significant, the price of these logs can be high. The GraphAPIAuditEvents table does not have ingestion and storage cost, as it comes as part of the Defender XDR tables.
Hunting potential
When the MicrosoftGraphActivityLogs table was anounced I already drafted a blog (Investigating Microsoft Graph Activity Logs) on how to effectively query the logs and what detection potential it has. This section discusses translated queries from that blog and new hunts to kickstart the usage of the GraphApiAuditEvents table.
Before diving into the hunting potential, effective parsing of the data is explained, which helps filtering the data for detection and statistical analysis.
Parsing RequestUri
The column RequestUri contains the request that has been executed, this often results in a very long string, and searching this with has or contains is not effective. It is recommended to parse the URL of the RequestUri to get valuable information on the endpoints that are queried.
To extract the different request parameters from the RequestUri column the parse_url() function is used. This function parses the request and returns the URL components.
GraphAPIAuditEvents
| extend ParsedUri = tostring(parse_url(RequestUri))
| project ParsedUri, RequestUri
| take 10
The initial RequestUri https://graph.microsoft.com/beta/users/microsoft.graph.delta()?$deltatoken=rvjDUCsbAVEsCEIyk6Grb--......" is now parsed to the JSON below and saved in the ParsedUri column. This allows us to split the original RequestUri into different areas of interest.
{
"Scheme":"https",
"Host":"graph.microsoft.com",
"Port":"",
"Path":"/beta/users/microsoft.graph.delta()",
"Username":"",
"Password":"",
"Query Parameters":{
"$deltatoken":"=rvjDUCsbAVEsCEIyk6Grb--......"
},
"Fragment":""
}
Retrieving Request Statistics
Retrieving request statistics returns insight into which Graph API endpoints and request are made. One can now summarize all the GraphAPI request types easily with the following query. The unique deltatokens have been removed from the data, returning a better overview of the executed requests.
GraphAPIAuditEvents
| extend ParsedUri = tostring(parse_url(RequestUri).Path)
| summarize TotalRequest = count() by ParsedUri
| sort by TotalRequest
Graph API KQL: The GitHub Repository Hunting-Queries-Detection-Rules has a dedicated section for Graph API queries you can investigate: Graph API Queries.
Resource Statistics
The requests that are executed by the Graph API can be further standardized by only returning the Graph Endpoint the request connects to. For this we can use the RequestUri to get statistics on which Resource is requested. The {resource} parameter is used for the resource in Microsoft Graph that you’re referencing.
{HTTP method} https://graph.microsoft.com/{version}/{resource}?{query-parameters}
Source: Use the Microsoft Graph API
The table below shows some examples of users, security and identity resources and the RequestUriPath associated with those requests.
RequestUriPath | Resource |
---|---|
/beta/users/microsoft.graph.delta() | users |
/v1.0/security/alerts_v2 | security |
/v1.0/identity/conditionalAccess/policies | identity |
Because we know that the second parameter is the resource, we can use KQL to extract the resource. This is done using the line:
| extend GraphAPIResource = tostring(split(GraphAPIPath, "/")[2])
This line splits the GraphAPIPath at each /, resulting in an array of elements. For the request /v1.0/security/alerts_v2 this array is ["",“v1.0”,“security”,“alerts_v2”]. The [2] in the query selects the third element (count starts at 0) and the column GraphAPIResource is filled with this value. When performing statistics on the resource types, the results below are retrieved.
GraphAPIAuditEvents
| extend ParsedUri = tostring(parse_url(RequestUri).Path)
// Normalize Data
| extend GraphAPIPath = tolower(replace_string(ParsedUri, "//", "/"))
// Extract
| extend GraphAPIResource = tostring(split(GraphAPIPath, "/")[2])
| summarize TotalRequest = count() by GraphAPIResource
| sort by TotalRequest
Graph API Enrichment: The Graph API logs contain mainly IDs to identify which user, service principal or application executed an API query. These IDs can be enriched by adding more context for the analysts, the previous blog Investigating Microsoft Graph Activity Logs shares how these can be enriched. Note: The queries in that blog need slight modification as the schema is slightly different before they can be applied to the GraphAPIAuditEvents.
Enrich Graph API logs
The Graph API logs contain the unique identifiers of the applications or objects related to the request, these identifiers can be enriched with their common names to get context directly in your results. The queries below enrich the application and user information of the targetted application and the user that made the request.
Detecting AzureHound
The Graph API logs are a good addition to the existing logs you already have, it gives visibilty on discovery tools used for Entra and Office365, one of these tools is AzureHound. These new logs enable you to identify actors that are querying the entities in for example your Entra ID tenant.
The query below detects the usage of AzureHound. This is done by filtering on GET requests with status 200 since AzureHound is a collector that submits GET requests to retrieve the data. Furthermore, statistics are applied to count the number of unique requests that are executed within the timeframe of one hour. Lastly, the stats are compared against the thresholds, if the results are bigger than the thresholds, the results are returned and your analysis can begin to investigate the incident. These thresholds depend on the size of your Entra ID tenant. My test environment has a limited set of accounts, thus the total amount of unique requests is limited. If your organisation has more than 1000 users, the UniqueRequestThreshold can easily be set above 5000.
let WhitelistedObjects = dynamic(["obj1", "obj2"]);
let UniqueRequestThreshold = 1000; // Depends on Entra ID tentant size. You can use the function 0.5 * TotalAzure Resources to get this number. KQL: arg("").Resources | count
let ResourceThreshold = 4;
let ReconResources = dynamic(["organization","groups","devices","applications","users","rolemanagement","serviceprincipals"]);
GraphAPIAuditEvents
| where RequestMethod == "GET"
| where ResponseStatusCode == 200
| extend ParsedUri = tostring(parse_url(RequestUri).Path)
| extend GraphAPIPath = tolower(replace_string(ParsedUri, "//", "/"))
| extend GraphAPIResource = tostring(split(GraphAPIPath, "/")[2])
| where GraphAPIResource in (ReconResources)
// Filter whitelist
| where not(AccountObjectId in (WhitelistedObjects))
| summarize UniqueRequests = dcount(ClientRequestId), Requests = make_set(RequestUri, 1000), Paths = make_set(GraphAPIPath), Resources = make_set(GraphAPIResource), UniqueResourceCount = dcount(GraphAPIResource) by AccountObjectId, bin(TimeGenerated, 1h)
| where UniqueRequests >= UniqueRequestThreshold and UniqueResourceCount >= ResourceThreshold
To genarate logs AzureHound is exeuted every odd day using GitHub actions against my environment, and as you an see from the results the AzureHound collection is detected each time.
Other AzureHound Detections: Fabian Bader recently posted another blog on the usage of the GraphAPIAuditEvents, I highly recommend checking that one as well to get even more valuable insights into these new logs.
Moving to the GraphApiAuditEvents
The decicion to use the GraphApiAuditEvents or the MicrosoftGraphActivityLogs depends on the needs of your organization. In case you did not ingest the MicrosoftGraphActivityLogs the logical decision is to start using the GraphApiAuditEvents events. In case you already ingest the MicrosoftGraphActivityLogs in Sentinel you could migrate to the GraphApiAuditEvents to reduce cost, but keep in mind that the retention of the logs will be reduced to 30 days only. If you do not require longer retention it is good to keep the following in mind for a migration:
- Change Analytics Rules to Custom Detections
- Change Workbooks
- Update any automation that uses the MicrosoftGraphActivityLogs table