Detection rules › Kusto
ThreatConnect TI Map URL Entity to OfficeActivity Data
'This query identifies any URL indicators of compromise (IOCs) from threat intelligence (TI) by searching for matches in OfficeActivity data.'
MITRE ATT&CK coverage
| Tactic | Techniques |
|---|---|
| Command & Control | T1071 Application Layer Protocol |
Rule body kusto
id: 12c3b31b-66a6-53ff-b6ab-6ae45e56dc92
name: ThreatConnect TI Map URL Entity to OfficeActivity Data
version: 1.0.1
kind: Scheduled
description: |
'This query identifies any URL indicators of compromise (IOCs) from threat intelligence (TI) by searching for matches in OfficeActivity data.'
severity: Medium
requiredDataConnectors:
- connectorId: Office365
dataTypes:
- OfficeActivity
- connectorId: ThreatIntelligence
dataTypes:
- ThreatIntelligenceIndicator
- connectorId: MicrosoftDefenderThreatIntelligence
dataTypes:
- ThreatIntelligenceIndicator
queryFrequency: 1h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
- CommandAndControl
relevantTechniques:
- T1071
query: |
let dt_lookBack = 1h;
let ioc_lookBack = 14d;
ThreatIntelligenceIndicator
| where TimeGenerated >= ago(ioc_lookBack)
| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by IndicatorId
| where ExpirationDateTime > now() and Active == true
// Filter out non ThreatConnect TI Sources
| where SourceSystem startswith "ThreatConnect-"
// Picking up only IOC's that contain the entities we want
| where isnotempty(Url)
// using innerunique to keep perf fast and result set low, we only need one match to indicate potential malicious activity that needs to be investigated
| join kind=innerunique (
OfficeActivity
| where TimeGenerated >= ago(dt_lookBack)
//Extract the Url from a number of potential fields
| extend Url = iif(OfficeWorkload == "AzureActiveDirectory",extract("(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+);", 1,ModifiedProperties),tostring(parse_json(ModifiedProperties)[12].NewValue))
| where isnotempty(Url)
// Ensure we get a clean URL
| extend Url = tostring(split(Url, ';')[0])
| extend OfficeActivity_TimeGenerated = TimeGenerated
// Project a single user identity that we can use for entity mapping
| extend User = iif(isnotempty(UserId), UserId, iif(isnotempty(Actor), tostring(parse_json(Actor)[0].ID), tostring(parse_json(Parameters)[0].Value)))
) on Url
| where OfficeActivity_TimeGenerated < ExpirationDateTime
| summarize OfficeActivity_TimeGenerated = arg_max(OfficeActivity_TimeGenerated, *) by IndicatorId, Url
| project OfficeActivity_TimeGenerated, Description, ActivityGroupNames, IndicatorId, ThreatType, ExpirationDateTime, ConfidenceScore, Operation,
UserType, OfficeWorkload, Parameters, Url, User
| extend timestamp = OfficeActivity_TimeGenerated, Name = tostring(split(User, '@', 0)[0]), UPNSuffix = tostring(split(User, '@', 1)[0])
entityMappings:
- entityType: Account
fieldMappings:
- identifier: Name
columnName: Name
- identifier: UPNSuffix
columnName: UPNSuffix
- entityType: URL
fieldMappings:
- identifier: Url
columnName: Url
Stages and Predicates
Parameters
let dt_lookBack = 1h;
let ioc_lookBack = 14d;
Stage 1: source
ThreatIntelligenceIndicator
Stage 2: where
| where TimeGenerated >= ago(ioc_lookBack)
Stage 3: summarize
| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by IndicatorId
Stage 4: where
| where ExpirationDateTime > now() and Active == true
Stage 5: where
| where SourceSystem startswith "ThreatConnect-"
Stage 6: where
| where isnotempty(Url)
Stage 7: join
| join kind=innerunique (
OfficeActivity
| where TimeGenerated >= ago(dt_lookBack)
| extend Url = iif(OfficeWorkload == "AzureActiveDirectory",extract("(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+);", 1,ModifiedProperties),tostring(parse_json(ModifiedProperties)[12].NewValue))
| where isnotempty(Url)
| extend Url = tostring(split(Url, ';')[0])
| extend OfficeActivity_TimeGenerated = TimeGenerated
| extend User = iif(isnotempty(UserId), UserId, iif(isnotempty(Actor), tostring(parse_json(Actor)[0].ID), tostring(parse_json(Parameters)[0].Value)))
) on Url
Stage 8: where
| where OfficeActivity_TimeGenerated < ExpirationDateTime
Stage 9: summarize
| summarize OfficeActivity_TimeGenerated = arg_max(OfficeActivity_TimeGenerated, *) by IndicatorId, Url
Stage 10: project
| project OfficeActivity_TimeGenerated, Description, ActivityGroupNames, IndicatorId, ThreatType, ExpirationDateTime, ConfidenceScore, Operation,
UserType, OfficeWorkload, Parameters, Url, User
Stage 11: extend
| extend timestamp = OfficeActivity_TimeGenerated, Name = tostring(split(User, '@', 0)[0]), UPNSuffix = tostring(split(User, '@', 1)[0])
Indicators
Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.
| Field | Kind | Values |
|---|---|---|
Active | eq |
|
OfficeActivity_TimeGenerated | lt |
|
SourceSystem | starts_with |
|
Url | is_not_null |
Output fields
Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.
| Field | Source |
|---|---|
ActivityGroupNames | project |
ConfidenceScore | project |
Description | project |
ExpirationDateTime | project |
IndicatorId | project |
OfficeActivity_TimeGenerated | project |
OfficeWorkload | project |
Operation | project |
Parameters | project |
ThreatType | project |
Url | project |
User | project |
UserType | project |
Name | extend |
UPNSuffix | extend |
timestamp | extend |