learn-cyber-security-with-phoebe / Builder session 7 of 8
Learn Cyber Security with Phoebe · Builder session 7 of 8

Detection engineering

Session 5 wrote two detections by looking at where customer data leaves. Tonight works the other way round: start from what an attacker is trying to do, say what that would look like in Ironwood's own logs, and write the query that would catch it. Five tactics from the ATT&CK list cover every edge on the graph that still has no log against it: a service account logging in from somewhere new, a Friday admin grant, an unusual read, a copy to a bucket nobody approved, and a DROP against the table or its backups. Each becomes a query with a threshold tuned against its own history and a named person who reads what it writes. By the end the inventory file has a "seen by" entry on every remaining edge, and the review table is short enough that somebody will actually open it.

🔴 Hardest Platform and analytics engineers Whoever reads the review table 45 min
0-3 · Welcome 3-20 · From a tactic to a query 20-40 · Build-along: five detections, one review table 40-45 · Q&A
Part 0

Where session 7 sits on the ladder

Six sessions in, Ironwood's bench reads three paths to the customers table, none of them undetected, and twelve of twelve tables recoverable. The two detections from session 5 did the undetected work: query logging and egress alerting take that number from three to zero while the path count stays at three. What they do not do is watch the edges that lead up to a read. A service key used from a new address, a role granted on a Friday, a DROP against the backup schema: none of those is a SELECT on customers, so none of them appears in the session 5 review table. Tonight fills those gaps with three more detections and puts all five in one place.

Live = presented in the 45 minutes Self-study = the full depth, after class 🔍 Every diagram zooms

★ What you walk out with tonight. A method that turns any tactic on the ATT&CK list into a query over the logs you already have, in five steps. Five detections over login history, grant history and query history, each with a threshold tuned so it fires less than once a week without a real cause. One review table all five write to, with a tactic, an object, an actor, the evidence and a named reviewer on every row. And an inventory file where every remaining edge names the log that sees it.

LiveWhy the finished design is only defensible because these queries exist3 min▶

The finished Ironwood design has one open path: the support agent's own legitimate session reading customer records. No control removes it without stopping the work. The bench accepts that path as the honest remainder for one reason only: it crosses a logged edge, and somebody reads the log. Turn detection off and keep every prevention control on, and the bench reads one path and one undetected. Same door, same room, and now it is dark.

So the queries written tonight are not a monitoring nicety layered on top of a secure estate. They are the thing that makes it acceptable to leave the last door open. A leader who signs off the design is signing off the assumption that these detections run, that their thresholds are sane, and that a named human reads the table every day. The engineering that follows is about making that assumption true.

Real world

Ironwood's Friday admin grant was found by the session 5 unusual-read detection, six weeks after it was made, the first time the analyst used the role against customers. Grant history had recorded the grant itself within a second of it happening. Nobody had written the query that reads grant history, so the earliest possible signal sat unread for six weeks while the later signal did the work. Detection engineering is the practice of writing the earlier query.

Self-studyThe vocabulary for tonight3 min▶
  • Tactic - ATT&CK's word for the attacker's goal at one stage. Version 19.2 lists fifteen for the enterprise matrix; tonight uses five of them: Credential Access, Privilege Escalation, Collection, Exfiltration and Impact.
  • Hypothesis - a sentence of the form "if an attacker were doing this tactic here, the log would show that". Every detection starts as one, and a detection that cannot be stated as one is a query looking for a purpose.
  • Login history - the warehouse's record of every authentication: who, when, from which address and client, whether it succeeded. Distinct from query history, which starts after the login.
  • Grant history - the record of every role granted or revoked, to whom, by whom, when. Most platforms expose it as a system view or an account-usage table; some only as an audit log export.
  • Change window - the agreed hours in which privilege changes are expected, with a ticket. A grant outside one is not wrong; it is a row.
  • Precision - of the rows a detection writes, the share that had a real cause. The discipline is to raise it by tuning against the detection's own history, never by making the query blind.
  • Review table - the one table all detections write to. Its reviewer and outcome columns are the control; everything upstream is plumbing.
Part 1 · covers the five-step method and the five detections that finish Ironwood's graph

A detection is a hypothesis about a tactic, written as a query

9 min live

The mistake most detection programmes make is to start from the log and ask what could be queried. Start from the attacker instead. Pick a tactic, say what it would look like in your own logs, then write the query. The tactic list is finite and public; the logs are yours; the hypotheses in between are the engineering.

tactic, as ATT&CK v19.2 names it Credential Access in the log login_history: service account from a new client or IP detection new_client_login the leaked ETL key used from elsewhere Privilege Escalation in the log grant history: role to a human outside a window detection out_of_window_grant the Friday admin grant, same day Collection in the log query_history: SELECT on customers above role median detection unusual_read from session 5, reused as written Exfiltration in the log query_history: COPY to a location not on the allowlist detection unlisted_copy flag, not block; allowlist has owners Impact in the log query_history: DROP or purge on customers, backups detection drop_or_purge the ransom note, an hour earlier export bucket path: a request to the object store, outside all five lanes; its own log, its own inventory row Five tactics, five queries, one review table. The bucket is watched by the object store's log or by nothing.
🔍 Click to zoom - five tactics as five lanes, what each looks like in the log, and the path outside all of them.
LiveThe method, in five steps3 min▶
  • Pick a tactic. Use the ATT&CK v19.2 names, because they are the vocabulary the rest of the industry uses when it describes an incident, and because the list is finite. Fifteen tactics; a warehouse estate needs detections against roughly a third of them.
  • State the hypothesis in your own logs' terms. "If someone were doing Credential Access with Ironwood's ETL key, login history would show that service account authenticating from a client or address it has never used." If you cannot finish that sentence for a tactic, you either lack the log or the tactic does not apply, and both are findings worth writing down.
  • Write the query. The hypothesis becomes a WHERE clause and usually a join to a baseline or an allowlist. Keep it read-only over the log until the final step.
  • Decide the threshold, then tune it against history. Run the query over the last 90 days before scheduling it. Count the rows. Each one either had a real cause or is a reason to sharpen the query.
  • Name the reviewer. A person, not a channel. A detection with no named reader is a detection that will be read by the incident responder, afterwards.
LiveThe five detections that finish Ironwood's graph3 min▶
Tactic (v19.2)Hypothesis in Ironwood's logsLogDetection
Credential AccessA service account authenticates from a client application or address it has not used in 30 days. The leaked ETL key, used from anywhere but the runner.login_historynew_client_login
Privilege EscalationA role is granted to a human account outside an agreed change window, with no ticket. The Friday admin grant, the day it happens.grant historyout_of_window_grant
CollectionA role reads customers when it has not in 30 days, above ten times its median rows, or at an hour it never works. Session 5, unchanged.query_historyunusual_read
ExfiltrationA COPY or unload of customers names a destination that is not on the owned allowlist. Session 5, unchanged.query_historyunlisted_copy
ImpactA DROP, TRUNCATE or purge statement names customers or anything in the backup schema. The hour before the ransom note.query_historydrop_or_purge

Two of the five already exist from session 5. The point of writing the table anyway is the first column: each detection now says which tactic it is a hypothesis about, which is the sentence an incident report needs and the sentence a new engineer needs to know whether the set is complete.

Self-studyWhy these five and not the other ten4 min▶

ATT&CK v19.2 lists fifteen enterprise tactics. Five have detections tonight because five correspond to edges on Ironwood's graph that lead to or through the crown jewels. The rest are not ignored; they are either closed by prevention, outside the warehouse, or someone else's log.

  • Initial Access at Ironwood is phishing and a public repo. MFA and key rotation closed those edges in session 2; the residual signal is the new-client login, which is why Credential Access is watched rather than Initial Access.
  • Execution and Persistence live on the CI runner and the laptops, whose logs belong to the platform and endpoint teams. The warehouse sees their consequence, which is a login or a query, and that is where tonight's detections sit.
  • Stealth and Defense Impairment, the two tactics v19 split out of the old Defense Evasion, would appear in a warehouse as somebody disabling the logging or the scheduled job itself. The right control is that the review table notices when it stops receiving rows; the homework asks for that check.
  • Discovery and Lateral Movement show up in query history as SHOW and DESCRIBE statements from a role that never issues them, and as a login hopping between roles. Both are reasonable sixth and seventh detections once the first five are quiet.
  • Reconnaissance, Resource Development and Command and Control happen outside anything the warehouse records. Naming them here is what stops a future engineer from believing the five are the whole picture.
Part 2 · covers the noise rule, the single review table, what a log cannot attribute, and detections as evidence

Precision, review, and the detection nobody reads

8 min live

Five queries that run are not five detections. A detection exists when it fires seldom enough to be believed, writes to a place one person reads every day, and is honest about what its evidence can and cannot prove. Most detection programmes fail on the first point, quietly, within a quarter.

Log login, grant, query history you own Hypothesis one tactic, one sentence in log terms Query WHERE plus a join to baseline or list Threshold tuned on 90 days of its own history Review table all five write here tactic, actor, evidence Named reviewer one person per row reads it daily less than once a week without a real cause Tune until a week's rows fit on one screen. A detection that pages every day is switched off by month two.
🔍 Click to zoom - log, hypothesis, query, threshold, table, reviewer, and the noise rule under the threshold.
LiveThe precision discipline: less than once a week without a real cause3 min▶

Data observability learned the false-page rule the expensive way: a monitor that pages on every late load is muted within a month, and it is muted the month it matters. Security detection inherits the rule with a sharper edge, because the person who stops opening the review table is the person the whole design assumes is there.

  • Tune against the detection's own history before it goes live. Run each query over 90 days. Every row it returns is either a real cause you can name or a defect in the query. Fix the defects: a service account whose "new client" is the runner's second address, a change window nobody wrote down, a backup schema whose nightly rotation issues a legitimate DROP.
  • The target is less than one row a week without a real cause. Not zero. A detection that never fires is either perfect or blind, and the tuning history is how you tell them apart.
  • Tune the join, never the eyes. Add the runner's second address to the service account's expected clients. Add the rotation job's role to an exception list with an owner and a review date. Do not raise a threshold globally to silence one role; that blinds the detection for every other role.
  • Record every tuning decision with a reason and an owner. An exception list without owners is the next public bucket.
LiveOne review table, a named reviewer per row, read daily2 min▶

Session 5's table took two detections. Tonight's takes five, and the reason for keeping one table rather than five is the reader. One table, one morning read, one place where "reviewed by" and "outcome" get filled in. Five tables become five habits, and habits are what a detection programme actually runs on.

Each row carries the tactic it is evidence for, the object it concerns, the actor as the log recorded them, the evidence itself, and the name of the person who owns the answer. The reviewer is chosen per detection, not per row: the platform lead owns new-client logins and drops, the access owner owns out-of-window grants, the data owner of customers owns unusual reads and unlisted copies. Their names are in the job, so a row is born with a reader.

The response is a question to the actor's owner, never an accusation. "Your service account authenticated from a new address at 04:10; was that a planned change?" resolves most rows in one reply. The one that does not resolve is the incident, and the row is its first exhibit.

Self-studyThe honest limits: attribution to a role set, and the bucket outside all five4 min▶

Two limits are worth writing next to the detections, because the review table will otherwise be read as saying more than it can.

  • Query history attributes a read to a user and the roles active in the session, not to the grant that permitted it. When the analyst read customers with the admin role active, the row says user and role. It does not say whether the read was permitted by the Friday grant, by an older grant to a role the admin role inherits from, or by a direct grant on the table. Answering that needs a join to grant history as of the query's timestamp, and on many platforms the reconstruction is approximate. The review table records what the log recorded; the reviewer's question is what recovers the grant.
  • The export bucket path is outside all five. A read of the nightly extract is a request to the object store. It is not a login to the warehouse, not a grant, not a SELECT, not a COPY, not a DROP. Session 3 closed that door with a private bucket; the only log that would ever see it being opened again is the object store's own access log, and it needs its own detection with its own reviewer, or an honest blank in the inventory's "seen by" column.

Write both limits into the runbook next to the table. The incident that tests them will not be the moment to discover them.

Self-studyDetection as evidence: the timeline only these tables hold3 min▶

When an incident is confirmed, the first document anyone asks for is the timeline: when did the credential first authenticate from the new address, when was the role granted, when was the first unusual read, when did the copy leave, when was the backup schema touched. Every one of those timestamps lives in a row one of tonight's detections wrote, or in the underlying history the detection reads.

  • Retain the review table for longer than the log. Platform query history is often retained for weeks or a year; the review table is yours and can be kept for as long as the postmortem process needs it.
  • Keep the query text and query identifier on the row. The evidence column that says "above 10x typical" is a summary; the identifier is what lets the responder pull the exact statement later.
  • The outcome column is part of the record. "Expected, confirmed by owner on the day" is what turns a row from noise into a documented decision, and a stack of those is what lets a postmortem say the detections were working before the incident, not just after.

What a confirmed exfiltration obliges the company to tell regulators and customers is the governance course; what the first hour looks like and what the report funds is leader session 6. Tonight's contribution to both is that the timeline exists.

Build-along

Five detections, one review table

★ 20 min · everyone builds

Everything runs against your own warehouse's history views, read-only until the last two steps, which create tables and a scheduled job in a schema you own. The views are written here as login_history, role_grant_history and query_history with generic column names; every platform ships these under its own names, with its own columns for client address, application and statement type, so substitute yours. The session 5 objects sec.egress_allowlist and the unusual-read query are reused as they were written.

Write the new-client login detection for service accounts (Credential Access). Start by listing your service accounts in a table with an owner, because "service account" is a business fact the login history does not know. Then flag any successful login by one of them from a client application and address pair it has not used in the last 30 days. The left join against the baseline does the work; a pair with no baseline row is new.

★ Try it now create table if not exists sec.service_accounts ( user_name varchar, owner varchar, -- the human who answers the question purpose varchar ); with known as ( select user_name, client_ip, client_application from login_history where event_time between current_date - 30 and current_date - 1 and is_success = true group by user_name, client_ip, client_application ) select l.event_time, l.user_name, l.client_ip, l.client_application, s.owner from login_history l join sec.service_accounts s on s.user_name = l.user_name left join known k on k.user_name = l.user_name and k.client_ip = l.client_ip and k.client_application = l.client_application where l.event_time >= current_date - 1 and l.is_success = true and k.user_name is null;

Write the out-of-window role grant detection (Privilege Escalation). Change windows are a table too, with a ticket per window. A grant to a human account, meaning any account not in the service account list, that falls in no window is a row. The Friday afternoon grant at Ironwood matches on the day it is made rather than six weeks later.

★ Try it now create table if not exists sec.change_windows ( starts_at timestamp, ends_at timestamp, ticket varchar, approved_by varchar ); select g.granted_at, g.grantee_name, g.role_name, g.granted_by from role_grant_history g left join sec.service_accounts s on s.user_name = g.grantee_name where g.granted_at >= current_date - 1 and g.action = 'GRANT' and s.user_name is null -- a human, not a service account and not exists ( select 1 from sec.change_windows w where g.granted_at between w.starts_at and w.ends_at );

Reuse the session 5 unusual-read detection (Collection). Nothing changes in the query: a role new to customers in 30 days, above ten times its median rows per query, or at an hour it has never read the table in. What changes is the row it writes, which now carries the tactic name and a reviewer. Keep the query in one place and reference it from the job rather than pasting a second copy that will drift.

Write the unlisted-external-location COPY detection against the allowlist (Exfiltration). Also from session 5, and also reused; the allowlist table is the one that already has owners and review dates on every row. The two additions worth making tonight are to match on the destination the platform recorded where it records one separately from the statement text, and to include shares alongside COPY and unload, because a share is a copy that never shows up as bytes leaving.

★ Try it now select q.query_id, q.user_name, q.role_name, q.start_time, left(q.query_text, 200) as statement from query_history q where q.start_time >= current_date - 1 and (upper(q.query_text) like 'COPY INTO %' or upper(q.query_text) like '%UNLOAD%' or upper(q.query_text) like 'CREATE SHARE%' or upper(q.query_text) like 'GRANT % TO SHARE%') and lower(q.query_text) like '%customers%' and not exists ( select 1 from sec.egress_allowlist a where lower(q.query_text) like '%' || lower(a.location_prefix) || '%' );

Write the DROP-or-purge detection against customers and its backups (Impact). Statement type is more reliable than text where your platform records it. Name the crown-jewel table and the whole backup schema, because an attacker who drops the schema does not name the tables. Expect the session 6 rotation job to appear on the first run; it goes into an exception list with an owner, not into a wider blind spot.

★ Try it now create table if not exists sec.destructive_exceptions ( role_name varchar, -- the backup rotation role, and nothing else owner varchar, review_by date ); select q.query_id, q.user_name, q.role_name, q.start_time, left(q.query_text, 200) as statement from query_history q left join sec.destructive_exceptions x on x.role_name = q.role_name where q.start_time >= current_date - 1 and (q.query_type in ('DROP', 'TRUNCATE_TABLE', 'ALTER_TABLE_DROP_CLUSTERING_KEY') or upper(q.query_text) like 'DROP %' or upper(q.query_text) like '%PURGE%' or upper(q.query_text) like 'TRUNCATE %') and (lower(q.query_text) like '%customers%' or lower(q.query_text) like '%backup%') and x.role_name is null;

The statement-type names in the IN list are placeholders for whatever your platform records; keep the text matches as the fallback so a rename in the platform's own vocabulary does not silently blind the detection.

Union all five into one scheduled job writing to one review table. The table has a tactic, an object, an actor, the evidence and a reviewer on every row, plus the two columns a human fills in. The reviewer is set per detection in the job. The scheduler object is a task, a job or a DAG depending on your platform; hourly is fine, the morning read is what matters.

★ Try it now create table if not exists sec.detection_review ( flagged_at timestamp, tactic varchar, -- ATT&CK v19.2 tactic name detection varchar, object varchar, -- customers, backups, a role, a service account actor varchar, -- user and active role as the log recorded them evidence varchar, -- query id or grant id, plus a one-line summary reviewer varchar, -- a named person, set per detection reviewed_at timestamp, -- filled in by a human outcome varchar -- 'expected', 'question sent', 'incident' ); -- the body of the scheduled job: one insert, five branches insert into sec.detection_review (flagged_at, tactic, detection, object, actor, evidence, reviewer) select current_timestamp, 'Credential Access', 'new_client_login', user_name, user_name, client_application || ' from ' || client_ip, owner from ( /* step 1 query */ ) a union all select current_timestamp, 'Privilege Escalation', 'out_of_window_grant', role_name, grantee_name, 'granted by ' || granted_by || ' at ' || granted_at, 'access owner' from ( /* step 2 query */ ) b union all select current_timestamp, 'Collection', 'unusual_read', 'customers', user_name || ' as ' || role_name, query_id || ': ' || reason, 'customers data owner' from ( /* session 5 unusual-read query */ ) c union all select current_timestamp, 'Exfiltration', 'unlisted_copy', 'customers', user_name || ' as ' || role_name, query_id || ': ' || statement, 'customers data owner' from ( /* step 4 query */ ) d union all select current_timestamp, 'Impact', 'drop_or_purge', 'customers or backups', user_name || ' as ' || role_name, query_id || ': ' || statement, 'platform lead' from ( /* step 5 query */ ) e;

Update the inventory file so every remaining edge has a log. Name the log and the detection on each row. The bench counts a path as seen when it crosses a logged edge; the file is where you prove which query does the seeing, and the bucket row is where you record the honest blank if the object store's log is not wired yet.

★ The inventory file, "seen by" filled in # from -> to | the move | closed by | seen by repo -> svc_key | key read out of repo history | key rotation | login_history: new_client_login (Credential Access) ci_runner -> svc_key | runner environment holds the key | pinned deps | login_history: new_client_login (Credential Access) user_analyst -> admin | admin role granted on a Friday, never revoked | least privilege | role_grant_history: out_of_window_grant (Privilege Escalation) warehouse -> crown | SELECT within a role that must read it | (cannot) | query_history: unusual_read (Collection) crown -> egress | COPY INTO an external stage or unload | allowlist | query_history: unlisted_copy (Exfiltration) admin -> backups | drop and purge, time travel included | immutable backups | query_history: drop_or_purge (Impact) bucket_export -> crown | nightly extract of the table | private bucket | object store access log: not yet wired

Run all five over 90 days before you schedule anything. The first pass tells you which service account has a second legitimate address, which change window nobody wrote down, and which rotation job issues a nightly DROP. Fix each with a row in the right exception table, owned and dated, and only then hand the review table to its readers. A detection handed over while it is loud will be muted, and muted is worse than never written, because the inventory file now says the edge is seen.

Homework

Before session 8

30 min
  1. Run steps 1, 2 and 5 over the last 90 days of your own history and count the rows per detection. Anything above roughly a dozen per detection is tuning work, not a finding. Record each tuning decision with an owner and a reason.
  2. Fill the service account table and the change window table with what actually exists. Ask each service account's owner to confirm the client and address pairs the baseline found. At least one pair will be news to them.
  3. Write a sixth detection with no query: a check that the review table received at least one row from the job in the last 24 hours, and a row when it did not. A job that stops silently is Stealth or Defense Impairment in v19.2 terms, and it is the detection most often missing.
  4. Take the one remaining path on your own graph, the one no control can close, and write down the detection, the reviewer and the retention of the review table that make leaving it open defensible. Bring that sentence to session 8; it is what the bench scores.
Check yourself

Three questions

The review table shows the analyst read customers with the admin role active. What does query history establish about which grant permitted the read?

The log attributes a read to a user and their active roles, not to a specific grant. The admin role may hold the permission through an inherited role or a direct table grant. Reconstructing which one needs grant history at the query's timestamp, and the reviewer's question to the owner is what usually settles it.

Every prevention control on Ironwood's bench is on and every detection is off. What does the bench read, and why does it matter?

All prevention with no detection leaves one path and one undetected. The finished design leaves the same one path with zero undetected. The path is identical; the difference is whether anyone would know it was used, and that difference is the five detections.

The drop-or-purge detection fires every night at 02:00 on the session 6 backup rotation job. What is the right fix?

Tune the join, never the eyes. Narrowing the detection to exclude the backup schema or the hour blinds it to exactly the attack it exists for; an exception on the one role, with an owner and a review date, keeps it precise and keeps the decision recorded.

Sources covered

What tonight maps to

✓ MITRE ATT&CK v19.2 tactics as detection hypotheses Credential Access, Privilege Escalation, Collection, Exfiltration and Impact, each stated as what it looks like in a warehouse's own logs and turned into a query. The full fifteen and the v19 rename are leader session 3.
✓ Detection engineering method Tactic, hypothesis, query, threshold tuned on the detection's own history, named reviewer. Five detections over login, grant and query history, one review table, one scheduled job.
✓ Undetected 3 to 0, paths unchanged at 3 Query logging and egress alerting on the bench; the all-prevention-no-detection over-correction at one path and one undetected; why the finished design's one remaining path is defensible.
✓ NIST CSF 2.0 - Detect function, engineered Possible compromises found and analysed, on one estate, with a reviewer per row. The six functions as a whole are leader session 2.
◐ Grants, role hierarchy and what a role set can prove Named as the attribution limit: a read is attributed to a user and active roles, not to a grant. The grants themselves are the access-control course.
◐ The incident timeline and the postmortem The review table as the evidence store and its retention. What the first hour and the report look like is leader session 6.
· Data-quality monitoring Not covered. The false-page rule is borrowed from the data observability course; the detections here find attacker behaviour, not data defects.
· Endpoint, CI and network logs Not covered. Execution and Persistence on the runner and the laptops are somebody else's log; the warehouse sees their consequence as a login or a query.

Session 7 cheat sheet

Start from the tacticPick a v19.2 tactic, state what it looks like in your logs, write the query, tune the threshold, name the reviewer.
Five detectionsnew_client_login, out_of_window_grant, unusual_read, unlisted_copy, drop_or_purge. Three logs: login, grant, query history.
Paths 3, undetected 3 to 0Detection changes nothing about ways in and everything about whether you would know. Prevention alone leaves one path, one undetected.
Less than once a weekWithout a real cause. Tune against 90 days of the detection's own history before scheduling it.
Tune the join, never the eyesException tables with owners and review dates. A global threshold raised for one role blinds every role.
One table, one readerTactic, object, actor, evidence, reviewer on every row. The reviewer is set per detection; the row is born with a reader.
A role set, not a grantQuery history attributes a read to a user and active roles. Which grant permitted it needs grant history at that timestamp.
The bucket is outside all fiveA read of the extract is a request to the object store. Its own log, its own reviewer, or an honest blank in the inventory.