---
description: '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…'
---

<!-- GENERATED by grafana/build_rules.py from its HUNTS list — do not edit by
     hand. Edit the hunt there, then run `make rules`. Every query below is built
     by the same typed-filter path as the shipped alert rules, so a misspelled
     attribute fails CI instead of silently matching nothing. -->

# 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](../runbooks.md): 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(...) > 0` over
  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](../signals.md).
- 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?

```logql
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`](../runbooks.md#g2o-detect-exchange-inbox-rule-change), [`g2o-detect-mailbox-permission-grant`](../runbooks.md#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?

```logql
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`](../runbooks.md#g2o-detect-legacy-auth-signin), [`g2o-detect-interactive-signin-anomaly`](../runbooks.md#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?

```logql
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`](../runbooks.md#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?

```logql
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`](../runbooks.md#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?

```logql
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`](../runbooks.md#g2o-detect-identity-risk-detection)


## Which workload identities have risk detections

**Question:** Which service principals has Identity Protection flagged, and for what?

```logql
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`](../runbooks.md#g2o-detect-workload-identity-risk)


## Which post-delivery mail remediations succeed

**Question:** When Defender removes a message after delivery, does the removal succeed?

```logql
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`](../runbooks.md#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?

```logql
{service_name="graph2otel"} | event_name=`entra.federated_identity_credential`
```

**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`](../runbooks.md#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?

```logql
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`](../runbooks.md#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?

```logql
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`](../runbooks.md#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?

```logql
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`](../runbooks.md#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?

```logql
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`](../runbooks.md#g2o-detect-security-alert-unresolved), [`g2o-detect-security-incident-active`](../runbooks.md#g2o-detect-security-incident-active)


## Which applications take Graph authorization denials

**Question:** Which callers receive HTTP 403 from Microsoft Graph, and how many?

```logql
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`](../runbooks.md#g2o-detect-graph-403-burst)
