Skip to content
Home

Detection Engineering

The detection library.

Every rule here was exported from a live Microsoft Sentinel workspace, not written as an example. Each carries its real KQL, severity, cadence, MITRE ATT&CK mapping and entity mappings. They’re tuned for a small, quiet tenant, so re-baseline the thresholds before you deploy anywhere real.

0
Analytics rules
15/20/3
High / Med / Low
0
Data sources
0
ATT&CK tactics

38 rules

HighHoneypotevery 5 mins

Command Execution In Unexpected Interactive Session

ExecutionDiscovery

A command was executed inside an interactive SSH session on the decoy host after a successful unexpected login. This shows exactly what the actor is after once inside: reconnaissance (uname, whoami, cat /proc/cpuinfo), persistence, credential hunting, or staging a payload. Read the commands in order. Note what the actor enumerated first, whether any files under /root, /opt, or /var/backups were read, and whether any download was attempted. Map the activity to MITRE tactics. Pivot on the Syslog table filtered to the same SessionId to reconstruct the full session as a timeline.

Data source
CommonSecurityLog
Look back
5 mins
Entities
IP (AttackerIP), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "command.input"
| project TimeGenerated, Computer, AttackerIP = SourceIP, SessionId = DeviceCustomString1,
 Command = DeviceCustomString3
HighHoneypotevery 30 mins

Network Scan Followed By Interactive Login Attempt

ReconnaissanceCredentialAccess

The same source IP was seen by both the network layer (IDS or firewall) and an application-layer SSH session on the decoy host within one hour. That is a full attack progression: find the host, then try to get in. This is genuine cross-source correlation: one IP, two independent evidence sources, one narrative. Pivot between the firewall/IDS view and the session view for the same actor. Build a timeline. Note when scanning started, how long until the first login attempt, whether the login eventually succeeded, and what the actor did afterward.

Data source
CommonSecurityLog
Look back
1 hour
Entities
IP (SourceIP), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
let networkLayer =
 CommonSecurityLog
 | where DeviceVendor in ("Suricata", "nftables")
 | where isnotempty(SourceIP)
 | summarize NetworkEvents = count(), FirstNetworkSeen = min(TimeGenerated) by SourceIP;
let appLayer =
 CommonSecurityLog
 | where DeviceVendor == "sshd" and DeviceEventClassID == "session.connect"
 | summarize ServiceConnections = count(), FirstConnectSeen = min(TimeGenerated) by SourceIP;
networkLayer
| join kind=inner appLayer on SourceIP
| extend Computer = "<decoy-host>"
| extend SecondsFromScanToConnect = datetime_diff('second', FirstConnectSeen, FirstNetworkSeen)
| project SourceIP, Computer, NetworkEvents, ServiceConnections, FirstNetworkSeen, FirstConnectSeen, SecondsFromScanToConnect
HighHoneypotevery 5 mins

Outbound Download Attempt From Interactive Session

CommandAndControlExecution

A session on the decoy host tried to pull a file from the internet (wget, curl, tftp, or a pipe straight into a shell). This is the classic second-stage payload delivery step. The URL is a live, actor-controlled indicator: a real IOC harvested from a real attack, not a hypothetical. Extract the URL and the host it points to. Do not visit it from an unmanaged browser. Record it as an IOC and check the downloaded artifact's hash against threat intel before assuming it is inert. Pivot on the Syslog table filtered to the same SessionId to see the surrounding command and login activity for this actor.

Data source
CommonSecurityLog
Look back
5 mins
Entities
IP (AttackerIP), URL (DownloadUrl), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "command.input"
| where DeviceCustomString3 has_any ("wget", "curl", "tftp")
| extend DownloadUrl = extract(@"(https?://[^\s]+)", 1, DeviceCustomString3)
| project TimeGenerated, Computer, AttackerIP = SourceIP, SessionId = DeviceCustomString1,
 Command = DeviceCustomString3, DownloadUrl
HighHoneypotevery 5 mins

Sensitive Credential File Accessed In Interactive Session

CredentialAccessCollection

An interactive session ran a command that touched a credential, key, or backup file the decoy host is known to hold: the SSH private key, AWS credentials, the application .env, or a database backup archive. Reaching for one of these specific files is not routine browsing. It is exactly what an actor who has gained a shell does next, hunting for something to escalate with (a working credential) or exfiltrate (customer or financial data). This confirms intent beyond a bare login. Determine which file was targeted and whether the actor only read it or also tried to copy it out (scp, curl -T, base64 piping). Pivot on SessionId in the Syslog table to see the full session timeline. If a credential was read, treat it as compromised and rotate it, even though this decoy copy will not grant access anywhere real.

Data source
CommonSecurityLog
Look back
5 mins
Grouping
AllEntities over 5 hours
KQL
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "command.input"
| where DeviceCustomString3 has_any (
 ".ssh/id_rsa", ".aws/credentials", "/opt/app/.env", ".bash_history", "finance_prod"
 )
| extend SessionId = DeviceCustomString1
| extend AttackerIP = SourceIP
| extend FileTargeted = case(
 DeviceCustomString3 has ".ssh/id_rsa", "SSH private key",
 DeviceCustomString3 has ".aws/credentials", "AWS credentials",
 DeviceCustomString3 has "/opt/app/.env", "Application config (DB + cloud credentials)",
 DeviceCustomString3 has "finance_prod", "Database backup archive",
 DeviceCustomString3 has ".bash_history", "Shell history",
 "Unknown"
 )
| project TimeGenerated, Computer, AttackerIP, SessionId, FileTargeted, Command = DeviceCustomString3
HighHoneypotevery 5 mins

Successful Login - Unexpected Interactive Session

InitialAccessCredentialAccess

An external source successfully authenticated over SSH to the decoy host. The decoy presents as a server backing production financial data, administered through automation and service accounts rather than interactive human logins, so a successful interactive session from an untrusted source is extremely unusual and should be treated as high-confidence malicious until proven otherwise. Check which credentials were used and whether the account should hold them, what the session did next under the same session ID, and whether this IP has appeared in other alerts. Confirm with the account owner or change records whether the login was expected before downgrading. Where to look: the Syslog table, filtered to the decoy host and the SSH honeypot process.

Data source
CommonSecurityLog
Look back
5 mins
Entities
IP (AttackerIP), Account (Username), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "login.success"
| project TimeGenerated, Computer, AttackerIP = SourceIP, Username = DestinationUserName,
 PasswordUsed = DeviceCustomString2, SessionId = DeviceCustomString1, Activity
HighHoneypotevery 1 hour

Same source probing honeypot and website

ReconnaissanceDiscoveryCredentialAccess

A single source IP was seen attacking both the SSH honeypot and the public website within 24 hours. Most internet background noise is single-protocol: a mass scanner hits port 22 or it hits port 443, rarely both from the same host. An IP appearing on both surfaces suggests deliberate enumeration of this specific target rather than indiscriminate sweeping, which makes it materially more interesting than either alert alone. Investigate what it tried on each surface and in what order.

Data source
CommonSecurityLog
Look back
1 day
Entities
IP (SourceIP)
Grouping
Selected over 1 day
KQL
let webAttackers = AppServiceHTTPLogs
| where TimeGenerated > ago(24h)
| summarize WebRequests = count(), WebPaths = make_set(CsUriStem, 12), WebAgents = make_set(UserAgent, 4), FirstWeb = min(TimeGenerated), LastWeb = max(TimeGenerated) by CIp
| project-rename SourceIP = CIp;
let honeypotAttackers = CommonSecurityLog
| where TimeGenerated > ago(24h)
| where isnotempty(SourceIP)
| summarize HoneypotEvents = count(), Activities = make_set(Activity, 8), Ports = make_set(DestinationPort, 5), FirstHp = min(TimeGenerated), LastHp = max(TimeGenerated) by SourceIP;
webAttackers
| join kind=inner honeypotAttackers on SourceIP
| extend FirstSeen = min_of(FirstWeb, FirstHp), LastSeen = max_of(LastWeb, LastHp)
| extend SurfacesProbed = "SSH honeypot + public website"
| project SourceIP, SurfacesProbed, WebRequests, WebPaths, WebAgents, HoneypotEvents, Activities, Ports, FirstSeen, LastSeen
| order by HoneypotEvents desc
HighWeb applicationevery 1 hour

Customer portal brute force

CredentialAccess

Repeated sign-in attempts against the customer portal at /account/. The portal authenticates nothing and stores no credentials, so no attempt can succeed - but volume, source, and the account references tried are all worth recording. Compare against /admin/ activity from the same source: an attacker working both portals is enumerating the site deliberately rather than replaying a generic credential list.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where CsMethod == "POST" and CsUriStem startswith "/account"
| summarize Attempts = count(), UserAgents = make_set(UserAgent, 5), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by CIp, bin(TimeGenerated, 10m)
| where Attempts > 8
HighWeb applicationevery 1 hour

Exploit pattern in request URI

InitialAccessExecution

A request URI contained a pattern associated with path traversal, local file inclusion, SQL injection, or command injection. Single hits are common background noise from mass scanners; repeated or targeted attempts from one source are worth escalating.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where CsUriStem has_any ("../", "..%2f", "..%5c", "etc/passwd", "boot.ini", "union+select", "union%20select", "<script", "%3cscript", "cmd.exe", "/bin/sh", "${jndi:", "eval(", "base64_decode")
| summarize Attempts = count(), Patterns = make_set(CsUriStem, 10), UserAgents = make_set(UserAgent, 4) by CIp, bin(TimeGenerated, 10m)
HighWeb applicationevery 1 hour

Injection payload in query string

InitialAccessExecutionCredentialAccess

A request carried an injection payload in its query string. The site's search parameter is the obvious target and App Service records the full query string, so this rule sees the actual payload text rather than just the fact something was attempted. IMPORTANT CONTEXT FOR THE ANALYST: none of these can succeed. The search page has no database, so there is no SQL to inject; output is HTML-escaped, so reflected XSS cannot execute; and there is no file access, template engine, or shell behind it. This alert is therefore about ATTACKER TRADECRAFT, not about compromise. The value is in reading what was tried, in what order, and with which encoding - that is what distinguishes an automated scanner working a payload list from a human iterating on a hypothesis. Pivot on the source IP and compare against honeypot activity from the same source.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where isnotempty(CsUriQuery)
| extend Decoded = url_decode(CsUriQuery)
| extend LowerQ = tolower(Decoded)
| extend SQLi = LowerQ has_any ("union select", "' or ", "\" or ", "1=1", "sleep(", "benchmark(", "waitfor delay", "information_schema", "@@version", "xp_cmdshell", "' and '", "or 1=1")
| extend XSS = LowerQ has_any ("<script", "javascript:", "onerror=", "onload=", "<img src", "<svg", "document.cookie", "alert(", "prompt(")
| extend Traversal = LowerQ has_any ("../", "..\\", "etc/passwd", "boot.ini", "win.ini", "/proc/self")
| extend SSTI = LowerQ has_any ("{{", "${", "<%=", "#{", "${jndi:")
| extend CmdInj = LowerQ has_any ("; ls", "| whoami", "&& cat", "$(", "`id`", "nc -e", "curl http", "wget http")
| where SQLi or XSS or Traversal or SSTI or CmdInj
| extend Classes = strcat_array(pack_array(
 iff(SQLi, "SQLi", ""), iff(XSS, "XSS", ""), iff(Traversal, "Traversal", ""),
 iff(SSTI, "Template", ""), iff(CmdInj, "CommandInj", "")), " ")
| summarize Attempts = count(), Techniques = make_set(Classes, 6), Payloads = make_set(Decoded, 12),
 Paths = make_set(CsUriStem, 6), UserAgents = make_set(UserAgent, 4),
 FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by CIp
| order by Attempts desc
HighWeb applicationevery 1 hour

Staff portal brute force

CredentialAccess

Repeated POST attempts against the staff portal sign-in page from one source. The page authenticates nothing and stores no credentials, so this is attacker effort with no possible success - but the source, volume and timing are worth recording.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where CsMethod == "POST" and CsUriStem startswith "/admin"
| summarize Attempts = count(), UserAgents = make_set(UserAgent, 5), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by CIp, bin(TimeGenerated, 10m)
| where Attempts > 8
HighIdentity & authenticationevery 1 hour

MFA method removed or security info deregistered

CredentialAccessPersistence

Flags removal of a user's strong authentication method or security info - a common post-compromise step to weaken or lock out a victim's MFA.

Data source
AuditLogs
Look back
1 hour
Entities
Account (InitiatorUPN), IP (InitiatorIP)
Grouping
AllEntities over 1 day
KQL
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName in ("Delete StrongAuthenticationMethod", "Disable Strong Authentication", "User deregistered security info", "Delete registered owner for security info", "User deleted security info")
| extend InitiatorUPN = tostring(parse_json(InitiatedBy).user.userPrincipalName), InitiatorIP = tostring(parse_json(InitiatedBy).user.ipAddress)
| project TimeGenerated, OperationName, InitiatorUPN, InitiatorIP
HighIdentity & authenticationevery 1 hour

Privileged Entra ID role assigned

PrivilegeEscalation

Flags a user or service principal being added to a highly privileged Entra ID directory role (Global Administrator, Privileged Role Administrator, Security Administrator, etc.).

Data source
AuditLogs
Look back
1 hour
Entities
Account (InitiatorUPN), IP (InitiatorIP)
Grouping
AllEntities over 1 day
KQL
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName == "Add member to role"
| extend Targets = parse_json(TargetResources)
| mv-expand Targets
| extend ModProps = parse_json(Targets.modifiedProperties)
| mv-expand ModProps
| where tostring(ModProps.displayName) == "Role.DisplayName"
| extend RoleName = trim('"', tostring(ModProps.newValue))
| where RoleName in ("Global Administrator", "Privileged Role Administrator", "Security Administrator", "User Administrator", "Application Administrator", "Cloud Application Administrator", "Exchange Administrator", "SharePoint Administrator", "Conditional Access Administrator", "Privileged Authentication Administrator")
| extend InitiatorUPN = tostring(parse_json(InitiatedBy).user.userPrincipalName), InitiatorIP = tostring(parse_json(InitiatedBy).user.ipAddress)
| extend TargetDisplayName = tostring(Targets.displayName)
| project TimeGenerated, RoleName, TargetDisplayName, InitiatorUPN, InitiatorIP
HighAzure control planeevery 1 hour

Diagnostic setting, data connector, or workspace deleted

DefenseEvasion

Flags deletion of the Log Analytics workspace, a diagnostic setting, or a Sentinel data connector - classic log-tampering / defense-evasion behavior to blind detection.

Data source
AzureActivity
Look back
1 hour
Entities
Account (Caller), IP (CallerIpAddress)
Grouping
AllEntities over 1 day
KQL
AzureActivity
| where TimeGenerated > ago(1h)
| where OperationNameValue in ("MICROSOFT.OPERATIONALINSIGHTS/WORKSPACES/DELETE", "MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE", "MICROSOFT.SECURITYINSIGHTS/DATACONNECTORS/DELETE")
| where ActivityStatusValue == "Success"
| project TimeGenerated, Caller, CallerIpAddress, ResourceId, OperationNameValue
HighAzure control planeevery 1 hour

Mass resource deletion by a single caller

Impact

Flags a single caller performing 10+ successful delete operations within an hour - possible destructive/sabotage activity or a compromised account being used to tear down infrastructure.

Data source
AzureActivity
Look back
1 hour
Entities
Account (Caller), IP (CallerIpAddress)
Grouping
AllEntities over 1 day
KQL
AzureActivity
| where TimeGenerated > ago(1h)
| where OperationNameValue endswith "/DELETE"
| where ActivityStatusValue == "Success"
| summarize DeleteCount = count(), Resources = make_set(ResourceId, 10) by Caller, CallerIpAddress
| where DeleteCount >= 10
HighOperationsevery 1 hour

Workspace ingestion cap approaching

Impact

Today's billable ingestion has passed 70% of the daily cap. When the cap is hit, Log Analytics STOPS INGESTING FOR THE REST OF THE DAY across the entire workspace - which means the SSH honeypot goes dark too, not just the website. This is the alert that protects the real attacker telemetry from being crowded out by website traffic. If this fires repeatedly, the options are: raise the cap and accept the added cost, filter AppServiceHTTPLogs at ingestion to drop routine rows, or move web logs to their own workspace so the honeypot has its own headroom.

Data source
Usage
Look back
1 day
Entities
Host (Workspace)
Grouping
Selected over 1 day
KQL
Usage
| where TimeGenerated > startofday(now())
| where IsBillable == true
| summarize IngestedGB = sum(Quantity) / 1024 by DataType
| summarize TotalGB = round(sum(IngestedGB), 4), ByType = make_bag(pack(DataType, round(IngestedGB, 4)))
| extend CapGB = <daily-cap-gb>
| extend PercentOfCap = round(TotalGB * 100 / CapGB, 1)
| where PercentOfCap > 70
| extend Warning = 'Cap hit stops ALL ingestion today, including the honeypot'
| project TotalGB, CapGB, PercentOfCap, ByType, Warning
| extend Workspace = '<workspace>'
MediumHoneypotevery 15 mins

Port Scan - Multiple Sensitive Ports Probed

ReconnaissanceDiscovery

The firewall blocked one source IP probing 5 or more distinct ports that the decoy host does not serve. A dedicated firewall rule watches ports the host deliberately does not run (telnet 23, SMB 445, RDP 3389, MSSQL 1433, MySQL 3306, Postgres 5432, Redis 6379, MongoDB 27017) and blocks them inline. Legitimate traffic has no reason to touch those, so hitting several in sequence is a port sweep, and DeviceAction confirms the traffic was denied rather than merely observed. Check which ports were probed and in what order. Sequential ports suggest a broad sweep; a targeted set such as only database ports suggests the actor is hunting for a specific service to exploit. The threshold was tuned against an earlier logging-only version of this control and may need re-tuning now that matching traffic is actively blocked rather than just logged.

Data source
CommonSecurityLog
Look back
15 mins
Entities
IP (SourceIP), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "nftables"
| where DeviceAction == "Denied"
| where isnotempty(SourceIP)
| summarize
 ProbeCount = count(),
 DistinctPorts = dcount(DestinationPort),
 PortsProbed = make_set(DestinationPort, 30),
 FirstSeen = min(TimeGenerated),
 LastSeen = max(TimeGenerated)
 by SourceIP, Computer
| where DistinctPorts >= 5
| extend ScanDurationSeconds = datetime_diff('second', LastSeen, FirstSeen)
MediumHoneypotevery 10 mins

SSH Brute Force From Single IP

CredentialAccess

A single source IP made 5 or more failed SSH login attempts against the decoy host within 10 minutes. The host runs no interactive workloads and has no reason to see routine failed logins, so repeated failed-login events from one address in a short window almost always mean an external party is trying to guess credentials. Check how many distinct usernames and passwords were tried and whether it is a dictionary sweep (many users, common passwords) or targeted (one user, many passwords). Check whether any attempt eventually succeeded, and cross-reference the source IP against threat intel. Where to look: the Syslog table, filtered to the decoy host and the SSH honeypot process.

Data source
CommonSecurityLog
Look back
10 mins
Entities
IP (AttackerIP), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "login.failed"
| summarize
 FailedAttempts = count(),
 DistinctUsernames = dcount(DestinationUserName),
 UsernamesTried = make_set(DestinationUserName, 20),
 FirstAttempt = min(TimeGenerated),
 LastAttempt = max(TimeGenerated)
 by AttackerIP = SourceIP, Computer
| where FailedAttempts >= 5
| extend AttackDurationSeconds = datetime_diff('second', LastAttempt, FirstAttempt)
MediumHoneypotevery 10 mins

Suricata High Severity Signature Match

InitialAccessExecution

Suricata matched an ET Open signature at Priority 1 or 2 against traffic involving the decoy host. The packet payload matched a known-bad pattern in the Emerging Threats ruleset. Priority 1 and 2 signatures cover exploit attempts, malware C2 patterns, and attack responses rather than informational noise. Read the signature name in Activity; it usually says exactly what was detected. Check the traffic direction (external IP to the host is an inbound attack; host to external is possible compromise) and DeviceAction ("Denied" means the inline IPS blocked it; "Allowed" means it was detected but passed through and needs follow-up). Check whether the source IP also appears in application-layer session alerts. A signature match is not proof of compromise on its own; confirm with session data whether the actor actually got in.

Data source
CommonSecurityLog
Look back
10 mins
Entities
IP (SourceIP), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "Suricata"
| where toint(LogSeverity) >= 6
| where isnotempty(Activity)
| project TimeGenerated, Computer, SignatureId = DeviceEventClassID, SignatureName = Activity,
 SourceIP, DestinationIP, DestinationPort, Protocol, DeviceAction
MediumWeb applicationevery 1 hour

Website request surge

Impact

The public website took an unusually high request volume in one hour. Expected when the site is shared with a testing community, but worth knowing about because sustained volume does two things: it burns the App Service F1 60 CPU-minute daily quota, after which the site returns 503 until midnight UTC, and it eats into the lab's small ingestion budget, which the honeypot also depends on. Check the top sources. A single host generating most of it is usually one person running a wordlist rather than a distributed event.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
Host (Site)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| summarize Requests = count(), Sources = dcount(CIp),
 TopSource = arg_max(1, CIp), Marked = countif(UserAgent has '<test-marker>')
| extend Unmarked = Requests - Marked
| where Requests > 5000
| project Requests, Sources, Marked, Unmarked
| extend Site = '<site-fqdn>'
MediumWeb applicationevery 1 hour

API endpoint enumeration

DiscoveryReconnaissance

A source probed the /api/ surface. The API advertises routes in its v1 index that deliberately do not exist, so any request to those paths is following the advertised structure rather than guessing. Sustained probing here indicates an attacker reading the site's own responses and working through them, which is more deliberate than blind path fuzzing.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where CsUriStem startswith "/api"
| summarize Requests = count(), Paths = make_set(CsUriStem, 15), Methods = make_set(CsMethod, 5), Statuses = make_set(ScStatus, 6), UserAgents = make_set(UserAgent, 4) by CIp, bin(TimeGenerated, 15m)
| where Requests > 5
MediumWeb applicationevery 1 hour

Path enumeration burst

DiscoveryReconnaissance

A single source generated a high volume of 404s against the public website in a short window - the signature of automated directory or vulnerability scanning. Investigate the source IP and the paths attempted.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where ScStatus == 404
| summarize Hits = count(), DistinctPaths = dcount(CsUriStem), SamplePaths = make_set(CsUriStem, 12), UserAgents = make_set(UserAgent, 5) by CIp, bin(TimeGenerated, 10m)
| where Hits > 20 and DistinctPaths > 10
MediumWeb applicationevery 1 hour

Payment surface probing

DiscoveryCollection

A source requested the invoicing and checkout paths. There is no card entry anywhere on this site by design - /checkout/, /cart/ and /payment/ are informational pages stating that invoicing is handled offline. Traffic here is therefore card-testing bots, e-commerce vulnerability scanners, or skimmer-injection attempts probing for a payment form that does not exist. POST attempts against these paths are the more interesting subset.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where CsUriStem has_any ("/checkout", "/cart", "/payment", "/basket", "/order", "/pay")
| summarize Requests = count(), Posts = countif(CsMethod == "POST"), Paths = make_set(CsUriStem, 12), UserAgents = make_set(UserAgent, 4) by CIp, bin(TimeGenerated, 15m)
| where Requests > 3
MediumWeb applicationevery 1 hour

Search parameter fuzzing

Discovery

A single source sent a high volume of distinct query strings to the search endpoint. This is the shape of automated parameter fuzzing - a tool working through a payload list - rather than a person searching for freight services. This rule fires on payload VOLUME and VARIETY, as opposed to content-based injection detection which fires on payload CONTENT. A scanner will usually trigger both; a human probing carefully may trigger only the content-based signal.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where CsUriStem has "search" and isnotempty(CsUriQuery)
| summarize Requests = count(), DistinctQueries = dcount(CsUriQuery),
 SampleQueries = make_set(url_decode(CsUriQuery), 10), UserAgents = make_set(UserAgent, 4)
 by CIp, bin(TimeGenerated, 10m)
| where DistinctQueries > 15
MediumWeb applicationevery 1 hour

Sensitive path probe

DiscoveryCollection

A source requested paths associated with exposed secrets or admin tooling - version control directories, environment files, cloud credential paths, or common CMS and database admin panels. None exist on this site; the request itself is the signal.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where CsUriStem has_any ("/.git", "/.env", "/.aws", "/.ssh", "/wp-admin", "/wp-login", "/phpmyadmin", "/pma", "/adminer", "/config.php", "/backup", "/.DS_Store", "/server-status", "/actuator", "/.well-known/security.txt")
| summarize Probes = count(), Paths = make_set(CsUriStem, 15), UserAgents = make_set(UserAgent, 5) by CIp, bin(TimeGenerated, 30m)
MediumIdentity & authenticationevery 1 hour

Legacy authentication protocol used

CredentialAccessDefenseEvasion

Flags sign-ins via legacy protocols (IMAP, POP3, SMTP, ActiveSync) that don't support modern auth / MFA - a common way attackers bypass MFA on a compromised account.

Data source
SigninLogs
Look back
1 hour
Entities
Account (UserPrincipalName), IP (IPAddress)
Grouping
AllEntities over 1 day
KQL
SigninLogs
| where TimeGenerated > ago(1h)
| where ClientAppUsed in ("Exchange ActiveSync", "IMAP4", "POP3", "Other clients", "Authenticated SMTP", "IMAP", "SMTP", "MAPI over HTTP", "Offline Address Book")
| project TimeGenerated, UserPrincipalName, IPAddress, ClientAppUsed, AppDisplayName, ResultType
MediumIdentity & authenticationevery 1 hour

New credential added to an application or service principal

Persistence

Flags a new client secret or certificate added to an app registration or service principal - a common persistence technique after gaining access to an app.

Data source
AuditLogs
Look back
1 hour
Entities
Account (InitiatorUPN), IP (InitiatorIP)
Grouping
AllEntities over 1 day
KQL
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName in ("Add service principal credentials", "Create application – Certificates and secrets management", "Update application – Certificates and secrets management", "Add application credential")
| extend InitiatorUPN = tostring(parse_json(InitiatedBy).user.userPrincipalName), InitiatorIP = tostring(parse_json(InitiatedBy).user.ipAddress)
| project TimeGenerated, OperationName, InitiatorUPN, InitiatorIP
MediumIdentity & authenticationevery 1 hour

OAuth application consent or permission grant

DefenseEvasionLateralMovement

Flags OAuth consent grants or new app role/delegated permission assignments - the mechanism behind illicit consent-grant phishing attacks.

Data source
AuditLogs
Look back
1 hour
Entities
Account (InitiatorUPN), IP (InitiatorIP)
Grouping
AllEntities over 1 day
KQL
AuditLogs
| where TimeGenerated > ago(1h)
| where OperationName in ("Consent to application", "Add app role assignment to service principal", "Add delegated permission grant", "Add OAuth2PermissionGrant")
| extend InitiatorUPN = tostring(parse_json(InitiatedBy).user.userPrincipalName), InitiatorIP = tostring(parse_json(InitiatedBy).user.ipAddress)
| project TimeGenerated, OperationName, InitiatorUPN, InitiatorIP
MediumIdentity & authenticationevery 1 hour

Repeated sign-in failures from a single source

CredentialAccess

Flags 5+ failed sign-ins from the same source IP within an hour - brute force or password spray against Entra ID accounts.

Data source
SigninLogs
Look back
1 hour
Entities
IP (IPAddress)
Grouping
AllEntities over 1 day
KQL
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType != "0"
| summarize FailureCount = count(), Users = make_set(UserPrincipalName, 10) by IPAddress
| where FailureCount >= 5
MediumAzure control planeevery 1 hour

Network security group or firewall rule modified

DefenseEvasion

Flags changes to NSGs or Azure Firewall - a common step to open a hole in network perimeter defenses.

Data source
AzureActivity
Look back
1 hour
Entities
Account (Caller), IP (CallerIpAddress)
Grouping
AllEntities over 1 day
KQL
AzureActivity
| where TimeGenerated > ago(1h)
| where OperationNameValue has_any ("NETWORKSECURITYGROUPS", "AZUREFIREWALLS", "FIREWALLPOLICIES")
| where OperationNameValue has_any ("WRITE", "DELETE")
| where ActivityStatusValue == "Success"
| project TimeGenerated, Caller, CallerIpAddress, ResourceId, OperationNameValue
MediumAzure control planeevery 1 hour

RBAC role assignment created

PrivilegeEscalation

Flags any new Azure RBAC role assignment. In this small lab subscription, role assignments should be rare and deliberate - useful signal for privilege escalation attempts.

Data source
AzureActivity
Look back
1 hour
Entities
Account (Caller), IP (CallerIpAddress)
Grouping
AllEntities over 1 day
KQL
AzureActivity
| where TimeGenerated > ago(1h)
| where OperationNameValue == "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"
| where ActivityStatusValue == "Success"
| project TimeGenerated, Caller, CallerIpAddress, ResourceGroup, ResourceId, OperationNameValue, ActivityStatusValue
MediumThreat intelligenceevery 1 hour

TI Map IP Entity to AuditLogs (custom)

CommandAndControl

Matches active threat intelligence IP indicators against audit events. Rewritten against the current ThreatIntelIndicators schema (the legacy ThreatIntelligenceIndicator table is empty in this workspace).

Data source
AuditLogs
Look back
14 days
Entities
IP (InitiatorIP), Account (InitiatorUPN, Name, UPNSuffix)
Grouping
AllEntities over 1 day
KQL
let dt_lookBack = 1h;
let ioc_lookBack = 14d;
let IP_Indicators = ThreatIntelIndicators
| where TimeGenerated >= ago(ioc_lookBack)
| where IsActive == true and (Revoked == false or isempty(Revoked))
| where ValidUntil > now()
| where ObservableKey in ("ipv4-addr:value", "ipv6-addr:value")
| extend TI_ipEntity = ObservableValue
| where ipv4_is_private(TI_ipEntity) == false
| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by ObservableValue;
let AuditEvents = AuditLogs
| where TimeGenerated >= ago(dt_lookBack)
| extend InitiatorIP = tostring(parse_json(tostring(InitiatedBy)).user.ipAddress)
| where isnotempty(InitiatorIP)
| extend AuditLogs_TimeGenerated = TimeGenerated;
IP_Indicators
| join kind=innerunique (AuditEvents) on $left.TI_ipEntity == $right.InitiatorIP
| where AuditLogs_TimeGenerated < ValidUntil
| summarize AuditLogs_TimeGenerated = arg_max(AuditLogs_TimeGenerated, *) by Id, InitiatorIP
| extend TI_Description = tostring(parse_json(tostring(Data)).description)
| extend InitiatorUPN = tostring(parse_json(tostring(InitiatedBy)).user.userPrincipalName)
| extend Name = tostring(split(InitiatorUPN, '@', 0)[0]), UPNSuffix = tostring(split(InitiatorUPN, '@', 1)[0])
| project AuditLogs_TimeGenerated, Id, TI_ipEntity, InitiatorIP, InitiatorUPN, Name, UPNSuffix,
 Confidence, ValidUntil, SourceSystem, TI_Description, OperationName, Category, Type
| extend timestamp = AuditLogs_TimeGenerated
MediumThreat intelligenceevery 1 hour

TI Map IP Entity to AzureActivity

CommandAndControl

Matches active threat intelligence IP indicators against azact events. Rewritten against the current ThreatIntelIndicators schema (the legacy ThreatIntelligenceIndicator table is empty in this workspace).

Data source
AzureActivity
Look back
14 days
Entities
IP (CallerIpAddress), Account (Caller, Name, UPNSuffix)
Grouping
AllEntities over 1 day
KQL
let dt_lookBack = 1h;
let ioc_lookBack = 14d;
let IP_Indicators = ThreatIntelIndicators
| where TimeGenerated >= ago(ioc_lookBack)
| where IsActive == true and (Revoked == false or isempty(Revoked))
| where ValidUntil > now()
| where ObservableKey in ("ipv4-addr:value", "ipv6-addr:value")
| extend TI_ipEntity = ObservableValue
| where ipv4_is_private(TI_ipEntity) == false
| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by ObservableValue;
IP_Indicators
| join kind=innerunique (
 AzureActivity
 | where TimeGenerated >= ago(dt_lookBack)
 | where isnotempty(CallerIpAddress)
 | extend AzureActivity_TimeGenerated = TimeGenerated
) on $left.TI_ipEntity == $right.CallerIpAddress
| where AzureActivity_TimeGenerated < ValidUntil
| summarize AzureActivity_TimeGenerated = arg_max(AzureActivity_TimeGenerated, *) by Id, CallerIpAddress
| extend TI_Description = tostring(parse_json(tostring(Data)).description)
| extend Name = tostring(split(Caller, '@', 0)[0]), UPNSuffix = tostring(split(Caller, '@', 1)[0])
| project AzureActivity_TimeGenerated, Id, TI_ipEntity, CallerIpAddress, Caller, Name, UPNSuffix,
 Confidence, ValidUntil, SourceSystem, TI_Description, OperationNameValue,
 ActivityStatusValue, ResourceGroup, _ResourceId, Type
| extend timestamp = AzureActivity_TimeGenerated
MediumThreat intelligenceevery 1 hour

TI Map IP Entity to CommonSecurityLog

CommandAndControl

Matches active threat intelligence IP indicators against csl events. Rewritten against the current ThreatIntelIndicators schema (the legacy ThreatIntelligenceIndicator table is empty in this workspace).

Data source
CommonSecurityLog
Look back
14 days
Entities
IP (CS_ipEntity)
Grouping
AllEntities over 1 day
KQL
let dt_lookBack = 1h;
let ioc_lookBack = 14d;
let IP_Indicators = ThreatIntelIndicators
| where TimeGenerated >= ago(ioc_lookBack)
| where IsActive == true and (Revoked == false or isempty(Revoked))
| where ValidUntil > now()
| where ObservableKey in ("ipv4-addr:value", "ipv6-addr:value")
| extend TI_ipEntity = ObservableValue
| where ipv4_is_private(TI_ipEntity) == false
| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by ObservableValue;
IP_Indicators
| join kind=innerunique (
 CommonSecurityLog
 | where TimeGenerated >= ago(dt_lookBack)
 | extend CS_ipEntity = coalesce(SourceIP, DestinationIP)
 | where isnotempty(CS_ipEntity)
 | extend CommonSecurityLog_TimeGenerated = TimeGenerated
) on $left.TI_ipEntity == $right.CS_ipEntity
| where CommonSecurityLog_TimeGenerated < ValidUntil
| summarize CommonSecurityLog_TimeGenerated = arg_max(CommonSecurityLog_TimeGenerated, *) by Id, CS_ipEntity
| extend TI_Description = tostring(parse_json(tostring(Data)).description)
| project CommonSecurityLog_TimeGenerated, Id, TI_ipEntity, CS_ipEntity, SourceIP, DestinationIP,
 Confidence, ValidUntil, SourceSystem, TI_Description, DeviceVendor, DeviceProduct,
 Activity, LogSeverity, DeviceAction, Message, Type
| extend timestamp = CommonSecurityLog_TimeGenerated
MediumThreat intelligenceevery 1 hour

TI Map IP Entity to SigninLogs

CommandAndControl

Matches active threat intelligence IP indicators against signin events. Rewritten against the current ThreatIntelIndicators schema (the legacy ThreatIntelligenceIndicator table is empty in this workspace).

Data source
SigninLogs
Look back
14 days
Entities
IP (IPAddress), Account (UserPrincipalName, Name, UPNSuffix)
Grouping
AllEntities over 1 day
KQL
let dt_lookBack = 1h;
let ioc_lookBack = 14d;
let IP_Indicators = ThreatIntelIndicators
| where TimeGenerated >= ago(ioc_lookBack)
| where IsActive == true and (Revoked == false or isempty(Revoked))
| where ValidUntil > now()
| where ObservableKey in ("ipv4-addr:value", "ipv6-addr:value")
| extend TI_ipEntity = ObservableValue
| where ipv4_is_private(TI_ipEntity) == false
| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by ObservableValue;
IP_Indicators
| join kind=innerunique (
 SigninLogs
 | where TimeGenerated >= ago(dt_lookBack)
 | where isnotempty(IPAddress)
 | extend SigninLogs_TimeGenerated = TimeGenerated
) on $left.TI_ipEntity == $right.IPAddress
| where SigninLogs_TimeGenerated < ValidUntil
| summarize SigninLogs_TimeGenerated = arg_max(SigninLogs_TimeGenerated, *) by Id, IPAddress
| extend TI_Description = tostring(parse_json(tostring(Data)).description)
| extend Name = tostring(split(UserPrincipalName, '@', 0)[0]), UPNSuffix = tostring(split(UserPrincipalName, '@', 1)[0])
| project SigninLogs_TimeGenerated, Id, TI_ipEntity, IPAddress, UserPrincipalName, Name, UPNSuffix,
 Confidence, ValidUntil, SourceSystem, TI_Description, AppDisplayName, ResultType, Type
| extend timestamp = SigninLogs_TimeGenerated
MediumThreat intelligenceevery 1 hour

TI map IP entity to AppServiceHTTPLogs

CommandAndControl

Matches active threat intelligence IP indicators against appsvc events. Rewritten against the current ThreatIntelIndicators schema (the legacy ThreatIntelligenceIndicator table is empty in this workspace).

Data source
AppServiceHTTPLogs
Look back
14 days
Entities
IP (CIp), Host (HostName, DnsDomain)
Grouping
AllEntities over 1 day
KQL
let dt_lookBack = 1h;
let ioc_lookBack = 14d;
let IP_Indicators = ThreatIntelIndicators
| where TimeGenerated >= ago(ioc_lookBack)
| where IsActive == true and (Revoked == false or isempty(Revoked))
| where ValidUntil > now()
| where ObservableKey in ("ipv4-addr:value", "ipv6-addr:value")
| extend TI_ipEntity = ObservableValue
| where ipv4_is_private(TI_ipEntity) == false
| summarize LatestIndicatorTime = arg_max(TimeGenerated, *) by ObservableValue;
IP_Indicators
| join kind=innerunique (
 AppServiceHTTPLogs
 | where TimeGenerated >= ago(dt_lookBack)
 | where isnotempty(CIp)
 | extend AppService_TimeGenerated = TimeGenerated
) on $left.TI_ipEntity == $right.CIp
| where AppService_TimeGenerated < ValidUntil
| summarize AppService_TimeGenerated = arg_max(AppService_TimeGenerated, *) by Id, CIp
| extend TI_Description = tostring(parse_json(tostring(Data)).description)
| extend HostName = tostring(split(CsHost, '.', 0)[0]), DnsDomain = tostring(strcat_array(array_slice(split(CsHost, '.'), 1, -1), '.'))
| project AppService_TimeGenerated, Id, TI_ipEntity, CIp, Confidence, ValidUntil, SourceSystem,
 TI_Description, CsMethod, CsUriStem, ScStatus, UserAgent, CsHost, HostName, DnsDomain, Type
| extend timestamp = AppService_TimeGenerated
LowHoneypotevery 15 mins

High Volume Connection Attempts From Single IP

Reconnaissance

One source IP opened 20 or more connections to the decoy host in 15 minutes, indicating automated scanning or a brute-force tool rather than a human. Many rapid connections from one address in a short window is the signature of a bot sweeping address space or hammering a login endpoint. Compare connection count to session duration. Sub-second connections that never attempt login are usually banner-grabbing scanners; longer ones with login attempts are brute-force tools. Cross-reference the IP against failed-login activity. The severity is deliberately low: internet-wide scanning is constant background noise. It is real, but not by itself evidence of a targeted campaign against this host.

Data source
CommonSecurityLog
Look back
15 mins
Entities
IP (AttackerIP), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "session.connect"
| summarize
 ConnectionCount = count(),
 FirstSeen = min(TimeGenerated),
 LastSeen = max(TimeGenerated)
 by AttackerIP = SourceIP, Computer
| where ConnectionCount >= 20
| extend WindowSeconds = datetime_diff('second', LastSeen, FirstSeen)
| extend ConnectionsPerMinute = round(ConnectionCount / (todouble(WindowSeconds) / 60.0), 1)
LowHoneypotevery 15 mins

Malformed Packet - Likely Raw Socket Scanner

Reconnaissance

Suricata detected packets with invalid checksums or malformed TCP structure arriving at the decoy host. Normal operating-system network stacks produce correct checksums, so malformed packets usually mean the sender crafted them by hand with raw sockets, which is what port scanners such as nmap and masscan and some exploit tools do. This is a strong indicator of deliberate scanning rather than accidental traffic. Check how many distinct ports the source touched and compare against application-layer connection data for the same IP. A scanner that finds an open service port often follows up with a brute-force attempt.

Data source
CommonSecurityLog
Look back
15 mins
Entities
IP (SourceIP), Host (Computer)
Grouping
AllEntities over 1 hour
KQL
CommonSecurityLog
| where DeviceVendor == "Suricata"
| where Activity has_any ("invalid checksum", "TCPv4 invalid", "stream", "malformed")
| where isnotempty(SourceIP)
| summarize
 EventCount = count(),
 PortsTargeted = make_set(DestinationPort, 20),
 DistinctPorts = dcount(DestinationPort),
 Signatures = make_set(Activity, 5),
 FirstSeen = min(TimeGenerated),
 LastSeen = max(TimeGenerated)
 by SourceIP, Computer
LowWeb applicationevery 1 hour

Known scanner user agent

Reconnaissance

Request presented a user agent belonging to a recognised security scanner or automated tool. Low severity by default - this is reconnaissance rather than exploitation, and it is useful mainly as corroboration alongside the other web detections.

Data source
AppServiceHTTPLogs
Look back
1 hour
Entities
IP (CIp)
Grouping
Selected over 1 day
KQL
AppServiceHTTPLogs
| where TimeGenerated > ago(1h)
| where UserAgent has_any ("nikto", "sqlmap", "nmap", "masscan", "dirbuster", "gobuster", "nuclei", "zgrab", "wpscan", "acunetix", "nessus", "havij", "netsparker")
| summarize Requests = count(), Tools = make_set(UserAgent, 5), Paths = make_set(CsUriStem, 10) by CIp, bin(TimeGenerated, 30m)