My Profile Photo

Jason Lazerus


“Beware the quiet man. For while others speak, he watches. And while others act, he plans. And when they finally rest… he strikes.” - Anonymous


Microsoft Flow - Alert Bot

In this walkthrough, I’ll describe how I used Microsoft Flow to perform several security related tasks saving time for our security teams when responding to alerts. We’ll be re-using a lot of the items referenced in my previous post about Starting Tenable scans with a Code-Less Slack Bot. One notable difference in this flow is that we’ll be creating numerous empty variables up front. The reason for this is because we’ll be using a lot of Condition actions and you cannot initialize a new variable within a Condition.

TOC

Takeaways

This is a pretty lengthy flow and is actually the 2nd version I’ve created. It is easily expandable and currently performs the following tasks.

  1. Detects when a new alert ticket is created (using SalesForce in this example).
  2. Parses the type of alert that has been generated.
  3. Parses specific data out of each alert including IP Address and Username.
  4. Pulls whois data.
  5. Offers to contain a device (using Crowdstrike).
  6. Starts an approval process to validate alert actions were intentional and not caused by account takeover.

Variables

Use this as a reference after the initial trigger is created. All of these should be considered string variables unless otherwise noted.

  • AlertSource
  • Approver
  • CaseDesciption (Set the value to the SalesForce Description)
  • CaseID (Set the value to the SalesForce CaseID)
  • CaseNumber (Set the value to the SalesForce Case Number)
  • Continue (Boolean)
  • Domain (Set the value to your domain name)
  • EMailAddress
  • FirstName
  • LastName
  • Username
  • tempuser
  • AdminEmail
  • ServiceAccount
  • SourceIP
  • DestinationIP
  • Port
  • SlackStatement

Flow

Upon creating a new flow, you’ll have to choose a Trigger action. In my case, I’m looking for a new case created in a specific SalesForce queue.

Then, initialize all of the variables mentioned above. Be sure to name both the variable and the action appropriately.

Next, each AlertSource will need its own Condition action. You don’t need to give each AlertSource its own separate action and can nest them, however you may run into an issue if you have a lot of alert sources as the maximum number of nesting levels is 12. Before you begin creating these Conditions, make sure you know how to differentiate each of these alert types. For every example below, I will only be using the ‘If yes’ fields. I am intentionally going to leave the ‘If no’ fields empty since I do not want to nest these Conditions until I reach the ‘Continue’ action.

Since each of these sources are different, they each need to be parsed separately. This walkthrough will show you three of those sources each of which have different options.

Continue

If you created all the variables listed above, you’ll notice one called ‘Continue’. We’re going to use that variable in a Condition action to end the flow depending on the type of AlertSource. Let’s create that now since some of the AlertSources below will not continue after they take action.

Create a new Condition action and choose the ‘Continue’ variable, then set the ‘trigger’ to ‘true’.

Crowdstrike

Create a new Condition action above the Continue action.

To start the Crowdstrike portion of this flow, I begin by setting the AlertSource variable to ‘CrowdStrike’ and the Continue variable to ‘false’.

Next, I’ll parse the hostname from the subject of the case since that’s how its sent by the vendor. In this case, the hostname is the last word in the Subject line. To extract this, I create a Compose action with the following statement. This will get the last word of the variable ‘Subject’ when split with a space.

last(split(triggerBody()?['Subject'],' '))

At this point, I’m going to send a Slack notification to a channel I already have setup via an incoming webhook. This notification lets the security team that an alert has been generated for the extracted hostname and also gives the case number and URL for the case.

This part is nice but it’s just a notification and my goal is to speed up response. Let’s add a button that an analyst can click on that can contain a host. To do so, we’ll need Oauth2 credentials from Crowdstrike and we’ll have to make a few HTTP requests to get the data needed prior to offering a button. The first HTTP request below will be for the access_token, which we’ll then need to parse using the ‘Parse JSON’ action.

{
    "type": "object",
    "properties": {
        "access_token": {
            "type": "string"
        },
        "token_type": {
            "type": "string"
        },
        "expires_in": {
            "type": "integer"
        }
    }
}

With the token_type and access_token, you can now request the aid (agent ID) from Crowdstrike which you’ll need to be able to contain a device. To do this, you’ll query the API with the hostname as shown in the image below.

Next, create another ‘Parse JSON’ action using the schema below and the Content as the Body of the previous HTTP request.

{
    "type": "object",
    "properties": {
        "meta": {
            "type": "object",
            "properties": {
                "query_time": {
                    "type": "number"
                },
                "pagination": {
                    "type": "object",
                    "properties": {
                        "offset": {
                            "type": "integer"
                        },
                        "limit": {
                            "type": "integer"
                        },
                        "total": {
                            "type": "integer"
                        }
                    }
                },
                "powered_by": {
                    "type": "string"
                },
                "trace_id": {
                    "type": "string"
                }
            }
        },
        "resources": {
            "type": "array",
            "items": {
                "type": "string"
            }
        },
        "errors": {
            "type": "array"
        }
    }
}

From this response, we are going to build the JSON body for the Slack message. Start by creating a new Compose action. Feel free to use the JSON below as a starter changing whatever text you want. I recommend replacing the ‘HOSTNAME’ on the “text” object below with your hostname variable. Also, note that under the “actions” below, I have two choices (Contain and Skip Containment). For the Contain “value”, you’ll need to insert “resources” object that was created as a result of parsing the previous JSON statement. Note: It will appear as though this part of your flow has now changed. This is expected as the resource value is an array and under normal circumstances, can have multiple values. Flow is assuming this is the case and is dropping it into an ‘apply to each’ statement. Do not delete this statement or this will not work.

{
  "text": "Would you like to contain HOSTNAME in Crowdstrike?",
  "attachments": [
    {
      "text": "",
      "fallback": "Please view this message in the Slack app.",
      "callback_id": "contain",
      "color": "danger",
      "attachment_type": "default",
      "actions": [
        {
          "name": "choice",
          "text": "Contain",
          "type": "button",
          "style": "danger",
          "value": ""
        },
        {
          "name": "choice",
          "text": "Skip Containment",
          "type": "button",
          "value": "skip"
        }
      ]
    }
  ]
}

Next, we can send the Slack message by creating an HTTP action. Use the same Slack webhook from earlier as the URI and set the Body to the JSON you just created.

If this was done correctly, your slack post should look like this.

Since we set the Continue variable to false and we’re not using any of the ‘If no’ conditions above the Continue action, this flow will end intentionally. We still need to build the 2nd part of the flow for the actual Contain action.

Save this flow before continuing.

To contain a host in Crowdstrike using this method, we’re actually going to create a second separate much shorter flow. Create a new flow in Microsoft Flow and as the trigger action, choose ‘Request’. Then name your flow and hit save. This should generate a URI for you to use with your button. We’ll finish this flow before creating the interactive component in Slack.

Next, you’ll want to create your ‘Response’ object because Slack needs a response within 3000ms.

We’ll need to do a bunch of application/x-www-form-urlencoded parsing for this so we’ll start by setting the body of the above request to a string variable.

For each of these items within the string body, we’ll create a Scope action (just for easier reading and moving if necessary).

Get Button Decision

You may have to adjust these to fit your response.

Compose 1: Find the index of the first character in the decision. add(int(indexOf(decodeUriComponent(variables('RequestBody')), 'interactive_message\",\"actions\":[{\"name\":\"choice\",\"type\":\"button\",\"value\":\"')), 92)

Compose 2: Find the index of the last character in the decision. add(int(indexOf(decodeUriComponent(variables('RequestBody')), 'callback_id')),-5)

Compose 3: Find the length of the decision. sub(outputs('Get_Index_of_callback'),outputs('Get_First_Index'))

Compose 4: Get the substring of the decision and decode it. substring(decodeUriComponent(variables('RequestBody')),outputs('Get_First_Index'),outputs('Get_Last_Index'))


Get Agent ID

Compose 1: Find the index of the first character in the Agent ID. add(int(indexOf(decodeUriComponent(variables('RequestBody')), 'danger')), -43)

Compose 2: Find the index of the last character in the Agent ID. add(int(indexOf(decodeUriComponent(variables('RequestBody')), 'danger')), -11)

Compose 3: Find the length of the Agent ID. sub(outputs('Agent_Get_Danger_Index'),outputs('Agent_First_Index'))

Compose 4: Get the substring of the Agent ID and decode it. substring(decodeUriComponent(variables('RequestBody')),outputs('Agent_First_Index'),outputs('Agent_Get_Last_Index'))


Get Hostname

Compose 1: Find the index of the first character in the hostname. add(int(indexOf(decodeUriComponent(variables('RequestBody')), 'to contain ')), 11)

Compose 2: Find the index of the last character in the hostname. add(int(indexOf(decodeUriComponent(variables('RequestBody')), ' in Crowdstrike?')), 0)

Compose 3: Find the length of the hostname. sub(outputs('Get_Index_of_In_Crowdstrike'),outputs('Get_Index_of_Hostname'))

Compose 4: Get the substring of the hostname and decode it. substring(decodeUriComponent(variables('RequestBody')),outputs('Get_Index_of_Hostname'),outputs('Get_Last_Index_-_Hostname'))


Get User (who clicked on the button)

Compose 1: Find the index of the first character in the username. add(int(indexOf(decodeUriComponent(variables('RequestBody')), 'user')), 32)

Compose 2: Find the index of the last character in the username. add(int(indexOf(decodeUriComponent(variables('RequestBody')), 'action_ts')), -4)

Compose 3: Find the length of the username. sub(outputs('User_-_Get_Second_Index'),outputs('User_-_Get_First_Index'))

Compose 4: Get the substring of the username and decode it. substring(decodeUriComponent(variables('RequestBody')),outputs('User_-_Get_First_Index'),outputs('User_-_Get_Last_Index'))


Next, create a Condition action to check if the user chose to Contain or Skip Containment.

For the If yes option, we’ll get a new Crowdstrike Token, create a JSON message and send the HTTP POST to contain the host.

{
    "type": "object",
    "properties": {
        "access_token": {
            "type": "string"
        },
        "token_type": {
            "type": "string"
        },
        "expires_in": {
            "type": "integer"
        }
    }
}

Using the below JSON text, send the HTTP post to the URI in the image below.

{
  "action_parameters": [
    {
      "name": "name",
      "value": "contain"
    }
  ],
  "ids": [
    "@{outputs('Agent_ID')}"
  ]
}

Next, we’ll verify that the request was successful. If it is successful, we’re sending notifications to multiple channels (IncidentResponse, Desktop Support, and the channel where the alert button is (so no one else clicks on it)).

Create a new Condition action using the Status Code of the HTTP request above and check that its equal to 202. If it is, the containment request was successful and you can send your notifications. You can send the same JSON text to multiple channels.

If the Status Code does not equal 202, then you can create a similar notification stating that the Containment request was unsuccessful and that it requires manual followup. The same can be done if the containment was skipped in the previous Condition action.

Interactive Component

The final item needed for this flow to work is to create a new Interactive Component in Slack. Once you create the item, use the Request URL from this new flow in the Request URL of the Interactive Component and turn it on.

This concludes the Crowdstrike section of this flow.

SIEM

We’ll start the SIEM section the same way we started the Crowdstrike section, with a Condition action.

In this instance, you’ll want to have this section continue by setting the Continue variable to true. (We’ll build out the continue section after finishing the SIEM section).

In my SalesForce case, my SIEM drops this data into the description field. You’ll see that in this particular alert, the user ‘b.simmons’ is adding the user ‘j.smith’ to the Domain Admins group. We want to confirm that b.simmons did so intentionally. From it, we’ll need to parse the Source User.

Alarm Name: User Added to Domain Administrators Group

EventID = 2371938279387423|203710389793
Source IP = 192.168.1.102
Destination IP = 192.168.1.5
Source Port = 42069
Destination Port = 40396
Source User = b.simmons
Destination User = j.smith

Using the same parsing method as earlier, we’ll create the four Compose actions and grab the username.

Compose 1: Find the index of the first character in the username. add(int(indexOf(triggerBody()?['Description'], 'Source User = ')), 14)

Compose 2: Find the index of the last character in the username. add(int(indexOf(triggerBody()?['Description'], 'Destination User')),-1)

Compose 3: Find the length of the username. sub(outputs('Mc_-_Get_Index_of_Destination_User'),outputs('Mc_-_Get_Index_of_Source_User'))

Compose 4: Get the substring of the username. substring(triggerBody()?['Description'],outputs('Mc_-_Get_Index_of_Source_User'),outputs('Mc_-_Get_Last_Index'))

Then, you can set the tempuser variable to the output of Compose 4. The reason you’re only setting the tempuser variable is because the username may need some cleaning. Some users come in with a dot or attached to a domain name. If your username always comes in the same way, you can just set it to the username variable. Otherwise, let’s do a quick cleanup.

Create a new Condition action using the tempuser as the value, the expression to ‘contains’, and the search term to whatever you want to check for which, in my case, looks for a ‘.’. This can be done with yet another Compose action which you can then set to the username variable.

replace(variables('user'),'.','')

Now, we know we’re going to have to do a lookup for some user data in order to get into contact with them. To do this, we’re going to have to use a new action called ‘Get User Profile (V2)’. To use this, your organization must be using Azure AD or at least replicating your users to Azure AD. In order to do the lookup, you’ll need to search for either the user’s e-mail address or user principal name (UPN). (If you’re not using it, you can run a powershell script that exports a list of users and some domain attributes, then pull it into Flow with OneDrive, Box, or other plugin and do the lookup against it.)

Since we now have a clean username, we’ll need to add the domain name to it in order to make it a valid UPN. This is something that should be easy to do with another Compose action, but it will require an extra step.

First, we’ll create the Compose action to add the domain name.

concat(variables('user'),'@',variables('domain'))

This works… sort of. But you’ll notice when running this that the username will appear like this.

bsimmons
@domain.com

Now we’ll have to get rid of this line break. It took me a while to figure this out because normal regex options did not work. In another Compose action, enter this into the trigger and then set the username again to the output of the following Compose action.

uriComponentToString(replace(uriComponent(variables('Username')), '%0A', ''))

Now that you have a properly formatted UPN, create the Get User Profile (V2) action and enter the username variable. This will automatically create a list of attributes that you can use afterwards in the flow.

As a safety measure, you can create a Condition action after this option to ensure it successfully pulled a username. For the sake of time, I’m going to skip that for now and assume this works.

At this point, we’re going to send a message to the user asking if they recognize the activity. Create a action called ‘Start and Wait for an Approval (V2)’. Once you choose this, it will ask you which approval type you want. This is at your descretion. I have a premium account and prefer the ‘Custom Response - Wait for one response (Premium)’ option. This way, you can create your own choices. Otherwise, the user will be prompted with buttons that say Approve/Reject. The nice feature about Approval is that it allows the responding user to enter a comment which you can later on insert into the case.

When all the fields are filled out, it should look like the image below. Note that I am temporarily using my own e-mail address in the Assigned To field because I want to test this for a while before moving it to production. Once you know this is working properly, change this field to the e-mail address of the user whose profile you obtained above.

To get the user’s response, you’ll need to create another Condition that checks answer to the ‘Outcome’ of the ‘Start and Wait for an Approval (V2)’.

If the user approved it, I’ll perform an additional Approval action, but this time, will send it to the Security team for review and approval. This step is important to ensure that the action performed is legitimate. In this case, a new Domain Administrator was created and perhaps someone new was given additional roles… or maybe not. In my organization, I’m very familiar with who should have that permission and generally know when this type of alert is coming. For the purposes of this walkthrough, let’s say I was expecting it.

Once the security team approves it as well, we can close this case with one last action.

Create a new ‘Update record’ SalesForce action. This requires some specific fields (CaseID, Business Hours ID) and then any fields your organization requires to close or update the case. I generally update the resolution like this:

Of course you can customize any of this process to add things like Slack notifications, other HTTP actions, and additional customization.

This ends SIEM section.


FireEye

This last section will be similar to the Crowdstrike section and will offer the same option to contain a host. For FireEye, I’ll be displaying the flow as you can use the same parsing techniques as above.

Here’s an actual alert from FireEye for an infected PC. Note: this is an actual bad domain and IP.

alerts:
  msg: normal
  product: Web MPS
  version: 8.2.0.782612
  appliance: westla-sf-fe10.domain.com
  appliance-id: 00239479198
  alert (id:92200, name:infection-match):
    ack: no
    severity: minr
    uuid: d949ae30-2b05-40fc-8394-029f0239ab93
    explanation:
      protocol: tcp
      analysis: content
      malware-detected:
        malware (name:Phish.URL):
          stype: bot-command
          sid: 84400894
      cnc-services:
        cnc-service:
          type: SignatureMatch
          sname: Phish.URL
          protocol: tcp
          port: 80
          sid: 84400894
          url: hxxp://projecttest1.in.net/OverdueInvoice/onedri/one/
          host: projecttest1.in.net
          address: 104.18.54.97
          channel: GET /OverdueInvoice/onedri/one/ HTTP/1.1::~~Accept: text/html, application/xhtml+xml, */*::~~DNT: 1::~~Accept-Language: en-US::~~User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko::~~Accept-Encoding: gzip, deflate, peerdist::~~Connection: Keep-Alive::~~X-P2P-PeerDist: Version=1.0::~~Host: projecttest1.in.net::~~::~~
    src:
      vlan: 255
      ip: 192.168.34.52
      host: myworkstation1.domain.com
      port: 54868
      mac: 54:75:d0:f2:81:dd
    dst:
      ip: 104.18.54.97
      mac: 00:00:5e:00:01:92
      port: 80
    occurred: 2019-03-28T15:23:25Z
      mode: block
      label: B
    interface (mode:block, label:B): atom3
    alert-url: https://westla-sf-fe10.domain.com/event_stream/events_for_bot?ev_id=00001
    action: blocked

We’ll being with the same Condition action to determine if this is a FireEye alert. Set the Continue to ‘false’ and the AlertSource to ‘FireEye’.

Next, you’ll want to parse the external or C2 IP address from your alert as well as the hostname that was affected (see earlier steps).

Once they’re parsed, lets do a whois lookup against the IP. To do this, I use ipinfo.org’s API and an HTTP action.

Then, rebuild the Crowdstrike contain a host from earlier.

Finally, we can send this data to our Slack channel. Start by creating a Condition action to ensure that the whois was successful. You can set the value to the Body of the ipinfo.org result, the test to ‘is not equal to’, and leave the second value empty. Its basically saying output!=””.

For the ‘If yes’ value, you’ll want to create a Parse JSON action and parse the output of the ipinfo.org response body. You can use this as your schema:

{
    "type": "object",
    "properties": {
        "ip": {
            "type": "string"
        },
        "hostname": {
            "type": "string"
        },
        "city": {
            "type": "string"
        },
        "region": {
            "type": "string"
        },
        "country": {
            "type": "string"
        },
        "loc": {
            "type": "string"
        },
        "postal": {
            "type": "string"
        },
        "phone": {
            "type": "string"
        },
        "org": {
            "type": "string"
        }
    }
}

Then, using the send Slack message action, send a formatted message to the channel of your choosing.

Now, you can send the Contain option the same way you did earlier. If an analyst chooses to Contain the host, it will use the secondary flow you created earlier.

Once complete, the alert should look like this.

Conclusion

I hope you enjoyed this walkthrough. While I only explained three of the alert sources I use, this can be easily expanded to a nearly unlimited number of sources an actions.