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.
38 rules
HighHoneypotevery 5 minsCommand Execution In Unexpected Interactive Session
ExecutionDiscovery
Command Execution In Unexpected Interactive Session
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
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "command.input"
| project TimeGenerated, Computer, AttackerIP = SourceIP, SessionId = DeviceCustomString1,
Command = DeviceCustomString3HighHoneypotevery 30 minsNetwork Scan Followed By Interactive Login Attempt
ReconnaissanceCredentialAccess
Network Scan Followed By Interactive Login Attempt
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
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, SecondsFromScanToConnectHighHoneypotevery 5 minsOutbound Download Attempt From Interactive Session
CommandAndControlExecution
Outbound Download Attempt From Interactive Session
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
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, DownloadUrlHighHoneypotevery 5 minsSensitive Credential File Accessed In Interactive Session
CredentialAccessCollection
Sensitive Credential File Accessed In Interactive Session
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
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 = DeviceCustomString3HighHoneypotevery 5 minsSuccessful Login - Unexpected Interactive Session
InitialAccessCredentialAccess
Successful Login - Unexpected Interactive Session
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
CommonSecurityLog
| where DeviceVendor == "sshd" and DeviceEventClassID == "login.success"
| project TimeGenerated, Computer, AttackerIP = SourceIP, Username = DestinationUserName,
PasswordUsed = DeviceCustomString2, SessionId = DeviceCustomString1, ActivityHighHoneypotevery 1 hourSame source probing honeypot and website
ReconnaissanceDiscoveryCredentialAccess
Same source probing honeypot and website
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
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 descHighWeb applicationevery 1 hourCustomer portal brute force
CredentialAccess
Customer portal brute force
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
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 > 8HighWeb applicationevery 1 hourExploit pattern in request URI
InitialAccessExecution
Exploit pattern in request URI
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
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 hourInjection payload in query string
InitialAccessExecutionCredentialAccess
Injection payload in query string
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
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 descHighWeb applicationevery 1 hourStaff portal brute force
CredentialAccess
Staff portal brute force
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
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 > 8HighIdentity & authenticationevery 1 hourMFA method removed or security info deregistered
CredentialAccessPersistence
MFA method removed or security info deregistered
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
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, InitiatorIPHighIdentity & authenticationevery 1 hourPrivileged Entra ID role assigned
PrivilegeEscalation
Privileged Entra ID role assigned
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
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, InitiatorIPHighAzure control planeevery 1 hourDiagnostic setting, data connector, or workspace deleted
DefenseEvasion
Diagnostic setting, data connector, or workspace deleted
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
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, OperationNameValueHighAzure control planeevery 1 hourMass resource deletion by a single caller
Impact
Mass resource deletion by a single caller
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
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 >= 10HighOperationsevery 1 hourWorkspace ingestion cap approaching
Impact
Workspace ingestion cap approaching
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
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 minsPort Scan - Multiple Sensitive Ports Probed
ReconnaissanceDiscovery
Port Scan - Multiple Sensitive Ports Probed
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
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 minsSSH Brute Force From Single IP
CredentialAccess
SSH Brute Force From Single IP
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
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 minsSuricata High Severity Signature Match
InitialAccessExecution
Suricata High Severity Signature Match
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
CommonSecurityLog
| where DeviceVendor == "Suricata"
| where toint(LogSeverity) >= 6
| where isnotempty(Activity)
| project TimeGenerated, Computer, SignatureId = DeviceEventClassID, SignatureName = Activity,
SourceIP, DestinationIP, DestinationPort, Protocol, DeviceActionMediumWeb applicationevery 1 hourWebsite request surge
Impact
Website request surge
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
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 hourAPI endpoint enumeration
DiscoveryReconnaissance
API endpoint enumeration
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
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 > 5MediumWeb applicationevery 1 hourPath enumeration burst
DiscoveryReconnaissance
Path enumeration burst
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
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 > 10MediumWeb applicationevery 1 hourPayment surface probing
DiscoveryCollection
Payment surface probing
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
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 > 3MediumWeb applicationevery 1 hourSearch parameter fuzzing
Discovery
Search parameter fuzzing
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
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 > 15MediumWeb applicationevery 1 hourSensitive path probe
DiscoveryCollection
Sensitive path probe
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
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 hourLegacy authentication protocol used
CredentialAccessDefenseEvasion
Legacy authentication protocol used
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
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, ResultTypeMediumIdentity & authenticationevery 1 hourNew credential added to an application or service principal
Persistence
New credential added to an application or service principal
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
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, InitiatorIPMediumIdentity & authenticationevery 1 hourOAuth application consent or permission grant
DefenseEvasionLateralMovement
OAuth application consent or permission grant
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
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, InitiatorIPMediumIdentity & authenticationevery 1 hourRepeated sign-in failures from a single source
CredentialAccess
Repeated sign-in failures from a single source
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
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType != "0"
| summarize FailureCount = count(), Users = make_set(UserPrincipalName, 10) by IPAddress
| where FailureCount >= 5MediumAzure control planeevery 1 hourNetwork security group or firewall rule modified
DefenseEvasion
Network security group or firewall rule modified
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
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, OperationNameValueMediumAzure control planeevery 1 hourRBAC role assignment created
PrivilegeEscalation
RBAC role assignment created
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
AzureActivity
| where TimeGenerated > ago(1h)
| where OperationNameValue == "MICROSOFT.AUTHORIZATION/ROLEASSIGNMENTS/WRITE"
| where ActivityStatusValue == "Success"
| project TimeGenerated, Caller, CallerIpAddress, ResourceGroup, ResourceId, OperationNameValue, ActivityStatusValueMediumThreat intelligenceevery 1 hourTI Map IP Entity to AuditLogs (custom)
CommandAndControl
TI Map IP Entity to AuditLogs (custom)
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
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_TimeGeneratedMediumThreat intelligenceevery 1 hourTI Map IP Entity to AzureActivity
CommandAndControl
TI Map IP Entity to AzureActivity
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
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_TimeGeneratedMediumThreat intelligenceevery 1 hourTI Map IP Entity to CommonSecurityLog
CommandAndControl
TI Map IP Entity to CommonSecurityLog
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
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_TimeGeneratedMediumThreat intelligenceevery 1 hourTI Map IP Entity to SigninLogs
CommandAndControl
TI Map IP Entity to SigninLogs
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
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_TimeGeneratedMediumThreat intelligenceevery 1 hourTI map IP entity to AppServiceHTTPLogs
CommandAndControl
TI map IP entity to AppServiceHTTPLogs
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
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_TimeGeneratedLowHoneypotevery 15 minsHigh Volume Connection Attempts From Single IP
Reconnaissance
High Volume Connection Attempts From Single IP
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
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 minsMalformed Packet - Likely Raw Socket Scanner
Reconnaissance
Malformed Packet - Likely Raw Socket Scanner
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
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, ComputerLowWeb applicationevery 1 hourKnown scanner user agent
Reconnaissance
Known scanner user agent
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
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)