diff --git a/sentinel/analytics-rules/README.md b/sentinel/analytics-rules/README.md new file mode 100644 index 0000000..e505b95 --- /dev/null +++ b/sentinel/analytics-rules/README.md @@ -0,0 +1,87 @@ +# RetailShield — Sentinel Analytics Rule Templates + +This directory contains 13 ARM template files, one per retail detection rule, ready to deploy as Microsoft Sentinel Scheduled Analytics Rules. + +## Rules + +| File | Display Name | Severity | Frequency | MITRE Technique | +|------|-------------|----------|-----------|-----------------| +| `after_hours_access.json` | After-Hours System Access Detection | Medium | 5 min | T1078 | +| `ai_voice_fraud.json` | AI-Assisted Voice Fraud (Vishing) Detection | High | 30 min | T1598 | +| `credential_stuffing.json` | Credential Stuffing Attack Detection | High | 5 min | T1110.004 | +| `data_exfiltration.json` | Data Exfiltration via Alternative Protocol | Critical | 5 min | T1048 | +| `gift_card_fraud.json` | Gift Card Fraud Pattern Detection | High | 5 min | T1657 | +| `mfa_fatigue.json` | MFA Fatigue / Push Bombing Attack | High | 5 min | T1621 | +| `phishing_detection.json` | Retail Phishing Email Detection | High | 5 min | T1566.001 | +| `pos_anomaly.json` | POS Terminal Anomaly Detection | High | 15 min | T1056.001 | +| `pos_void_refund.json` | POS Void/Refund Fraud Pattern | High | 5 min | T1056.001 | +| `privileged_role_addition.json` | Privileged Role Assignment Detection | High | 5 min | T1098/T1078 | +| `ransomware_indicator.json` | Ransomware Indicator Detection | Critical | 5 min | T1486 | +| `supplier_impossible_travel.json` | Supplier Impossible Travel Detection | Medium | 15 min | T1199/T1078 | +| `supply_chain_anomaly.json` | Supply Chain / Third-Party Anomaly Detection | High | 30 min | T1195 | + +## Deployment + +### Deploy a single rule + +```bash +az deployment group create \ + --resource-group \ + --template-file sentinel/analytics-rules/phishing_detection.json \ + --parameters workspaceName= +``` + +### Deploy all rules + +```bash +for f in sentinel/analytics-rules/*.json; do + az deployment group create \ + --resource-group \ + --template-file "$f" \ + --parameters workspaceName= +done +``` + +### Import via Sentinel UI + +1. Open Microsoft Sentinel → **Analytics** → **Import** +2. Select one or more `.json` files from this directory +3. Review each rule's settings and enable + +## Template Structure + +Each template follows the `Microsoft.OperationalInsights/workspaces/providers/alertRules` ARM schema: + +```json +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "parameters": { "workspaceName": { "type": "string" } }, + "resources": [{ + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "kind": "Scheduled", + "properties": { + "displayName": "...", + "severity": "High|Critical|Medium", + "query": "", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "tactics": ["..."], + "techniques": ["T...."], + "entityMappings": [...], + "customDetails": { "PlaybookTrigger": "...", "RiskScore": "..." } + } + }] +} +``` + +## Watchlist Dependencies + +Several rules require these watchlists to be deployed first (see `docs/deployment_guide.md`): + +| Watchlist | Used By | +|-----------|---------| +| `RetailIOCWatchlist` | data_exfiltration, ransomware_indicator | +| `AbuseIPDBWatchlist` | credential_stuffing | +| `RetailServiceAccounts` | after_hours_access | +| `RetailSupplierAccounts` | supplier_impossible_travel | diff --git a/sentinel/analytics-rules/after_hours_access.json b/sentinel/analytics-rules/after_hours_access.json new file mode 100644 index 0000000..0da1407 --- /dev/null +++ b/sentinel/analytics-rules/after_hours_access.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0007-4e5f-a007-000000000007')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - After-Hours System Access Detection", + "description": "Detects interactive sign-ins and resource access occurring outside configured business hours for retail accounts. Cross-references RetailServiceAccounts watchlist to exclude known automation. MITRE T1078.", + "severity": "Medium", + "enabled": true, + "query": "// ============================================================\n// RetailShield — After Hours Access Detection Rule\n// MITRE ATT&CK : T1078 — Valid Accounts\n// Tactic : Persistence\n// Severity : Medium\n// Frequency : Every 5 minutes | Lookback: 30 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : CIFAS Insider Threat Report 2024 — £1,000+ loss per\n// employee per year; access outside hours is a key indicator\n// ============================================================\n\nlet LookbackPeriod = 30m;\nlet BusinessHourStart = 7; // 07:00 local time\nlet BusinessHourEnd = 20; // 20:00 local time\n\n// ── Service accounts and automation excluded from alerting ───────────────────\nlet ServiceAccountExclusions = (\n _GetWatchlist(\"RetailServiceAccounts\")\n | project ExcludedAccount = tolower(UserPrincipalName)\n);\n\n// ── Step 1: Interactive logins outside business hours ────────────────────────\nlet AfterHoursLogins =\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType == \"0\" // successful sign-ins only\n | where IsInteractive == true // exclude non-interactive service flows\n | where isnotempty(UserPrincipalName)\n | extend HourOfDay = hourofday(TimeGenerated)\n | where HourOfDay < BusinessHourStart or HourOfDay >= BusinessHourEnd\n | extend NormUser = tolower(UserPrincipalName)\n | join kind=leftanti (ServiceAccountExclusions)\n on $left.NormUser == $right.ExcludedAccount // exclude known service accounts\n | summarize\n LoginCount = count(),\n SourceIPs = make_set(IPAddress, 5),\n Locations = make_set(Location, 5),\n AppNames = make_set(AppDisplayName, 5),\n EarliestLogin = min(TimeGenerated),\n LatestLogin = max(TimeGenerated)\n by UserPrincipalName, bin(TimeGenerated, 5m)\n | extend ThreatSignal = \"AfterHoursInteractiveLogin\";\n\n// ── Step 2: Privileged role access outside hours ─────────────────────────────\nlet AfterHoursPrivilegedAccess =\n AuditLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where Category in (\"RoleManagement\", \"UserManagement\", \"GroupManagement\")\n | where OperationName has_any (\n \"Add member to role\",\n \"Remove member from role\",\n \"Reset user password\",\n \"Update user\",\n \"Delete user\"\n )\n | extend HourOfDay = hourofday(TimeGenerated)\n | where HourOfDay < BusinessHourStart or HourOfDay >= BusinessHourEnd\n | extend InitiatedByUser = tostring(InitiatedBy.user.userPrincipalName)\n | where isnotempty(InitiatedByUser)\n | extend NormUser = tolower(InitiatedByUser)\n | join kind=leftanti (ServiceAccountExclusions)\n on $left.NormUser == $right.ExcludedAccount\n | summarize\n ActionCount = count(),\n Operations = make_set(OperationName, 5),\n TargetObjects = make_set(tostring(TargetResources[0].displayName), 5),\n EarliestLogin = min(TimeGenerated),\n LatestLogin = max(TimeGenerated)\n by InitiatedByUser, bin(TimeGenerated, 5m)\n | extend ThreatSignal = \"AfterHoursPrivilegedOperation\";\n\n// ── Step 3: Sensitive system access after hours ───────────────────────────────\nlet AfterHoursSensitiveAccess =\n DeviceLogonEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType == \"LogonSuccess\"\n | where LogonType in (\"Interactive\", \"RemoteInteractive\")\n | where isnotempty(AccountName)\n | extend HourOfDay = hourofday(Timestamp)\n | where HourOfDay < BusinessHourStart or HourOfDay >= BusinessHourEnd\n | where DeviceName has_any (\"pos-\", \"erpserver\", \"payroll\", \"finance\", \"dc-\")\n | extend NormUser = tolower(AccountName)\n | join kind=leftanti (ServiceAccountExclusions)\n on $left.NormUser == $right.ExcludedAccount\n | summarize\n LoginCount = count(),\n Devices = make_set(DeviceName, 5),\n EarliestLogin = min(Timestamp),\n LatestLogin = max(Timestamp)\n by AccountName, RemoteIP, bin(Timestamp, 5m)\n | extend ThreatSignal = \"AfterHoursSensitiveDeviceLogon\";\n\n// ── Step 4: Union all signals and surface alerts ──────────────────────────────\nunion\n (AfterHoursLogins\n | extend\n AccountName = UserPrincipalName,\n Devices = dynamic([]), ActionCount = 0,\n Operations = dynamic([]), TargetObjects = dynamic([]),\n RemoteIP = tostring(SourceIPs[0])),\n (AfterHoursPrivilegedAccess\n | extend\n AccountName = InitiatedByUser,\n LoginCount = ActionCount, SourceIPs = dynamic([]),\n Locations = dynamic([]), AppNames = dynamic([]),\n Devices = dynamic([]), RemoteIP = \"\"),\n (AfterHoursSensitiveAccess\n | extend\n UserPrincipalName = AccountName,\n SourceIPs = dynamic([]), Locations = dynamic([]),\n AppNames = dynamic([]), ActionCount = 0,\n Operations = dynamic([]), TargetObjects = dynamic([]))\n| extend\n AlertSeverity = \"Medium\",\n MitreTechnique = \"T1078\",\n MitreTactic = \"Persistence\",\n PlaybookTrigger = \"notify_soc\",\n RiskScore = 60\n| project\n Timestamp = coalesce(EarliestLogin),\n AccountName,\n ThreatSignal,\n LoginCount,\n SourceIPs,\n Locations,\n AppNames,\n Devices,\n RemoteIP,\n Operations,\n TargetObjects,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT30M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Persistence" + ], + "techniques": [ + "T1078" + ], + "alertRuleTemplateName": "a1b2c3d4-0007-4e5f-a007-000000000007", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "UserPrincipalName" + } + ] + }, + { + "entityType": "IP", + "fieldMappings": [ + { + "identifier": "Address", + "columnName": "IPAddress" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "AccessHour": "AccessHour" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "SigninLogs", + "AuditLogs", + "DeviceLogonEvents" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/ai_voice_fraud.json b/sentinel/analytics-rules/ai_voice_fraud.json new file mode 100644 index 0000000..47ce3c9 --- /dev/null +++ b/sentinel/analytics-rules/ai_voice_fraud.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0005-4e5f-a005-000000000005')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - AI-Assisted Voice Fraud (Vishing) Detection", + "description": "Detects anomalous voice-channel events consistent with AI-generated vishing attacks targeting retail customers or staff. Analyses RetailShield_Logs_CL voice call metadata for suspicious patterns. MITRE T1598.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — AI Voice Fraud Detection\n// Rule ID : RS-VOI-001\n// MITRE ATT&CK : T1598 — Phishing for Information (Voice)\n// Tactic : Reconnaissance\n// Severity : High\n// Frequency : Every 30 minutes | Lookback: 30 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// ============================================================\n// Detects AI-generated deepfake voice calls targeting retail\n// finance and management staff. Covers high AI confidence score\n// calls, urgent financial requests from unverified callers,\n// caller ID spoofing patterns, and after-hours vishing attempts.\n// Retail-specific: targets CFO/finance-role impersonation and\n// urgent payment override requests unique to retail operations.\n// ============================================================\n\nlet LookbackWindow = 30m;\nlet BusinessHoursStart = 7; // 07:00 UTC\nlet BusinessHoursEnd = 20; // 20:00 UTC\nlet AIConfidenceThreshold = 0.85; // AI voice deepfake confidence above this is high-risk\nlet HighRiskSuspicionScore = 85;\n\n// Roles that are high-value targets for voice fraud\nlet SensitiveTargetRoles = dynamic([\n \"finance\", \"director\", \"manager\", \"cfo\", \"ceo\",\n \"payment\", \"accounts\", \"payroll\", \"head\"\n]);\n\n// Impersonation personas used in retail voice fraud\nlet KnownImpersonationEntities = dynamic([\n \"Area Manager\", \"Head Office\", \"CFO\", \"CEO\",\n \"Finance Director\", \"Regional Director\", \"DHL\",\n \"HMRC\", \"Bank\", \"IT Support\"\n]);\n\n// Keywords that signal urgency / bypass request — fraud indicators\nlet FraudKeywords = dynamic([\n \"urgent\", \"immediate\", \"emergency\", \"override\",\n \"bypass\", \"redirect\", \"transfer\", \"wire\", \"bacs\",\n \"authorise\", \"approve now\", \"time sensitive\", \"confidential\"\n]);\n\n// ── Signal 1 — High AI confidence score on detected voice call ───────────────\nlet HighConfidenceVoiceFraud =\n RetailShield_Logs_CL\n | where ingestion_time() > ago(LookbackWindow)\n | where EventType_s == \"AI_Voice_Fraud\"\n | where todouble(AIConfidenceScore_d) >= AIConfidenceThreshold\n | project\n TimeGenerated,\n SignalType = \"HighConfidenceVoiceFraud\",\n TargetEmployee = tostring(TargetEmployee_s),\n AIConfidenceScore = AIConfidenceScore_d,\n ImpersonatingEntity = tostring(ImpersonatingEntity_s),\n RequestMade = tostring(RequestMade_s),\n SuspicionScore = todouble(SuspicionScore_d);\n\n// ── Signal 2 — Urgent financial / bypass request on voice channel ────────────\nlet UrgentFinancialRequest =\n RetailShield_Logs_CL\n | where ingestion_time() > ago(LookbackWindow)\n | where EventType_s == \"AI_Voice_Fraud\"\n | where tolower(tostring(RequestMade_s)) has_any (FraudKeywords)\n | where tolower(tostring(TargetEmployee_s)) has_any (SensitiveTargetRoles)\n or tostring(ImpersonatingEntity_s) in (KnownImpersonationEntities)\n | project\n TimeGenerated,\n SignalType = \"UrgentFinancialRequest\",\n TargetEmployee = tostring(TargetEmployee_s),\n AIConfidenceScore = AIConfidenceScore_d,\n ImpersonatingEntity = tostring(ImpersonatingEntity_s),\n RequestMade = tostring(RequestMade_s),\n SuspicionScore = todouble(SuspicionScore_d);\n\n// ── Signal 3 — Caller ID spoofing pattern detected ───────────────────────────\nlet SpoofedCallerID =\n RetailShield_Logs_CL\n | where ingestion_time() > ago(LookbackWindow)\n | where EventType_s == \"AI_Voice_Fraud\"\n | where SuspicionScore_d >= HighRiskSuspicionScore\n | where isnotempty(CallerID_s)\n | where tostring(CallerID_s) matches regex @\"^(\\+44|0044|0)[0-9]{9,10}$\"\n | project\n TimeGenerated,\n SignalType = \"SpoofedCallerID\",\n TargetEmployee = tostring(TargetEmployee_s),\n AIConfidenceScore = AIConfidenceScore_d,\n ImpersonatingEntity = tostring(ImpersonatingEntity_s),\n RequestMade = tostring(RequestMade_s),\n SuspicionScore = todouble(SuspicionScore_d);\n\n// ── Signal 4 — After-hours vishing attempt ───────────────────────────────────\nlet AfterHoursVoiceFraud =\n RetailShield_Logs_CL\n | where ingestion_time() > ago(LookbackWindow)\n | where EventType_s == \"AI_Voice_Fraud\"\n | extend CallHour = hourofday(TimeGenerated)\n | where CallHour < BusinessHoursStart or CallHour >= BusinessHoursEnd\n | project\n TimeGenerated,\n SignalType = \"AfterHoursVoiceFraud\",\n TargetEmployee = tostring(TargetEmployee_s),\n AIConfidenceScore = AIConfidenceScore_d,\n ImpersonatingEntity = tostring(ImpersonatingEntity_s),\n RequestMade = tostring(RequestMade_s),\n SuspicionScore = todouble(SuspicionScore_d);\n\n// ── Union all signals ─────────────────────────────────────────────────────────\nunion HighConfidenceVoiceFraud, UrgentFinancialRequest, SpoofedCallerID, AfterHoursVoiceFraud\n| summarize\n SignalCount = count(),\n Signals = make_set(SignalType),\n FirstSeen = min(TimeGenerated),\n LastSeen = max(TimeGenerated),\n MaxAIScore = max(todouble(AIConfidenceScore)),\n MaxSuspicionScore = max(todouble(SuspicionScore)),\n TargetEmployee = take_any(TargetEmployee),\n ImpersonatingEntity = take_any(ImpersonatingEntity),\n RequestMade = take_any(RequestMade)\n by bin(TimeGenerated, LookbackWindow)\n| where SignalCount >= 1\n| extend\n RiskScore = case(SignalCount >= 3, 90, SignalCount == 2, 78, 65),\n MitreTechnique = \"T1598\",\n MitreTactic = \"Reconnaissance\",\n AlertSeverity = case(SignalCount >= 3, \"CRITICAL\", \"HIGH\"),\n Severity = \"HIGH\",\n PlaybookTrigger = \"notify_soc\",\n DeviceName = \"VOIP-SYSTEM\",\n AlertTitle = strcat(\"AI Voice Fraud Detected — Target: \", TargetEmployee,\n \" | Impersonating: \", ImpersonatingEntity)\n| project\n AlertTitle,\n DeviceName,\n TargetEmployee,\n ImpersonatingEntity,\n RequestMade,\n AlertSeverity,\n Severity,\n RiskScore,\n SignalCount,\n Signals,\n MaxAIScore,\n MaxSuspicionScore,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n FirstSeen,\n LastSeen\n| sort by RiskScore desc\n", + "queryFrequency": "PT30M", + "queryPeriod": "PT30M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Reconnaissance" + ], + "techniques": [ + "T1598" + ], + "alertRuleTemplateName": "a1b2c3d4-0005-4e5f-a005-000000000005", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "CallerID" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "CallCount": "CallCount" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "RetailShield_Logs_CL" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/credential_stuffing.json b/sentinel/analytics-rules/credential_stuffing.json new file mode 100644 index 0000000..e8db082 --- /dev/null +++ b/sentinel/analytics-rules/credential_stuffing.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0006-4e5f-a006-000000000006')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Credential Stuffing Attack Detection", + "description": "Detects credential stuffing attacks against retail authentication endpoints by correlating high-volume failed sign-in attempts from IPs on the AbuseIPDB watchlist in SigninLogs. MITRE T1110.004.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Credential Stuffing Detection Rule\n// MITRE ATT&CK : T1110.004 — Credential Stuffing\n// Tactic : Credential Access\n// Severity : High\n// Frequency : Every 5 minutes | Lookback: 15 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : Retail customer portal attacks — distributed login abuse\n// using breached credential pairs from dark web dumps\n// ============================================================\n\nlet LookbackPeriod = 15m;\nlet FailThreshPerAcct = 20; // failed logins per account within lookback\nlet MinSourceIPs = 3; // minimum distinct source IPs (distinguishes from brute force)\nlet SuccessAfterFail = true; // flag accounts that succeed after threshold failures\n\n// ── AbuseIPDB watchlist — known credential-stuffing infrastructure ────────────\nlet AbuseIPDBWatchlist = (\n _GetWatchlist(\"AbuseIPDBWatchlist\")\n | where Type == \"IP\"\n | project IOCValue = tolower(Value)\n);\n\n// ── Step 1: Failed login velocity — many IPs targeting same account ───────────\nlet FailedLoginsByAccount =\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType != \"0\" // non-zero = failure\n | where ResultType !in (\"50074\", \"50076\", \"50079\") // exclude MFA prompts (not failures)\n | where isnotempty(UserPrincipalName)\n | where isnotempty(IPAddress)\n | summarize\n FailCount = count(),\n SourceIPs = dcount(IPAddress),\n SourceIPList = make_set(IPAddress, 10),\n UserAgents = make_set(UserAgent, 5),\n AppNames = make_set(AppDisplayName, 5),\n FirstAttempt = min(TimeGenerated),\n LastAttempt = max(TimeGenerated)\n by UserPrincipalName, bin(TimeGenerated, 5m)\n | where FailCount >= FailThreshPerAcct\n and SourceIPs >= MinSourceIPs\n | extend ThreatSignal = \"DistributedLoginFailure\";\n\n// ── Step 2: Successful login after stuffing threshold — account taken over ────\nlet SuccessAfterStuffing =\n FailedLoginsByAccount\n | join kind=inner (\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType == \"0\"\n | where isnotempty(UserPrincipalName)\n | project\n UserPrincipalName,\n SuccessTime = TimeGenerated,\n SuccessIP = IPAddress,\n SuccessCountry = Location,\n SuccessApp = AppDisplayName\n ) on UserPrincipalName\n | where SuccessTime > LastAttempt // success after the failure spike\n and SuccessTime < LastAttempt + 10m // within 10 minutes\n | extend ThreatSignal = \"AccountTakeoverAfterStuffing\";\n\n// ── Step 3: Known bad IP involvement — stuffing from blacklisted source ───────\nlet KnownBadIPStuffing =\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType != \"0\"\n | where isnotempty(IPAddress)\n | extend NormIP = tolower(IPAddress)\n | join kind=inner (AbuseIPDBWatchlist)\n on $left.NormIP == $right.IOCValue\n | summarize\n FailCount = count(),\n TargetAccts = dcount(UserPrincipalName),\n TargetList = make_set(UserPrincipalName, 10),\n FirstSeen = min(TimeGenerated),\n LastSeen = max(TimeGenerated)\n by IPAddress, Location\n | extend ThreatSignal = \"StuffingFromBlacklistedIP\";\n\n// ── Step 4: Impossible travel after credential stuffing ───────────────────────\nlet ImpossibleTravelPostStuffing =\n FailedLoginsByAccount\n | join kind=inner (\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType == \"0\"\n | where RiskLevelDuringSignIn in (\"high\", \"medium\")\n | project\n UserPrincipalName,\n RiskySigninTime = TimeGenerated,\n RiskLevel = RiskLevelDuringSignIn,\n RiskyIP = IPAddress,\n RiskyLocation = Location\n ) on UserPrincipalName\n | where RiskySigninTime > LastAttempt\n | extend ThreatSignal = \"RiskySigninAfterStuffing\";\n\n// ── Step 5: Union and surface alerts ─────────────────────────────────────────\nunion\n (FailedLoginsByAccount\n | extend\n SuccessIP = \"\", SuccessCountry = \"\", SuccessApp = \"\",\n TargetAccts = 0, TargetList = dynamic([]),\n RiskLevel = \"\", RiskyIP = \"\", RiskyLocation = \"\"),\n (SuccessAfterStuffing\n | extend\n TargetAccts = 0, TargetList = dynamic([]),\n RiskLevel = \"\", RiskyIP = \"\", RiskyLocation = \"\"),\n (KnownBadIPStuffing\n | extend\n UserPrincipalName = \"\", FailCount = 0, SourceIPs = 0,\n SourceIPList = dynamic([]), UserAgents = dynamic([]),\n AppNames = dynamic([]), SuccessIP = \"\", SuccessCountry = \"\",\n SuccessApp = \"\", RiskLevel = \"\", RiskyIP = \"\", RiskyLocation = \"\"),\n (ImpossibleTravelPostStuffing\n | extend\n TargetAccts = 0, TargetList = dynamic([]),\n SuccessIP = \"\", SuccessCountry = \"\", SuccessApp = \"\")\n| extend\n AlertSeverity = \"High\",\n MitreTechnique = \"T1110.004\",\n MitreTactic = \"Credential Access\",\n PlaybookTrigger = \"block_ip\",\n RiskScore = 80\n| project\n Timestamp = coalesce(FirstAttempt, FirstSeen, RiskySigninTime),\n UserPrincipalName,\n ThreatSignal,\n FailCount,\n SourceIPs,\n SourceIPList,\n IPAddress,\n TargetAccts,\n TargetList,\n SuccessIP,\n SuccessCountry,\n RiskLevel,\n AppNames,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "CredentialAccess" + ], + "techniques": [ + "T1110.004" + ], + "alertRuleTemplateName": "a1b2c3d4-0006-4e5f-a006-000000000006", + "entityMappings": [ + { + "entityType": "IP", + "fieldMappings": [ + { + "identifier": "Address", + "columnName": "IPAddress" + } + ] + }, + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "UserPrincipalName" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "FailedAttempts": "FailedAttempts" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "SigninLogs" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/data_exfiltration.json b/sentinel/analytics-rules/data_exfiltration.json new file mode 100644 index 0000000..d1312ed --- /dev/null +++ b/sentinel/analytics-rules/data_exfiltration.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0004-4e5f-a004-000000000004')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Data Exfiltration via Alternative Protocol", + "description": "Detects large-volume outbound data transfers from retail endpoints to external destinations, especially from known threat-intel IPs in RetailIOCWatchlist. Correlates DeviceNetworkEvents and DeviceFileEvents. MITRE T1048.", + "severity": "Critical", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Data Exfiltration Detection Rule\n// MITRE ATT&CK : T1048 — Exfiltration Over Alternative Protocol\n// Tactic : Exfiltration\n// Severity : Critical\n// Frequency : Every 5 minutes | Lookback: 60 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : Nike third-party breach (2018) — 1.4 TB customer & IP data exposed\n// ============================================================\n\nlet LookbackPeriod = 60m;\nlet DNSQueryThresh = 200; // DNS queries to single domain within lookback\nlet SubdomainLenThresh = 40; // characters — flags long encoded subdomains\nlet OutboundMBThresh = 100; // MB transferred outbound to single external IP\nlet StagingFileThresh = 50; // bulk file reads before outbound connection\n\n// ── IOCs from Sentinel watchlist ────────────────────────────────────────────\nlet ExfilIOCs = (\n _GetWatchlist(\"RetailIOCWatchlist\")\n | where Type in (\"Domain\", \"IP\")\n | project IOCValue = tolower(Value), IOCType = Type\n);\n\n// ── Step 1: DNS tunneling — high-volume queries with long encoded subdomains ─\nlet DNSTunneling =\n DeviceNetworkEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType == \"ConnectionSuccess\"\n | where RemotePort == 53\n | where isnotempty(RemoteUrl)\n | extend\n SubdomainPart = tostring(split(tolower(RemoteUrl), \".\")[-3]), // label before apex\n DomainApex = strcat(\n tostring(split(tolower(RemoteUrl), \".\")[-2]),\n \".\",\n tostring(split(tolower(RemoteUrl), \".\")[-1])\n )\n | where strlen(SubdomainPart) >= SubdomainLenThresh\n or RemoteUrl matches regex @\"[a-z0-9]{32,}\" // hex/base32 encoded chunks\n | summarize\n QueryCount = count(),\n UniqueSubdomains = dcount(RemoteUrl),\n SampleQueries = make_set(RemoteUrl, 5),\n FirstSeen = min(Timestamp),\n LastSeen = max(Timestamp)\n by DeviceId, DeviceName, DomainApex\n | where QueryCount >= DNSQueryThresh or UniqueSubdomains >= 50\n | extend ThreatSignal = \"DNSTunneling\";\n\n// ── Step 2: Large outbound transfer over non-standard port ───────────────────\nlet LargeOutboundTransfer =\n DeviceNetworkEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType == \"ConnectionSuccess\"\n | where isnotempty(RemoteIP)\n | where RemoteIP !startswith \"10.\"\n and RemoteIP !startswith \"192.168.\"\n and RemoteIP !startswith \"172.\"\n and RemoteIP != \"127.0.0.1\"\n | where RemotePort !in (80, 443, 8080, 8443) // exclude standard web\n | where SentBytes > 0\n | summarize\n TotalSentBytes = sum(SentBytes),\n ConnectionCount = count(),\n PortsUsed = make_set(RemotePort, 5),\n FirstSeen = min(Timestamp),\n LastSeen = max(Timestamp)\n by DeviceId, DeviceName, RemoteIP\n | extend TotalSentMB = TotalSentBytes / (1024 * 1024)\n | where TotalSentMB >= OutboundMBThresh\n | extend ThreatSignal = \"LargeOutboundTransfer\";\n\n// ── Step 3: IOC-matched exfiltration domain / IP ────────────────────────────\nlet IOCMatchedExfil =\n DeviceNetworkEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType == \"ConnectionSuccess\"\n | where isnotempty(RemoteUrl) or isnotempty(RemoteIP)\n | extend NormalisedTarget = tolower(coalesce(RemoteUrl, RemoteIP))\n | join kind=inner (ExfilIOCs)\n on $left.NormalisedTarget == $right.IOCValue\n | summarize\n HitCount = count(),\n FirstSeen = min(Timestamp),\n LastSeen = max(Timestamp)\n by DeviceId, DeviceName, NormalisedTarget, IOCType\n | extend ThreatSignal = \"IOCMatchedExfilTarget\";\n\n// ── Step 4: Data staging — bulk file reads followed by external connection ───\nlet BulkFileReads =\n DeviceFileEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType == \"FileRead\"\n | where FolderPath has_any (\n \"\\\\customers\\\\\", \"\\\\orders\\\\\", \"\\\\finance\\\\\",\n \"\\\\payroll\\\\\", \"\\\\hr\\\\\", \"\\\\export\\\\\",\n \"\\\\reports\\\\\", \"\\\\backup\\\\\", \"\\\\archive\\\\\"\n )\n | summarize\n FileReadCount = count(),\n UniqueFolders = dcount(FolderPath),\n SampleFiles = make_set(FileName, 5),\n StagingEnd = max(Timestamp)\n by DeviceId, DeviceName, AccountName;\n\nlet DataStagingToExfil =\n BulkFileReads\n | where FileReadCount >= StagingFileThresh\n | join kind=inner (\n DeviceNetworkEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType == \"ConnectionSuccess\"\n | where RemoteIP !startswith \"10.\"\n and RemoteIP !startswith \"192.168.\"\n and RemoteIP !startswith \"172.\"\n and RemoteIP != \"127.0.0.1\"\n | where SentBytes > 0\n | project DeviceId, ExfilTimestamp = Timestamp, RemoteIP, SentBytes\n ) on DeviceId\n | where ExfilTimestamp > StagingEnd // exfil after staging\n and ExfilTimestamp < StagingEnd + 30m // within 30 min window\n | summarize\n TotalSentBytes = sum(SentBytes),\n ExfilTargets = dcount(RemoteIP)\n by DeviceId, DeviceName, AccountName, FileReadCount, UniqueFolders, SampleFiles\n | extend ThreatSignal = \"DataStagingToExfil\";\n\n// ── Step 5: Union all signals and surface alerts ──────────────────────────────\nunion\n (DNSTunneling\n | extend AccountName = \"\", RemoteIP = \"\", TotalSentMB = 0.0,\n ConnectionCount = 0, FileReadCount = 0, SampleFiles = dynamic([]),\n UniqueFolders = 0, ExfilTargets = 0, TotalSentBytes = 0,\n NormalisedTarget = DomainApex),\n (LargeOutboundTransfer\n | extend AccountName = \"\", QueryCount = 0, UniqueSubdomains = 0,\n SampleQueries = dynamic([]), DomainApex = \"\",\n FileReadCount = 0, SampleFiles = dynamic([]),\n UniqueFolders = 0, ExfilTargets = 0, NormalisedTarget = RemoteIP),\n (IOCMatchedExfil\n | extend QueryCount = 0, UniqueSubdomains = 0, SampleQueries = dynamic([]),\n DomainApex = \"\", TotalSentMB = 0.0, ConnectionCount = 0,\n FileReadCount = 0, SampleFiles = dynamic([]),\n UniqueFolders = 0, ExfilTargets = 0, TotalSentBytes = 0,\n AccountName = \"\", RemoteIP = NormalisedTarget),\n (DataStagingToExfil\n | extend QueryCount = 0, UniqueSubdomains = 0, SampleQueries = dynamic([]),\n DomainApex = \"\", TotalSentMB = 0.0, ConnectionCount = 0,\n RemoteIP = \"\", NormalisedTarget = \"\",\n TotalSentBytes = tolong(TotalSentBytes))\n| extend\n AlertSeverity = \"Critical\",\n MitreTechnique = \"T1048\",\n MitreTactic = \"Exfiltration\",\n PlaybookTrigger = \"data_exfil_contain\",\n RiskScore = 90\n| project\n Timestamp = coalesce(FirstSeen, StagingEnd),\n DeviceName,\n DeviceId,\n AccountName,\n ThreatSignal,\n NormalisedTarget,\n QueryCount,\n UniqueSubdomains,\n TotalSentMB,\n TotalSentBytes,\n FileReadCount,\n SampleFiles,\n UniqueFolders,\n ExfilTargets,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Exfiltration" + ], + "techniques": [ + "T1048" + ], + "alertRuleTemplateName": "a1b2c3d4-0004-4e5f-a004-000000000004", + "entityMappings": [ + { + "entityType": "Host", + "fieldMappings": [ + { + "identifier": "HostName", + "columnName": "DeviceName" + } + ] + }, + { + "entityType": "IP", + "fieldMappings": [ + { + "identifier": "Address", + "columnName": "RemoteIP" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "BytesSent": "BytesSent" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "DeviceNetworkEvents", + "DeviceFileEvents" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/gift_card_fraud.json b/sentinel/analytics-rules/gift_card_fraud.json new file mode 100644 index 0000000..ab490b3 --- /dev/null +++ b/sentinel/analytics-rules/gift_card_fraud.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0008-4e5f-a008-000000000008')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Gift Card Fraud Pattern Detection", + "description": "Detects abnormal gift card issuance, reload, and redemption patterns at POS that are consistent with gift card fraud or money laundering. Analyses RetailShield_POS_CL for velocity and value anomalies. MITRE T1657.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Gift-Card / High-Velocity Refund Fraud\n// MITRE ATT&CK : T1657 — Financial Theft\n// Tactic : Impact\n// Severity : High\n// Frequency : Every 5 minutes | Lookback: 30 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : Organised retail crime — gift card draining via stolen\n// activations and refund abuse across multiple store locations\n// ============================================================\n\nlet LookbackPeriod = 30m;\nlet GiftCardActivThresh = 10; // activations per terminal in window\nlet StructuringThreshold = 200.0; // GBP — transactions just below this = structuring risk\nlet StructuringBand = 20.0; // GBP — tolerance band (180–200 = structured)\nlet MultiTerminalThresh = 3; // same card at N different terminals = suspicious\nlet DrainWindowMins = 15; // minutes — refund then re-activation window\n\n// ── Step 1: High-velocity gift card activations at a single terminal ──────────\nlet HighVelocityActivations =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s == \"GIFT_CARD_ACTIVATE\"\n | where isnotempty(TerminalId_s)\n | summarize\n ActivationCount = count(),\n UniqueCards = dcount(GiftCardNumber_s),\n TotalValue = sum(toreal(TransactionAmount_d)),\n OperatorIDs = make_set(OperatorId_s, 5),\n EarliestTx = min(TimeGenerated),\n LatestTx = max(TimeGenerated)\n by TerminalId_s, StoreId_s, bin(TimeGenerated, 5m)\n | where ActivationCount >= GiftCardActivThresh\n | extend ThreatSignal = \"HighVelocityGiftCardActivation\";\n\n// ── Step 2: Structured transactions — purchases just below reporting threshold ─\nlet StructuredPurchases =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s in (\"GIFT_CARD_ACTIVATE\", \"GIFT_CARD_RELOAD\")\n | extend TxAmount = toreal(TransactionAmount_d)\n | where TxAmount >= (StructuringThreshold - StructuringBand)\n and TxAmount < StructuringThreshold\n | summarize\n StructuredCount = count(),\n TotalValue = sum(TxAmount),\n UniqueCards = dcount(GiftCardNumber_s),\n TerminalIDs = make_set(TerminalId_s, 5),\n EarliestTx = min(TimeGenerated),\n LatestTx = max(TimeGenerated)\n by OperatorId_s, StoreId_s, bin(TimeGenerated, 5m)\n | where StructuredCount >= 3 // multiple structured tx = pattern\n | extend ThreatSignal = \"StructuredGiftCardPurchase\";\n\n// ── Step 3: Drain-and-reload — refund then re-activation of same card ─────────\nlet DrainAndReload =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s == \"GIFT_CARD_REFUND\"\n | where isnotempty(GiftCardNumber_s)\n | join kind=inner (\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s == \"GIFT_CARD_ACTIVATE\"\n | project\n GiftCardNumber_s,\n ReactivateTime = TimeGenerated,\n ReactivateStore = StoreId_s,\n ReactivateTerm = TerminalId_s\n ) on GiftCardNumber_s\n | where ReactivateTime > TimeGenerated\n and ReactivateTime < TimeGenerated + totimespan(strcat(tostring(DrainWindowMins), \"m\"))\n | extend ThreatSignal = \"GiftCardDrainAndReload\";\n\n// ── Step 4: Same card used at multiple terminals in short window ──────────────\nlet MultiTerminalCard =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s in (\"GIFT_CARD_REDEEM\", \"GIFT_CARD_BALANCE_CHECK\")\n | where isnotempty(GiftCardNumber_s)\n | summarize\n TerminalCount = dcount(TerminalId_s),\n StoreCount = dcount(StoreId_s),\n TerminalList = make_set(TerminalId_s, 10),\n TxCount = count(),\n EarliestTx = min(TimeGenerated),\n LatestTx = max(TimeGenerated)\n by GiftCardNumber_s, bin(TimeGenerated, 10m)\n | where TerminalCount >= MultiTerminalThresh\n | extend ThreatSignal = \"GiftCardMultiTerminalUse\";\n\n// ── Step 5: Union all signals ─────────────────────────────────────────────────\nunion\n (HighVelocityActivations\n | extend\n GiftCardNumber_s = \"\", OperatorId_s = tostring(OperatorIDs[0]),\n StructuredCount = ActivationCount, TerminalList = dynamic([]),\n TerminalCount = 1, StoreCount = 1,\n ReactivateStore = \"\", ReactivateTerm = \"\"),\n (StructuredPurchases\n | extend\n GiftCardNumber_s = \"\", ActivationCount = StructuredCount,\n UniqueCards = 0, TerminalList = dynamic([]),\n TerminalCount = 1, StoreCount = 1,\n ReactivateStore = \"\", ReactivateTerm = \"\",\n TerminalId_s = tostring(TerminalIDs[0])),\n (DrainAndReload\n | extend\n ActivationCount = 1, StructuredCount = 1,\n UniqueCards = 1, TotalValue = toreal(TransactionAmount_d),\n TerminalList = dynamic([]), TerminalCount = 1, StoreCount = 1,\n OperatorId_s = \"\", EarliestTx = TimeGenerated, LatestTx = ReactivateTime),\n (MultiTerminalCard\n | extend\n ActivationCount = TxCount, StructuredCount = TxCount,\n UniqueCards = 1, TotalValue = 0.0,\n OperatorId_s = \"\", TerminalId_s = \"\", StoreId_s = \"\",\n ReactivateStore = \"\", ReactivateTerm = \"\")\n| extend\n AlertSeverity = \"High\",\n MitreTechnique = \"T1657\",\n MitreTactic = \"Impact\",\n PlaybookTrigger = \"notify_soc\",\n RiskScore = 85\n| project\n Timestamp = coalesce(EarliestTx, TimeGenerated),\n GiftCardNumber_s,\n OperatorId_s,\n StoreId_s,\n TerminalId_s,\n ThreatSignal,\n ActivationCount,\n UniqueCards,\n TotalValue,\n TerminalCount,\n TerminalList,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Impact" + ], + "techniques": [ + "T1657" + ], + "alertRuleTemplateName": "a1b2c3d4-0008-4e5f-a008-000000000008", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "OperatorID" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "TotalGiftCardValue": "TotalGiftCardValue" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "RetailShield_POS_CL" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/mfa_fatigue.json b/sentinel/analytics-rules/mfa_fatigue.json new file mode 100644 index 0000000..966d4e4 --- /dev/null +++ b/sentinel/analytics-rules/mfa_fatigue.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0003-4e5f-a003-000000000003')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - MFA Fatigue / Push Bombing Attack", + "description": "Detects repeated MFA push notification attempts against retail accounts, indicating an MFA fatigue (push bombing) attack. Correlates failed MFA events in SigninLogs. MITRE T1621.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — MFA Fatigue / Push Bombing Detection Rule\n// MITRE ATT&CK : T1621 — Multi-Factor Authentication Request Generation\n// Tactic : Credential Access\n// Severity : High\n// Frequency : Every 5 minutes | Lookback: 10 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : M&S / Co-op 2025 — Scattered Spider MFA fatigue technique\n// ============================================================\n\nlet LookbackPeriod = 10m;\nlet MFAPromptThresh = 10; // MFA prompts within lookback before fatigue alert\nlet FatigueSuccThresh = 5; // failed MFA attempts before a success counts as fatigue-driven\nlet MFAResultCodes = dynamic([\"50074\", \"50076\", \"50079\"]); // MFA required / prompt codes\n\n// ── Step 1: High-velocity MFA prompt flood to a single account ───────────────\nlet MFAPromptFlood =\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType in (MFAResultCodes)\n | where isnotempty(UserPrincipalName)\n | summarize\n PromptCount = count(),\n SourceIPs = dcount(IPAddress),\n SourceIPList = make_set(IPAddress, 10),\n Locations = make_set(Location, 5),\n AppNames = make_set(AppDisplayName, 5),\n FirstPrompt = min(TimeGenerated),\n LastPrompt = max(TimeGenerated)\n by UserPrincipalName, bin(TimeGenerated, 5m)\n | where PromptCount >= MFAPromptThresh\n | extend ThreatSignal = \"MFAPromptFlood\";\n\n// ── Step 2: MFA success after fatigue spike — victim accepted under pressure ──\nlet FatigueAcceptance =\n MFAPromptFlood\n | join kind=inner (\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType == \"0\" // successful sign-in\n | where isnotempty(UserPrincipalName)\n | project\n UserPrincipalName,\n SuccessTime = TimeGenerated,\n SuccessIP = IPAddress,\n SuccessCountry = Location,\n SuccessApp = AppDisplayName\n ) on UserPrincipalName\n | where SuccessTime > LastPrompt\n and SuccessTime < LastPrompt + 15m // accepted within 15 min of last prompt\n | extend ThreatSignal = \"FatigueAcceptance\";\n\n// ── Step 3: Multi-IP prompt flood — distributed attack infrastructure ─────────\nlet DistributedMFAFlood =\n MFAPromptFlood\n | where SourceIPs >= 3 // prompts from ≥3 different IPs\n | extend ThreatSignal = \"DistributedMFAFlood\";\n\n// ── Step 4: Risky sign-in following MFA prompt flood ─────────────────────────\nlet RiskyPostMFA =\n MFAPromptFlood\n | join kind=inner (\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType == \"0\"\n | where RiskLevelDuringSignIn in (\"high\", \"medium\")\n | project\n UserPrincipalName,\n RiskyTime = TimeGenerated,\n RiskLevel = RiskLevelDuringSignIn,\n RiskyIP = IPAddress,\n RiskyLocation = Location\n ) on UserPrincipalName\n | where RiskyTime > LastPrompt\n | extend ThreatSignal = \"RiskySigninPostMFAFlood\";\n\n// ── Step 5: Union all signals ─────────────────────────────────────────────────\nunion\n (MFAPromptFlood\n | extend\n SuccessIP = \"\", SuccessCountry = \"\", SuccessApp = \"\",\n RiskLevel = \"\", RiskyIP = \"\", RiskyLocation = \"\",\n SuccessTime = datetime(null)),\n (FatigueAcceptance\n | extend\n RiskLevel = \"\", RiskyIP = \"\", RiskyLocation = \"\"),\n (DistributedMFAFlood\n | extend\n SuccessIP = \"\", SuccessCountry = \"\", SuccessApp = \"\",\n RiskLevel = \"\", RiskyIP = \"\", RiskyLocation = \"\",\n SuccessTime = datetime(null)),\n (RiskyPostMFA\n | extend\n SuccessIP = \"\", SuccessCountry = \"\", SuccessApp = \"\",\n SuccessTime = datetime(null))\n| extend\n AlertSeverity = \"High\",\n MitreTechnique = \"T1621\",\n MitreTactic = \"Credential Access\",\n PlaybookTrigger = \"block_ip\",\n RiskScore = 85\n| project\n Timestamp = coalesce(FirstPrompt, RiskyTime),\n UserPrincipalName,\n ThreatSignal,\n PromptCount,\n SourceIPs,\n SourceIPList,\n Locations,\n AppNames,\n SuccessIP,\n SuccessCountry,\n RiskLevel,\n RiskyIP,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "CredentialAccess" + ], + "techniques": [ + "T1621" + ], + "alertRuleTemplateName": "a1b2c3d4-0003-4e5f-a003-000000000003", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "UserPrincipalName" + } + ] + }, + { + "entityType": "IP", + "fieldMappings": [ + { + "identifier": "Address", + "columnName": "IPAddress" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "MFAAttemptCount": "MFAAttemptCount" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "SigninLogs" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/phishing_detection.json b/sentinel/analytics-rules/phishing_detection.json new file mode 100644 index 0000000..1107e33 --- /dev/null +++ b/sentinel/analytics-rules/phishing_detection.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0001-4e5f-a001-000000000001')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Retail Phishing Email Detection", + "description": "Detects phishing emails with malicious attachments targeting retail staff. Correlates EmailAttachmentInfo and EmailEvents to identify suspicious sender/attachment combinations aligned with MITRE T1566.001.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Phishing Detection Rule\n// MITRE ATT&CK : T1566.001 — Spearphishing Attachment\n// Tactic : Initial Access\n// Severity : High\n// Frequency : Every 5 minutes | Lookback: 1 hour\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// ============================================================\n\nlet LookbackPeriod = 1h;\n\n// File extensions weaponised in retail phishing campaigns\nlet SuspiciousExtensions = dynamic([\n \".exe\", \".js\", \".vbs\", \".docm\", \".xlsm\", \".pptm\",\n \".hta\", \".bat\", \".ps1\", \".lnk\", \".iso\", \".img\", \".wsf\"\n]);\n\n// Pull approved sender domains from Sentinel watchlist\n// (populate sentinel/watchlists/retail-ioc-watchlist.csv)\nlet ApprovedSenderDomains = (\n _GetWatchlist(\"RetailApprovedSenders\")\n | project SenderDomain = tolower(tostring(column_ifexists(\"SenderDomain\", \"\")))\n);\n\n// ── Step 1: Identify suspicious inbound attachments ──────────\nlet SuspiciousAttachments =\n EmailAttachmentInfo\n | where ingestion_time() > ago(LookbackPeriod)\n | where isnotempty(FileName)\n | extend FileExtension = tolower(tostring(split(FileName, \".\")[-1]))\n | extend FileExtensionDot = strcat(\".\", FileExtension)\n | where FileExtensionDot in (SuspiciousExtensions)\n or FileName matches regex @\"(?i)\\.(exe|js|vbs|docm|xlsm|pptm|hta|bat|ps1|lnk|iso|img|wsf)$\"\n | project\n NetworkMessageId,\n FileName,\n FileExtension,\n FileSize,\n SHA256;\n\n// ── Step 2: Join with delivered email events ─────────────────\nlet PhishingEmails =\n EmailEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where DeliveryAction == \"Delivered\"\n | where EmailDirection == \"Inbound\"\n | extend SenderDomain = tolower(tostring(split(SenderFromAddress, \"@\")[-1]))\n | join kind=leftanti (ApprovedSenderDomains) on SenderDomain\n | join kind=inner (SuspiciousAttachments) on NetworkMessageId\n | extend\n // Classify lure type based on subject keywords common in retail phishing\n LureCategory = case(\n Subject has_any (\"invoice\", \"remittance\", \"payment\", \"order confirmation\",\n \"dispatch\", \"delivery\", \"supplier\", \"purchase order\"),\n \"Financial / Supply-Chain Lure\",\n Subject has_any (\"urgent\", \"action required\", \"account suspended\",\n \"verify\", \"password reset\", \"security alert\"),\n \"Credential Harvesting Lure\",\n Subject has_any (\"payroll\", \"salary\", \"HR\", \"rota\", \"schedule\", \"shift\"),\n \"HR / Insider Lure\",\n Subject has_any (\"CEO\", \"director\", \"executive\", \"management\"),\n \"Business Email Compromise\",\n \"General Phishing\"\n )\n | extend\n RecipientAccount = tostring(split(RecipientEmailAddress, \"@\")[0]),\n RecipientDomain = tostring(split(RecipientEmailAddress, \"@\")[1]),\n SenderAddressLower = tolower(SenderFromAddress);\n\n// ── Step 3: Surface final alert fields ───────────────────────\nPhishingEmails\n| project\n TimeGenerated = Timestamp,\n SenderAddress = SenderFromAddress,\n SenderDomain,\n RecipientEmailAddress,\n RecipientAccount,\n Subject,\n LureCategory,\n AttachmentFileName = FileName,\n AttachmentExtension = FileExtension,\n AttachmentSizeMB = round(toreal(FileSize) / 1048576, 2),\n AttachmentSHA256 = SHA256,\n NetworkMessageId,\n DeliveryLocation = LatestDeliveryLocation\n| extend\n // Fields consumed by the triage Logic App\n AlertSeverity = \"High\",\n MitreTechnique = \"T1566.001\",\n MitreTactic = \"Initial Access\",\n PlaybookTrigger = \"quarantine_email\"\n| order by TimeGenerated desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "InitialAccess" + ], + "techniques": [ + "T1566.001" + ], + "alertRuleTemplateName": "a1b2c3d4-0001-4e5f-a001-000000000001", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "RecipientEmailAddress" + } + ] + }, + { + "entityType": "Mailbox", + "fieldMappings": [ + { + "identifier": "MailboxPrimaryAddress", + "columnName": "SenderFromAddress" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "SignalName": "SignalName" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "EmailAttachmentInfo", + "EmailEvents" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/pos_anomaly.json b/sentinel/analytics-rules/pos_anomaly.json new file mode 100644 index 0000000..f6aa8d2 --- /dev/null +++ b/sentinel/analytics-rules/pos_anomaly.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0009-4e5f-a009-000000000009')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - POS Terminal Anomaly Detection", + "description": "Detects anomalous POS terminal behaviour including RAM scraping patterns, unusual process execution on POS hosts, and suspicious outbound network connections. Correlates DeviceEvents, RetailShield_Logs_CL, and DeviceNetworkEvents. MITRE T1056.001.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — POS Anomaly Detection\n// Rule ID : RS-POS-001\n// MITRE ATT&CK : T1056.001 — Keylogging / RAM Scraping\n// Tactic : Collection\n// Severity : High\n// Frequency : Every 15 minutes | Lookback: 30 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// ============================================================\n// Detects RAM-scraping keyloggers and anomalous activity on\n// retail POS terminals. Covers unknown DLL injection into POS\n// processes, abnormal transaction volume spikes (statistical\n// anomaly), process memory dumps, and suspicious outbound\n// network connections from POS endpoints to threat IPs.\n// ============================================================\n\nlet LookbackWindow = 30m;\nlet BaselineWindow = 30d;\nlet TransactionVolumeThreshold = 3.5; // std deviations above 30-day baseline\n\n// POS application process names\nlet POSProcessNames = dynamic([\n \"xstore.exe\", \"aloha.exe\", \"tcxpos.exe\",\n \"posready.exe\", \"retail.exe\", \"revel.exe\", \"squarepos.exe\"\n]);\n\n// Hostname prefixes that identify POS / till hardware\nlet RetailTerminalPrefix = dynamic([\n \"pos-\", \"till-\", \"kiosk-\", \"ped-\", \"term-\"\n]);\n\n// Folders that are trusted for DLL loads — flag anything outside\nlet TrustedDLLPaths = dynamic([\n @\"C:\\Windows\\System32\",\n @\"C:\\Windows\\SysWOW64\",\n @\"C:\\Program Files\",\n @\"C:\\Program Files (x86)\"\n]);\n\n// ── Signal 1 — Unknown unsigned DLL injected into POS process ────────────────\nlet UnknownDLLInjection =\n DeviceEvents\n | where ingestion_time() > ago(LookbackWindow)\n | where ActionType == \"ImageLoaded\"\n | where InitiatingProcessFileName has_any (POSProcessNames)\n | where FileName endswith \".dll\" or FileName endswith \".sys\"\n | where isempty(SHA1) or Signer == \"\" or isempty(Signer)\n | where not(FolderPath has_any (TrustedDLLPaths))\n | extend DeviceLower = tolower(DeviceName)\n | where DeviceLower has_any (RetailTerminalPrefix)\n | project\n TimeGenerated, DeviceName, DeviceId,\n SignalType = \"UnknownDLLInjection\",\n POSProcess = InitiatingProcessFileName,\n SuspiciousDLL = FileName,\n DLLPath = FolderPath,\n SHA256 = InitiatingProcessSHA256;\n\n// ── Signal 2 — Abnormal transaction volume (statistical spike) ───────────────\nlet AbnormalTransactionVolume =\n RetailShield_Logs_CL\n | where ingestion_time() > ago(BaselineWindow)\n | where EventType_s == \"POS_Transaction\"\n | summarize HourlyCount = count() by TerminalID_s, bin(TimeGenerated, 1h)\n | summarize AvgCount = avg(HourlyCount), StdDev = stdev(HourlyCount)\n by TerminalID_s\n | join kind=inner (\n RetailShield_Logs_CL\n | where ingestion_time() > ago(LookbackWindow)\n | where EventType_s == \"POS_Transaction\"\n | summarize RecentCount = count() by TerminalID_s\n ) on TerminalID_s\n | where RecentCount > AvgCount + (TransactionVolumeThreshold * StdDev)\n | extend AnomalyScore = round((RecentCount - AvgCount) / StdDev, 2)\n | project\n TimeGenerated = now(), DeviceName = TerminalID_s, DeviceId = \"\",\n SignalType = \"AbnormalTransactionVolume\",\n POSProcess = \"POS_Transaction\",\n SuspiciousDLL = \"\",\n DLLPath = \"\",\n SHA256 = \"\";\n\n// ── Signal 3 — Process memory dump targeting a POS application ───────────────\nlet ProcessMemoryDump =\n DeviceEvents\n | where ingestion_time() > ago(LookbackWindow)\n | where ActionType in (\"ProcessDumped\", \"MemoryDumpCreated\")\n or (ActionType == \"ProcessAccessed\"\n and ProcessCommandLine has_any (\"MiniDump\", \"ProcDump\", \"createdump\", \"procdump\"))\n | where tolower(TargetProcessFileName) has_any (POSProcessNames)\n | project\n TimeGenerated, DeviceName, DeviceId,\n SignalType = \"ProcessMemoryDump\",\n POSProcess = TargetProcessFileName,\n SuspiciousDLL = InitiatingProcessFileName,\n DLLPath = ProcessCommandLine,\n SHA256 = InitiatingProcessSHA256;\n\n// ── Signal 4 — Suspicious outbound network from POS to threat IP ─────────────\nlet SuspiciousNetworkFromPOS =\n DeviceNetworkEvents\n | where ingestion_time() > ago(LookbackWindow)\n | where tolower(DeviceName) has_any (RetailTerminalPrefix)\n | where RemoteIPType == \"Public\"\n | where not(RemotePort in (443, 80, 8443, 8080))\n | join kind=inner (\n _GetWatchlist(\"RetailIOCWatchlist\")\n | project ThreatIP = tostring(column_ifexists(\"SearchKey\", \"\"))\n ) on $left.RemoteIP == $right.ThreatIP\n | project\n TimeGenerated, DeviceName, DeviceId,\n SignalType = \"SuspiciousNetworkFromPOS\",\n POSProcess = InitiatingProcessFileName,\n SuspiciousDLL = RemoteIP,\n DLLPath = tostring(RemotePort),\n SHA256 = \"\";\n\n// ── Union all signals ─────────────────────────────────────────────────────────\nunion UnknownDLLInjection, AbnormalTransactionVolume, ProcessMemoryDump, SuspiciousNetworkFromPOS\n| summarize\n SignalCount = count(),\n Signals = make_set(SignalType),\n FirstSeen = min(TimeGenerated),\n LastSeen = max(TimeGenerated),\n SuspiciousDLL = take_any(SuspiciousDLL),\n POSProcess = take_any(POSProcess),\n DeviceId = take_any(DeviceId)\n by DeviceName\n| where SignalCount >= 1\n| extend\n RiskScore = case(SignalCount >= 3, 95, SignalCount == 2, 82, 65),\n MitreTechnique = \"T1056.001\",\n MitreTactic = \"Collection\",\n Severity = case(SignalCount >= 2, \"HIGH\", \"HIGH\"),\n AlertSeverity = case(SignalCount >= 3, \"CRITICAL\", \"HIGH\"),\n PlaybookTrigger = \"suspend_terminal\",\n AlertTitle = strcat(\"POS Anomaly — Potential RAM Scraping on \", DeviceName)\n| project\n AlertTitle,\n DeviceName,\n DeviceId,\n AlertSeverity,\n Severity,\n RiskScore,\n SignalCount,\n Signals,\n SuspiciousDLL,\n POSProcess,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n FirstSeen,\n LastSeen\n| sort by RiskScore desc\n", + "queryFrequency": "PT15M", + "queryPeriod": "PT15M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Collection" + ], + "techniques": [ + "T1056.001" + ], + "alertRuleTemplateName": "a1b2c3d4-0009-4e5f-a009-000000000009", + "entityMappings": [ + { + "entityType": "Host", + "fieldMappings": [ + { + "identifier": "HostName", + "columnName": "DeviceName" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "TerminalID": "TerminalID" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "DeviceEvents", + "RetailShield_Logs_CL", + "DeviceNetworkEvents" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/pos_void_refund.json b/sentinel/analytics-rules/pos_void_refund.json new file mode 100644 index 0000000..022f7bc --- /dev/null +++ b/sentinel/analytics-rules/pos_void_refund.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0002-4e5f-a002-000000000002')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - POS Void/Refund Fraud Pattern", + "description": "Detects suspicious void and refund patterns at POS terminals indicative of employee fraud or skimming. Correlates high-volume voids and refunds per employee against RetailShield_POS_CL. MITRE T1056.001.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — POS Off-Hours Void / Refund Anomaly\n// MITRE ATT&CK : T1056.001 — Keylogging / Input Capture (POS)\n// Tactic : Collection\n// Severity : High\n// Frequency : Every 5 minutes | Lookback: 30 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : UK retail insider fraud — employees processing late-night\n// voids to siphon cash; CIFAS insider fraud report 2024\n// ============================================================\n\nlet LookbackPeriod = 30m;\nlet BusinessHourStart = 7; // 07:00\nlet BusinessHourEnd = 21; // 21:00\nlet VoidRefundThresh = 5; // per operator per window\nlet HighValueThresh = 500.0; // GBP — high-value void/refund without override\n\n// ── Step 1: Voids / refunds processed outside business hours ─────────────────\nlet AfterHoursVoids =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s in (\"VOID\", \"REFUND\", \"RETURN\")\n | where isnotempty(OperatorId_s)\n | extend HourOfDay = hourofday(TimeGenerated)\n | where HourOfDay < BusinessHourStart or HourOfDay >= BusinessHourEnd\n | summarize\n TransactionCount = count(),\n TotalValue = sum(toreal(TransactionAmount_d)),\n TerminalIDs = make_set(TerminalId_s, 5),\n TransactionTypes = make_set(TransactionType_s, 3),\n EarliestTx = min(TimeGenerated),\n LatestTx = max(TimeGenerated)\n by OperatorId_s, StoreId_s, bin(TimeGenerated, 5m)\n | extend ThreatSignal = \"AfterHoursVoidRefund\";\n\n// ── Step 2: High-volume voids / refunds per operator in a single session ──────\nlet HighVolumeVoids =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s in (\"VOID\", \"REFUND\", \"RETURN\")\n | where isnotempty(OperatorId_s)\n | summarize\n TransactionCount = count(),\n TotalValue = sum(toreal(TransactionAmount_d)),\n TerminalIDs = make_set(TerminalId_s, 5),\n TransactionTypes = make_set(TransactionType_s, 3),\n EarliestTx = min(TimeGenerated),\n LatestTx = max(TimeGenerated)\n by OperatorId_s, StoreId_s, bin(TimeGenerated, 5m)\n | where TransactionCount >= VoidRefundThresh\n | extend ThreatSignal = \"HighVolumeVoidRefund\";\n\n// ── Step 3: High-value void without manager override ─────────────────────────\nlet HighValueVoidNoOverride =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s in (\"VOID\", \"REFUND\")\n | where toreal(TransactionAmount_d) >= HighValueThresh\n | where isempty(ManagerOverrideId_s) or ManagerOverrideId_s == \"\"\n | extend ThreatSignal = \"HighValueVoidNoOverride\";\n\n// ── Step 4: Refund to different tender type than original payment ─────────────\nlet TenderMismatchRefund =\n RetailShield_POS_CL\n | where ingestion_time() > ago(LookbackPeriod)\n | where TransactionType_s == \"REFUND\"\n | where isnotempty(OriginalTenderType_s) and isnotempty(RefundTenderType_s)\n | where OriginalTenderType_s != RefundTenderType_s\n | where RefundTenderType_s in (\"CASH\", \"GIFT_CARD\") // higher-risk refund tenders\n | extend ThreatSignal = \"TenderMismatchRefund\";\n\n// ── Step 5: Union all signals ─────────────────────────────────────────────────\nunion\n (AfterHoursVoids\n | extend\n TransactionAmount_d = TotalValue,\n TerminalId_s = tostring(TerminalIDs[0]),\n ManagerOverrideId_s = \"\",\n OriginalTenderType_s = \"\", RefundTenderType_s = \"\"),\n (HighVolumeVoids\n | extend\n TransactionAmount_d = TotalValue,\n TerminalId_s = tostring(TerminalIDs[0]),\n ManagerOverrideId_s = \"\",\n OriginalTenderType_s = \"\", RefundTenderType_s = \"\"),\n (HighValueVoidNoOverride\n | extend\n TransactionCount = 1,\n TotalValue = toreal(TransactionAmount_d),\n TerminalIDs = pack_array(TerminalId_s),\n TransactionTypes = pack_array(TransactionType_s),\n EarliestTx = TimeGenerated, LatestTx = TimeGenerated,\n OriginalTenderType_s = \"\", RefundTenderType_s = \"\"),\n (TenderMismatchRefund\n | extend\n TransactionCount = 1,\n TotalValue = toreal(TransactionAmount_d),\n TerminalIDs = pack_array(TerminalId_s),\n TransactionTypes = pack_array(TransactionType_s),\n EarliestTx = TimeGenerated, LatestTx = TimeGenerated,\n ManagerOverrideId_s = \"\")\n| extend\n AlertSeverity = \"High\",\n MitreTechnique = \"T1056.001\",\n MitreTactic = \"Collection\",\n PlaybookTrigger = \"notify_soc\",\n RiskScore = 80\n| project\n Timestamp = coalesce(EarliestTx, TimeGenerated),\n OperatorId_s,\n StoreId_s,\n TerminalId_s,\n ThreatSignal,\n TransactionCount,\n TotalValue,\n TransactionTypes,\n OriginalTenderType_s,\n RefundTenderType_s,\n ManagerOverrideId_s,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Collection" + ], + "techniques": [ + "T1056.001" + ], + "alertRuleTemplateName": "a1b2c3d4-0002-4e5f-a002-000000000002", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "EmployeeID" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "TerminalID": "TerminalID" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "RetailShield_POS_CL" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/privileged_role_addition.json b/sentinel/analytics-rules/privileged_role_addition.json new file mode 100644 index 0000000..9665888 --- /dev/null +++ b/sentinel/analytics-rules/privileged_role_addition.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0013-4e5f-a013-000000000013')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Privileged Role Assignment Detection", + "description": "Detects when a new privileged role (Global Admin, Security Admin, Privileged Role Admin, etc.) is assigned to any retail account. Analyses AuditLogs for role assignment events. MITRE T1098/T1078.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Privileged Role / Group Addition Detection Rule\n// MITRE ATT&CK : T1098 — Account Manipulation\n// T1078 — Valid Accounts\n// Tactic : Persistence, Privilege Escalation\n// Severity : High\n// Frequency : Every 5 minutes | Lookback: 15 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : Scattered Spider / UNC3944 Azure AD persistence via\n// Global Admin role assignments in retail sector breaches\n// ============================================================\n\nlet LookbackPeriod = 15m;\n\n// ── High-privilege roles that should trigger an alert on assignment ───────────\nlet SensitiveRoles = dynamic([\n \"Global Administrator\",\n \"Privileged Role Administrator\",\n \"Security Administrator\",\n \"Exchange Administrator\",\n \"SharePoint Administrator\",\n \"Hybrid Identity Administrator\",\n \"Application Administrator\",\n \"Cloud Application Administrator\",\n \"Authentication Administrator\",\n \"User Administrator\"\n]);\n\n// ── Step 1: Direct role assignment to a user ─────────────────────────────────\nlet DirectRoleAddition =\n AuditLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where Category == \"RoleManagement\"\n | where OperationName in (\n \"Add member to role\",\n \"Add eligible member to role\",\n \"Add scoped member to role\"\n )\n | extend\n InitiatedByUser = tostring(InitiatedBy.user.userPrincipalName),\n InitiatedByApp = tostring(InitiatedBy.app.displayName),\n TargetUser = tostring(TargetResources[0].userPrincipalName),\n RoleDisplayName = tostring(TargetResources[1].displayName)\n | where RoleDisplayName in (SensitiveRoles)\n | extend ThreatSignal = \"SensitiveRoleAssigned\";\n\n// ── Step 2: Role assignment outside business hours ────────────────────────────\nlet AfterHoursRoleAddition =\n AuditLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where Category == \"RoleManagement\"\n | where OperationName has \"Add member to role\"\n | extend HourOfDay = hourofday(TimeGenerated)\n | where HourOfDay < 7 or HourOfDay >= 20\n | extend\n InitiatedByUser = tostring(InitiatedBy.user.userPrincipalName),\n TargetUser = tostring(TargetResources[0].userPrincipalName),\n RoleDisplayName = tostring(TargetResources[1].displayName)\n | extend ThreatSignal = \"AfterHoursRoleAddition\";\n\n// ── Step 3: Role addition followed immediately by new MFA method registration ─\nlet RoleAdditionWithMFAChange =\n DirectRoleAddition\n | join kind=inner (\n AuditLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where Category == \"UserManagement\"\n | where OperationName has_any (\n \"User registered security info\",\n \"User changed default security info\",\n \"User deleted security info\",\n \"User registered all required security info\"\n )\n | extend MFAUser = tostring(TargetResources[0].userPrincipalName)\n | project MFAUser, MFAChangeTime = TimeGenerated, MFAOperation = OperationName\n ) on $left.TargetUser == $right.MFAUser\n | where MFAChangeTime > TimeGenerated\n and MFAChangeTime < TimeGenerated + 30m // MFA change within 30 min of role grant\n | extend ThreatSignal = \"RoleAdditionFollowedByMFAChange\";\n\n// ── Step 4: Sensitive group membership addition ───────────────────────────────\nlet SensitiveGroupAddition =\n AuditLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where Category == \"GroupManagement\"\n | where OperationName == \"Add member to group\"\n | extend\n GroupName = tostring(TargetResources[0].displayName),\n AddedUser = tostring(TargetResources[1].userPrincipalName),\n InitiatedByUser = tostring(InitiatedBy.user.userPrincipalName)\n | where GroupName has_any (\n \"Domain Admins\", \"Enterprise Admins\", \"Schema Admins\",\n \"Exchange Organization Admins\", \"Organization Management\",\n \"IT Admins\", \"Security Operations\", \"SOC\"\n )\n | extend ThreatSignal = \"SensitiveGroupMemberAdded\";\n\n// ── Step 5: Union all signals ─────────────────────────────────────────────────\nunion\n (DirectRoleAddition\n | extend GroupName = \"\", AddedUser = TargetUser,\n HourOfDay = hourofday(TimeGenerated),\n MFAOperation = \"\", MFAChangeTime = datetime(null)),\n (AfterHoursRoleAddition\n | extend GroupName = \"\", AddedUser = TargetUser,\n MFAOperation = \"\", MFAChangeTime = datetime(null)),\n (RoleAdditionWithMFAChange\n | extend GroupName = \"\", AddedUser = TargetUser,\n HourOfDay = hourofday(TimeGenerated)),\n (SensitiveGroupAddition\n | extend RoleDisplayName = GroupName, TargetUser = AddedUser,\n HourOfDay = hourofday(TimeGenerated),\n MFAOperation = \"\", MFAChangeTime = datetime(null))\n| extend\n AlertSeverity = \"High\",\n MitreTechnique = \"T1098, T1078\",\n MitreTactic = \"Persistence, Privilege Escalation\",\n PlaybookTrigger = \"notify_soc\",\n RiskScore = 85\n| project\n Timestamp = TimeGenerated,\n InitiatedByUser,\n TargetUser,\n RoleDisplayName,\n ThreatSignal,\n HourOfDay,\n MFAOperation,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Persistence", + "PrivilegeEscalation" + ], + "techniques": [ + "T1098", + "T1078" + ], + "alertRuleTemplateName": "a1b2c3d4-0013-4e5f-a013-000000000013", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "TargetUPN" + } + ] + }, + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "InitiatedByUPN" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "RoleAssigned": "RoleAssigned" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "AuditLogs" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/ransomware_indicator.json b/sentinel/analytics-rules/ransomware_indicator.json new file mode 100644 index 0000000..3391223 --- /dev/null +++ b/sentinel/analytics-rules/ransomware_indicator.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0010-4e5f-a010-000000000010')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Ransomware Indicator Detection", + "description": "Detects multi-stage ransomware indicators on retail endpoints: mass file encryption events, shadow copy deletion processes, and outbound C2 connections to IPs in RetailIOCWatchlist. Correlates DeviceFileEvents, DeviceProcessEvents, DeviceNetworkEvents. MITRE T1486.", + "severity": "Critical", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Ransomware Indicator Detection Rule\n// MITRE ATT&CK : T1486 — Data Encrypted for Impact\n// Tactic : Impact\n// Severity : Critical\n// Frequency : Every 5 minutes | Lookback: 30 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : M&S supply-chain ransomware attack (2025) — £300M impact\n// ============================================================\n\nlet LookbackPeriod = 30m;\nlet MassRenameThresh = 100; // file renames within the lookback window\nlet BeaconThresh = 20; // outbound connections to a single external IP\n\n// ── IOCs from Sentinel watchlist ────────────────────────────────────────────\nlet RansomwareIOCs = (\n _GetWatchlist(\"RetailIOCWatchlist\")\n | where Type in (\"IP\", \"Domain\", \"Hash\")\n | project IOCValue = tolower(Value), IOCType = Type\n);\n\n// ── Step 1: Mass file rename / encryption staging ────────────────────────────\nlet MassFileRename =\n DeviceFileEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType in (\"FileRenamed\", \"FileCreated\")\n | where isnotempty(FileName)\n | extend\n OriginalExtension = tolower(tostring(split(PreviousFileName, \".\")[-1])),\n NewExtension = tolower(tostring(split(FileName, \".\")[-1]))\n | where OriginalExtension != NewExtension // extension changed\n and NewExtension !in (\"tmp\", \"bak\", \"log\", \"lnk\", \"ini\") // exclude benign\n | summarize\n RenameCount = count(),\n SampleFiles = make_set(FileName, 5),\n AffectedFolders = dcount(FolderPath)\n by DeviceId, DeviceName, bin(Timestamp, 5m)\n | where RenameCount >= MassRenameThresh\n | extend ThreatSignal = \"MassFileRename\";\n\n// ── Step 2: Shadow copy deletion commands ────────────────────────────────────\nlet ShadowCopyDeletion =\n DeviceProcessEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ProcessCommandLine has_any (\n \"vssadmin delete shadows\",\n \"vssadmin.exe delete\",\n \"wmic shadowcopy delete\",\n \"wbadmin delete catalog\",\n \"bcdedit /set recoveryenabled no\",\n \"bcdedit /set bootstatuspolicy ignoreallfailures\",\n \"diskshadow /s\",\n \"Get-WmiObject Win32_ShadowCopy\"\n )\n | project\n Timestamp,\n DeviceId,\n DeviceName,\n AccountName,\n InitiatingProcessFileName,\n ProcessCommandLine,\n ThreatSignal = \"ShadowCopyDeletion\";\n\n// ── Step 3: Known ransomware process names ────────────────────────────────────\nlet RansomwareProcesses =\n DeviceProcessEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where FileName has_any (\n \"ryuk\", \"conti\", \"lockbit\", \"blackcat\", \"alphv\",\n \"revil\", \"sodinokibi\", \"darkside\", \"blackmatter\",\n \"hive\", \"clop\", \"maze\", \"egregor\", \"ragnar\"\n )\n | project\n Timestamp,\n DeviceId,\n DeviceName,\n AccountName,\n InitiatingProcessFileName,\n ProcessCommandLine,\n ThreatSignal = \"KnownRansomwareProcess\";\n\n// ── Step 4: C2 beacon — high-frequency connections to single external IP ─────\nlet C2Beaconing =\n DeviceNetworkEvents\n | where ingestion_time() > ago(LookbackPeriod)\n | where ActionType == \"ConnectionSuccess\"\n | where isnotempty(RemoteIP)\n | where RemoteIP !startswith \"10.\"\n and RemoteIP !startswith \"192.168.\"\n and RemoteIP !startswith \"172.\"\n and RemoteIP != \"127.0.0.1\"\n | join kind=inner (RansomwareIOCs | where IOCType == \"IP\")\n on $left.RemoteIP == $right.IOCValue\n | summarize\n BeaconCount = count(),\n FirstSeen = min(Timestamp),\n LastSeen = max(Timestamp)\n by DeviceId, DeviceName, RemoteIP, RemotePort\n | where BeaconCount >= BeaconThresh\n | extend ThreatSignal = \"C2BeaconToRansomwareIP\";\n\n// ── Step 5: Union all signals and surface alerts ──────────────────────────────\nunion\n (MassFileRename | extend ProcessCommandLine = \"\", AccountName = \"\", RemoteIP = \"\"),\n (ShadowCopyDeletion | extend RenameCount = 0, SampleFiles = dynamic([]), AffectedFolders = 0, RemoteIP = \"\", BeaconCount = 0),\n (RansomwareProcesses | extend RenameCount = 0, SampleFiles = dynamic([]), AffectedFolders = 0, RemoteIP = \"\", BeaconCount = 0),\n (C2Beaconing | extend ProcessCommandLine = \"\", AccountName = \"\", InitiatingProcessFileName = \"\", RenameCount = 0, SampleFiles = dynamic([]), AffectedFolders = 0)\n| extend\n AlertSeverity = \"Critical\",\n MitreTechnique = \"T1486\",\n MitreTactic = \"Impact\",\n PlaybookTrigger = \"isolate_endpoint\",\n RiskScore = 100\n| project\n Timestamp,\n DeviceName,\n DeviceId,\n AccountName,\n ThreatSignal,\n ProcessCommandLine,\n RenameCount,\n SampleFiles,\n AffectedFolders,\n RemoteIP,\n BeaconCount,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT5M", + "queryPeriod": "PT5M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "Impact" + ], + "techniques": [ + "T1486" + ], + "alertRuleTemplateName": "a1b2c3d4-0010-4e5f-a010-000000000010", + "entityMappings": [ + { + "entityType": "Host", + "fieldMappings": [ + { + "identifier": "HostName", + "columnName": "DeviceName" + } + ] + }, + { + "entityType": "Process", + "fieldMappings": [ + { + "identifier": "ProcessId", + "columnName": "ProcessId" + }, + { + "identifier": "CommandLine", + "columnName": "ProcessCommandLine" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "FilesEncrypted": "FilesEncrypted" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "DeviceFileEvents", + "DeviceProcessEvents", + "DeviceNetworkEvents" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/supplier_impossible_travel.json b/sentinel/analytics-rules/supplier_impossible_travel.json new file mode 100644 index 0000000..3b4ccb0 --- /dev/null +++ b/sentinel/analytics-rules/supplier_impossible_travel.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0012-4e5f-a012-000000000012')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Supplier Impossible Travel Detection", + "description": "Detects authentication events from supplier accounts that are geographically impossible given prior login location and elapsed time. Cross-references RetailSupplierAccounts watchlist. MITRE T1199/T1078.", + "severity": "Medium", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Supplier Impossible Travel / New Geo Detection Rule\n// MITRE ATT&CK : T1199 — Trusted Relationship\n// T1078 — Valid Accounts\n// Tactic : Initial Access\n// Severity : Medium\n// Frequency : Every 15 minutes | Lookback: 4 hours\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// Reference : Retail supply chain compromise — vendor credentials abused\n// from threat-actor infrastructure in foreign jurisdictions\n// ============================================================\n\nlet LookbackPeriod = 4h;\nlet ImpossibleSpeedKmph = 900.0; // max plausible travel speed (commercial flight)\nlet HighRiskCountries = dynamic([\n \"RU\", \"CN\", \"KP\", \"IR\", \"BY\", \"SY\"\n]);\n\n// ── Supplier accounts watchlist ───────────────────────────────────────────────\nlet SupplierAccounts = (\n _GetWatchlist(\"RetailSupplierAccounts\")\n | project SupplierUPN = tolower(UserPrincipalName), SupplierName\n);\n\n// ── Step 1: Baseline — all supplier sign-ins with location in lookback ─────────\nlet SupplierSignins =\n SigninLogs\n | where ingestion_time() > ago(LookbackPeriod)\n | where ResultType == \"0\"\n | where isnotempty(UserPrincipalName)\n | extend NormUPN = tolower(UserPrincipalName)\n | join kind=inner (SupplierAccounts) on $left.NormUPN == $right.SupplierUPN\n | extend\n Latitude = toreal(LocationDetails.geoCoordinates.latitude),\n Longitude = toreal(LocationDetails.geoCoordinates.longitude),\n CountryCode = tostring(LocationDetails.countryOrRegion)\n | project\n UserPrincipalName, SupplierName, TimeGenerated,\n IPAddress, CountryCode, City = tostring(LocationDetails.city),\n Latitude, Longitude, AppDisplayName;\n\n// ── Step 2: Impossible travel — two sign-ins too far apart too fast ────────────\nlet ImpossibleTravel =\n SupplierSignins\n | join kind=inner (\n SupplierSignins\n | project\n UserPrincipalName,\n Time2 = TimeGenerated,\n Country2 = CountryCode,\n City2 = City,\n Lat2 = Latitude,\n Lon2 = Longitude,\n IP2 = IPAddress\n ) on UserPrincipalName\n | where Time2 > TimeGenerated // second sign-in is later\n and Time2 < TimeGenerated + 4h // within 4-hour window\n and CountryCode != Country2 // different countries\n | extend\n TimeDeltaHours = datetime_diff(\"minute\", Time2, TimeGenerated) / 60.0,\n DistanceKm = geo_distance_2points(Longitude, Latitude, Lon2, Lat2) / 1000.0\n | where TimeDeltaHours > 0\n | extend RequiredSpeedKmph = DistanceKm / TimeDeltaHours\n | where RequiredSpeedKmph > ImpossibleSpeedKmph\n | extend ThreatSignal = \"ImpossibleTravel\";\n\n// ── Step 3: First-ever sign-in from a new country for this supplier account ───\nlet NewCountrySignin =\n SupplierSignins\n | summarize\n CountriesSeen = make_set(CountryCode, 50),\n SigninCount = count(),\n LastSeen = max(TimeGenerated)\n by UserPrincipalName, SupplierName\n | mv-expand CountryCode = CountriesSeen to typeof(string)\n | join kind=inner (\n SupplierSignins\n | where ingestion_time() > ago(30m) // recent sign-ins only\n | project UserPrincipalName, RecentCountry = CountryCode,\n RecentTime = TimeGenerated, RecentIP = IPAddress\n ) on UserPrincipalName\n | where RecentCountry !in (CountriesSeen) // country never seen before\n | extend ThreatSignal = \"NewCountryForSupplier\";\n\n// ── Step 4: Sign-in from high-risk country ────────────────────────────────────\nlet HighRiskCountrySignin =\n SupplierSignins\n | where CountryCode in (HighRiskCountries)\n | extend ThreatSignal = \"HighRiskCountrySignin\";\n\n// ── Step 5: Union all signals ─────────────────────────────────────────────────\nunion\n (ImpossibleTravel\n | project\n Timestamp = TimeGenerated,\n UserPrincipalName, SupplierName,\n IPAddress, CountryCode, City,\n ThreatSignal,\n RequiredSpeedKmph,\n DistanceKm,\n SecondCountry = Country2,\n SecondCity = City2),\n (NewCountrySignin\n | project\n Timestamp = RecentTime,\n UserPrincipalName, SupplierName,\n IPAddress = RecentIP,\n CountryCode = RecentCountry,\n City = \"\",\n ThreatSignal,\n RequiredSpeedKmph = 0.0,\n DistanceKm = 0.0,\n SecondCountry = \"\",\n SecondCity = \"\"),\n (HighRiskCountrySignin\n | project\n Timestamp = TimeGenerated,\n UserPrincipalName, SupplierName,\n IPAddress, CountryCode, City,\n ThreatSignal,\n RequiredSpeedKmph = 0.0,\n DistanceKm = 0.0,\n SecondCountry = \"\",\n SecondCity = \"\")\n| extend\n AlertSeverity = \"Medium\",\n MitreTechnique = \"T1199, T1078\",\n MitreTactic = \"Initial Access\",\n PlaybookTrigger = \"notify_soc\",\n RiskScore = 70\n| project\n Timestamp,\n UserPrincipalName,\n SupplierName,\n ThreatSignal,\n IPAddress,\n CountryCode,\n City,\n SecondCountry,\n SecondCity,\n RequiredSpeedKmph,\n DistanceKm,\n AlertSeverity,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n RiskScore\n| order by Timestamp desc\n", + "queryFrequency": "PT15M", + "queryPeriod": "PT15M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "InitialAccess" + ], + "techniques": [ + "T1199", + "T1078" + ], + "alertRuleTemplateName": "a1b2c3d4-0012-4e5f-a012-000000000012", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "UserPrincipalName" + } + ] + }, + { + "entityType": "IP", + "fieldMappings": [ + { + "identifier": "Address", + "columnName": "IPAddress" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "TravelSpeedKmH": "TravelSpeedKmH" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "SigninLogs" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/sentinel/analytics-rules/supply_chain_anomaly.json b/sentinel/analytics-rules/supply_chain_anomaly.json new file mode 100644 index 0000000..54ad033 --- /dev/null +++ b/sentinel/analytics-rules/supply_chain_anomaly.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "Name of the Log Analytics workspace where Microsoft Sentinel is deployed." + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules", + "apiVersion": "2022-11-01-preview", + "name": "[concat(parameters('workspaceName'), '/Microsoft.SecurityInsights/a1b2c3d4-0011-4e5f-a011-000000000011')]", + "kind": "Scheduled", + "location": "[resourceGroup().location]", + "properties": { + "displayName": "RetailShield - Supply Chain / Third-Party Anomaly Detection", + "description": "Detects anomalous activity patterns originating from supplier-associated accounts or infrastructure, including unusual API calls, configuration changes, and access from unexpected locations. Correlates AzureDiagnostics and AuditLogs. MITRE T1195.", + "severity": "High", + "enabled": true, + "query": "// ============================================================\n// RetailShield — Supply Chain Anomaly Detection\n// Rule ID : RS-SUP-001\n// MITRE ATT&CK : T1195 — Supply Chain Compromise\n// Tactic : Initial Access\n// Severity : High\n// Frequency : Every 30 minutes | Lookback: 30 minutes\n// Author : Tanvir Farhad — ShieldTech Ltd, London\n// ============================================================\n// Detects compromised or malicious supplier API behaviour\n// against the retail platform. Covers supplier keys accessing\n// admin endpoints, new service principals created by supplier\n// accounts, out-of-spec endpoint access, and mass data export.\n// Retail-specific: tracks supplier_api_key patterns tied to\n// logistics, payment, and stock management vendors.\n// ============================================================\n\nlet LookbackWindow = 30m;\nlet BulkExportThreshold = 1000; // records exported above this is suspicious\nlet MinAnomalousRequests = 3; // minimum out-of-spec requests before alert\n\n// Admin / privileged endpoints suppliers must never call\nlet AdminEndpoints = dynamic([\n \"/admin\", \"/management\", \"/config\", \"/users\",\n \"/audit\", \"/security\", \"/settings\", \"/permissions\"\n]);\n\n// Sensitive data endpoints — acceptable only for authorised internal accounts\nlet SensitiveEndpoints = dynamic([\n \"/customers\", \"/export\", \"/payment\", \"/finance\",\n \"/employees\", \"/cardholder\", \"/transactions\"\n]);\n\n// Endpoints that are part of the agreed supplier integration spec\nlet AgreeableEndpoints = dynamic([\n \"/inventory\", \"/stock\", \"/orders\", \"/delivery\",\n \"/products\", \"/shipment\", \"/tracking\", \"/catalogue\"\n]);\n\n// ── Signal 1 — Supplier API key accessing admin / privileged endpoints ────────\nlet SupplierAdminAccess =\n AzureDiagnostics\n | where ingestion_time() > ago(LookbackWindow)\n | where ResourceType == \"APIMANAGEMENT/SERVICE\"\n | extend APIKey = tostring(column_ifexists(\"apimSubscriptionId_s\", \"\"))\n | extend Endpoint = tostring(column_ifexists(\"requestPath_s\", \"\"))\n | where APIKey startswith \"supplier_api_key\"\n | where Endpoint has_any (AdminEndpoints)\n | project\n TimeGenerated,\n SignalType = \"SupplierAdminAccess\",\n SupplierKey = APIKey,\n Endpoint,\n Method = tostring(column_ifexists(\"requestMethod_s\", \"\")),\n StatusCode = tostring(column_ifexists(\"responseCode_d\", \"\"));\n\n// ── Signal 2 — New Azure AD service principal created by supplier account ──────\nlet NewServicePrincipal =\n AuditLogs\n | where ingestion_time() > ago(LookbackWindow)\n | where OperationName has_any (\"Add service principal\", \"Add application\")\n | extend InitiatedBy = tostring(InitiatedBy.user.userPrincipalName)\n | where InitiatedBy has_any (\"supplier\", \"vendor\", \"partner\", \"api\", \"3rd-party\")\n or InitiatedBy matches regex @\"svc_[a-z0-9]+@\"\n | extend NewPrincipal = tostring(TargetResources[0].displayName)\n | project\n TimeGenerated,\n SignalType = \"NewServicePrincipal\",\n SupplierKey = InitiatedBy,\n Endpoint = NewPrincipal,\n Method = CorrelationId,\n StatusCode = \"\";\n\n// ── Signal 3 — Supplier accessing endpoints outside agreed integration spec ────\nlet UnauthorisedEndpointAccess =\n AzureDiagnostics\n | where ingestion_time() > ago(LookbackWindow)\n | where ResourceType == \"APIMANAGEMENT/SERVICE\"\n | extend APIKey = tostring(column_ifexists(\"apimSubscriptionId_s\", \"\"))\n | extend Endpoint = tostring(column_ifexists(\"requestPath_s\", \"\"))\n | where APIKey startswith \"supplier_api_key\"\n | where not(Endpoint has_any (AgreeableEndpoints))\n | where Endpoint has_any (SensitiveEndpoints)\n | summarize\n AccessCount = count(),\n Endpoints = make_set(Endpoint),\n FirstSeen = min(TimeGenerated),\n LastSeen = max(TimeGenerated)\n by APIKey\n | where AccessCount >= MinAnomalousRequests\n | project\n TimeGenerated = FirstSeen,\n SignalType = \"UnauthorisedEndpointAccess\",\n SupplierKey = APIKey,\n Endpoint = tostring(Endpoints),\n Method = \"\",\n StatusCode = tostring(AccessCount);\n\n// ── Signal 4 — Mass data export by supplier API key ──────────────────────────\nlet MassDataExport =\n AzureDiagnostics\n | where ingestion_time() > ago(LookbackWindow)\n | where ResourceType == \"APIMANAGEMENT/SERVICE\"\n | extend APIKey = tostring(column_ifexists(\"apimSubscriptionId_s\", \"\"))\n | extend Endpoint = tostring(column_ifexists(\"requestPath_s\", \"\"))\n | extend RecordsServed = toint(column_ifexists(\"responseBodyLength_d\", 0))\n | where APIKey startswith \"supplier_api_key\"\n | where Endpoint has_any (SensitiveEndpoints)\n | summarize\n TotalRecords = sum(RecordsServed),\n RequestCount = count(),\n Endpoints = make_set(Endpoint)\n by APIKey, bin(TimeGenerated, LookbackWindow)\n | where TotalRecords >= BulkExportThreshold\n | project\n TimeGenerated,\n SignalType = \"MassDataExport\",\n SupplierKey = APIKey,\n Endpoint = tostring(Endpoints),\n Method = \"\",\n StatusCode = tostring(TotalRecords);\n\n// ── Union all signals ─────────────────────────────────────────────────────────\nunion SupplierAdminAccess, NewServicePrincipal, UnauthorisedEndpointAccess, MassDataExport\n| summarize\n SignalCount = count(),\n Signals = make_set(SignalType),\n FirstSeen = min(TimeGenerated),\n LastSeen = max(TimeGenerated),\n SupplierKey = take_any(SupplierKey),\n Endpoints = make_set(Endpoint)\n by bin(TimeGenerated, LookbackWindow)\n| where SignalCount >= 1\n| extend\n RiskScore = case(SignalCount >= 3, 90, SignalCount == 2, 78, 60),\n MitreTechnique = \"T1195\",\n MitreTactic = \"Initial Access\",\n AlertSeverity = case(SignalCount >= 3, \"CRITICAL\", SignalCount == 2, \"HIGH\", \"MEDIUM\"),\n Severity = case(SignalCount >= 2, \"HIGH\", \"MEDIUM\"),\n PlaybookTrigger = \"notify_soc\",\n DeviceName = \"API-GATEWAY\",\n AlertTitle = strcat(\"Supply Chain Anomaly — Supplier Behavioural Deviation: \", SupplierKey)\n| project\n AlertTitle,\n DeviceName,\n SupplierKey,\n AlertSeverity,\n Severity,\n RiskScore,\n SignalCount,\n Signals,\n Endpoints,\n MitreTechnique,\n MitreTactic,\n PlaybookTrigger,\n FirstSeen,\n LastSeen\n| sort by RiskScore desc\n", + "queryFrequency": "PT30M", + "queryPeriod": "PT30M", + "triggerOperator": "GreaterThan", + "triggerThreshold": 0, + "suppressionDuration": "PT5H", + "suppressionEnabled": false, + "tactics": [ + "InitialAccess" + ], + "techniques": [ + "T1195" + ], + "alertRuleTemplateName": "a1b2c3d4-0011-4e5f-a011-000000000011", + "entityMappings": [ + { + "entityType": "Account", + "fieldMappings": [ + { + "identifier": "FullName", + "columnName": "InitiatedBy" + } + ] + }, + { + "entityType": "IP", + "fieldMappings": [ + { + "identifier": "Address", + "columnName": "CallerIpAddress" + } + ] + } + ], + "customDetails": { + "PlaybookTrigger": "PlaybookTrigger", + "RiskScore": "RiskScore", + "SupplierName": "SupplierName" + }, + "requiredDataConnectors": [ + { + "connectorId": "RetailShield", + "dataTypes": [ + "AzureDiagnostics", + "AuditLogs" + ] + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/detection-rules/test_kql_rules.py b/tests/detection-rules/test_kql_rules.py index 62c6525..4b9d1e0 100644 --- a/tests/detection-rules/test_kql_rules.py +++ b/tests/detection-rules/test_kql_rules.py @@ -1 +1,1262 @@ -"""\nRetailShield — KQL Rule Validation Test Suite\nValidates syntax structure, required metadata, and logic correctness\nfor all detection-rules/*.kql files without a live Sentinel connection.\n"""\n\nimport re\nfrom pathlib import Path\n\nRULES_DIR = Path(__file__).parent.parent.parent / \"detection-rules\"\n\n\ndef load_rule(filename: str) -> str:\n path = RULES_DIR / filename\n assert path.exists(), f\"Rule file not found: {path}\"\n return path.read_text(encoding=\"utf-8\")\n\n\n# ── Helpers ─────────────────────────────────────────────────────────────────────────────\n\ndef assert_metadata(content: str, key: str, value: str):\n pattern = rf\"//\\s*{re.escape(key)}\\s*:.*{re.escape(value)}\"\n assert re.search(pattern, content, re.IGNORECASE), (\n f\"Missing or incorrect metadata '{key}: {value}'\"\n )\n\n\ndef assert_contains(content: str, *fragments: str):\n for fragment in fragments:\n assert fragment in content, f\"Expected '{fragment}' not found in rule\"\n\n\ndef assert_not_contains(content: str, *fragments: str):\n for fragment in fragments:\n assert fragment not in content, f\"Unexpected fragment '{fragment}' found in rule\"\n\n\n# ── phishing_detection.kql ─────────────────────────────────────────────────────────────────────\n\nclass TestPhishingDetection:\n RULE = \"retail/phishing_detection.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1566.001\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Initial Access\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_queries_email_attachment_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"EmailAttachmentInfo\")\n\n def test_queries_email_events_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"EmailEvents\")\n\n def test_filters_inbound_delivered_emails(self):\n content = load_rule(self.RULE)\n assert_contains(content, '\"Delivered\"', '\"Inbound\"')\n\n def test_suspicious_extensions_defined(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"SuspiciousExtensions\",\n '\".exe\"',\n '\".ps1\"',\n '\".vbs\"',\n '\".docm\"',\n )\n\n def test_suppresses_approved_senders(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ApprovedSenderDomains\", \"leftanti\")\n\n def test_lure_classification_present(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"LureCategory\",\n \"Financial\",\n \"Credential\",\n )\n\n def test_playbook_trigger_field_exposed(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"quarantine_email\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content), (\n \"Rule contains a hardcoded GUID — use a watchlist or parameter instead\"\n )\n\n def test_uses_ingestion_time_not_static_timestamp(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ingestion_time()\")\n assert_not_contains(content, \"datetime(2\")\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n required_fields = [\n \"SenderAddress\",\n \"RecipientEmailAddress\",\n \"Subject\",\n \"AttachmentFileName\",\n \"AttachmentSHA256\",\n \"NetworkMessageId\",\n \"AlertSeverity\",\n ]\n for field in required_fields:\n assert_contains(content, field)\n\n\n# ── ransomware_indicator.kql ──────────────────────────────────────────────────────────────────\n\nclass TestRansomwareIndicator:\n RULE = \"retail/ransomware_indicator.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1486\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Impact\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"Critical\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_mass_file_rename_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"DeviceFileEvents\", \"FileRenamed\", \"MassRenameThresh\")\n\n def test_shadow_copy_deletion_signal(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"vssadmin delete shadows\",\n \"wmic shadowcopy delete\",\n \"bcdedit /set recoveryenabled no\",\n \"ShadowCopyDeletion\",\n )\n\n def test_known_ransomware_process_names(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"lockbit\",\n \"blackcat\",\n \"conti\",\n \"ryuk\",\n \"KnownRansomwareProcess\",\n )\n\n def test_c2_beacon_signal(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"DeviceNetworkEvents\",\n \"RetailIOCWatchlist\",\n \"BeaconThresh\",\n \"C2BeaconToRansomwareIP\",\n )\n\n def test_ioc_watchlist_used(self):\n content = load_rule(self.RULE)\n assert_contains(content, '_GetWatchlist(\"RetailIOCWatchlist\")')\n\n def test_private_ip_exclusions(self):\n content = load_rule(self.RULE)\n assert_contains(content, '\"10.\"', '\"192.168.\"', '\"127.0.0.1\"')\n\n def test_union_combines_all_signals(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"MassFileRename\",\n \"ShadowCopyDeletion\",\n \"RansomwareProcesses\",\n \"C2Beaconing\",\n )\n\n def test_playbook_trigger_field_exposed(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"isolate_endpoint\")\n\n def test_risk_score_is_100(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"100\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content), (\n \"Rule contains a hardcoded GUID — use a watchlist or parameter instead\"\n )\n\n def test_uses_ingestion_time_not_static_timestamp(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ingestion_time()\")\n assert_not_contains(content, \"datetime(2\")\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n required_fields = [\n \"DeviceName\",\n \"DeviceId\",\n \"ThreatSignal\",\n \"ProcessCommandLine\",\n \"RenameCount\",\n \"SampleFiles\",\n \"AffectedFolders\",\n \"RemoteIP\",\n \"BeaconCount\",\n \"AlertSeverity\",\n \"MitreTechnique\",\n \"MitreTactic\",\n \"PlaybookTrigger\",\n \"RiskScore\",\n ]\n for field in required_fields:\n assert_contains(content, field)\n\n\n# ── pos_anomaly.kql ─────────────────────────────────────────────────────────────────────────────\n\nclass TestPosAnomaly:\n RULE = \"retail/pos_anomaly.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1056.001\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Collection\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"15 minutes\")\n\n def test_pos_process_names_defined(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"POSProcessNames\", \"xstore.exe\", \"aloha.exe\", \"posready.exe\")\n\n def test_retail_terminal_prefix_defined(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RetailTerminalPrefix\", '\"pos-\"', '\"till-\"', '\"kiosk-\"')\n\n def test_unknown_dll_injection_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"UnknownDLLInjection\", \"DeviceEvents\", \"ImageLoaded\")\n\n def test_abnormal_transaction_volume_signal(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"AbnormalTransactionVolume\",\n \"RetailShield_Logs_CL\",\n \"TransactionVolumeThreshold\",\n )\n\n def test_process_memory_dump_signal(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"ProcessMemoryDump\",\n \"ProcessDumped\",\n \"ProcDump\",\n )\n\n def test_suspicious_network_signal(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"SuspiciousNetworkFromPOS\",\n \"DeviceNetworkEvents\",\n \"RetailIOCWatchlist\",\n )\n\n def test_playbook_trigger_field_exposed(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"suspend_terminal\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_uses_ingestion_time_not_static_timestamp(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ingestion_time()\")\n assert_not_contains(content, \"datetime(2\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content), (\n \"Rule contains a hardcoded GUID — use a watchlist or parameter instead\"\n )\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n required_fields = [\n \"AlertTitle\",\n \"DeviceName\",\n \"DeviceId\",\n \"AlertSeverity\",\n \"RiskScore\",\n \"SignalCount\",\n \"Signals\",\n \"MitreTechnique\",\n \"MitreTactic\",\n \"PlaybookTrigger\",\n \"FirstSeen\",\n \"LastSeen\",\n ]\n for field in required_fields:\n assert_contains(content, field)\n\n\n# ── ai_voice_fraud.kql ─────────────────────────────────────────────────────────────────────\n\nclass TestAiVoiceFraud:\n RULE = \"retail/ai_voice_fraud.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1598\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Reconnaissance\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"30 minutes\")\n\n def test_ai_confidence_threshold_defined(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AIConfidenceThreshold\")\n\n def test_fraud_keywords_defined(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"FraudKeywords\",\n '\"urgent\"',\n '\"override\"',\n '\"bypass\"',\n '\"transfer\"',\n )\n\n def test_business_hours_defined(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"BusinessHoursStart\", \"BusinessHoursEnd\")\n\n def test_high_confidence_voice_fraud_signal(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"HighConfidenceVoiceFraud\",\n \"RetailShield_Logs_CL\",\n \"AI_Voice_Fraud\",\n )\n\n def test_urgent_financial_request_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"UrgentFinancialRequest\")\n\n def test_spoofed_caller_id_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"SpoofedCallerID\")\n\n def test_after_hours_voice_fraud_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AfterHoursVoiceFraud\", \"hourofday\")\n\n def test_playbook_trigger_field_exposed(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"notify_soc\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_uses_ingestion_time_not_static_timestamp(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ingestion_time()\")\n assert_not_contains(content, \"datetime(2\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content), (\n \"Rule contains a hardcoded GUID — use a watchlist or parameter instead\"\n )\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n required_fields = [\n \"AlertTitle\",\n \"DeviceName\",\n \"TargetEmployee\",\n \"ImpersonatingEntity\",\n \"RequestMade\",\n \"AlertSeverity\",\n \"RiskScore\",\n \"SignalCount\",\n \"Signals\",\n \"MitreTechnique\",\n \"MitreTactic\",\n \"PlaybookTrigger\",\n \"FirstSeen\",\n \"LastSeen\",\n ]\n for field in required_fields:\n assert_contains(content, field)\n\n\n# ── supply_chain_anomaly.kql ──────────────────────────────────────────────────────────────────\n\nclass TestSupplyChainAnomaly:\n RULE = \"retail/supply_chain_anomaly.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1195\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Initial Access\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"30 minutes\")\n\n def test_admin_endpoints_defined(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"AdminEndpoints\",\n '\"/admin\"',\n '\"/management\"',\n '\"/config\"',\n )\n\n def test_agreeable_endpoints_defined(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"AgreeableEndpoints\",\n '\"/inventory\"',\n '\"/orders\"',\n )\n\n def test_bulk_export_threshold_defined(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"BulkExportThreshold\")\n\n def test_supplier_admin_access_signal(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"SupplierAdminAccess\",\n \"AzureDiagnostics\",\n \"supplier_api_key\",\n )\n\n def test_new_service_principal_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"NewServicePrincipal\", \"AuditLogs\")\n\n def test_unauthorised_endpoint_access_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"UnauthorisedEndpointAccess\")\n\n def test_mass_data_export_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MassDataExport\", \"BulkExportThreshold\")\n\n def test_playbook_trigger_field_exposed(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"notify_soc\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_uses_ingestion_time_not_static_timestamp(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ingestion_time()\")\n assert_not_contains(content, \"datetime(2\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content), (\n \"Rule contains a hardcoded GUID — use a watchlist or parameter instead\"\n )\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n required_fields = [\n \"AlertTitle\",\n \"DeviceName\",\n \"SupplierKey\",\n \"AlertSeverity\",\n \"RiskScore\",\n \"SignalCount\",\n \"Signals\",\n \"MitreTechnique\",\n \"MitreTactic\",\n \"PlaybookTrigger\",\n \"FirstSeen\",\n \"LastSeen\",\n ]\n for field in required_fields:\n assert_contains(content, field)\n\n\n# ── pos_void_refund.kql ──────────────────────────────────────────────────────────────────\n\nclass TestPosVoidRefund:\n RULE = \"retail/pos_void_refund.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1056.001\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Collection\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_source_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RetailShield_POS_CL\")\n\n def test_business_hours_params(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"BusinessHourStart\", \"BusinessHourEnd\")\n\n def test_threshold_params(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"VoidRefundThresh\", \"HighValueThresh\")\n\n def test_after_hours_void_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AfterHoursVoidRefund\")\n\n def test_high_volume_void_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"HighVolumeVoidRefund\")\n\n def test_high_value_void_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"HighValueVoidNoOverride\")\n\n def test_tender_mismatch_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"TenderMismatchRefund\", \"CASH\", \"GIFT_CARD\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"notify_soc\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"80\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"OperatorId_s\", \"StoreId_s\", \"TerminalId_s\", \"ThreatSignal\",\n \"TotalValue\", \"AlertSeverity\", \"MitreTechnique\", \"MitreTactic\",\n \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n\n\n# ── gift_card_fraud.kql ──────────────────────────────────────────────────────────────────\n\nclass TestGiftCardFraud:\n RULE = \"retail/gift_card_fraud.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1657\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Impact\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_source_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RetailShield_POS_CL\")\n\n def test_activation_threshold(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"GiftCardActivThresh\")\n\n def test_structuring_params(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"StructuringThreshold\", \"StructuringBand\")\n\n def test_multi_terminal_threshold(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MultiTerminalThresh\")\n\n def test_high_velocity_activation_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"HighVelocityGiftCardActivation\")\n\n def test_structured_purchase_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"StructuredGiftCardPurchase\")\n\n def test_drain_and_reload_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"GiftCardDrainAndReload\")\n\n def test_multi_terminal_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"GiftCardMultiTerminalUse\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"notify_soc\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"85\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"GiftCardNumber_s\", \"OperatorId_s\", \"StoreId_s\", \"TerminalId_s\",\n \"ThreatSignal\", \"ActivationCount\", \"UniqueCards\", \"TotalValue\",\n \"TerminalCount\", \"TerminalList\", \"AlertSeverity\", \"MitreTechnique\",\n \"MitreTactic\", \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n\n\n# ── mfa_fatigue.kql ──────────────────────────────────────────────────────────────────\n\nclass TestMfaFatigue:\n RULE = \"retail/mfa_fatigue.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1621\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Credential Access\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_source_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"SigninLogs\")\n\n def test_mfa_prompt_threshold(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MFAPromptThresh\")\n\n def test_mfa_result_codes(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MFAResultCodes\", \"50074\", \"50076\", \"50079\")\n\n def test_prompt_flood_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MFAPromptFlood\")\n\n def test_fatigue_acceptance_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"FatigueAcceptance\")\n\n def test_distributed_mfa_flood_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"DistributedMFAFlood\")\n\n def test_risky_signin_post_flood_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskySigninPostMFAFlood\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"block_ip\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"85\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"UserPrincipalName\", \"ThreatSignal\", \"PromptCount\", \"SourceIPs\",\n \"SourceIPList\", \"Locations\", \"AppNames\", \"SuccessIP\", \"SuccessCountry\",\n \"RiskLevel\", \"RiskyIP\", \"AlertSeverity\", \"MitreTechnique\",\n \"MitreTactic\", \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n\n\n# ── credential_stuffing.kql ──────────────────────────────────────────────────────────────────\n\nclass TestCredentialStuffing:\n RULE = \"retail/credential_stuffing.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1110.004\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Credential Access\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_source_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"SigninLogs\")\n\n def test_abuse_ipdb_watchlist(self):\n content = load_rule(self.RULE)\n assert_contains(content, '_GetWatchlist(\"AbuseIPDBWatchlist\")')\n\n def test_threshold_params(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"FailThreshPerAcct\", \"MinSourceIPs\")\n\n def test_distributed_login_failure_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"DistributedLoginFailure\")\n\n def test_account_takeover_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AccountTakeoverAfterStuffing\")\n\n def test_blacklisted_ip_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"StuffingFromBlacklistedIP\")\n\n def test_risky_signin_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskySigninAfterStuffing\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"block_ip\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"80\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"UserPrincipalName\", \"ThreatSignal\", \"FailCount\", \"SourceIPs\",\n \"SourceIPList\", \"IPAddress\", \"TargetAccts\", \"TargetList\",\n \"SuccessIP\", \"SuccessCountry\", \"RiskLevel\", \"AppNames\",\n \"AlertSeverity\", \"MitreTechnique\", \"MitreTactic\",\n \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n\n\n# ── after_hours_access.kql ──────────────────────────────────────────────────────────────────\n\nclass TestAfterHoursAccess:\n RULE = \"retail/after_hours_access.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1078\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Persistence\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"Medium\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_source_tables(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"SigninLogs\", \"AuditLogs\", \"DeviceLogonEvents\")\n\n def test_service_accounts_watchlist(self):\n content = load_rule(self.RULE)\n assert_contains(content, '_GetWatchlist(\"RetailServiceAccounts\")', \"leftanti\")\n\n def test_business_hours_params(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"BusinessHourStart\", \"BusinessHourEnd\")\n\n def test_after_hours_interactive_login_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AfterHoursInteractiveLogin\")\n\n def test_after_hours_privileged_operation_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AfterHoursPrivilegedOperation\")\n\n def test_after_hours_device_logon_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AfterHoursSensitiveDeviceLogon\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"notify_soc\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"60\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"Timestamp\", \"AccountName\", \"ThreatSignal\", \"LoginCount\",\n \"SourceIPs\", \"Locations\", \"AppNames\", \"Devices\",\n \"RemoteIP\", \"Operations\", \"TargetObjects\", \"AlertSeverity\",\n \"MitreTechnique\", \"MitreTactic\", \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n\n\n# ── data_exfiltration.kql ──────────────────────────────────────────────────────────────────\n\nclass TestDataExfiltration:\n RULE = \"retail/data_exfiltration.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1048\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Exfiltration\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"Critical\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_source_tables(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"DeviceNetworkEvents\", \"DeviceFileEvents\")\n\n def test_ioc_watchlist(self):\n content = load_rule(self.RULE)\n assert_contains(content, '_GetWatchlist(\"RetailIOCWatchlist\")')\n\n def test_threshold_params(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"DNSQueryThresh\", \"SubdomainLenThresh\",\n \"OutboundMBThresh\", \"StagingFileThresh\")\n\n def test_private_ip_exclusions(self):\n content = load_rule(self.RULE)\n assert_contains(content, '\"10.\"', '\"192.168.\"', '\"172.\"', '\"127.0.0.1\"')\n\n def test_dns_tunneling_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"DNSTunneling\")\n\n def test_large_outbound_transfer_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"LargeOutboundTransfer\")\n\n def test_ioc_matched_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"IOCMatchedExfilTarget\")\n\n def test_data_staging_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"DataStagingToExfil\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"data_exfil_contain\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"90\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"Timestamp\", \"DeviceName\", \"DeviceId\", \"AccountName\", \"ThreatSignal\",\n \"NormalisedTarget\", \"QueryCount\", \"UniqueSubdomains\", \"TotalSentMB\",\n \"TotalSentBytes\", \"FileReadCount\", \"SampleFiles\", \"UniqueFolders\",\n \"ExfilTargets\", \"AlertSeverity\", \"MitreTechnique\", \"MitreTactic\",\n \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n\n\n# ── supplier_impossible_travel.kql ──────────────────────────────────────────────────────────\n\nclass TestSupplierImpossibleTravel:\n RULE = \"retail/supplier_impossible_travel.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1199\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Initial Access\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"Medium\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"15 minutes\")\n\n def test_source_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"SigninLogs\")\n\n def test_supplier_accounts_watchlist(self):\n content = load_rule(self.RULE)\n assert_contains(content, '_GetWatchlist(\"RetailSupplierAccounts\")')\n\n def test_impossible_speed_threshold(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ImpossibleSpeedKmph\", \"900\")\n\n def test_high_risk_countries(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"HighRiskCountries\", '\"RU\"', '\"CN\"', '\"KP\"', '\"IR\"')\n\n def test_geo_distance_function(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"geo_distance_2points\")\n\n def test_impossible_travel_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"ImpossibleTravel\", \"RequiredSpeedKmph\")\n\n def test_new_country_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"NewCountryForSupplier\")\n\n def test_high_risk_country_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"HighRiskCountrySignin\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"notify_soc\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"70\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"Timestamp\", \"UserPrincipalName\", \"SupplierName\", \"ThreatSignal\",\n \"IPAddress\", \"CountryCode\", \"City\", \"SecondCountry\", \"SecondCity\",\n \"RequiredSpeedKmph\", \"DistanceKm\", \"AlertSeverity\", \"MitreTechnique\",\n \"MitreTactic\", \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n\n\n# ── privileged_role_addition.kql ──────────────────────────────────────────────────────────\n\nclass TestPrivilegedRoleAddition:\n RULE = \"retail/privileged_role_addition.kql\"\n\n def test_file_exists(self):\n assert (RULES_DIR / self.RULE).exists()\n\n def test_mitre_technique_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"MITRE ATT&CK\", \"T1098\")\n\n def test_mitre_tactic_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Tactic\", \"Persistence\")\n\n def test_severity_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Severity\", \"High\")\n\n def test_frequency_metadata(self):\n content = load_rule(self.RULE)\n assert_metadata(content, \"Frequency\", \"5 minutes\")\n\n def test_source_table(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AuditLogs\")\n\n def test_sensitive_roles_defined(self):\n content = load_rule(self.RULE)\n assert_contains(\n content,\n \"SensitiveRoles\",\n \"Global Administrator\",\n \"Privileged Role Administrator\",\n \"Security Administrator\",\n )\n\n def test_sensitive_role_assigned_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"SensitiveRoleAssigned\")\n\n def test_after_hours_role_addition_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"AfterHoursRoleAddition\", \"HourOfDay\")\n\n def test_mfa_change_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RoleAdditionFollowedByMFAChange\")\n\n def test_sensitive_group_member_signal(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"SensitiveGroupMemberAdded\")\n\n def test_playbook_trigger(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"PlaybookTrigger\", \"notify_soc\")\n\n def test_risk_score(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"RiskScore\", \"85\")\n\n def test_mitre_fields_in_output(self):\n content = load_rule(self.RULE)\n assert_contains(content, \"MitreTechnique\", \"MitreTactic\")\n\n def test_no_hardcoded_tenant_ids(self):\n content = load_rule(self.RULE)\n tenant_pattern = re.compile(\n r\"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\",\n re.IGNORECASE,\n )\n assert not tenant_pattern.search(content)\n\n def test_output_contains_required_fields(self):\n content = load_rule(self.RULE)\n for field in [\n \"Timestamp\", \"InitiatedByUser\", \"TargetUser\", \"RoleDisplayName\",\n \"ThreatSignal\", \"HourOfDay\", \"MFAOperation\", \"AlertSeverity\",\n \"MitreTechnique\", \"MitreTactic\", \"PlaybookTrigger\", \"RiskScore\",\n ]:\n assert_contains(content, field)\n \ No newline at end of file +""" +RetailShield — KQL Rule Validation Test Suite +Validates syntax structure, required metadata, and logic correctness +for all detection-rules/*.kql files without a live Sentinel connection. +""" + +import re +from pathlib import Path + +RULES_DIR = Path(__file__).parent.parent.parent / "detection-rules" + + +def load_rule(filename: str) -> str: + path = RULES_DIR / filename + assert path.exists(), f"Rule file not found: {path}" + return path.read_text(encoding="utf-8") + + +# ── Helpers ───────────────────────────────────────────────────────────────────────────── + +def assert_metadata(content: str, key: str, value: str): + pattern = rf"//\s*{re.escape(key)}\s*:.*{re.escape(value)}" + assert re.search(pattern, content, re.IGNORECASE), ( + f"Missing or incorrect metadata '{key}: {value}'" + ) + + +def assert_contains(content: str, *fragments: str): + for fragment in fragments: + assert fragment in content, f"Expected '{fragment}' not found in rule" + + +def assert_not_contains(content: str, *fragments: str): + for fragment in fragments: + assert fragment not in content, f"Unexpected fragment '{fragment}' found in rule" + + +# ── phishing_detection.kql ───────────────────────────────────────────────────────────────────── + +class TestPhishingDetection: + RULE = "retail/phishing_detection.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1566.001") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Initial Access") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_queries_email_attachment_table(self): + content = load_rule(self.RULE) + assert_contains(content, "EmailAttachmentInfo") + + def test_queries_email_events_table(self): + content = load_rule(self.RULE) + assert_contains(content, "EmailEvents") + + def test_filters_inbound_delivered_emails(self): + content = load_rule(self.RULE) + assert_contains(content, '"Delivered"', '"Inbound"') + + def test_suspicious_extensions_defined(self): + content = load_rule(self.RULE) + assert_contains( + content, + "SuspiciousExtensions", + '".exe"', + '".ps1"', + '".vbs"', + '".docm"', + ) + + def test_suppresses_approved_senders(self): + content = load_rule(self.RULE) + assert_contains(content, "ApprovedSenderDomains", "leftanti") + + def test_lure_classification_present(self): + content = load_rule(self.RULE) + assert_contains( + content, + "LureCategory", + "Financial", + "Credential", + ) + + def test_playbook_trigger_field_exposed(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "quarantine_email") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content), ( + "Rule contains a hardcoded GUID — use a watchlist or parameter instead" + ) + + def test_uses_ingestion_time_not_static_timestamp(self): + content = load_rule(self.RULE) + assert_contains(content, "ingestion_time()") + assert_not_contains(content, "datetime(2") + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + required_fields = [ + "SenderAddress", + "RecipientEmailAddress", + "Subject", + "AttachmentFileName", + "AttachmentSHA256", + "NetworkMessageId", + "AlertSeverity", + ] + for field in required_fields: + assert_contains(content, field) + + +# ── ransomware_indicator.kql ────────────────────────────────────────────────────────────────── + +class TestRansomwareIndicator: + RULE = "retail/ransomware_indicator.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1486") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Impact") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "Critical") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_mass_file_rename_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "DeviceFileEvents", "FileRenamed", "MassRenameThresh") + + def test_shadow_copy_deletion_signal(self): + content = load_rule(self.RULE) + assert_contains( + content, + "vssadmin delete shadows", + "wmic shadowcopy delete", + "bcdedit /set recoveryenabled no", + "ShadowCopyDeletion", + ) + + def test_known_ransomware_process_names(self): + content = load_rule(self.RULE) + assert_contains( + content, + "lockbit", + "blackcat", + "conti", + "ryuk", + "KnownRansomwareProcess", + ) + + def test_c2_beacon_signal(self): + content = load_rule(self.RULE) + assert_contains( + content, + "DeviceNetworkEvents", + "RetailIOCWatchlist", + "BeaconThresh", + "C2BeaconToRansomwareIP", + ) + + def test_ioc_watchlist_used(self): + content = load_rule(self.RULE) + assert_contains(content, '_GetWatchlist("RetailIOCWatchlist")') + + def test_private_ip_exclusions(self): + content = load_rule(self.RULE) + assert_contains(content, '"10."', '"192.168."', '"127.0.0.1"') + + def test_union_combines_all_signals(self): + content = load_rule(self.RULE) + assert_contains( + content, + "MassFileRename", + "ShadowCopyDeletion", + "RansomwareProcesses", + "C2Beaconing", + ) + + def test_playbook_trigger_field_exposed(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "isolate_endpoint") + + def test_risk_score_is_100(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "100") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content), ( + "Rule contains a hardcoded GUID — use a watchlist or parameter instead" + ) + + def test_uses_ingestion_time_not_static_timestamp(self): + content = load_rule(self.RULE) + assert_contains(content, "ingestion_time()") + assert_not_contains(content, "datetime(2") + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + required_fields = [ + "DeviceName", + "DeviceId", + "ThreatSignal", + "ProcessCommandLine", + "RenameCount", + "SampleFiles", + "AffectedFolders", + "RemoteIP", + "BeaconCount", + "AlertSeverity", + "MitreTechnique", + "MitreTactic", + "PlaybookTrigger", + "RiskScore", + ] + for field in required_fields: + assert_contains(content, field) + + +# ── pos_anomaly.kql ───────────────────────────────────────────────────────────────────────────── + +class TestPosAnomaly: + RULE = "retail/pos_anomaly.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1056.001") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Collection") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "15 minutes") + + def test_pos_process_names_defined(self): + content = load_rule(self.RULE) + assert_contains(content, "POSProcessNames", "xstore.exe", "aloha.exe", "posready.exe") + + def test_retail_terminal_prefix_defined(self): + content = load_rule(self.RULE) + assert_contains(content, "RetailTerminalPrefix", '"pos-"', '"till-"', '"kiosk-"') + + def test_unknown_dll_injection_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "UnknownDLLInjection", "DeviceEvents", "ImageLoaded") + + def test_abnormal_transaction_volume_signal(self): + content = load_rule(self.RULE) + assert_contains( + content, + "AbnormalTransactionVolume", + "RetailShield_Logs_CL", + "TransactionVolumeThreshold", + ) + + def test_process_memory_dump_signal(self): + content = load_rule(self.RULE) + assert_contains( + content, + "ProcessMemoryDump", + "ProcessDumped", + "ProcDump", + ) + + def test_suspicious_network_signal(self): + content = load_rule(self.RULE) + assert_contains( + content, + "SuspiciousNetworkFromPOS", + "DeviceNetworkEvents", + "RetailIOCWatchlist", + ) + + def test_playbook_trigger_field_exposed(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "suspend_terminal") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_uses_ingestion_time_not_static_timestamp(self): + content = load_rule(self.RULE) + assert_contains(content, "ingestion_time()") + assert_not_contains(content, "datetime(2") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content), ( + "Rule contains a hardcoded GUID — use a watchlist or parameter instead" + ) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + required_fields = [ + "AlertTitle", + "DeviceName", + "DeviceId", + "AlertSeverity", + "RiskScore", + "SignalCount", + "Signals", + "MitreTechnique", + "MitreTactic", + "PlaybookTrigger", + "FirstSeen", + "LastSeen", + ] + for field in required_fields: + assert_contains(content, field) + + +# ── ai_voice_fraud.kql ───────────────────────────────────────────────────────────────────── + +class TestAiVoiceFraud: + RULE = "retail/ai_voice_fraud.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1598") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Reconnaissance") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "30 minutes") + + def test_ai_confidence_threshold_defined(self): + content = load_rule(self.RULE) + assert_contains(content, "AIConfidenceThreshold") + + def test_fraud_keywords_defined(self): + content = load_rule(self.RULE) + assert_contains( + content, + "FraudKeywords", + '"urgent"', + '"override"', + '"bypass"', + '"transfer"', + ) + + def test_business_hours_defined(self): + content = load_rule(self.RULE) + assert_contains(content, "BusinessHoursStart", "BusinessHoursEnd") + + def test_high_confidence_voice_fraud_signal(self): + content = load_rule(self.RULE) + assert_contains( + content, + "HighConfidenceVoiceFraud", + "RetailShield_Logs_CL", + "AI_Voice_Fraud", + ) + + def test_urgent_financial_request_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "UrgentFinancialRequest") + + def test_spoofed_caller_id_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "SpoofedCallerID") + + def test_after_hours_voice_fraud_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "AfterHoursVoiceFraud", "hourofday") + + def test_playbook_trigger_field_exposed(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "notify_soc") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_uses_ingestion_time_not_static_timestamp(self): + content = load_rule(self.RULE) + assert_contains(content, "ingestion_time()") + assert_not_contains(content, "datetime(2") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content), ( + "Rule contains a hardcoded GUID — use a watchlist or parameter instead" + ) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + required_fields = [ + "AlertTitle", + "DeviceName", + "TargetEmployee", + "ImpersonatingEntity", + "RequestMade", + "AlertSeverity", + "RiskScore", + "SignalCount", + "Signals", + "MitreTechnique", + "MitreTactic", + "PlaybookTrigger", + "FirstSeen", + "LastSeen", + ] + for field in required_fields: + assert_contains(content, field) + + +# ── supply_chain_anomaly.kql ────────────────────────────────────────────────────────────────── + +class TestSupplyChainAnomaly: + RULE = "retail/supply_chain_anomaly.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1195") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Initial Access") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "30 minutes") + + def test_admin_endpoints_defined(self): + content = load_rule(self.RULE) + assert_contains( + content, + "AdminEndpoints", + '"/admin"', + '"/management"', + '"/config"', + ) + + def test_agreeable_endpoints_defined(self): + content = load_rule(self.RULE) + assert_contains( + content, + "AgreeableEndpoints", + '"/inventory"', + '"/orders"', + ) + + def test_bulk_export_threshold_defined(self): + content = load_rule(self.RULE) + assert_contains(content, "BulkExportThreshold") + + def test_supplier_admin_access_signal(self): + content = load_rule(self.RULE) + assert_contains( + content, + "SupplierAdminAccess", + "AzureDiagnostics", + "supplier_api_key", + ) + + def test_new_service_principal_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "NewServicePrincipal", "AuditLogs") + + def test_unauthorised_endpoint_access_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "UnauthorisedEndpointAccess") + + def test_mass_data_export_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "MassDataExport", "BulkExportThreshold") + + def test_playbook_trigger_field_exposed(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "notify_soc") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_uses_ingestion_time_not_static_timestamp(self): + content = load_rule(self.RULE) + assert_contains(content, "ingestion_time()") + assert_not_contains(content, "datetime(2") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content), ( + "Rule contains a hardcoded GUID — use a watchlist or parameter instead" + ) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + required_fields = [ + "AlertTitle", + "DeviceName", + "SupplierKey", + "AlertSeverity", + "RiskScore", + "SignalCount", + "Signals", + "MitreTechnique", + "MitreTactic", + "PlaybookTrigger", + "FirstSeen", + "LastSeen", + ] + for field in required_fields: + assert_contains(content, field) + + +# ── pos_void_refund.kql ────────────────────────────────────────────────────────────────── + +class TestPosVoidRefund: + RULE = "retail/pos_void_refund.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1056.001") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Collection") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_source_table(self): + content = load_rule(self.RULE) + assert_contains(content, "RetailShield_POS_CL") + + def test_business_hours_params(self): + content = load_rule(self.RULE) + assert_contains(content, "BusinessHourStart", "BusinessHourEnd") + + def test_threshold_params(self): + content = load_rule(self.RULE) + assert_contains(content, "VoidRefundThresh", "HighValueThresh") + + def test_after_hours_void_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "AfterHoursVoidRefund") + + def test_high_volume_void_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "HighVolumeVoidRefund") + + def test_high_value_void_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "HighValueVoidNoOverride") + + def test_tender_mismatch_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "TenderMismatchRefund", "CASH", "GIFT_CARD") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "notify_soc") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "80") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "OperatorId_s", "StoreId_s", "TerminalId_s", "ThreatSignal", + "TotalValue", "AlertSeverity", "MitreTechnique", "MitreTactic", + "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field) + + +# ── gift_card_fraud.kql ────────────────────────────────────────────────────────────────── + +class TestGiftCardFraud: + RULE = "retail/gift_card_fraud.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1657") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Impact") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_source_table(self): + content = load_rule(self.RULE) + assert_contains(content, "RetailShield_POS_CL") + + def test_activation_threshold(self): + content = load_rule(self.RULE) + assert_contains(content, "GiftCardActivThresh") + + def test_structuring_params(self): + content = load_rule(self.RULE) + assert_contains(content, "StructuringThreshold", "StructuringBand") + + def test_multi_terminal_threshold(self): + content = load_rule(self.RULE) + assert_contains(content, "MultiTerminalThresh") + + def test_high_velocity_activation_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "HighVelocityGiftCardActivation") + + def test_structured_purchase_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "StructuredGiftCardPurchase") + + def test_drain_and_reload_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "GiftCardDrainAndReload") + + def test_multi_terminal_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "GiftCardMultiTerminalUse") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "notify_soc") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "85") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "GiftCardNumber_s", "OperatorId_s", "StoreId_s", "TerminalId_s", + "ThreatSignal", "ActivationCount", "UniqueCards", "TotalValue", + "TerminalCount", "TerminalList", "AlertSeverity", "MitreTechnique", + "MitreTactic", "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field) + + +# ── mfa_fatigue.kql ────────────────────────────────────────────────────────────────── + +class TestMfaFatigue: + RULE = "retail/mfa_fatigue.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1621") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Credential Access") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_source_table(self): + content = load_rule(self.RULE) + assert_contains(content, "SigninLogs") + + def test_mfa_prompt_threshold(self): + content = load_rule(self.RULE) + assert_contains(content, "MFAPromptThresh") + + def test_mfa_result_codes(self): + content = load_rule(self.RULE) + assert_contains(content, "MFAResultCodes", "50074", "50076", "50079") + + def test_prompt_flood_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "MFAPromptFlood") + + def test_fatigue_acceptance_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "FatigueAcceptance") + + def test_distributed_mfa_flood_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "DistributedMFAFlood") + + def test_risky_signin_post_flood_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskySigninPostMFAFlood") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "block_ip") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "85") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "UserPrincipalName", "ThreatSignal", "PromptCount", "SourceIPs", + "SourceIPList", "Locations", "AppNames", "SuccessIP", "SuccessCountry", + "RiskLevel", "RiskyIP", "AlertSeverity", "MitreTechnique", + "MitreTactic", "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field) + + +# ── credential_stuffing.kql ────────────────────────────────────────────────────────────────── + +class TestCredentialStuffing: + RULE = "retail/credential_stuffing.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1110.004") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Credential Access") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_source_table(self): + content = load_rule(self.RULE) + assert_contains(content, "SigninLogs") + + def test_abuse_ipdb_watchlist(self): + content = load_rule(self.RULE) + assert_contains(content, '_GetWatchlist("AbuseIPDBWatchlist")') + + def test_threshold_params(self): + content = load_rule(self.RULE) + assert_contains(content, "FailThreshPerAcct", "MinSourceIPs") + + def test_distributed_login_failure_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "DistributedLoginFailure") + + def test_account_takeover_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "AccountTakeoverAfterStuffing") + + def test_blacklisted_ip_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "StuffingFromBlacklistedIP") + + def test_risky_signin_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskySigninAfterStuffing") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "block_ip") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "80") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "UserPrincipalName", "ThreatSignal", "FailCount", "SourceIPs", + "SourceIPList", "IPAddress", "TargetAccts", "TargetList", + "SuccessIP", "SuccessCountry", "RiskLevel", "AppNames", + "AlertSeverity", "MitreTechnique", "MitreTactic", + "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field) + + +# ── after_hours_access.kql ────────────────────────────────────────────────────────────────── + +class TestAfterHoursAccess: + RULE = "retail/after_hours_access.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1078") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Persistence") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "Medium") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_source_tables(self): + content = load_rule(self.RULE) + assert_contains(content, "SigninLogs", "AuditLogs", "DeviceLogonEvents") + + def test_service_accounts_watchlist(self): + content = load_rule(self.RULE) + assert_contains(content, '_GetWatchlist("RetailServiceAccounts")', "leftanti") + + def test_business_hours_params(self): + content = load_rule(self.RULE) + assert_contains(content, "BusinessHourStart", "BusinessHourEnd") + + def test_after_hours_interactive_login_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "AfterHoursInteractiveLogin") + + def test_after_hours_privileged_operation_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "AfterHoursPrivilegedOperation") + + def test_after_hours_device_logon_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "AfterHoursSensitiveDeviceLogon") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "notify_soc") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "60") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "Timestamp", "AccountName", "ThreatSignal", "LoginCount", + "SourceIPs", "Locations", "AppNames", "Devices", + "RemoteIP", "Operations", "TargetObjects", "AlertSeverity", + "MitreTechnique", "MitreTactic", "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field) + + +# ── data_exfiltration.kql ────────────────────────────────────────────────────────────────── + +class TestDataExfiltration: + RULE = "retail/data_exfiltration.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1048") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Exfiltration") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "Critical") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_source_tables(self): + content = load_rule(self.RULE) + assert_contains(content, "DeviceNetworkEvents", "DeviceFileEvents") + + def test_ioc_watchlist(self): + content = load_rule(self.RULE) + assert_contains(content, '_GetWatchlist("RetailIOCWatchlist")') + + def test_threshold_params(self): + content = load_rule(self.RULE) + assert_contains( + content, "DNSQueryThresh", "SubdomainLenThresh", + "OutboundMBThresh", "StagingFileThresh" + ) + + def test_private_ip_exclusions(self): + content = load_rule(self.RULE) + assert_contains(content, '"10."', '"192.168."', '"172."', '"127.0.0.1"') + + def test_dns_tunneling_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "DNSTunneling") + + def test_large_outbound_transfer_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "LargeOutboundTransfer") + + def test_ioc_matched_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "IOCMatchedExfilTarget") + + def test_data_staging_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "DataStagingToExfil") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "data_exfil_contain") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "90") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "Timestamp", "DeviceName", "DeviceId", "AccountName", "ThreatSignal", + "NormalisedTarget", "QueryCount", "UniqueSubdomains", "TotalSentMB", + "TotalSentBytes", "FileReadCount", "SampleFiles", "UniqueFolders", + "ExfilTargets", "AlertSeverity", "MitreTechnique", "MitreTactic", + "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field) + + +# ── supplier_impossible_travel.kql ────────────────────────────────────────────────────────── + +class TestSupplierImpossibleTravel: + RULE = "retail/supplier_impossible_travel.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1199") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Initial Access") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "Medium") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "15 minutes") + + def test_source_table(self): + content = load_rule(self.RULE) + assert_contains(content, "SigninLogs") + + def test_supplier_accounts_watchlist(self): + content = load_rule(self.RULE) + assert_contains(content, '_GetWatchlist("RetailSupplierAccounts")') + + def test_impossible_speed_threshold(self): + content = load_rule(self.RULE) + assert_contains(content, "ImpossibleSpeedKmph", "900") + + def test_high_risk_countries(self): + content = load_rule(self.RULE) + assert_contains(content, "HighRiskCountries", '"RU"', '"CN"', '"KP"', '"IR"') + + def test_geo_distance_function(self): + content = load_rule(self.RULE) + assert_contains(content, "geo_distance_2points") + + def test_impossible_travel_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "ImpossibleTravel", "RequiredSpeedKmph") + + def test_new_country_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "NewCountryForSupplier") + + def test_high_risk_country_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "HighRiskCountrySignin") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "notify_soc") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "70") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "Timestamp", "UserPrincipalName", "SupplierName", "ThreatSignal", + "IPAddress", "CountryCode", "City", "SecondCountry", "SecondCity", + "RequiredSpeedKmph", "DistanceKm", "AlertSeverity", "MitreTechnique", + "MitreTactic", "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field) + + +# ── privileged_role_addition.kql ────────────────────────────────────────────────────────── + +class TestPrivilegedRoleAddition: + RULE = "retail/privileged_role_addition.kql" + + def test_file_exists(self): + assert (RULES_DIR / self.RULE).exists() + + def test_mitre_technique_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "MITRE ATT&CK", "T1098") + + def test_mitre_tactic_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Tactic", "Persistence") + + def test_severity_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Severity", "High") + + def test_frequency_metadata(self): + content = load_rule(self.RULE) + assert_metadata(content, "Frequency", "5 minutes") + + def test_source_table(self): + content = load_rule(self.RULE) + assert_contains(content, "AuditLogs") + + def test_sensitive_roles_defined(self): + content = load_rule(self.RULE) + assert_contains( + content, + "SensitiveRoles", + "Global Administrator", + "Privileged Role Administrator", + "Security Administrator", + ) + + def test_sensitive_role_assigned_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "SensitiveRoleAssigned") + + def test_after_hours_role_addition_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "AfterHoursRoleAddition", "HourOfDay") + + def test_mfa_change_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "RoleAdditionFollowedByMFAChange") + + def test_sensitive_group_member_signal(self): + content = load_rule(self.RULE) + assert_contains(content, "SensitiveGroupMemberAdded") + + def test_playbook_trigger(self): + content = load_rule(self.RULE) + assert_contains(content, "PlaybookTrigger", "notify_soc") + + def test_risk_score(self): + content = load_rule(self.RULE) + assert_contains(content, "RiskScore", "85") + + def test_mitre_fields_in_output(self): + content = load_rule(self.RULE) + assert_contains(content, "MitreTechnique", "MitreTactic") + + def test_no_hardcoded_tenant_ids(self): + content = load_rule(self.RULE) + tenant_pattern = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + assert not tenant_pattern.search(content) + + def test_output_contains_required_fields(self): + content = load_rule(self.RULE) + for field in [ + "Timestamp", "InitiatedByUser", "TargetUser", "RoleDisplayName", + "ThreatSignal", "HourOfDay", "MFAOperation", "AlertSeverity", + "MitreTechnique", "MitreTactic", "PlaybookTrigger", "RiskScore", + ]: + assert_contains(content, field)