Detection rules › Kusto

Hunt for devices organized by subnet

Group by
DeviceId, Joiner, NetworkAddress, ReportId
Author
Robbe Van den Daele
Source
github.com/HybridBrothers/Hunting-Queries-Detection-Rules

This rule helps you organize devices by subnet in your networks. By doing this, you can identify how many not-onboarded devices, devices not supporting MDE containment, and types of devices live in your subnet ranges.

References

Telemetry coverage

Rule body

let isolationSupportedOS = dynamic(["Windows11", "Windows10", "WindowsServer2025", "WindowsServer2022", "WindowsServer2019", "WindowsServer2016", "WindowsServer2012R2", "Linux", "macOS"]);
let containmentSupportedOS = dynamic(["Windows11", "Windows10", "WindowsServer2025", "WindowsServer2022", "WindowsServer2019", "WindowsServer2016", "WindowsServer2012R2"]);
let base = DeviceNetworkInfo
    // Expand all IPs
    | mv-expand todynamic(IPAddresses)
    // Ignore IPv6 addresses
    | where tostring(IPAddresses.IPAddress) !contains ":"
    // Save the Prefix as an extra property and set it to /32 when empty
    | extend Prefix = iff(isnotempty(tostring(IPAddresses.SubnetPrefix)), tostring(IPAddresses.SubnetPrefix), "32");
let networks = base
    // Get network addresses with a non /32 prefix
    | where Prefix != "32"
    // Get the network address related to the IP
    | extend NetworkAddress = format_ipv4(tostring(IPAddresses.IPAddress), tolong(Prefix))
    // Build the IP and Network Address with the CIDR notation
    | extend IPAddress = strcat(tostring(IPAddresses.IPAddress), "/", Prefix)
    | extend NetworkAddress = strcat(NetworkAddress, "/", Prefix)
    // Join the Device Info information
    | join kind=inner DeviceInfo on DeviceId, ReportId
    // Ignore APIPA addresses
    | where NetworkAddress != "169.254.0.0/16"
    // Ignore merged device IDs
    | where MergedToDeviceId == ""
    // Make a set of all the Device Objects belonging to the same subnet
    | extend DeviceObj = pack(
        "DeviceName", DeviceName,
        "IPAddress", IPAddress,
        "DeviceType", DeviceType,
        "DeviceCategory", DeviceCategory,
        "IsInternetFacing", IsInternetFacing,
        "OnboardingStatus", OnboardingStatus,
        "OSDistribution", OSDistribution,
        "OSPlatform", OSPlatform
    )
    // Make a list of the objects in the same subnet
    | summarize make_set(DeviceObj) by NetworkAddress;
let device_with_host_prefix = base
    // Get network addresses with /32 Prefix to try and match other networks
    | where Prefix == "32"
    // Build the IP Address with the CIDR notation
    | extend IPAddress = strcat(tostring(IPAddresses.IPAddress), "/", Prefix)
    // Join the Device Info information
    | join kind=inner DeviceInfo on DeviceId, ReportId
    // Ignore merged device IDs
    | where MergedToDeviceId == ""
    // Make a set of all the Device Objects
    | extend DeviceObj = pack(
        "DeviceName", DeviceName,
        "IPAddress", IPAddress,
        "DeviceType", DeviceType,
        "DeviceCategory", DeviceCategory,
        "IsInternetFacing", IsInternetFacing,
        "OnboardingStatus", OnboardingStatus,
        "OSDistribution", OSDistribution,
        "OSPlatform", OSPlatform
    )
    | extend Joiner = 1;
let network_addresses = base
    // Get network addresses with a non /32 prefix
    | where Prefix != "32"
    // Get the network address related to the IP
    | extend NetworkAddress = format_ipv4(tostring(IPAddresses.IPAddress), tolong(Prefix))
    | extend NetworkAddress = strcat(NetworkAddress, "/", Prefix)
    // Create joiner to find host addresses related to certain networks
    | distinct NetworkAddress
    | extend Joiner = 1;
let networks2 = device_with_host_prefix
    // Try to join /32 IPs
    | join kind=inner network_addresses on Joiner
    // Check if IP is in the network range, and only return those IPs
    | extend InRange = ipv4_is_in_range(IPAddress, NetworkAddress)
    | where InRange == 1
    // Make a list of the objects in the same subnet
    | summarize make_set(DeviceObj) by NetworkAddress;
union networks, networks2
    // Expand the Device Objects
    | mv-expand set_DeviceObj
    // Save the DeviceType, DeviceCategory, and Onboarding Status
    | extend DeviceType = set_DeviceObj.DeviceType
    | extend DeviceCategory = set_DeviceObj.DeviceCategory
    | extend OnboardingStatus = set_DeviceObj.OnboardingStatus
    // Count how many servers, workstations, network devices, iot devices, and ot devices exists in a subnet, the onboarding estate, and OS Distribution
    | summarize Servers = countif(set_DeviceObj.DeviceType=="Server"),
        Workstations = countif(set_DeviceObj.DeviceType=="Workstation"),
        NetworkDevices = countif(set_DeviceObj.DeviceCategory=="NetworkDevice"),
        IoTDevices = countif(set_DeviceObj.DeviceCategory=="IoT"),
        OTDevices = countif(set_DeviceObj.DeviceCategory=="OT"),
        Onboarded = countif(set_DeviceObj.OnboardingStatus=="Onboarded"),
        NotOnboarded = countif(set_DeviceObj.OnboardingStatus!="Onboarded"),
        IsolateSupportedOS = countif((set_DeviceObj.OSDistribution has_any (isolationSupportedOS) or set_DeviceObj.OSPlatform == "Linux") and set_DeviceObj.OnboardingStatus == "Onboarded"),
        ContainSupportedOS = countif(set_DeviceObj.OSDistribution has_any (containmentSupportedOS) and set_DeviceObj.OnboardingStatus == "Onboarded") by NetworkAddress
    // Join the network subnets so we have the device objects again
    | join kind=leftouter networks on NetworkAddress
    | join kind=leftouter networks2 on NetworkAddress
    // Extend Array Concat
    | extend set_DeviceObj = array_concat(set_DeviceObj, set_DeviceObj1)
    // Remove duplicate columns
    | project-away NetworkAddress1, NetworkAddress2, set_DeviceObj1
    // Count how many IPs there are in one subnet
    | extend CountIPs = array_length(set_DeviceObj)
    | sort by CountIPs desc

Stages and Predicates

Stage 0: let

let isolationSupportedOS = dynamic(["Windows11", "Windows10", "WindowsServer2025", "WindowsServer2022", "WindowsServer2019", "WindowsServer2016", "WindowsServer2012R2", "Linux", "macOS"]);
let containmentSupportedOS = dynamic(["Windows11", "Windows10", "WindowsServer2025", "WindowsServer2022", "WindowsServer2019", "WindowsServer2016", "WindowsServer2012R2"]);
let base = DeviceNetworkInfo <inlined as stages below>;
let networks = base <inlined as stages below>;
let device_with_host_prefix = base <inlined as stages below>;
let network_addresses = base
    | where Prefix != "32"
    | extend NetworkAddress = format_ipv4(tostring(IPAddresses.IPAddress), tolong(Prefix))
    | extend NetworkAddress = strcat(NetworkAddress, "/", Prefix)
    | distinct NetworkAddress
    | extend Joiner = 1;
let networks2 = device_with_host_prefix <inlined as stages below>;

Stage 1: source

let base

Stage 2: source

let networks

Stage 3: source

let device_with_host_prefix

Stage 4: source

let network_addresses

Stage 5: source

let networks2

Stage 6: union

union of 2 branches

Stage 7: source

DeviceNetworkInfo

Stage 8: mv-expand

mv-expand

Stage 9: where

where not (IPAddress contains ":")

Stage 10: extend

extend Prefix
Prefix =
if/* macro: isnotempty(tostring(IPAddresses.SubnetPrefix)) */tostring(IPAddresses.SubnetPrefix)
else"32"

Stage 11: where

where Prefix != 32

Stage 12: extend (3 consecutive steps)

extend IPAddress, NetworkAddress

Stage 13: join

join kind=inner (DeviceInfo) on DeviceId, ReportId

Stage 14: where

where NetworkAddress !~ "169.254.0.0/16"

Stage 15: where

where MergedToDeviceId =~ ""

Stage 16: extend

extend DeviceObj

Stage 17: summarize

summarize by NetworkAddress

Stage 18: source

DeviceNetworkInfo

Stage 19: mv-expand

mv-expand

Stage 20: where

where not (IPAddress contains ":")

Stage 21: extend

extend Prefix
Prefix =
if/* macro: isnotempty(tostring(IPAddresses.SubnetPrefix)) */tostring(IPAddresses.SubnetPrefix)
else"32"

Stage 22: where

where Prefix == 32

Stage 23: extend

extend IPAddress

Stage 24: join

join kind=inner (DeviceInfo) on DeviceId, ReportId

Stage 25: where

where MergedToDeviceId =~ ""

Stage 26: extend

extend DeviceObj

Stage 27: extend

extend Joiner

Stage 28: join

join kind=inner (network_addresses) on Joiner

Stage 29: extend

extend InRange

Stage 30: where

where InRange == 1

Stage 31: summarize

summarize by NetworkAddress

Stage 32: mv-expand

mv-expand set_DeviceObj

Stage 33: extend (3 consecutive steps)

extend DeviceCategory, DeviceType, OnboardingStatus

Stage 34: summarize

summarize ContainSupportedOS, IoTDevices, IsolateSupportedOS, NetworkDevices, NotOnboarded, OTDevices, Onboarded, Servers, Workstations by NetworkAddress

Stage 35: join

join kind=leftouter (networks) on NetworkAddress

Stage 36: join

join kind=leftouter (networks2) on NetworkAddress

Stage 37: extend

extend set_DeviceObj

Stage 38: project-away

project-away NetworkAddress1, NetworkAddress2, set_DeviceObj1

Stage 39: extend

extend CountIPs

Stage 40: sort

sort by CountIPs

Indicators

These rows show field, operator, and value matches.

Exclusions

The rule actively suppresses these predicates.

FieldKindExcluded valuesSearch
IPAddresscontains:excludes:IPAddress field:"IPAddress" value:":"

Output fields

These fields are emitted when the rule matches.

FieldSource
ContainSupportedOSsummarize
IoTDevicessummarize
IsolateSupportedOSsummarize
NetworkAddresssummarize
NetworkDevicessummarize
NotOnboardedsummarize
OTDevicessummarize
Onboardedsummarize
Serverssummarize
Workstationssummarize
set_DeviceObjextend
CountIPsextend