In this section
The Cross-Environment Timeline
What you already know
You can prove the cloud, Windows, and Linux identities are one actor. Now you order their actions in time, and the obstacle is that each environment keeps its own clock and its own log format, so the raw timestamps don't line up.
Scenario
Three timelines, three stories. The cloud shows a token grant at 08:22, the endpoint a process launch at 08:25, the Linux box an SSH login at 08:19. Read separately they suggest the Linux event came first. Merged and skew-corrected, the real order emerges: cloud token, then endpoint, then the SSH key used to reach Linux. The sequence was hidden in the clock differences.
Each environment produces its own timeline. The cloud timeline shows sign-ins, consent grants, and mailbox activity. The Windows timeline shows process execution, logon events, and file access. The Linux timeline shows SSH sessions, command execution, and file modifications. During triage, the responder has three separate timelines that must be mentally merged to understand the attack sequence. Mental merging fails under pressure: the analyst forgets a critical event from the cloud timeline while examining the Windows timeline, or confuses the order of events across environments. The solution is a unified timeline: a single KQL query that merges events from all three environments into one chronologically ordered result set. The pivot point: the moment the attacker crossed from one environment to another, becomes visible as a change in the Source column within a continuous timeline.
Figure TR6.3. The CHAIN-HARVEST unified timeline at Northgate Engineering. Seven events across three environments in nine minutes. The two red pivot markers identify the exact moments the attack crossed environment boundaries. Without a unified timeline, the triage responder sees only the three cloud events and misses the six-event attack chain.
Building the unified timeline query
The technical challenge is that Sentinel stores cloud, Windows, and Linux events in different tables with different schemas. SigninLogs has TimeGenerated, UserPrincipalName, and IPAddress. DeviceLogonEvents has Timestamp, AccountName, and RemoteIP. Syslog has TimeGenerated, HostName, and an unstructured SyslogMessage field. You cannot join these tables directly: the column names and data types do not align.
The solution is the KQL union operator with schema normalization. Each table is queried independently, the results are projected into a common schema, and the union merges them into a single result set sorted by timestamp.
// UNIFIED CROSS-ENVIRONMENT TIMELINE
// Replace variables with values from your initial triage
let targetUser = "j.morrison@northgateeng.com";
let targetSAM = "j.morrison";
let suspectIPs = dynamic(["185.220.101.42"]);
let triageWindow = 48h;
// Cloud events
let cloudTimeline = SigninLogs
| where TimeGenerated > ago(triageWindow)
| where UserPrincipalName =~ targetUser or IPAddress has_any (suspectIPs)
| where ResultType == "0"
| project
EventTime = TimeGenerated,
Environment = "Cloud",
Source = "SigninLogs",
Entity = UserPrincipalName,
Action = strcat("Sign-in to ", AppDisplayName),
Detail = strcat("IP: ", IPAddress, " | Device: ", tostring(DeviceDetail.displayName), " | Risk: ", RiskLevelDuringSignIn),
IP = IPAddress;
let cloudAudit = AuditLogs
| where TimeGenerated > ago(triageWindow)
| where InitiatedBy has targetUser or InitiatedBy has_any (suspectIPs)
| project
EventTime = TimeGenerated,
Environment = "Cloud",
Source = "AuditLogs",
Entity = tostring(InitiatedBy.user.userPrincipalName),
Action = OperationName,
Detail = strcat("Result: ", Result, " | Target: ", tostring(TargetResources[0].displayName)),
IP = tostring(InitiatedBy.user.ipAddress);
// Windows events
let windowsLogon = DeviceLogonEvents
| where Timestamp > ago(triageWindow)
| where AccountName =~ targetSAM or RemoteIP has_any (suspectIPs)
| project
EventTime = Timestamp,
Environment = "Windows",
Source = "DeviceLogonEvents",
Entity = strcat(AccountDomain, "\\", AccountName),
Action = strcat("Logon type: ", LogonType),
Detail = strcat("Device: ", DeviceName, " | Protocol: ", Protocol),
IP = RemoteIP;
let windowsNetwork = DeviceNetworkEvents
| where Timestamp > ago(triageWindow)
| where InitiatingProcessAccountName =~ targetSAM
| where RemotePort == 22 // SSH pivot detection
| project
EventTime = Timestamp,
Environment = "Windows",
Source = "DeviceNetworkEvents",
Entity = InitiatingProcessAccountName,
Action = strcat(InitiatingProcessFileName, " → ", RemoteIP, ":", tostring(RemotePort)),
Detail = strcat("Device: ", DeviceName, " | CmdLine: ", InitiatingProcessCommandLine),
IP = RemoteIP;
// Linux events
let linuxTimeline = Syslog
| where TimeGenerated > ago(triageWindow)
| where Facility == "auth" or Facility == "authpriv"
| where SyslogMessage has targetSAM or SyslogMessage has_any (suspectIPs)
| where SyslogMessage has "Accepted" or SyslogMessage has "session opened" or SyslogMessage has "sudo"
| project
EventTime = TimeGenerated,
Environment = "Linux",
Source = strcat("Syslog:", HostName),
Entity = targetSAM,
Action = extract("(Accepted \\w+ for \\S+|session opened for user \\S+|sudo:.*COMMAND=\\S+)", 1, SyslogMessage),
Detail = SyslogMessage,
IP = extract("from (\\d+\\.\\d+\\.\\d+\\.\\d+)", 1, SyslogMessage);
// UNION all environments into a single timeline
cloudTimeline
| union cloudAudit
| union windowsLogon
| union windowsNetwork
| union linuxTimeline
| sort by EventTime asc
| extend TimeOffset = datetime_diff('second', EventTime, prev(EventTime))
Line-by-line annotation for the critical sections:
project EventTime = TimeGenerated and project EventTime = Timestamp, normalizes the timestamp field name. SigninLogs and AuditLogs use TimeGenerated. Defender tables use Timestamp. Syslog uses TimeGenerated. After projection, all events share the EventTime column.
extend TimeOffset = datetime_diff('second', EventTime, prev(EventTime)), calculates the time gap between consecutive events. This is the pivot detection mechanism. A cluster of cloud events 30-60 seconds apart followed by a gap of 60+ seconds followed by a cluster of Windows events signals the cloud-to-endpoint pivot. The gap is the boundary crossing.
The extract function in the Linux section uses regex to pull structured data from the unstructured SyslogMessage field. extract("from (\\d+\\.\\d+\\.\\d+\\.\\d+)", 1, SyslogMessage) extracts the source IP from the SSH accepted message. This parsing is necessary because Syslog data is not structured, unlike SigninLogs where IPAddress is a dedicated column.
Reading the unified timeline: identifying pivot points
The unified timeline for CHAIN-HARVEST at NE produces seven rows when run against the triage window:
Figure TR6.3b. The CHAIN-HARVEST unified timeline. Three environment lanes, seven events, two pivot points. The orange markers identify the exact moments the attack crossed environment boundaries. Without a unified timeline, the triage responder sees only the three cloud events and misses the six-event chain.
The two pivot points are visible as transitions in the Environment column:
Anti-Pattern
Merging timestamps across environments without correcting for skew
Each environment stamps events with its own clock, and a few minutes of skew is enough to reorder cause and effect in a merged timeline. Stacking raw timestamps can make the victim's response look like it preceded the attack. Normalize every source to UTC and account for known skew before you read the order, because the order is the whole point of the timeline.
Handling timeline gaps and missing data
Not every cross-environment attack produces a clean seven-event timeline. Common gaps that complicate triage:
Missing Windows endpoint telemetry. If the attacker accesses a device not onboarded to Defender for Endpoint, DeviceLogonEvents and DeviceNetworkEvents will have no entries for that device. The cloud-to-endpoint pivot shows only as a cloud VPN authentication followed by a gap, then a Linux SSH acceptance from an internal IP. You know the pivot happened: the Linux event proves the attacker is inside the network, but you cannot identify which endpoint they used. The triage report should note this gap and recommend the investigation team identify the intermediate endpoint.
Missing Linux Syslog forwarding. If the Linux server does not forward auth.log to Sentinel, the Linux events are absent from the unified timeline. The Windows-to-Linux pivot shows as an outbound SSH connection from the Windows endpoint with no corresponding acceptance event. You can infer the pivot from the Windows side alone, but you cannot confirm it. The triage report should note that direct examination of the Linux server's local auth.log is needed to confirm the pivot.
VPN IP pool overlap. If the VPN assigns IPs dynamically and the logs do not record the user-to-IP assignment, you cannot reliably correlate the VPN IP in the Windows logon event back to the specific cloud user. At NE, GlobalProtect logs record the assignment, but not all VPN solutions provide this mapping in Sentinel-accessible logs. If the VPN IP cannot be correlated, fall back to timestamp proximity and user account matching.
Extending the timeline with additional data sources
The base unified timeline query covers authentication and network events, enough to identify pivot points. During triage, you may need to extend the timeline with additional tables to understand what the attacker DID in each environment after arriving.
Adding file access events: When the triage suggests data access (BEC with file server access, database compromise, SharePoint exfiltration), add DeviceFileEvents for Windows and OfficeActivity for cloud:
// TIMELINE EXTENSION: File access events
let targetUser = "j.morrison@northgateeng.com";
let targetSAM = "j.morrison";
let triageWindow = 48h;
let fileTimeline = DeviceFileEvents
| where Timestamp > ago(triageWindow)
| where InitiatingProcessAccountName =~ targetSAM
| where ActionType in ("FileCreated", "FileModified", "FileRead", "FileRenamed")
| where FileName !endswith ".tmp" and FileName !endswith ".log"
| project
EventTime = Timestamp,
Environment = "Windows",
Source = "DeviceFileEvents",
Entity = InitiatingProcessAccountName,
Action = strcat(ActionType, ": ", FileName),
Detail = strcat("Path: ", FolderPath, " | Process: ", InitiatingProcessFileName),
IP = "";
let cloudFileTimeline = OfficeActivity
| where TimeGenerated > ago(triageWindow)
| where UserId =~ targetUser
| where Operation in ("FileAccessed", "FileDownloaded", "FileUploaded", "FileCopied")
| project
EventTime = TimeGenerated,
Environment = "Cloud",
Source = "OfficeActivity",
Entity = UserId,
Action = strcat(Operation, ": ", OfficeObjectId),
Detail = strcat("Site: ", Site_Url, " | ClientIP: ", ClientIP),
IP = ClientIP;
fileTimeline | union cloudFileTimeline | sort by EventTime asc
This extension reveals the attacker's objectives. In CHAIN-HARVEST, adding DeviceFileEvents to the timeline showed the SSH key access at 08:19:45, between the endpoint logon (08:17) and the SSH connection (08:22). Without file events, the timeline shows a five-minute gap between landing on the endpoint and pivoting to Linux. With file events, the gap is filled: the attacker spent those five minutes exploring the file system and finding the SSH key.
Adding process execution events: When the triage suggests malware execution, credential dumping, or defense evasion, add DeviceProcessEvents:
// TIMELINE EXTENSION: Process execution on compromised endpoint
let targetDevice = "DESKTOP-NGE042";
let triageWindow = 48h;
let processTimeline = DeviceProcessEvents
| where Timestamp > ago(triageWindow)
| where DeviceName =~ targetDevice
| where InitiatingProcessAccountName !in ("SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE")
| where FileName !in ("svchost.exe", "RuntimeBroker.exe", "backgroundTaskHost.exe", "SearchProtocolHost.exe")
| project
EventTime = Timestamp,
Environment = "Windows",
Source = "DeviceProcessEvents",
Entity = InitiatingProcessAccountName,
Action = strcat("Executed: ", FileName),
Detail = strcat("CmdLine: ", ProcessCommandLine, " | Parent: ", InitiatingProcessFileName),
IP = "";
processTimeline | sort by EventTime asc
The exclusion filters (SYSTEM, LOCAL SERVICE, common background processes) reduce noise to show only user-initiated or attacker-initiated processes. During CHAIN-HARVEST, this query would reveal the exact ssh.exe command line including the -i flag pointing to the stolen key file, evidence that the attacker deliberately located and used the stored credential rather than attempting password authentication.
Adding Azure management operations: When the triage involves Azure IaaS or cloud-to-Linux pivots (CHAIN-DRIFT), add AzureActivity:
// TIMELINE EXTENSION: Azure management operations
let targetUser = "j.morrison@northgateeng.com";
let triageWindow = 48h;
let azureTimeline = AzureActivity
| where TimeGenerated > ago(triageWindow)
| where Caller =~ targetUser
| where ActivityStatusValue == "Success"
| project
EventTime = TimeGenerated,
Environment = "Azure",
Source = "AzureActivity",
Entity = Caller,
Action = OperationNameValue,
Detail = strcat("Resource: ", _ResourceId, " | Status: ", ActivityStatusValue),
IP = CallerIpAddress;
azureTimeline | sort by EventTime asc
Each extension adds one more log source to the union. The modular structure of the base query makes this straightforward: each environment's sub-query follows the same schema (EventTime, Environment, Source, Entity, Action, Detail, IP), so new sources slot into the union without modifying existing queries.
Timeline presentation for the triage report
The raw KQL output is useful for the analyst but not for the triage report. The triage report should present the timeline as a narrative with timestamps, not as a table of log entries. The format that works best for both technical and non-technical readers:
Annotate each pivot point explicitly. The investigation team will focus their initial effort at these transition points — understanding HOW the attacker crossed each boundary is the key to preventing recurrence.
Timeline analysis patterns: what timing reveals about the attacker
The unified timeline reveals more than just the attack sequence: the timing patterns within the timeline reveal the attacker's methodology, automation level, and operational security awareness.
Rapid-fire pattern (seconds between events): Events separated by 1-5 seconds indicate automated tooling. A human operator cannot authenticate to a VPN, establish an RDP session, navigate the file system, and initiate SSH within seconds. If your timeline shows sub-10-second gaps between pivots, the attacker is using a scripted attack chain, likely Cobalt Strike, Sliver, or a custom framework with pre-built pivot modules. Triage implication: the attacker has pre-planned this specific attack path, probably from prior reconnaissance. The attack may be further along than the timeline suggests, check for earlier reconnaissance activity in the same or previous days.
Deliberate pattern (minutes between events): Events separated by 2-10 minutes indicate a human operator making decisions in real time. The CHAIN-HARVEST timeline at NE shows this pattern: 08:14 (sign-in) → 08:15 (MFA registration, 1 minute) → 08:16 (VPN auth, 1 minute) → 08:17 (endpoint logon, 1 minute) → 08:19 (SSH key access, 2 minutes) → 08:22 (SSH connection, 3 minutes). The increasing gaps suggest the attacker spent time exploring the endpoint file system before finding the SSH key. Triage implication: you may have a brief window to contain before the attacker reaches the next target.
Long gap pattern (hours between phases): If the cloud compromise occurred at 08:14 but the endpoint activity does not begin until 14:30 (six hours later), the attacker may have exfiltrated the token and handed off to a different operator, or they may have waited for a specific time window (business hours when the VPN tunnel would blend with legitimate traffic, or after hours when monitoring is reduced). Triage implication: check whether the IP address changes between phases: a different IP suggests handoff between operators or infrastructure rotation.
Burst-and-pause pattern: Clusters of rapid activity separated by long pauses indicate an attacker who works in sessions, connecting, executing a set of objectives, disconnecting, then returning later. Each burst may target a different environment or a different objective within the same environment. Triage implication: look for MULTIPLE burst clusters in the timeline. The alert you are triaging may correspond to the second or third burst, and the earlier bursts (which may not have triggered alerts) may reveal additional compromised systems.
No-gap pattern (overlapping timestamps): Events in different environments occurring at the SAME timestamp (within 1-2 seconds) indicate the attacker is operating in multiple environments simultaneously, possible with multiple terminal windows or an automated C2 framework managing parallel sessions. Triage implication: containment must be simultaneous. If you contain one environment while the attacker is actively operating in another, they will detect the containment and accelerate their activity in the remaining environments.
Compliance myth: "A unified timeline is an investigation deliverable, not a triage deliverable. Triage just needs to determine if the alert is real." Operational reality: A unified timeline during triage takes five minutes to produce using the query in this subsection. It answers the two most important triage questions simultaneously: is the alert real (yes: the cloud event is connected to endpoint and Linux activity), and what is the scope (three environments, progressing from cloud identity to database server access). Without the timeline, the triage report says "cloud identity compromised, recommend investigation." With the timeline, the triage report says "cloud identity compromised, attacker pivoted to endpoint DESKTOP-NGE042 and accessed RHEL-DB01 production database within 9 minutes of initial compromise. Critical severity, immediate containment required in all three environments." The second report gives the investigation team and management everything they need to act.
⚖ Decision Point
Decision point. When to build the unified timeline during triage: Always build it when the initial single-environment triage confirms a true positive AND the compromised entity has cross-environment access (TR6.1 pivot recognition framework, Question 1). The five minutes invested in the unified timeline query pays for itself in triage accuracy. Skip it when the initial single-environment triage classifies the alert as a false positive with high confidence, OR when the compromised entity has no cross-environment access (a user with cloud-only access who has never logged into a Windows endpoint or Linux server).
Troubleshooting the unified timeline query
Problem: The union query times out. The triageWindow is too wide for the table sizes. Reduce from 48h to 24h or 12h. Alternatively, add the targetUser and suspectIPs filters BEFORE the project step (already done in the query above, but verify that the filters are reducing scan volume). If the tables are very large, run each environment's sub-query independently and manually merge the results.
Problem: The timeline shows events in the wrong order.
Check for timezone mismatches in the Syslog data. Run Syslog | where HostName == "RHEL-DB01" | take 1 | project TimeGenerated, SyslogMessage and compare the SyslogMessage timestamp with the TimeGenerated timestamp. If they differ by a fixed offset (e.g., exactly 1 hour), the Linux server is logging in local time, not UTC.
Problem: The extract function returns empty values for Linux IPs. The regex pattern assumes the standard OpenSSH log format: "Accepted publickey for user from IP port PORT." If your Linux servers use a different SSH implementation or a modified log format, adjust the regex. Test with: Syslog | where SyslogMessage has "Accepted" | take 5 | project SyslogMessage to see the actual format.
Try it: Run the unified timeline query against your Sentinel workspace using a known-good user account (not a suspected compromise, just a test). Replace the target user and remove the suspectIPs filter to see all activity for that user across all environments. Does the timeline show a coherent sequence? Are there gaps where you expected activity? Are there environment transitions that you can identify? This dry-run validates that your log sources are feeding the unified timeline before you need to use it during a real triage.