The Nag Machine: A Logic App That Badgers Your Team About Unowned Sentinel Incidents

The Nag Machine: A Logic App That Badgers Your Team About Unowned Sentinel Incidents

All right class

Today is a quick one so you can enjoy deploying it without reading a whole lot about this.

The idea is simple - you are getting Sentinel incident, but because for whatever reason you are not monitoring the queue 24/7, there is no way you can keep an eye on them. This logic app will flag it nicely for you in your Teams channel; you can then take ownership of the incident or dismiss it.

To deploy the logic app copy the ARM template below and go to Deploy from a custom template and Build your own template in the editor

Save

Fill in the necessary info and create

ARM Template

{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"PlaybookName": {
"defaultValue": "Sentinel-Nag-Machine",
"type": "String"
}
},
"variables": {
"AzureMonitorLogsConnectionName": "[concat('AzureMonitorLogs-', parameters('PlaybookName'))]",
"TeamsConnectionName": "[concat('Teams-', parameters('PlaybookName'))]",
"MicrosoftSentinelConnectionName": "[concat('MicrosoftSentinel-', parameters('PlaybookName'))]"
},
"resources": [
{
"type": "Microsoft.Web/connections",
"apiVersion": "2016-06-01",
"name": "[variables('AzureMonitorLogsConnectionName')]",
"location": "[resourceGroup().location]",
"kind": "V1",
"properties": {
"displayName": "[variables('AzureMonitorLogsConnectionName')]",
"api": {
"id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/azuremonitorlogs')]"
}
}
},
{
"type": "Microsoft.Web/connections",
"apiVersion": "2016-06-01",
"name": "[variables('TeamsConnectionName')]",
"location": "[resourceGroup().location]",
"kind": "V1",
"properties": {
"displayName": "[variables('TeamsConnectionName')]",
"api": {
"id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/teams')]"
}
}
},
{
"type": "Microsoft.Web/connections",
"apiVersion": "2016-06-01",
"name": "[variables('MicrosoftSentinelConnectionName')]",
"location": "[resourceGroup().location]",
"kind": "V1",
"properties": {
"displayName": "[variables('MicrosoftSentinelConnectionName')]",
"parameterValueType": "Alternative",
"api": {
"id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/azuresentinel')]"
}
}
},
{
"type": "Microsoft.Logic/workflows",
"apiVersion": "2017-07-01",
"name": "[parameters('PlaybookName')]",
"location": "[resourceGroup().location]",
"identity": {
"type": "SystemAssigned"
},
"dependsOn": [
"[resourceId('Microsoft.Web/connections', variables('AzureMonitorLogsConnectionName'))]",
"[resourceId('Microsoft.Web/connections', variables('TeamsConnectionName'))]",
"[resourceId('Microsoft.Web/connections', variables('MicrosoftSentinelConnectionName'))]"
],
"properties": {
"state": "Disabled",
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"$connections": {
"defaultValue": {},
"type": "Object"
}
},
"triggers": {
"Recurrence": {
"recurrence": {
"interval": 15,
"frequency": "Minute"
},
"evaluatedRecurrence": {
"interval": 15,
"frequency": "Minute"
},
"type": "Recurrence"
}
},
"actions": {
"Run_query_and_list_results_V2_(Preview)": {
"runAfter": {},
"type": "ApiConnection",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['azuremonitorlogs']['connectionId']"
}
},
"method": "post",
"body": {
"query": "SecurityIncident\n| where Status == "New"\n| where TimeGenerated < ago(30min)\n| extend AgeHours = toint(datetime_diff("hour", TimeGenerated, now()))\n| extend DisplayAgeHours = abs(AgeHours)\n| summarize \n CreatedTime = max(TimeGenerated),\n AgeHours = max(DisplayAgeHours),\n Severity = max(Severity),\n IncidentUrl = max(IncidentUrl)\n by IncidentNumber, Title\n| project \n IncidentNumber, \n Title, \n CreatedTime,\n AgeHours,\n Severity,\n IncidentUrl\n| sort by AgeHours desc",
"timerangetype": "2",
"timerange": {
"relativeTimeRange": "Last 24 hours"
}
},
"path": "/queryDataV2",
"queries": {
"subscriptions": "[subscription().subscriptionId]",
"resourcegroups": "",
"resourcetype": "Log Analytics Workspace",
"resourcename": ""
}
}
},
"For_each": {
"foreach": "@body('Run_query_and_list_results_V2_(Preview)')?['value']",
"actions": {
"Condition": {
"actions": {
"Post_adaptive_card_and_wait_for_a_response-copy": {
"type": "ApiConnectionWebhook",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['teams']['connectionId']"
}
},
"body": {
"notificationUrl": "@listCallbackUrl()",
"body": {
"messageBody": "{\n "$schema": "http://adaptivecards.io/schemas/adaptive-card.json\",\n "type": "AdaptiveCard",\n "version": "1.3",\n "body": [\n {\n "type": "TextBlock",\n "text": "🕐 INCIDENT #@{item()?['IncidentNumber']} – Less than 1 HOUR OLD! ⏳",\n "weight": "bolder",\n "size": "large"\n },\n {\n "type": "TextBlock",\n "text": "• Title: @{item()?['Title']}",\n "wrap": true\n },\n {\n "type": "TextBlock",\n "text": "• Severity: @{item()?['Severity']}",\n "wrap": true\n },\n {\n "type": "TextBlock",\n "text": "Click here to view the incident details!",\n "wrap": true,\n "color": "accent",\n "separator": true\n },\n {\n "type": "TextBlock",\n "text": "Fresh off the wire — get ahead of it before it snowballs! 🧊➡️💥",\n "wrap": true,\n "color": "good"\n },\n {\n "type": "TextBlock",\n "text": "Jump on it now! 🏃‍♂️",\n "wrap": true,\n "color": "accent"\n },\n {\n "type": "TextBlock",\n "text": "What would you like to do with this incident?",\n "wrap": true,\n "separator": true\n },\n {\n "type": "Input.ChoiceSet",\n "id": "incidentAction",\n "style": "expanded",\n "value": "own",\n "choices": [\n { "title": "Take Ownership 🧑‍💻", "value": "own" },\n { "title": "Dismiss ❌", "value": "dismiss" }\n ],\n "isMultiSelect": false\n }\n ],\n "actions": [\n {\n "type": "Action.Submit",\n "title": "Submit",\n "data": { "incidentNumber": "@{item()?['IncidentNumber']}" }\n }\n ]\n}\n",
"updateMessage": "Thanks for your response!",
"recipient": {
"groupId": "",
"channelId": ""
}
}
},
"path": "/v1.0/teams/conversation/gatherinput/poster/Flow bot/location/@{encodeURIComponent('Channel')}/$subscriptions"
}
},
"Parse_JSON2": {
"runAfter": {
"Post_adaptive_card_and_wait_for_a_response-copy": ["Succeeded"]
},
"type": "ParseJson",
"inputs": {
"content": "@body('Post_adaptive_card_and_wait_for_a_response-copy')",
"schema": {
"type": "object",
"properties": {
"responseTime": { "type": "string" },
"responder": {
"type": "object",
"properties": {
"objectId": { "type": "string" },
"tenantId": { "type": "string" },
"email": { "type": "string" },
"userPrincipalName": { "type": "string" },
"displayName": { "type": "string" }
}
},
"submitActionId": { "type": "string" },
"messageId": { "type": "string" },
"messageLink": { "type": "string" },
"data": {
"type": "object",
"properties": {
"incidentAction": { "type": "string" },
"incidentNumber": { "type": "string" }
}
}
}
}
}
},
"Compose_1-Email": {
"runAfter": { "Parse_JSON2": ["Succeeded"] },
"type": "Compose",
"inputs": "@body('Parse_JSON2')?['responder']?['email']"
},
"Compose1-Action": {
"runAfter": { "Compose_1-Email": ["Succeeded"] },
"type": "Compose",
"inputs": "@body('Parse_JSON2')?['data']?['incidentAction']"
},
"Condition_1-copy": {
"actions": {
"Update_incident_1": {
"type": "ApiConnection",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['azuresentinel']['connectionId']"
}
},
"method": "put",
"body": {
"incidentArmId": "@item()?['IncidentUrl']",
"ownerAction": "Assign",
"owner": "@{outputs('Compose_1-Email')}",
"status": "Active"
},
"path": "/Incidents"
}
}
},
"runAfter": { "Compose1-Action": ["Succeeded"] },
"else": { "actions": {} },
"expression": {
"or": [{ "equals": ["@outputs('Compose1-Action')", "own"] }]
},
"type": "If"
}
},
"else": { "actions": {} },
"expression": {
"or": [{ "lessOrEquals": ["@item()?['AgeHours']", 1] }]
},
"type": "If"
},
"Condition_2": {
"actions": {
"Post_adaptive_card_and_wait_for_a_response": {
"type": "ApiConnectionWebhook",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['teams']['connectionId']"
}
},
"body": {
"notificationUrl": "@listCallbackUrl()",
"body": {
"messageBody": "{\n "$schema": "http://adaptivecards.io/schemas/adaptive-card.json\",\n "type": "AdaptiveCard",\n "version": "1.3",\n "body": [\n {\n "type": "TextBlock",\n "text": "🚨🚨 INCIDENT #@{item()?['IncidentNumber']}@{item()?['AgeHours']} HOURS OLD! 🛑",\n "weight": "bolder",\n "size": "large"\n },\n {\n "type": "TextBlock",\n "text": "• Title: @{item()?['Title']}",\n "wrap": true\n },\n {\n "type": "TextBlock",\n "text": "• Severity: @{item()?['Severity']}",\n "wrap": true\n },\n {\n "type": "TextBlock",\n "text": "Click here to view the incident details!",\n "wrap": true,\n "color": "accent",\n "separator": true\n },\n {\n "type": "TextBlock",\n "text": "This is embarrassing... 😡 You've had enough time to fix this! Let's get it DONE before it blows up! 💥",\n "wrap": true,\n "color": "attention"\n },\n {\n "type": "TextBlock",\n "text": "FIX IT NOW! 🛠️",\n "wrap": true,\n "color": "accent"\n },\n {\n "type": "TextBlock",\n "text": "Fun to click!",\n "wrap": true,\n "color": "accent"\n },\n {\n "type": "TextBlock",\n "text": "What would you like to do with this incident?",\n "wrap": true,\n "separator": true\n },\n {\n "type": "Input.ChoiceSet",\n "id": "incidentAction",\n "style": "expanded",\n "value": "own",\n "choices": [\n { "title": "Take Ownership 🧑‍💻", "value": "own" },\n { "title": "Dismiss ❌", "value": "dismiss" }\n ],\n "isMultiSelect": false\n }\n ],\n "actions": [\n {\n "type": "Action.Submit",\n "title": "Submit",\n "data": { "incidentNumber": "@{item()?['IncidentNumber']}" }\n }\n ]\n}\n",
"updateMessage": "Thanks for your response!",
"recipient": {
"groupId": "",
"channelId": ""
}
}
},
"path": "/v1.0/teams/conversation/gatherinput/poster/Flow bot/location/@{encodeURIComponent('Channel')}/$subscriptions"
}
},
"Parse_JSON": {
"runAfter": {
"Post_adaptive_card_and_wait_for_a_response": ["Succeeded"]
},
"type": "ParseJson",
"inputs": {
"content": "@body('Post_adaptive_card_and_wait_for_a_response')",
"schema": {
"type": "object",
"properties": {
"responseTime": { "type": "string" },
"responder": {
"type": "object",
"properties": {
"objectId": { "type": "string" },
"tenantId": { "type": "string" },
"email": { "type": "string" },
"userPrincipalName": { "type": "string" },
"displayName": { "type": "string" }
}
},
"submitActionId": { "type": "string" },
"messageId": { "type": "string" },
"messageLink": { "type": "string" },
"data": {
"type": "object",
"properties": {
"incidentAction": { "type": "string" },
"incidentNumber": { "type": "string" }
}
}
}
}
}
},
"Compose_User_Principal_Name": {
"runAfter": { "Parse_JSON": ["Succeeded"] },
"type": "Compose",
"inputs": "@body('Parse_JSON')?['responder']?['email']"
},
"Compose_Action": {
"runAfter": { "Compose_User_Principal_Name": ["Succeeded"] },
"type": "Compose",
"inputs": "@body('Parse_JSON')?['data']?['incidentAction']"
},
"Condition_1": {
"actions": {
"Update_incident": {
"type": "ApiConnection",
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['azuresentinel']['connectionId']"
}
},
"method": "put",
"body": {
"incidentArmId": "@item()?['IncidentUrl']",
"ownerAction": "Assign",
"owner": "@{outputs('Compose_User_Principal_Name')}",
"status": "Active"
},
"path": "/Incidents"
}
}
},
"runAfter": { "Compose_Action": ["Succeeded"] },
"else": { "actions": {} },
"expression": {
"or": [{ "equals": ["@outputs('Compose_Action')", "own"] }]
},
"type": "If"
}
},
"else": { "actions": {} },
"expression": {
"or": [{ "greaterOrEquals": ["@item()?['AgeHours']", 2] }]
},
"type": "If"
}
},
"runAfter": {
"Run_query_and_list_results_V2_(Preview)": ["Succeeded"]
},
"type": "Foreach"
}
},
"outputs": {}
},
"parameters": {
"$connections": {
"value": {
"azuremonitorlogs": {
"connectionId": "[resourceId('Microsoft.Web/connections', variables('AzureMonitorLogsConnectionName'))]",
"connectionName": "[variables('AzureMonitorLogsConnectionName')]",
"id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/azuremonitorlogs')]"
},
"teams": {
"connectionId": "[resourceId('Microsoft.Web/connections', variables('TeamsConnectionName'))]",
"connectionName": "[variables('TeamsConnectionName')]",
"id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/teams')]"
},
"azuresentinel": {
"connectionId": "[resourceId('Microsoft.Web/connections', variables('MicrosoftSentinelConnectionName'))]",
"connectionName": "[variables('MicrosoftSentinelConnectionName')]",
"id": "[concat('/subscriptions/', subscription().subscriptionId, '/providers/Microsoft.Web/locations/', resourceGroup().location, '/managedApis/azuresentinel')]",
"connectionProperties": {
"authentication": {
"type": "ManagedServiceIdentity"
}
}
}
}
}
}
}
}
]
}

Your initial deployment will look like this:

Easy fixes - open each section that is highlighted with error ( Run KQL Adaptive Card and Update Incident)and add your connection to it (ensure that Subscription, Resource Group and Resource Name is correct.

You can also change the Time Range Type to Relative and then to 24 hours)

Add a new Sentinel connection (via OAuth)

Same goes for the Post adaptive card/Update incident

Adaptive Card look/feel can be easily changed by amending existing schema

Professional version of Adaptive Card (the one on the left)

{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.3",
"body": [
{
"type": "TextBlock",
"text": "⚠️ Incident #@{item()?['IncidentNumber']} Opened Less Than 1 Hour Ago",
"weight": "bolder",
"size": "large"
},
{
"type": "TextBlock",
"text": "Title: @{item()?['Title']}",
"wrap": true
},
{
"type": "TextBlock",
"text": "Severity: @{item()?['Severity']}",
"wrap": true
},
{
"type": "TextBlock",
"text": "Created: @{item()?['CreatedTime']}",
"wrap": true
},
{
"type": "TextBlock",
"text": "Age: @{item()?['AgeHours']} hour(s)",
"wrap": true
},
{
"type": "TextBlock",
"text": "View Incident Details",
"wrap": true,
"color": "accent",
"separator": true
},
{
"type": "TextBlock",
"text": "This incident was created recently. Early triage is recommended to prevent escalation.",
"wrap": true,
"color": "good"
},
{
"type": "TextBlock",
"text": "Please take action at your earliest opportunity.",
"wrap": true,
"color": "accent"
},
{
"type": "TextBlock",
"text": "How would you like to proceed with this incident?",
"wrap": true,
"separator": true
},
{
"type": "Input.ChoiceSet",
"id": "incidentAction",
"style": "expanded",
"value": "own",
"choices": [
{
"title": "Take Ownership",
"value": "own"
},
{
"title": "Dismiss",
"value": "dismiss"
}
],
"isMultiSelect": false
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit",
"data": {
"incidentNumber": "@{item()?['IncidentNumber']}"
}
}
]
}

Professional version of Adaptive Card (the one on the right)

{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.3",
"body": [
{
"type": "TextBlock",
"text": "🔴 Incident #@{item()?['IncidentNumber']} Has Been Open for @{item()?['AgeHours']} Hours",
"weight": "bolder",
"size": "large"
},
{
"type": "TextBlock",
"text": "Title: @{item()?['Title']}",
"wrap": true
},
{
"type": "TextBlock",
"text": "Severity: @{item()?['Severity']}",
"wrap": true
},
{
"type": "TextBlock",
"text": "Created: @{item()?['CreatedTime']}",
"wrap": true
},
{
"type": "TextBlock",
"text": "View Incident Details",
"wrap": true,
"color": "accent",
"separator": true
},
{
"type": "TextBlock",
"text": "This incident remains unresolved and requires immediate attention. Prolonged open incidents increase risk exposure.",
"wrap": true,
"color": "attention"
},
{
"type": "TextBlock",
"text": "Immediate action is required to remediate or close this incident.",
"wrap": true,
"color": "accent"
},
{
"type": "TextBlock",
"text": "How would you like to proceed with this incident?",
"wrap": true,
"separator": true
},
{
"type": "Input.ChoiceSet",
"id": "incidentAction",
"style": "expanded",
"value": "own",
"choices": [
{
"title": "Take Ownership",
"value": "own"
},
{
"title": "Dismiss",
"value": "dismiss"
}
],
"isMultiSelect": false
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit",
"data": {
"incidentNumber": "@{item()?['IncidentNumber']}"
}
}
]
}

Final output

Plenty of other things you can do with the app

  • Change OAuth to HTML POST requests (using managed identity)
  • Tweak existing adaptive card to fit your environment (add your logo etc)
  • Merge conditions (or keep only 1)
  • Change Time Generated on the KQL to match your requirements
  • Don't forget to actually enable the application!

Class dismissed

Consent Preferences