Microsoft SentinelKQLAnalytic Rules

Fixing the "Excessive Number of Failed Connections from a Single Source" Analytic Rule

Fixing the "Excessive Number of Failed Connections from a Single Source" Analytic Rule

Alright class.

Today we pull apart "Excessive number of failed connections from a single source" from the Network Session Essentials solution. The YAML behind it is literally named ExcessiveHTTPFailuresFromSource.yaml and its ParentAlert tag points at a Sophos XG rule, which explains a lot; nothing in it touches HTTP. It ships at Medium severity, mapped to T1499, Endpoint Denial of Service. Both claims fall over once you read the query.

let threshold = 5000;
_Im_NetworkSession(eventresult='Failure')
| summarize Count=count() by SrcIpAddr, bin(TimeGenerated,5m)
| where Count > threshold
| extend timestamp = TimeGenerated, threshold

A Counter Is Not a Detection

Five thousand failed sessions from one source in a five minute bin is seventeen failures per second.

Scenario one: a payroll application retries a database decommissioned last week, hitting the dead listener at twenty attempts per second on port 1433. Six thousand failures per bin, every bin, until someone fixes the connection string. Twelve alerts an hour about a machine that is not under attack.

Scenario two: an attacker on a compromised workstation sweeps one thousand hosts on port 445, one SYN each, across an hour. Peak rate lands near eighty four failures per bin, sixty times under the threshold, while the payroll box floods the queue.

Microsoft documents the only control on offer: raise the threshold and "less incidents will be generated". That is a volume knob: down and the payroll box fires harder, up and the sweep gains headroom.

The Wrong Threat on the Label

The T1499 mapping says denial of service, yet the summarize line groups by SrcIpAddr and never asks where the failures landed. Denial of service detection aggregates by target, because that is where the harm lives. The query cannot tell one host absorbing five thousand failures from five thousand hosts absorbing one each; the label points at the first shape; the query only sees the second, a scan.

There is no internal or external scoping either. Internet background noise against a public firewall clears five thousand failures without trying, all already blocked. Closing that incident means confirming the firewall did its job: great for auditing, not detection.

What This Rule Should Actually Catch

One behaviour on this axis deserves an incident: an internal host that suddenly fails connections to far more distinct destinations than its own history supports. A compromised box mapping the estate, a worm probing for its next hop, an implant fanning out to blocked command infrastructure. Volume is a side effect; fan-out is the signal. The rebuild scores fan-out against each source's own past and demotes the five thousand counter to a minor amplifier.

Known False Positives and the Suppression Design

Four benign shapes produce failed fan-out; each is handled structurally.

Vulnerability scanners. Nessus, Qualys, Lansweeper and Defender for Endpoint device discovery all sweep on a schedule, and the baseline compares each source against its own hourly fan-out peak over fourteen days, so last week's scan is this week's alibi. A new or rescoped scanner fires exactly once; confirm, allowlist in ExcludedSources, done.

Monitoring platforms. PRTG, Zabbix and SolarWinds poll the same hosts every cycle and some are always down; steady state never reaches three times its own peak, so the novelty gate stays shut without an allowlist entry.

Broken applications. The payroll box fails six thousand times to one destination. Candidacy demands twenty distinct destinations; a retry storm has one. It never enters scoring.

Configuration rollouts. Push a bad endpoint to a segment and one relay fails to hundreds of new destinations. That fires once, and it should; in log data it is indistinguishable from a sweep.

The Rebuild

The query scopes to internal sources first, RFC1918 plus anything in InternalRanges; external sources failing against your perimeter are the firewall working. Sources crossing both floors, one hundred failures and twenty destinations in the hour, become candidates, each compared against its own hourly fan-out peak from the previous fourteen days. The baseline ends where the detection window starts, so this hour of noise can never vouch for itself.

The gate is strict: the row must show new fan-out, current spread at least three times the historical peak or no failure history at all, and the score must reach eight. Novelty alone scores six and stays silent; it needs a corroborating signal. Estate-wide spread past one hundred and fifty destinations, a threat intelligence match, ten failures on lateral movement ports, a fifty port sweep, or the old five thousand volume line at weight one.

One deliberate hand-off: the built-in TI mapping rules already own the axis of an internal host contacting a listed IP, so a match here amplifies at weight three rather than firing alone. A baselined noisy source will not alert on a TI hit alone; that traffic belongs to the companion rules.

The RiskIndicators Field

Every row carries one plain sentence per signal, so the analyst reads the verdict instead of rebuilding it:

New fan-out: 312 destinations against a baseline hourly peak of 9 | Lateral movement ports: 296 failures on [445,3389] | High volume: 8214 failed sessions

The combination to chase is new fan-out plus lateral movement ports plus a threat intelligence match on one row: a quiet host sweeping admin services with a destination already listed. Isolate first, ask questions after.

The Blind Spots You Should Know About

Anything under twenty destinations or one hundred failures an hour never becomes a candidate; lowering the floors trades that for rollout noise. IPv6 only sources and internal hosts on public addresses sit outside scope until you extend InternalRanges. A compromise on an allowlisted scanner hides inside its own expected noise, and scanner boxes hold credentials while nobody questions their traffic. Parsers that never mark denies as Failure feed this rule nothing. Enumerating ports already known open produces successes, not failures. A source whose connector went live under fourteen days ago has less history, so early novelty hits run higher; that is data coverage, not a reason to wait.

MITRE Mappings for the Updated Rule

Tactic: Discovery.

T1046, Network Service Discovery: failed connections across many destinations and ports, the exhaust of service scanning and the primary shape here.

T1018, Remote System Discovery: one port swept across many hosts; the fan-out signal catches it just the same.

T1499 is gone: nothing here measures harm at a destination, and a mapping you cannot defend is worse than none.

Rule Settings

Hourly over a fourteen day window, as the baseline requires. Medium severity, one alert per result; the query collapses to one row per source. Incidents group on the IP entity across six hours, so a multi hour sweep lands in one. SrcIpAddr maps to IP, SrcHostname to Host, fifteen custom details carry everything, RiskIndicators included.

KQL

//=============================================================================
// Internal host failed connection fan-out (ASIM Network Session)
//=============================================================================
// Description:
//   Detects an internal host that suddenly fails connections to far more
//   distinct destinations than its own history supports. Scores each
//   candidate against its own hourly fan-out peak from the previous 14
//   days (the baseline excludes the detection window) and fires only
//   when the fan-out is new for that source and at least one
//   corroborating signal is present: estate-wide spread, a threat
//   intelligence match, lateral movement ports, a port sweep, or volume
//   above 5000 failures. Catches a compromised host mapping the estate,
//   worm propagation, and blocked command and control fan-out.
//
// Type:        Detection
// Tables:      _Im_NetworkSession (ASIM parser), ThreatIntelIndicators
// Connectors:  Any source feeding the ASIM NetworkSession schema
//              (firewalls, NSG flow logs, Defender for Endpoint).
//              Microsoft Defender Threat Intelligence or any TI
//              connector feeding ThreatIntelIndicators.
// License:     MIT
// Tuning:      ExcludedSources: allowlist authorised scanners first.
//              InternalRanges: add non RFC1918 internal blocks.
//              MinFailures 100 / MinFanout 20: candidacy floors.
//              FanoutMultiplier 3: novelty bar against own hourly peak.
//              FireThreshold 8: novelty (6) always needs a second signal.
//              BaselineLookback 14d: largest lookup, queryPeriod P14D.
//              ThreatIntelIndicators must exist or deployment fails;
//              enable the free Defender TI connector or remove TI_IPs.
// Known FPs:   New or rescoped scanners (Nessus, Qualys, Lansweeper,
//              MDE device discovery) fire once, then allowlist.
//              Configuration rollouts pointing a segment at a dead
//              endpoint fire once. Parsers leaking Success rows are
//              covered by the explicit EventResult guard. DHCP churn
//              moving a scanner to a fresh address looks novel.
// Author:      Bartosz Wysocki | itprofessor.cloud
// Version:     1.0 | 2026-07-19
//=============================================================================
// Tuning variables
let DetectionWindow = 1h;          // scoring window; keep equal to the rule run frequency so events are scored once
let DetectionStart = ago(DetectionWindow);   // derived start of the scoring window, do not edit
let BaselineLookback = 14d;        // fan-out history per source; the largest lookup, so the rule query period must be P14D
let MinFailures = 100;             // failed sessions in the window before a source is considered at all
let MinFanout = 20;                // distinct destination IPs in the window before a source is considered at all
let FanoutMultiplier = 3;          // current fan-out must reach this multiple of the source's own hourly peak
let WideFanoutFloor = 150;         // distinct destinations treated as estate-wide sweeping
let PortSweepFloor = 50;           // distinct destination ports treated as vertical port scanning
let SensitiveHitsFloor = 10;       // failed sessions on lateral movement ports before that signal counts
let HighVolumeFloor = 5000;        // the original rule's threshold, kept as a minor amplifier
let FireThreshold = 8;             // minimum RiskScore before a row survives
// Scoring weights: new fan-out is mandatory, the rest only stack on top of it
let W_NewFanout = 6;               // fan-out well above the source's own hourly peak, or no failure history at all
let W_WideFanout = 3;              // estate-wide destination spread
let W_TIMatch = 3;                 // failed sessions towards active threat intelligence indicators
let W_SensitivePorts = 3;          // failures concentrated on lateral movement ports
let W_PortSweep = 2;               // vertical port scanning shape
let W_HighVolume = 1;              // raw failure volume above the original 5000 threshold
// Lists
let SensitivePorts = dynamic([22, 23, 135, 139, 445, 1433, 3389, 5900, 5985, 5986]);
let ExcludedSources = dynamic([]);   // authorised scanners and monitoring platforms, for example ["10.10.5.4"]
let InternalRanges = dynamic(["255.255.255.255/32"]);  // extra internal ranges on top of RFC1918, for example ["100.64.0.0/10"]; keep the sentinel entry
// Lookup: latest version of each active IP indicator; empty when no TI connector feeds the table
let TI_IPs = ThreatIntelIndicators
    | where TimeGenerated > ago(BaselineLookback)
    | where ObservableKey == "ipv4-addr:value" and isnotempty(ObservableValue)
    | summarize arg_max(TimeGenerated, ObservableValue, IsActive, ValidUntil) by Id
    | where IsActive == true and (isnull(ValidUntil) or ValidUntil > now())
    | distinct ObservableValue;
// Lookup: one parser pass over the whole period; two passes push the ASIM union past the operator tree limit (CRT0015)
// The explicit EventResult filter guards against parsers that leak Success rows through the parameter
let Failures = materialize(
    _Im_NetworkSession(starttime=ago(BaselineLookback), eventresult='Failure')
    | where EventResult == "Failure"
    | where isnotempty(SrcIpAddr) and isnotempty(DstIpAddr)
    | where SrcIpAddr !in (ExcludedSources)
    | where ipv4_is_private(SrcIpAddr) or ipv4_is_in_any_range(SrcIpAddr, InternalRanges)
    | project TimeGenerated, SrcIpAddr, SrcHostname, DstIpAddr, DstPortNumber, NetworkProtocol
);
// Lookup: failed sessions from internal sources inside the scoring window, tagged with TI matches
let WindowFailures = Failures
    | where TimeGenerated >= DetectionStart
    | extend DstSubnet = strcat_array(array_slice(split(DstIpAddr, "."), 0, 2), ".")
    | lookup kind=leftouter (TI_IPs | extend TIHit = true) on $left.DstIpAddr == $right.ObservableValue;
// Lookup: candidate sources crossing both consideration floors
let Candidates = materialize(
    WindowFailures
    | summarize
        TotalFailures = count(),
        DistinctDsts = dcount(DstIpAddr),
        DistinctPorts = dcount(DstPortNumber),
        DistinctSubnets = dcount(DstSubnet),
        SensitiveHits = countif(DstPortNumber in (SensitivePorts)),
        SensitivePortsHit = make_set_if(DstPortNumber, DstPortNumber in (SensitivePorts), 10),
        TIMatches = countif(TIHit == true),
        TIMatchedDsts = make_set_if(DstIpAddr, TIHit == true, 10),
        SampleDsts = make_set(DstIpAddr, 10),
        SamplePorts = make_set(DstPortNumber, 15),
        Protocols = make_set(NetworkProtocol, 5),
        SrcHostname = take_anyif(SrcHostname, isnotempty(SrcHostname)),
        StartTime = min(TimeGenerated),
        EndTime = max(TimeGenerated)
        by SrcIpAddr
    | where TotalFailures >= MinFailures and DistinctDsts >= MinFanout
);
// Lookup: hourly fan-out history per candidate, ending at the scoring window so events cannot baseline themselves
let BaselineProfile = Failures
    | where TimeGenerated < DetectionStart
    | where SrcIpAddr in ((Candidates | project SrcIpAddr))
    | summarize HourlyFanout = dcount(DstIpAddr) by SrcIpAddr, bin(TimeGenerated, 1h)
    | summarize BaselinePeakFanout = max(HourlyFanout), BaselineActiveHours = count() by SrcIpAddr;
// Main pipeline: score candidates against their own history
Candidates
| join kind=leftouter (BaselineProfile) on SrcIpAddr
| extend BaselinePeakFanout = coalesce(BaselinePeakFanout, 0), BaselineActiveHours = coalesce(BaselineActiveHours, 0)
| extend
    NewFanout = DistinctDsts >= BaselinePeakFanout * FanoutMultiplier,
    WideFanout = DistinctDsts >= WideFanoutFloor,
    TIFlag = TIMatches > 0,
    SensitiveFlag = SensitiveHits >= SensitiveHitsFloor,
    SweepFlag = DistinctPorts >= PortSweepFloor,
    VolumeFlag = TotalFailures >= HighVolumeFloor
| extend RiskScore =
    iff(NewFanout, W_NewFanout, 0)
    + iff(WideFanout, W_WideFanout, 0)
    + iff(TIFlag, W_TIMatch, 0)
    + iff(SensitiveFlag, W_SensitivePorts, 0)
    + iff(SweepFlag, W_PortSweep, 0)
    + iff(VolumeFlag, W_HighVolume, 0)
| where NewFanout and RiskScore >= FireThreshold
| extend
    Risk_1 = iff(NewFanout, strcat("New fan-out: ", DistinctDsts, " destinations against a baseline hourly peak of ", BaselinePeakFanout), ""),
    Risk_2 = iff(WideFanout, strcat("Estate-wide spread: ", DistinctSubnets, " subnets touched"), ""),
    Risk_3 = iff(TIFlag, strcat("Threat intel match: ", TIMatches, " failed sessions towards listed IPs"), ""),
    Risk_4 = iff(SensitiveFlag, strcat("Lateral movement ports: ", SensitiveHits, " failures on ", tostring(SensitivePortsHit)), ""),
    Risk_5 = iff(SweepFlag, strcat("Port sweep: ", DistinctPorts, " distinct ports"), ""),
    Risk_6 = iff(VolumeFlag, strcat("High volume: ", TotalFailures, " failed sessions"), "")
| extend RiskIndicators = trim(@"\s\|\s*$", strcat(
    iff(isnotempty(Risk_1), strcat(Risk_1, " | "), ""),
    iff(isnotempty(Risk_2), strcat(Risk_2, " | "), ""),
    iff(isnotempty(Risk_3), strcat(Risk_3, " | "), ""),
    iff(isnotempty(Risk_4), strcat(Risk_4, " | "), ""),
    iff(isnotempty(Risk_5), strcat(Risk_5, " | "), ""),
    iff(isnotempty(Risk_6), strcat(Risk_6, " | "), "")))
| project StartTime, EndTime, SrcIpAddr, SrcHostname, TotalFailures, DistinctDsts, DistinctPorts, DistinctSubnets, BaselinePeakFanout, BaselineActiveHours, SensitiveHits, SensitivePortsHit, TIMatches, TIMatchedDsts, SampleDsts, SamplePorts, Protocols, RiskScore, RiskIndicators
| sort by RiskScore desc, EndTime desc

You can also download this as an analytic rule and import it directly to Sentinel.

Follow my repo - GitHub

What You Should Do Next

  1. Decide whether this rule can work for you at all. Summarise one day of _Im_NetworkSession by EventResult and EventVendor. No Failure rows means your sources do not log denies through the parser and the rule has nothing to score.
  2. Fill ExcludedSources with your authorised scanners before enabling the rule, not after the first incident.
  3. Confirm ThreatIntelIndicators exists in the workspace. Enable the Defender Threat Intelligence connector or remove the TI_IPs block; the rule will not deploy without the table.
  4. After a week, read what fired against BaselineActiveHours. If rollout churn dominates, raise MinFanout to thirty or FireThreshold to ten.

Class dismissed.

Bartosz Wysocki

Head of security engineering at a UK MSSP. I started as a SOC analyst and never stopped writing KQL. These days I build the detections, automation and reporting behind a managed SOC on Sentinel and Defender. Profo does the drawings.

Get the next one by email

One email per post, plus the KQL Vault.

Nearly there. Check your inbox and click the link to confirm.