breakfixlearn.com
%BLOG-5-SEC

Threat Intelligence in Sentinel, Part 1: Inventory What You Already Have

A feed switched on is not a feed working. Part 1 counts what Sentinel actually holds, collapses duplicate rows, then mines my own failed logons for 220 candidate bad IPs and asks the question that matters: did any of them get in?

show ip route | toc (7 sections)
  1. Preflight: four checks before any intelligence work
  2. Query 1.1: what kinds of indicators do you own?
  3. Queries 1.2 and 1.3: do the built-in IPs and hashes exist?
  4. Query 1.4: hygiene before you match anything
  5. Finding suspicious IPs in my own logs
  6. What goes into Part 2
  7. Frequently asked questions

Turning on a threat intelligence feed in Microsoft Sentinel takes a couple of clicks. Finding out whether it does anything useful takes a lot more. This project is my attempt to close that gap: start from the intelligence Microsoft already provides, add indicators built from my own logs, match both against real telemetry, turn the matches into analytics rules, and then check whether any of it earned its place.

The labs behind it come from the Cyber Range community (that link carries my referral code, so it costs you nothing and may credit me). I used them as a baseline and then extended them, which includes fixing a few inconsistencies in the originals. I point those out as they come up.

This is a three-part series. This post inventories what intelligence you already have and finds suspicious IPs in your own logs. Part 2 turns those IPs into STIX indicators with Python and matches them against logons, network flows and 90 days of history. Part 3 proves the file hash rule with the EICAR test file, builds three analytics rules and works through the incident. The full procedure lives in the RUNBOOK on GitHub and every query is in the KQL library next to it.

One thing to know before the screenshots: the Cyber Range workspace is shared. Many students' virtual machines report into it, so some rows in the results come from machines that are not mine. My own VM is called threatintel-lab. Where the difference matters, I say so.

Preflight: four checks before any intelligence work

Before querying indicators, confirm the data you will match them against actually exists. Four quick checks:

  • ThreatIntelIndicators | take 5 should return rows. If Sentinel says it failed to resolve the table, the connector is not on yet. Install the Threat Intelligence solution from the Content hub, connect Microsoft Defender Threat Intelligence, and wait fifteen to thirty minutes.
  • DeviceLogonEvents | take 5 should return rows. If it is empty, check that the Defender XDR connector is on with device events enabled.
  • NTANetAnalytics | take 5 should return rows if Traffic Analytics is enabled. It is only needed for one query in Part 2, so an empty table is not a blocker.
  • Create a watchlist called TI-Allowlist with one column, IPAddress, holding your own egress IP and any scanner or partner addresses you never want to alert on. The rules in Part 3 fail if this watchlist does not exist.

A note on schema, because it will trip you up if you copy older queries from blogs. Microsoft moved indicators into the ThreatIntelIndicators and ThreatIntelObjects tables, and its threat intelligence documentation says ingestion into the legacy ThreatIntelligenceIndicator table stopped after July 31, 2025. A query that still points at the old table will quietly return stale data. If a column in my queries is missing in your tenant, run ThreatIntelIndicators | take 1 and compare.

Microsoft Defender Watchlists page showing the TI-Allowlist watchlist with one row, source Trusted IP.csv and search key IPAddress
The TI-Allowlist watchlist after creation: one row, search key IPAddress, status Succeeded. Every rule in this project reads from it, so it has to exist before the rules do.

Query 1.1: what kinds of indicators do you own?

You cannot match what you have not inventoried, so the first real query counts indicators by type and source.

ThreatIntelIndicators
| where TimeGenerated > ago(30d)
| summarize Indicators = dcount(ObservableValue), Active = countif(IsActive == true) by ObservableKey, SourceSystem
| order by Indicators desc

ObservableKey tells you what kind of thing the indicator describes, such as an IP in a network traffic object, a domain name or a file hash. SourceSystem tells you who supplied it.

Microsoft Sentinel Logs results of a KQL query counting threat intelligence indicators by ObservableKey and SourceSystem, 51 rows
Query 1.1 returned 51 rows. The largest groups are file hashes and network traffic indicators from Microsoft Defender Threat Intelligence and a Premium source.

The query returned 51 rows. The biggest groups were not IPs at all:

Observable keySourceDistinct valuesActive rows
file:hashes.'SHA-256'Premium33,453398,336
file:hashes.'SHA-256'Microsoft Defender Threat Intelligence32,710378,834
network-traffic (IP)Premium22,007293,000
domain-namePremium16,152130,367
domain-nameMicrosoft Defender Threat Intelligence5,99847,322

Look at the last two columns together. The Active count is far bigger than the number of distinct values, and that is not a bug in the query. Active counts rows, and Sentinel stores a new row every time an indicator is updated. At the top of the list, one source showed 133,525 distinct values against 564,978 active rows. If I had counted rows and called them indicators, I would have overstated my coverage by a factor of four. That is the reason every later query collapses to the newest row per Id.

Queries 1.2 and 1.3: do the built-in IPs and hashes exist?

Next, confirm the two indicator types the rest of the series depends on. IP addresses first:

ThreatIntelIndicators
| where TimeGenerated > ago(7d)
| where IsActive == true
| where ObservableKey == "network-traffic:src_ref.value"
| project TimeGenerated, SourceSystem, ObservableValue, ObservableKey, ValidUntil, Confidence
| order by TimeGenerated desc
Sentinel Logs showing active built-in IPv4 threat intelligence indicators with confidence and ValidUntil columns, capped at 30,000 rows
Query 1.2 hit the 30,000 row display limit, so there are at least that many active IP indicators from the last seven days. The visible rows mostly carry confidence 50.

The results panel stopped at 30,000 rows, which tells you something by itself: the built-in feed is large. The confidence scores in the visible rows were mostly 50, with a 75 among them, and one indicator, 8.222.216.144, was valid until June 2034. A feed IP that stays active for eight years is exactly how a residential address ends up on a blocklist long after the botnet node moved on, and it is one reason I treat expiry as a first-class field later.

Then file hashes, summarised by key and source:

ThreatIntelIndicators
| where TimeGenerated > ago(14d)
| where IsActive == true
| where ObservableKey has "hashes"
| summarize Count = dcount(ObservableValue) by ObservableKey, SourceSystem
Sentinel Logs result listing built-in file hash indicators by SHA-256, SHA-1 and MD5 key and source
Query 1.3 returned six rows. SHA-256 dominates, but SHA-1 and MD5 indicators exist too, and that matters when we match antivirus events in Part 3.

Six rows came back: SHA-256 from two sources, SHA-1 from two, and MD5 from two. Write down the exact key string for SHA-256. In my tenant it is file:hashes.'SHA-256', quotes included, and later queries filter on it. I also noted that SHA-1 indicators exist, because that turns out to decide whether the hash test in Part 3 works at all.

The original lab heading talks about file hashes but its queries only use IPs. Query 1.3 and the hash matching in Part 3 are my additions to close that gap.

Query 1.4: hygiene before you match anything

The last inventory query is the one I would keep running monthly. It collapses every indicator to its newest row, drops anything expired, and shows how much is about to expire:

let LatestIndicators = ThreatIntelIndicators
| where TimeGenerated > ago(30d)
| summarize arg_max(TimeGenerated, *) by Id
| where IsActive == true and ValidUntil > now();
LatestIndicators
| summarize Active = count(), ExpiringIn7Days = countif(ValidUntil < now() + 7d) by SourceSystem

Microsoft's documentation says indicators are deduplicated on the Id property and the newest TimeGenerated wins. In the raw table I still saw several rows per indicator, so I collapse them myself with arg_max in every query rather than trusting the portal to have done it. Stale indicators are the most common reason a threat intelligence rule gets noisy, and they are cheap to exclude.

Finding suspicious IPs in my own logs

Third-party intelligence tells you what is bad somewhere in the world. The IPs that matter most are the ones that have already touched your own machines. So the next phase mines DeviceLogonEvents for them.

Query 2.1: the lab baseline

DeviceLogonEvents
| where ActionType == "LogonFailed"
| summarize Count = count() by RemoteIP, ActionType
| order by Count desc
Sentinel Logs showing failed logons grouped by RemoteIP over the last 24 hours, 113 rows, led by a private 10.0.0.8 address and a blank RemoteIP
The 24-hour baseline returned 113 rows. The first two are not attackers: a private 10.0.0.8 address with 725 failures and an empty RemoteIP with 614.

This is the baseline from the lab, and the top of the list is a good lesson in why you should never export a query like this straight into indicators. The biggest source, with 725 failed logons, was 10.0.0.8, a private address. The second biggest, with 614, was an empty RemoteIP. The first real public address, 218.147.202.136, came third with 219, followed by 182.95.42.14 (193), 186.232.214.20 (175), 217.218.10.46 (126) and 109.160.32.35 (116).

A private address can never be a useful indicator, and a blank one is not an address at all. Both have to be filtered out.

Query 2.2: built to produce a defensible indicator

My version adds a seven-day window, the private and empty filters, and three pieces of context: how many different devices the source hit, how many accounts it tried, and when it was first and last seen.

let startTime = ago(7d);
DeviceLogonEvents
| where TimeGenerated > startTime
| where ActionType == "LogonFailed"
| where isnotempty(RemoteIP)
| where not(ipv4_is_private(RemoteIP))
| summarize
    FailedLogons = count(),
    DistinctDevices = dcount(DeviceName),
    DistinctAccounts = dcount(AccountName),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by RemoteIP
| where FailedLogons >= 10
| order by FailedLogons desc
Sentinel Logs showing 220 public brute force source IPs with failed logon counts, distinct devices, distinct accounts, first seen and last seen
Query 2.2 produced 220 candidate IPs. The top one, 138.91.109.48, failed 1,306 times across 30 devices.

It returned 220 candidate IPs. The top four tell different stories:

Source IPFailed logonsDevices hitAccounts tried
138.91.109.481,306302
80.94.95.831,161187
23.94.69.145791117
80.66.83.43521102

The first and last rows are the same pattern: very few account names tried against many machines, which looks like a short username list swept across everything reachable (password guessing, T1110.001 in MITRE ATT&CK). The third row is different. Seventeen accounts across eleven devices is a much wider spread of usernames, and a wide spread of accounts with only a modest number of failures per account is the pattern to check for password spraying (T1110.003). Counts alone cannot confirm it, and 579 failures over 17 accounts is not a textbook spray, but this is exactly the kind of detail that makes an indicator more convincing than a bare IP and a number. An address that failed 700 times against nine different machines tells a stronger story than one that failed twelve times against one.

Remember that this is the shared workspace, so "30 devices" means 30 devices across many students' labs, not 30 of mine.

Query 2.3: did any of them ever get in?

A brute force source is an annoyance. A brute force source that later logged in is an incident, so this is the query I would not skip:

let Attackers = DeviceLogonEvents
| where TimeGenerated > ago(7d)
| where ActionType == "LogonFailed" and isnotempty(RemoteIP)
| summarize Failed = count() by RemoteIP
| where Failed >= 10;
DeviceLogonEvents
| where TimeGenerated > ago(7d)
| where ActionType == "LogonSuccess"
| join kind=inner Attackers on RemoteIP
| project TimeGenerated, DeviceName, AccountName, RemoteIP, LogonType, Failed
| order by TimeGenerated asc
Sentinel Logs showing 6,746 successful logons from IP addresses that previously failed ten or more times, with device, account, logon type and failure count
Query 2.3 returned 6,746 rows instead of the zero a quiet range would give, so the next step is scoping to devices you own.

On a quiet network the expected answer is zero rows, and my runbook says so. In this workspace the answer was 6,746 rows. The first visible row was a successful network logon on a device named win-azrstigjt from 20.80.241.91, an address with 25 earlier failures.

That number does not mean 6,746 compromises, and it does not mean the query is broken. My best explanation is that this is a shared, deliberately exposed lab range where lots of machines are reachable from the internet and most of them belong to other people, so a join across the whole workspace was never going to come back empty. The practical fix is the same one I use for the hash query in Part 3: scope the result to a device you own with | where DeviceName startswith "threatintel-lab", or summarise by device and look at the ones with the largest ratio of successes to failures. Keep the query as it is for a production tenant, where a single row should wake someone up.

What goes into Part 2

The output of this post is a list of 220 public IPs that have repeatedly failed logons, with a count, a device spread and a time window for each, plus a baseline answer to whether any of them got in. Part 2 takes that list, validates it with Python, scores confidence and turns each address into a STIX indicator that Sentinel can match against logons, network flows and 90 days of history.

If you want to see what the free tools look like on the same idea, my SOC-HomeLab threat hunt write-up walks through reading a day of Wazuh and Zeek alerts cold, and it is a useful contrast to doing the same work in Sentinel.

Frequently asked questions

What is the ThreatIntelIndicators table in Microsoft Sentinel?

It is the table where Sentinel stores threat indicators such as IP addresses, domains, URLs and file hashes, whether they came from Microsoft's own feed, a connector, a file import or the portal. It replaced the older ThreatIntelligenceIndicator table, and Microsoft's documentation says ingestion into the legacy table stopped after July 31, 2025, so queries written for the old table need to be moved over.

Why do the Active and Indicators numbers in my inventory query not match?

Because Active counts rows and Indicators counts distinct values. Sentinel keeps a new row each time an indicator is updated, so one indicator can appear many times. In my workspace one source showed 133,525 distinct values against 564,978 active rows. Collapsing to the newest row per Id with arg_max, and checking ValidUntil, gives you the real number.

Why filter out private and blank IP addresses before building indicators?

Because they are noise that looks like signal. In my 24-hour baseline the top source was a private 10.x address and the second was an empty RemoteIP, both ahead of any real attacker. An indicator for a private address can never be matched to anything useful, and one for your own network can cause alerts about yourself.

What does the failed-then-succeeded join tell you?

It separates an annoyance from a possible incident. A source that failed logons ten or more times is background noise on an internet-facing machine. The same source followed by a successful logon means a guess may have worked, and that is the result you escalate. Always check it before you turn a brute force source into an indicator.

Do I need Traffic Analytics and NTANetAnalytics to follow this series?

No. NTANetAnalytics, which comes from NSG flow logs with Traffic Analytics enabled, is only used for the network flow match in Part 2. If the table is empty in your workspace you can skip that one query and still finish everything else.

Will this cost anything to run?

In the Cyber Range lab environment I did not pay anything beyond the course. In your own tenant, Sentinel bills for the data you ingest and keep, so check the pricing for your workspace before turning on extra connectors. The queries themselves are ordinary KQL and add no separate charge.