Hunting queries¶
A hunt is a query you run, not a rule that runs itself. This page is the measurement instrument for the paused detections: each one names the measurement it needs before it is safe to enable, and the query that produces that measurement is here.
Three kinds of question live here rather than in a rule, and the boundary is deliberate:
- The correlation is one Loki cannot perform. Loki has no join, so "two sign-ins too far apart to be the same person" is not expressible as a rule over raw records. It is a fine question for a person with a grouped query, and where Microsoft has already computed the correlation the detection reads its verdict back instead of pretending to recompute it.
- The signal is an inventory, not an event stream. A snapshot collector re-emits every existing row on every poll, so
count_over_time(...) > 0over one is true forever and tells you nothing. Grouped and read by a person, the same query is the most useful thing here. - It is a threshold you do not have yet. Every number in the detection pack came from one tenant or from nowhere at all. These queries are how you replace it with your own.
How to run them¶
Paste a query into Explore, pick your Loki datasource, and set the time range to at least the window in the query. Then read the result, do not alert on it.
Two things about the query shape, both of which have bitten this project:
- The stream selector is always
{service_name="graph2otel"}and nothing else. Every attribute is Loki structured metadata, so{event_name="entra.signin"}matches zero rows silently — it is not an error, it is an empty graph that looks like a clean tenant. See Signals. - A negative filter also matches a record that lacks the attribute entirely, because a missing structured-metadata key compares equal to the empty string. Pair it with a presence term (
attr=~`.+`) when that matters.
Windows longer than a few days over a busy stream are slow. Narrow the window first, then widen it once you know the query returns what you expect.
Which audit operations does your tenant actually record¶
Question: Which unified-audit operations occur on this tenant, on which workload, and how often?
sum by (workload, operation) (count_over_time({service_name="graph2otel"} | event_name=`m365.audit` [24h]))
What to look for: The operation spelling. Both mail detections match a regex against operation, and if your tenant names the cmdlet differently the rule matches nothing and looks healthy. Sort descending and read the top of the list: whatever dominates is your routine administrative traffic, and anything you do not recognise is worth one look.
Measurement for: g2o-detect-exchange-inbox-rule-change, g2o-detect-mailbox-permission-grant
Which client apps sign in, and how much legacy protocol is left¶
Question: Which authentication client does each sign-in use, and does it succeed?
sum by (client_app_used, status_error_code) (count_over_time({service_name="graph2otel"} | event_name=`entra.signin` [7d]))
What to look for: Any client that is not a browser or a modern-auth client. Each one is a channel that cannot be challenged for MFA. Successful legacy sign-ins (status_error_code 0) are the urgent set; a wall of failures on a legacy protocol is usually spray traffic and is a trend, not an incident.
Measurement for: g2o-detect-legacy-auth-signin, g2o-detect-interactive-signin-anomaly
Which Conditional Access failures does your tenant produce, by error code¶
Question: Which error codes does Conditional Access actually refuse with here, and how often?
sum by (status_error_code, app_display_name) (count_over_time({service_name="graph2otel"} | event_name=`entra.signin` | sign_in_event_types=`interactiveUser` | conditional_access_status=`failure` [14d]))
What to look for: The window is 14d, not the 30d the rule's tuning note asks for, because a [30d] count_over_time exceeds the max query range on at least one Grafana Cloud Loki stack and comes back EMPTY rather than erroring — measured 2026-08-10, where 21d returned data and 30d returned nothing on the same stream. An empty result there is indistinguishable from a clean tenant, which is the exact trap this page exists to avoid; widen it only after checking your own backend answers at that range. The share of 50097 'Device authentication is required'. g2o-detect-interactive-signin-anomaly excludes it by default because a REPORT-ONLY policy still stamps conditional_access_status=failure on a sign-in it never blocked, and 50097 is the code it uses; on the tenant this was measured on it was 6 of 10 CA failures in 30 days and every one was followed by a success. Confirm that on your own tenant before trusting the exclusion: take a handful of 50097 records and read appliedConditionalAccessPolicies on each in Entra (graph2otel does not export it). If the only non-success entry is a reportOnlyFailure, the exclusion is right for you. If you ENFORCE a compliant-device or hybrid-join grant, it is not — put 50097 back, because there it is a real block. Whatever remains after that decision is the number your threshold has to clear.
Measurement for: g2o-detect-interactive-signin-anomaly
Where do your workload identities sign in from¶
Question: Which source addresses does each service-principal sign-in come from?
sum by (service_principal_name, ip_address) (count_over_time({service_name="graph2otel"} | event_name=`entra.signin` | service_principal_name=~`.+` [7d]))
What to look for: An automation identity with exactly one source address. That is the almost-zero-false-positive detection described in alerts/README.md, and this hunt is how you find which of your identities qualify and what their expected address is. Those values are yours and cannot ship here, which is why the rule does not.
Measurement for: g2o-detect-workload-identity-risk
Which risk detection types does Identity Protection raise here¶
Question: Which risk detections does this tenant produce, at which level?
sum by (risk_event_type, risk_level) (count_over_time({service_name="graph2otel"} | event_name=`entra.risk_detection` [30d]))
What to look for: Whether the tenant produces anything at all — an empty result on a tenant without Entra ID P2 is a licence wall, not a clean bill of health. Then the rate per type: leakedCredentials alone can exceed a sane page rate on a large tenant, and impossibleTravel is the correlation Loki could not compute for you.
Measurement for: g2o-detect-identity-risk-detection
Which workload identities have risk detections¶
Question: Which service principals has Identity Protection flagged, and for what?
sum by (risk_event_type, risk_state) (count_over_time({service_name="graph2otel"} | event_name=`entra.service_principal_risk_detection` [30d]))
What to look for: Usually nothing, which is the problem: a rule that has never matched is indistinguishable from a rule that cannot match. If this returns nothing over 90 days, prove the stream works before trusting the silence.
Measurement for: g2o-detect-workload-identity-risk
Which post-delivery mail remediations succeed¶
Question: When Defender removes a message after delivery, does the removal succeed?
sum by (action_type, action_result) (count_over_time({service_name="graph2otel"} | event_name=`defender.email_post_delivery` [30d]))
What to look for: The exact action_result values your tenant emits, and whether any record omits the field entirely — a missing structured-metadata key compares equal to the empty string, so it would match a bare negative filter. Anything that is not a success is a message still sitting in a mailbox Microsoft has already judged dangerous.
Measurement for: g2o-detect-mail-remediation-failed
Which applications hold federated identity credentials¶
Question: Which applications trust an external issuer to mint tokens for them, and which subject in that issuer?
What to look for: An issuer or subject you do not recognise. A federated identity credential is a keyless trust: whoever controls that subject at that issuer can obtain tokens for the application with no secret to leak or rotate, which makes adding one an attractive and quiet persistence step. This is an inventory rather than an event stream, so it is a hunt: the collector re-emits every existing credential every poll, and a rule counting them would fire forever. Read issuer, subject and audiences on each record.
Measurement for: g2o-detect-privileged-directory-change
Which consent grants exist, and how privileged are they¶
Question: What has been consented to in this tenant, at what privilege, and for the whole tenant or one user?
sum by (privilege, consent_type) (count_over_time({service_name="graph2otel"} | event_name=`entra.consent_grant` [24h]))
What to look for: High-privilege application-wide grants. consent_type separates a tenant-wide admin grant from one user's own, and the tenant-wide ones are the ones worth auditing line by line. Also an inventory rather than an event stream — the ACT of granting consent is already covered by the privileged-directory-change detection, so this hunt answers the different question of what is standing granted right now.
Measurement for: g2o-detect-privileged-directory-change
Who holds privileged roles, permanently or eligibly¶
Question: Which principals hold which directory roles, and is the assignment permanent or time-bound?
sum by (role_name, assignment_type, permanent) (count_over_time({service_name="graph2otel"} | event_name=`entra.role_member` [24h]))
What to look for: Permanent assignments to the high-privilege roles, and any assignment held by a service principal rather than a person. Role ACTIVATION is an event and is already covered by the privileged-directory-change detection; standing membership is a posture question, which is why it is a hunt — a rule over an inventory would fire on your correct configuration forever.
Measurement for: g2o-detect-privileged-directory-change
What operations do Intune administrators perform¶
Question: Which Intune administrative operations happen, on which object category, and do they succeed?
sum by (activity_operation_type, activity_result, category) (count_over_time({service_name="graph2otel"} | event_name=`intune.audit_event` [30d]))
What to look for: Deletions of compliance or configuration policies, and any operation type you did not expect. This project has only ever observed Create on the wire for activity_operation_type, which is exactly why a destructive-action rule is NOT shipped: a rule matching a value spelling nobody has seen is a rule that may never fire. Take this measurement, and if your tenant emits the deletion spelling, write the rule against what you measured.
Measurement for: g2o-detect-security-incident-active
Which security alerts arrive, from which Microsoft product¶
Question: What is the arrival rate of security alerts by severity, resolution status and originating product?
sum by (severity, status, service_source) (count_over_time({service_name="graph2otel"} | event_name=`entra.security_alert` [30d]))
What to look for: The rate. Two shipped detections page on this stream and its incident sibling, and their volume is set by your Defender licensing rather than by the query. If medium severity dominates, narrow to high before enabling either.
Measurement for: g2o-detect-security-alert-unresolved, g2o-detect-security-incident-active
Which applications take Graph authorization denials¶
Question: Which callers receive HTTP 403 from Microsoft Graph, and how many?
sum by (app_id) (count_over_time({service_name="graph2otel"} | event_name=`entra.graph_activity` | response_status_code=`403` [7d]))
What to look for: Your own baseline. The shipped burst detection uses ten in five minutes, which came from one small tenant; a tenant with more automation will have a caller that routinely exceeds it because it probes for an optional permission. Find that caller first, then set the threshold above it.
Measurement for: g2o-detect-graph-403-burst