Reading width
Wide uses the full column for everything, text, diagrams, code, and exercises. Narrow keeps the standard reading width.
Text size
Scales the body text. Headings and code blocks keep their size.
In this section
0.3 The Recording This Course Reads
Introduction
The attacker leads, and everything the attacker did is written down twice: once by the operator, and once by the range's defense. This course is built on that double record. Every test the engagement ran, every record the hosts and services kept, and every alert, incident and note the defense made are in one recorded dataset, the recording, and every lesson queries it in the console.
This lesson walks through the recording's tables, its days and its two time columns, so that when a lesson says a test left no record, you can trust that the query was looking in the right place.
The recording is also the course's shared vocabulary. When a lesson refers to PT-0103, PU-R02 or Run 6, it means a specific row, rule or day you can open in the console, and two students discussing the course can point at the same records.
That is harder in a course where every student's lab produces different data, and it is why the course uses both: the recording for the answers, your lab for the practice.
The recording runs from Thursday 10 September, a few quiet days before the first test, to Tuesday 29 September at 18:00 UTC. It is generated from a script with fixed seeds, so every student who runs a query gets the same answer, and every answer a lesson prints is checked against it when the lesson is built.
Reading a recording is different from reading a live workspace in one important way: nothing new arrives. That is what makes the course's results stable and comparable, and it is also why your own lab, which you build in Lessons 0.7 to 0.11, matters. The recording shows you what a known engagement looked like; your lab shows you what your own environment does with the same procedures.
Plan the round
How every query against the recording is writtenA few rules apply to every query in the course, and they are the reason a query's answer means what it says:
The decision line is the one this lesson is about. A query can return nothing for two reasons: the record does not exist, or the query was written so that it could not find it.
The second is surprisingly easy: the wrong time column, a window in the wrong hour, a table that had not started recording yet. Each rule above closes one of those, so that an empty result can be read as an absence.
Bounding every query is the rule that most often seems fussy and most often matters. The recording holds three weeks; the same procedure was run on several of those days, deliberately, as originals, retests and variants; and an unbounded query mixes them. Section 10 shows the difference on a single program, and you will meet it again whenever a lesson compares a test with its retest.
Keeping the query with the answer is the course's reporting habit. A count with no query beside it cannot be checked, and a count that cannot be checked is not evidence. Every result in every lesson is printed under the query that produced it for exactly that reason.
The attack: a Caldera operation, and the table it writes
PT-0404, the Enumerator adversary under the atomic plannerThe recording starts where the attacks start. On Friday 18 September the operator ran a Caldera operation against the workstation: the Enumerator adversary from MITRE's Stockpile plugin, five abilities that list processes and check the user's privileges, under the atomic planner, one ability at a time. Caldera keeps its own record of every link it sends, and the range forwards that record to the workspace as PurpleCalderaEvents_CL:
PurpleCalderaEvents_CL
| where Operation == "Friday Enumerator (atomic)"
| project DelegatedTimestamp, AbilityName, AbilityId, Pid, Status
| order by DelegatedTimestamp asc
Five links, each an ability with its ID, the process the agent started for it, and its status:
Delegated Ability Id Pid Status
--------- ---------------------------------- -------- ---- ------
11:00:30 WMIC Process Enumeration 94f21386 7300 0
11:01:35 tasklist Process Module Enumera... 8adf02e8 7301 0
11:02:40 PowerShell Process Enumeration 4d9b079c 7302 0
11:03:45 UAC Status 7c42a30c 7303 0
11:04:50 SysInternals PSTool Process Dis... cc191baa 7304 1
Status 0 is success. The fifth ability, the Sysinternals PSTools download, returned 1, because the range's allowlist refused the download, and that refusal is a result about the range's edge. Each link's command is in the Command field, exactly as the agent ran it, so a defender can match every process on the workstation to the ability that started it by its PID.
Run it: an Atomic test, with the execution log on
PT-0403, and the row the tool wroteAn Atomic test leaves the operator's own record too, when the operator asks for one. Earlier that morning the operator ran T1069.002 test 8, AdFind's group query, by GUID with the execution log on:
PS C:\> $g = '48ddc687-82af-40b7-8472-ff1e742e8274'
PS C:\> $log = 'C:\PurpleArtifacts\art.csv'
PS C:\> Invoke-AtomicTest T1069.002 -TestGuids $g -ExecutionLogPath $log
Invoke-AtomicTest's default logger appends one CSV row per run, with these columns, and the range forwards each row to the workspace as PurpleExecutionLog_CL:
"Execution Time (UTC)", "Execution Time (Local)", "Technique",
"Test Number", "Test Name", "Hostname", "IP Address",
"Username", "GUID", "ProcessId", "ExitCode"
The row for PT-0403, as the workspace holds it:
PurpleExecutionLog_CL
| where TimeGenerated between (datetime(2026-09-18T10:20:00Z)
.. datetime(2026-09-18T10:21:00Z))
| project TimeGenerated, Technique, TestName, Hostname, ExitCode
One run, with the tool's own time, host, user, GUID and exit code:
Time Technique Test Host Exit
-------- --------- ------------------------------ ---------- ----
10:20:04 T1069.002 Adfind - Query Active Direc... NE-PT-WS01 0
Exit code 0: AdFind ran and returned. The Caldera links and the execution log are two of six tables the purple team writes itself. Their row counts across the recording:
From the operator's seat the recording begins with six tables the purple team writes itself. Their row counts:
union
(PurpleTestLog_CL | summarize Rows = count()
| extend Table = "PurpleTestLog_CL"),
(PurpleExecutionLog_CL | summarize Rows = count()
| extend Table = "PurpleExecutionLog_CL"),
(PurpleCalderaEvents_CL | summarize Rows = count()
| extend Table = "PurpleCalderaEvents_CL"),
(PurpleReadiness_CL | summarize Rows = count()
| extend Table = "PurpleReadiness_CL"),
(PurpleCollabLog_CL | summarize Rows = count()
| extend Table = "PurpleCollabLog_CL"),
(PurpleReplay_CL | summarize Rows = count()
| extend Table = "PurpleReplay_CL")
| project Table, Rows
Six operator tables, from the test log down to a single replay:
Table Rows
---------------------- ----
PurpleTestLog_CL 78
PurpleExecutionLog_CL 41
PurpleCalderaEvents_CL 24
PurpleReadiness_CL 15
PurpleCollabLog_CL 18
PurpleReplay_CL 1
The test log, PurpleTestLog_CL, has one row per test: its ID, run, technique, Atomic GUID where there is one, target, operator, command, status and cleanup. Every other table hangs off it.
The execution log, PurpleExecutionLog_CL, is written by Invoke-AtomicTest for each Atomic run, with the exit code the operator's status should agree with; it starts on 18 September, when the team turned it on. The Caldera table, PurpleCalderaEvents_CL, has one row per link of every Caldera operation, with the times the link was delegated, collected and finished.
The other three record the exercise around the tests. PurpleReadiness_CL holds the operator's checks of the range itself, each with what was expected and what was observed. PurpleCollabLog_CL holds the exercise chat, the bridge notes and the SOC ticket for the collaborative day. PurpleReplay_CL holds one record replayed into the workspace to test a rule, which section 4 explains.
The test log's status column deserves a warning on day one. It is set by the operator, and it says whether the procedure ran as planned. It does not say whether the procedure succeeded; that is the exit code in the execution log, where there is one. A blocked test can be logged Completed with a non-zero exit, and the course reads both columns before it classifies anything.
The operator's tables are short because they describe intent and outcome, not activity. Seventy-eight test rows are matched by hundreds of records in the defense's tables, and the defender's first move with any test is to start from its row and find those records.
The variant: a record replayed
Testing a rule without running the test againThe operator has one more way to exercise a rule, and the recording holds one example. The replay table:
PurpleReplay_CL
| project TimeGenerated, ReplayOf, SourceTable, OriginalTimestamp, ActionType
One record, a copy of PT-0103's task creation, written back into the workspace on 18 September:
Time Replays Table Original Action
---------------- ------- ------------ ---------------- --------------------
2026-09-18 13:40 PT-0103 DeviceEvents 2026-09-15 09:40 ScheduledTaskCreated
A replay tests a rule against a record that a real test produced, without running the procedure again. It is quick and safe, and it proves one thing only: that the rule matches that record. It does not prove the procedure would produce the same record today, or that the alert would reach anyone. The operator's note on the method:
The course uses reruns to close gaps throughout, and mentions the replay here so that you recognize it when Module 4 uses it once.
Detection follows: the defense's tables
What the hosts, services and analysts recordedThe defense's side of the recording is larger. The row counts of its main tables:
union
(DeviceProcessEvents | summarize Rows = count()
| extend Table = "DeviceProcessEvents"),
(DeviceRegistryEvents | summarize Rows = count()
| extend Table = "DeviceRegistryEvents"),
(DeviceEvents | summarize Rows = count()
| extend Table = "DeviceEvents"),
(DeviceNetworkEvents | summarize Rows = count()
| extend Table = "DeviceNetworkEvents"),
(SecurityEvent | summarize Rows = count()
| extend Table = "SecurityEvent"),
(SigninLogs | summarize Rows = count()
| extend Table = "SigninLogs"),
(SecurityAlert | summarize Rows = count()
| extend Table = "SecurityAlert"),
(SecurityIncident | summarize Rows = count()
| extend Table = "SecurityIncident")
| project Table, Rows
| order by Rows desc
Eight of the defense's tables, from the largest down:
Table Rows
-------------------- ----
DeviceProcessEvents 257
DeviceNetworkEvents 243
SecurityEvent 102
SigninLogs 96
DeviceEvents 66
SecurityAlert 44
DeviceRegistryEvents 41
SecurityIncident 23
The defense's tables are written by the hosts and services, not by the purple team, and nothing in them knows a test was running. A process record from a test looks exactly like a process record from a person, which is the point: the defense has to recognize the activity from what it is, not from a label. The purple team's job is to tie the two records together afterward.
The Defender tables hold the workstation's and the server's activity: processes, registry changes, network connections and device events such as a scheduled task created or a tampering attempt blocked. SecurityEvent holds the Windows Security log from the server and the domain controllers.
SigninLogs holds the tenant's sign-ins from 22 September. SecurityAlert and SecurityIncident hold what the defense did: the alerts its rules raised and the incidents analysts opened, with their verdicts and comments.
Beyond these eight, the recording has Syslog from the Linux host, the PowerShell log in Event, the identity sensor's tables, the tenant's audit and Exchange records, DNS and the network sensor's connections. Lesson 0.2 gave the date each began.
The ordinary activity in every table is there too, the nightly inventory, the browser, the helpdesk's queries, because a rule has to be shown to leave that alone as well as to catch the tests.
The runs, day by day
Nine runs over seven daysTests are grouped into runs, each a planned set of tests on one day. The test log, by day and run:
PurpleTestLog_CL
| summarize Tests = count() by Day = format_datetime(TimeGenerated, "yyyy-MM-dd"), Run
| order by Day asc, Run asc
Nine runs on seven days, one day with two runs:
Day Run Tests
---------- --- -----
2026-09-15 1 14
2026-09-17 2 4
2026-09-17 3 13
2026-09-18 4 11
2026-09-21 5 12
2026-09-22 6 8
2026-09-24 8 9
2026-09-28 10 2
2026-09-29 11 5
The first run, on Tuesday 15 September, is the exercise week's opening: fourteen tests across the workstation, the server and the Linux host. Thursday 17 September has two runs, the morning retests of four repairs and the afternoon's attacks on those repairs.
Friday is the tooling day, Monday 21 September the endpoint validation day, Tuesday 22 September the identity, network and cloud day, Thursday 24 September the retest day, Monday 28 September the collaborative exercise, and Tuesday 29 September the unattended regression run.
A run is also the unit of planning. Each was written up before it started, with its tests, expected outcomes and any white cards, and each ended with a review. The runs table is the engagement's calendar, and the course's modules follow it closely enough that you can tell from a lesson's dates which run it is reading.
Each run matches a module of the course, roughly in order, and each module's lessons read mostly from its own run. That is why a lesson's queries are bounded to a day: the same procedure appears on several days, deliberately, and a query that is not bounded will mix an original with its retest.
Two time columns
Timestamp in Defender, TimeGenerated elsewhereThe recording keeps the time column each table has in production, and the two kinds differ. One process record's two columns:
DeviceProcessEvents
| take 1
| project Timestamp, TimeGenerated
One record, with one of its two time columns filled and the other empty:
Timestamp TimeGenerated
------------------- -------------
2026-09-29 10:40:12 None
Defender's advanced hunting tables carry their event time in Timestamp; the Sentinel tables and the operator's tables carry TimeGenerated.
A query that filters a Defender table on TimeGenerated returns nothing at all, and nothing at all looks exactly like a test that left no record. It is the single most common mistake in reading the recording, and every query in the course that touches a Defender table filters on Timestamp for that reason.
The opposite mistake is rarer and just as quiet: filtering an operator's table on Timestamp, which it does not have. The console reports an error for an unknown column in most cases, which is kinder than an empty result, but a query that unions several tables can hide the error in one branch. The course writes each branch's time column explicitly for that reason.
Every time in the recording is UTC. The workstation's execution log also carries a local time, an hour ahead, because the tool writes both; the course always uses the UTC column, and the lessons say so wherever the two could be confused.
Days left out
Two days kept for the staged assessmentsTwo days are missing from the recording on purpose. Wednesday 23 September and Friday 25 September are the recorded days of the course's first two staged assessments, and the capstone's assessment has two days of its own after the recording ends. None of them is in the dataset the lessons and the console read.
The days held back, and what each is for:
The reason is simple: a staged assessment asks you to find things in a day you have not seen, and if that day were in the console, any lesson's query could stumble onto its answers.
Keeping the days out means every assessment is a genuine first look. The runs table in section 3 shows the gap, with Runs 7 and 9 absent and no tests on the 23rd or the 25th.
Holding days back has a cost the course accepts. A lesson cannot quote what happened on the 23rd or the 25th, so the story of the engagement has two gaps in it, and a few later lessons mention work done on those days without showing its records, because showing them would answer an assessment's questions.
Each lesson that crosses a gap says so in a sentence, so the gaps are never mistaken for missing data, and the assessment that holds each day is named where it matters.
The staged assessments are taken in the same console, with their own datasets loaded in place of the course's. When you open one, the console says which dataset it is reading.
The same events through Splunk
One recording, two query languagesThe course teaches detections in KQL, SPL and Sigma, and the recording exists in a Splunk form as well, with the same events. Each source keeps the shape a Splunk deployment would give it:
The Splunk form is written by the same generator from the same rows, so a record that is in one form is in the other, with the same time and the same values. Differences between a KQL rule and its SPL translation are therefore differences in the rules, never in the data, and the course's rule checker uses that to catch a translation that drifts.
Every rule the course writes is run in both languages over the same events, and the two forms must return the same results before a lesson shows them. Lessons that teach SPL say which sourcetype they read; the rest of the course uses KQL by default, because that is the language of the platform the range runs on.
Sigma is the third form. A Sigma rule is a portable description of a detection, converted to a query for whichever platform runs it; the course gives each rule's Sigma source beside its KQL and SPL, and checks that it matches the same events on the recording where the range collects the log it reads.
The console
Where every query in the course runsEvery query on every page runs in the platform's console against the recording, with the Purple Range selected. Run any query a lesson shows and you get the result the lesson prints; change it and you get the answer to your own question. The console also holds the cases at the end of each lesson, where you write the queries yourself and the console checks your answers.
Working in the console follows the same few steps in every lesson:
Changing one thing at a time is the habit worth building first. A query changed in three places at once that returns something unexpected cannot tell you which change mattered, and the console makes it cheap to change one thing and run again.
The console is the reason the recording is small enough to read. Two weeks of a range with three targets and a tenant is a few thousand records, which is enough to hold every kind of result the course teaches and few enough that a query returns instantly.
Your own lab will produce a different recording of your own tests, and Lessons 0.7 to 0.11 show how to read it with the same habits.
Validate
A query without a window, and with oneThe obvious way to find PT-0103's process in the recording is to search for its program:
DeviceProcessEvents
| where FileName =~ "schtasks.exe"
| countDeviceProcessEvents
| where Timestamp between (datetime(2026-09-15T09:40:00Z)
.. datetime(2026-09-15T09:45:00Z))
| where FileName =~ "schtasks.exe"
| countThe first try's extra records are not noise to be thrown away; they are the same program on other days, several of them later tests of the same technique.
A defender who wanted to compare PT-0103 with its retest would want exactly those records, each in its own window. The point is not that unbounded queries are wrong but that each answer should come from the window its question is about.
The refined query is the shape of almost every query in the course: a table, a window from the test's row, and a condition. On the chain both seats use, the recording reads:
Outcome chain
The recordingWhat the recording gives the course
The same answer for every student.
- Six tables of intent and outcome.
- The hosts', services' and analysts' records.
- One console, every query reproducible.
A recording every lesson queries, small enough to read and complete enough to trust a silence.
The recording has limits of its own, and the course states them rather than hiding them. It is a reconstruction of one engagement on one small range, generated so that every result can be reproduced; its ordinary activity is a few predictable routines, not a busy office; and its rules are the range's, not a vendor's.
Those limits are why the course pairs it with your own lab, where the activity is your own and the surprises are real.
Every later lesson starts from this chain. When a lesson reports that a test was silent, the chain is how you know the silence was looked for in the right table, over the right window, with the right time column.
Adapt: four questions, and the tables that answer them
Where to look firstEvery question a purple team asks of a recording is one of four, and each has its own tables:
What did the operator run?
Was the range ready?
What did the hosts and services record?
What did the defense do?
Each question also has a typical mistake. Asking what the operator ran from the defense's tables credits the defense with tests it never saw; asking whether the range was ready from memory skips the checks that predicted half the results; asking what was recorded from the alert queue misses every silent test; and asking what the defense did from the rules alone misses every alert nobody opened.
The first two questions are answered by the purple team's own records, the last two by the defense's. A lesson always asks them in that order: what ran, whether the range was ready, what was recorded, and what the defense did. Starting from the third or fourth question, as a dashboard does, leaves the first two unasked, and they are where the most surprising results begin.
Learn the recording's tables
The case below reads the operator's tables and the runs, then the defense's tables and their time column. Work in the platform's Advanced hunting console with the Purple Range selected.
All four answers come from short queries over whole tables, which is the right way to learn what a recording holds before asking it anything specific. Most of the course's queries are narrower, bounded to one test's window, and you will write those from Lesson 0.4 onward.
The fourth answer is the one that will save you the most time: when a Defender query returns nothing, check the time column before concluding the record is absent.
Practice
Find a record in the right table your labThe practice is in the console, against the recording; your own lab is built in Lessons 0.7 to 0.11.
Operator: a test
Pick any test in Run 5 from the test log and note its time and host.
Defender: the wrong column
Query DeviceProcessEvents for its window on TimeGenerated, and note what comes back.
Defender: the right column
Run the same query on Timestamp, and note what comes back.
Both: the lesson
Write one sentence on why the first result was empty.
What you should end up with: A test's records found with the right time column, and the empty result explained.
Resources
Publishers revise these pages; where a page and this lesson disagree, the page is newer.