{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "workspace": {
      "type": "String"
    }
  },
  "resources": [
    {
      "id": "[concat(resourceId('Microsoft.OperationalInsights/workspaces/providers', parameters('workspace'), 'Microsoft.SecurityInsights'),'/alertRules/excessive-failed-connections')]",
      "name": "[concat(parameters('workspace'),'/Microsoft.SecurityInsights/excessive-failed-connections')]",
      "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules",
      "kind": "Scheduled",
      "apiVersion": "2023-12-01-preview",
      "properties": {
        "displayName": "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. The rule scores each internal source against its own hourly fan-out peak from the previous 14 days, excluding 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 on a destination, concentration on lateral movement ports, a port sweep, or raw volume above 5000 failures. This shape catches a compromised host mapping the estate, worm propagation, and blocked command and control fan-out, while staying silent on standing noise from monitoring platforms and scheduled scanners.\n\nTABLES: _Im_NetworkSession (ASIM parser), ThreatIntelIndicators.\nCONNECTORS: Any source feeding the ASIM NetworkSession schema (firewalls, NSG flow logs, Defender for Endpoint). Microsoft Defender Threat Intelligence or any TI connector feeding ThreatIntelIndicators.\nLICENSE: MIT.\n\nTUNING:\n1. ExcludedSources: add authorised vulnerability scanners and discovery platforms before enabling the rule. A new scanner will fire once by design; confirm and allowlist it.\n2. InternalRanges: the rule treats RFC1918 as internal. Add public or carrier grade NAT ranges your estate uses (for example DMZ blocks) so those sources stay in scope.\n3. MinFailures (100) and MinFanout (20): consideration floors. Raising them reduces noise and widens the low and slow blind spot.\n4. FanoutMultiplier (3): current fan-out must reach three times the source hourly peak. Raise to 4 or 5 if deployment churn keeps firing.\n5. FireThreshold (8): novelty scores 6, so at least one corroborating signal is always required. Raise to 10 to demand two strong signals.\n6. BaselineLookback (14d): the largest lookup, so queryPeriod must stay P14D. Drop to 7d on high volume estates to cut parser cost, and reduce queryPeriod to match.\n7. ThreatIntelIndicators must exist in the workspace or deployment validation fails. Enable the free Defender Threat Intelligence connector, or remove the TI_IPs block and the W_TIMatch signal.\n\nKNOWN FALSE POSITIVES:\n1. Newly deployed or rescoped vulnerability scanners (Nessus, Qualys, Lansweeper, Defender for Endpoint device discovery). Fires once, then allowlist.\n2. Configuration rollouts that point a segment at a dead endpoint; the relay fails to hundreds of new destinations and fires once.\n3. Sources whose parser leaks Success rows through the eventresult filter; the explicit EventResult guard covers the known cases.\n4. DHCP churn moving a scanner to a new address; the new address has no history and looks novel.\n\nINVESTIGATION:\n1. Read RiskIndicators first; it lists every signal that fired with its measured value.\n2. Check SampleDsts and SamplePorts for the sweep shape: one port across many hosts is host discovery, many ports on few hosts is service scanning.\n3. TIMatchedDsts above zero: pivot to those destination IPs in TI and check for allowed sessions from the same source.\n4. BaselineActiveHours near zero means the source has little or no failure history; identify the machine and its owner before anything else.\n5. Compare SensitivePortsHit against the source role; a print server has no business failing on 1433 and 5985.\n\nBLIND SPOTS: activity under 20 destinations or 100 failures per hour; IPv6 only sources; compromise hiding inside the noise of an allowlisted scanner; parsers that never mark denies as Failure; enumeration of ports already known to be open, which produces successes rather than failures.\n\nAuthor: Bartosz Wysocki | itprofessor.cloud\nVersion: 1.0 | 2026-07-19",
        "severity": "Medium",
        "enabled": true,
        "query": "//=============================================================================\n// Internal host failed connection fan-out (ASIM Network Session)\n//=============================================================================\n// Tables:   _Im_NetworkSession, ThreatIntelIndicators\n// License:  MIT\n// Tuning:   See rule description\n// Author:   Bartosz Wysocki | itprofessor.cloud\n// Version:  1.0 | 2026-07-19\n//=============================================================================\n// Tuning variables\nlet DetectionWindow = 1h;          // scoring window; keep equal to the rule run frequency so events are scored once\nlet DetectionStart = ago(DetectionWindow);   // derived start of the scoring window, do not edit\nlet BaselineLookback = 14d;        // fan-out history per source; the largest lookup, so the rule query period must be P14D\nlet MinFailures = 100;             // failed sessions in the window before a source is considered at all\nlet MinFanout = 20;                // distinct destination IPs in the window before a source is considered at all\nlet FanoutMultiplier = 3;          // current fan-out must reach this multiple of the source's own hourly peak\nlet WideFanoutFloor = 150;         // distinct destinations treated as estate-wide sweeping\nlet PortSweepFloor = 50;           // distinct destination ports treated as vertical port scanning\nlet SensitiveHitsFloor = 10;       // failed sessions on lateral movement ports before that signal counts\nlet HighVolumeFloor = 5000;        // the original rule's threshold, kept as a minor amplifier\nlet FireThreshold = 8;             // minimum RiskScore before a row survives\n// Scoring weights: new fan-out is mandatory, the rest only stack on top of it\nlet W_NewFanout = 6;               // fan-out well above the source's own hourly peak, or no failure history at all\nlet W_WideFanout = 3;              // estate-wide destination spread\nlet W_TIMatch = 3;                 // failed sessions towards active threat intelligence indicators\nlet W_SensitivePorts = 3;          // failures concentrated on lateral movement ports\nlet W_PortSweep = 2;               // vertical port scanning shape\nlet W_HighVolume = 1;              // raw failure volume above the original 5000 threshold\n// Lists\nlet SensitivePorts = dynamic([22, 23, 135, 139, 445, 1433, 3389, 5900, 5985, 5986]);\nlet ExcludedSources = dynamic([]);   // authorised scanners and monitoring platforms, for example [\"10.10.5.4\"]\nlet 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\n// Lookup: latest version of each active IP indicator; empty when no TI connector feeds the table\nlet TI_IPs = ThreatIntelIndicators\n    | where TimeGenerated > ago(BaselineLookback)\n    | where ObservableKey == \"ipv4-addr:value\" and isnotempty(ObservableValue)\n    | summarize arg_max(TimeGenerated, ObservableValue, IsActive, ValidUntil) by Id\n    | where IsActive == true and (isnull(ValidUntil) or ValidUntil > now())\n    | distinct ObservableValue;\n// Lookup: one parser pass over the whole period; two passes push the ASIM union past the operator tree limit (CRT0015)\n// The explicit EventResult filter guards against parsers that leak Success rows through the parameter\nlet Failures = materialize(\n    _Im_NetworkSession(starttime=ago(BaselineLookback), eventresult='Failure')\n    | where EventResult == \"Failure\"\n    | where isnotempty(SrcIpAddr) and isnotempty(DstIpAddr)\n    | where SrcIpAddr !in (ExcludedSources)\n    | where ipv4_is_private(SrcIpAddr) or ipv4_is_in_any_range(SrcIpAddr, InternalRanges)\n    | project TimeGenerated, SrcIpAddr, SrcHostname, DstIpAddr, DstPortNumber, NetworkProtocol\n);\n// Lookup: failed sessions from internal sources inside the scoring window, tagged with TI matches\nlet WindowFailures = Failures\n    | where TimeGenerated >= DetectionStart\n    | extend DstSubnet = strcat_array(array_slice(split(DstIpAddr, \".\"), 0, 2), \".\")\n    | lookup kind=leftouter (TI_IPs | extend TIHit = true) on $left.DstIpAddr == $right.ObservableValue;\n// Lookup: candidate sources crossing both consideration floors\nlet Candidates = materialize(\n    WindowFailures\n    | summarize\n        TotalFailures = count(),\n        DistinctDsts = dcount(DstIpAddr),\n        DistinctPorts = dcount(DstPortNumber),\n        DistinctSubnets = dcount(DstSubnet),\n        SensitiveHits = countif(DstPortNumber in (SensitivePorts)),\n        SensitivePortsHit = make_set_if(DstPortNumber, DstPortNumber in (SensitivePorts), 10),\n        TIMatches = countif(TIHit == true),\n        TIMatchedDsts = make_set_if(DstIpAddr, TIHit == true, 10),\n        SampleDsts = make_set(DstIpAddr, 10),\n        SamplePorts = make_set(DstPortNumber, 15),\n        Protocols = make_set(NetworkProtocol, 5),\n        SrcHostname = take_anyif(SrcHostname, isnotempty(SrcHostname)),\n        StartTime = min(TimeGenerated),\n        EndTime = max(TimeGenerated)\n        by SrcIpAddr\n    | where TotalFailures >= MinFailures and DistinctDsts >= MinFanout\n);\n// Lookup: hourly fan-out history per candidate, ending at the scoring window so events cannot baseline themselves\nlet BaselineProfile = Failures\n    | where TimeGenerated < DetectionStart\n    | where SrcIpAddr in ((Candidates | project SrcIpAddr))\n    | summarize HourlyFanout = dcount(DstIpAddr) by SrcIpAddr, bin(TimeGenerated, 1h)\n    | summarize BaselinePeakFanout = max(HourlyFanout), BaselineActiveHours = count() by SrcIpAddr;\n// Main pipeline: score candidates against their own history\nCandidates\n| join kind=leftouter (BaselineProfile) on SrcIpAddr\n| extend BaselinePeakFanout = coalesce(BaselinePeakFanout, 0), BaselineActiveHours = coalesce(BaselineActiveHours, 0)\n| extend\n    NewFanout = DistinctDsts >= BaselinePeakFanout * FanoutMultiplier,\n    WideFanout = DistinctDsts >= WideFanoutFloor,\n    TIFlag = TIMatches > 0,\n    SensitiveFlag = SensitiveHits >= SensitiveHitsFloor,\n    SweepFlag = DistinctPorts >= PortSweepFloor,\n    VolumeFlag = TotalFailures >= HighVolumeFloor\n| extend RiskScore =\n    iff(NewFanout, W_NewFanout, 0)\n    + iff(WideFanout, W_WideFanout, 0)\n    + iff(TIFlag, W_TIMatch, 0)\n    + iff(SensitiveFlag, W_SensitivePorts, 0)\n    + iff(SweepFlag, W_PortSweep, 0)\n    + iff(VolumeFlag, W_HighVolume, 0)\n| where NewFanout and RiskScore >= FireThreshold\n| extend\n    Risk_1 = iff(NewFanout, strcat(\"New fan-out: \", DistinctDsts, \" destinations against a baseline hourly peak of \", BaselinePeakFanout), \"\"),\n    Risk_2 = iff(WideFanout, strcat(\"Estate-wide spread: \", DistinctSubnets, \" subnets touched\"), \"\"),\n    Risk_3 = iff(TIFlag, strcat(\"Threat intel match: \", TIMatches, \" failed sessions towards listed IPs\"), \"\"),\n    Risk_4 = iff(SensitiveFlag, strcat(\"Lateral movement ports: \", SensitiveHits, \" failures on \", tostring(SensitivePortsHit)), \"\"),\n    Risk_5 = iff(SweepFlag, strcat(\"Port sweep: \", DistinctPorts, \" distinct ports\"), \"\"),\n    Risk_6 = iff(VolumeFlag, strcat(\"High volume: \", TotalFailures, \" failed sessions\"), \"\")\n| extend RiskIndicators = trim(@\"\\s\\|\\s*$\", strcat(\n    iff(isnotempty(Risk_1), strcat(Risk_1, \" | \"), \"\"),\n    iff(isnotempty(Risk_2), strcat(Risk_2, \" | \"), \"\"),\n    iff(isnotempty(Risk_3), strcat(Risk_3, \" | \"), \"\"),\n    iff(isnotempty(Risk_4), strcat(Risk_4, \" | \"), \"\"),\n    iff(isnotempty(Risk_5), strcat(Risk_5, \" | \"), \"\"),\n    iff(isnotempty(Risk_6), strcat(Risk_6, \" | \"), \"\")))\n| project StartTime, EndTime, SrcIpAddr, SrcHostname, TotalFailures, DistinctDsts, DistinctPorts, DistinctSubnets, BaselinePeakFanout, BaselineActiveHours, SensitiveHits, SensitivePortsHit, TIMatches, TIMatchedDsts, SampleDsts, SamplePorts, Protocols, RiskScore, RiskIndicators\n| sort by RiskScore desc, EndTime desc\n",
        "queryFrequency": "PT1H",
        "queryPeriod": "P14D",
        "triggerOperator": "GreaterThan",
        "triggerThreshold": 0,
        "suppressionDuration": "PT1H",
        "suppressionEnabled": false,
        "tactics": [
          "Discovery"
        ],
        "techniques": [
          "T1046",
          "T1018"
        ],
        "subTechniques": [],
        "alertRuleTemplateName": null,
        "incidentConfiguration": {
          "createIncident": true,
          "groupingConfiguration": {
            "enabled": true,
            "reopenClosedIncident": false,
            "lookbackDuration": "PT6H",
            "matchingMethod": "Selected",
            "groupByEntities": [
              "IP"
            ],
            "groupByAlertDetails": [],
            "groupByCustomDetails": []
          }
        },
        "eventGroupingSettings": {
          "aggregationKind": "AlertPerResult"
        },
        "alertDetailsOverride": {
          "alertDisplayNameFormat": "Internal host {{SrcIpAddr}} failed connections to {{DistinctDsts}} distinct destinations",
          "alertDescriptionFormat": "{{SrcIpAddr}} generated {{TotalFailures}} failed sessions across {{DistinctDsts}} distinct destinations in one hour, well above its own 14 day baseline. RiskIndicators carries the full signal breakdown."
        },
        "customDetails": {
          "TotalFailures": "TotalFailures",
          "DistinctDsts": "DistinctDsts",
          "DistinctPorts": "DistinctPorts",
          "DistinctSubnets": "DistinctSubnets",
          "BaselinePeakFanout": "BaselinePeakFanout",
          "BaselineActiveHours": "BaselineActiveHours",
          "SensitiveHits": "SensitiveHits",
          "SensitivePortsHit": "SensitivePortsHit",
          "TIMatches": "TIMatches",
          "TIMatchedDsts": "TIMatchedDsts",
          "SampleDsts": "SampleDsts",
          "SamplePorts": "SamplePorts",
          "Protocols": "Protocols",
          "RiskScore": "RiskScore",
          "RiskIndicators": "RiskIndicators"
        },
        "entityMappings": [
          {
            "entityType": "IP",
            "fieldMappings": [
              {
                "identifier": "Address",
                "columnName": "SrcIpAddr"
              }
            ]
          },
          {
            "entityType": "Host",
            "fieldMappings": [
              {
                "identifier": "HostName",
                "columnName": "SrcHostname"
              }
            ]
          }
        ]
      }
    }
  ]
}
