Recommendations for Azure Bastion shareable links

Azure Bastion just got a new feature in preview called “Shareable Links”. Without this feature, in order to grant a user access to use Azure Bastion to connect to a virtual machine, you will need to delegate reader access in Azure. At minimum you’ll need “reader” on the bastion host itself, on the virtual network connected to the VM and the VM itself.

While these permissions are not “scare”, it leaves you with permissions to handle somehow. The new sharable links feature, however, eliminates this by allowing you to create – well – a link that you can share that directly allows a user to connect to a VM using Azure Bastion.

The user will be sent directly to a view like below, typing a username and password, and they are in.

This is super neat, and also super single factor! I’m not saying don’t use this, as this absolutely has its use cases, but it can be wise to at least do one of the following: Deny use or Audit use.

Why? Well, any user that has contributor access to an Azure bastion host, can essentially plant a permanent backdoor into your systems, by generating a shareable link. They will still require a username and password to sign into the server, of course.

As you can see, a url will be generated per virtual machine.

Let’s start with the Deny method. The following Azure Policy denies the use of the feature completely on the Azure Bastion Host side, now allowing the enablement of the feature:

{
  "mode": "All",
  "policyRule": {
    "if": {
		"allOf": [{
            "field": "type",
            "equals": "Microsoft.Network/bastionHosts"
          },
          {
            "field": "Microsoft.Network/bastionHosts/enableShareableLink",
            "equals": "true"
          }
        ]
    },
    "then": {
      "effect": "deny"
    }
  },
  "parameters": {
  }
}

And for the audit? Well, you can easily change the effect above to “audit”, however, that will only allow you to audit the enablement of the feature on the bastion host level.

What I would really like is to be able to audit each and every url created through this method. But; the urls are not types in their own right, they are simply returned from the getShareableLinks action:

az rest --url https://management.azure.com/subscriptions/49a743cb-1b0b-4bbd-9986-f9fcf513526f/resourceGroups/bastion/providers/Microsoft.Network/bastionHosts/goodwbastion/getShareablelinks?api-version=2021-05-01 --method POST

So, I can currently find no way of auditing the urls. I guess solutions like this exists, but it just feels wrong..

Another solution would be to monitor the Azure activity log with Log Analytics, and alert whenever someone creates a url:

The problem is that this does not really tell you which VM the url was enabled for.

This feature is still in preview, and I hope there will be more features available such as:

  • Better logging
  • Azure policy auditing
  • Preauthentication with separate Azure RBAC role, with possibility of enforcing MFA

Want a list of all your Bastion shareable links? Script below helps you:

$enabledbastions = az graph query --graph-query "resources | where type == 'microsoft.network/bastionhosts' | where properties.enableShareableLink == true"
$enabledbastions | ConvertFrom-Json | Select-Object -ExpandProperty data | ForEach-Object{
    $bastionname = $_.name
    $urls = az rest --url "https://management.azure.com/$($_.id)/getShareablelinks?api-version=2021-05-01" --method POST | ConvertFrom-Json
    if($urls.value) {
        $urls.value | ForEach-Object {
            [PSCustomObject] @{
                Bastion = $bastionname
                VM = $_.vm.id
                Url = $_.bsl
            }
        }
    }
}

Deploying Sentinel hunting queries using Terraform

Ok, so creating hunting queries in Sentinel using Terraform works fine, but it is very funky to actually understand, even though it works. First of all, you need to use the log_analytics_saved_search resource, nothing more. However, for the keen observer, this resource does not support entity mapping and no MITRE ATT&CK. Or does it? 😉

Let’s say we save a hunting query in the GUI, with both entity mappings and Tactics and Techniques:

What will actually happen on the API side is interesting:

So, the entity mapping is actually added on the end of the query, while the tactics and techniques are tags!

Let’s first try some Terraform code without tags and the end of the query for entity mapping:

resource "azurerm_log_analytics_saved_search" "example" {
  name                       = "MARIUS - AV - Extension exluded from WD-AV"
  log_analytics_workspace_id = "/subscriptions/9889941a-7c00-4cf1-972e-ec150e322282/resourceGroups/sentinel/providers/Microsoft.OperationalInsights/workspaces/42sentineldev"

  category     = "Hunting Queries"
  display_name = "MARIUS - AV - Extension exluded from WD-AV"
  query        = <<QUERY
    // Use this rule to determine if an extension was excluded from Windows Defender AV 
    // (https://m365internals.com/2021/07/05/why-are-windows-defender-av-logs-so-important-and-how-to-monitor-them-with-azure-sentinel/)
    let timeframe = 7d; 
    Event 
    | where TimeGenerated >= ago(timeframe) 
    | where EventLog == "Microsoft-Windows-Windows Defender/Operational" 
    | parse EventData with * 'New Value">'RegistryKey'</Data>' * 
    | where RegistryKey startswith "HKLM\\SOFTWARE\\Microsoft\\Windows Defender\\Exclusions\\Extensions" 
    | extend ExcludedExtension = tostring(split(RegistryKey, "Extensions\\\\")[1]) 
    | extend ExcludedExtension = tostring(split(ExcludedExtension, "=")[0]) 
    | where ExcludedExtension endswith ".ps1" or ExcludedExtension endswith ".vbs" or ExcludedExtension endswith ".bat" 
    | project TimeGenerated, Computer, ExcludedExtension, RegistryKey
QUERY
}

As you can see, this has no techniques and no entity mappings:

However, now we add tags and entity mapping to our query:

resource "azurerm_log_analytics_saved_search" "example" {
  name                       = "MARIUS - AV - Extension exluded from WD-AV"
  log_analytics_workspace_id = "/subscriptions/9889941a-7c00-4cf1-972e-ec150e322282/resourceGroups/sentinel/providers/Microsoft.OperationalInsights/workspaces/42sentineldev"

  category     = "Hunting Queries"
  display_name = "MARIUS - AV - Extension exluded from WD-AV"
  query        = <<QUERY
    // Use this rule to determine if an extension was excluded from Windows Defender AV 
    // (https://m365internals.com/2021/07/05/why-are-windows-defender-av-logs-so-important-and-how-to-monitor-them-with-azure-sentinel/)
    let timeframe = 7d; 
    Event 
    | where TimeGenerated >= ago(timeframe) 
    | where EventLog == "Microsoft-Windows-Windows Defender/Operational" 
    | parse EventData with * 'New Value">'RegistryKey'</Data>' * 
    | where RegistryKey startswith "HKLM\\SOFTWARE\\Microsoft\\Windows Defender\\Exclusions\\Extensions" 
    | extend ExcludedExtension = tostring(split(RegistryKey, "Extensions\\\\")[1]) 
    | extend ExcludedExtension = tostring(split(ExcludedExtension, "=")[0]) 
    | where ExcludedExtension endswith ".ps1" or ExcludedExtension endswith ".vbs" or ExcludedExtension endswith ".bat" 
    | project TimeGenerated, Computer, ExcludedExtension, RegistryKey
    | extend IP_0_Address = Computer
QUERY

    tags = {
        tactics: "CredentialAccess,DefenseEvasion"
        techniques: "T1134,T1134.002"
    }
}

And that’s it, we have both entity mapping, tactics and techniques set for our hunting queries!

Good luck!

Microsoft Sentinel, error in EntityMappings: The given column does not exist

Long time, no blogging. Been busy starting a company. Working a lot with Microsoft Sentinel lately, so lots of content will be coming surrounding that. This time, we’ll do a short one:

Tables in log analytics are weird. Or, at least they can cause headaches because the available columns vary based on what content you ingest.

I reached an issue when creating scheduled alert rules / analytics rules (using the azurerm_sentinel_alert_rule_scheduled Terraform resource, but the method should be irrelevant), where my KQL was refering to a column called “PublicIPAddress”. This column is at least used when populating Azure DDoS Protection Standard logs to log analytics, but this environment did not have that. One of the entities was mapping that PublicIPAddress column to an IP entity, as follows:

resource "azurerm_sentinel_alert_rule_scheduled" "ar" {
    name                       = "ed38f228-0c0f-41ef-9078-54465b2b1589"
    display_name               = "Azure DDoS Protection Detected an attack"
    severity                   = "High"
    description                = "The Azure DDoS Protection Standard service has detected an active attack towards a public IP address."

    event_grouping {
        aggregation_method = "AlertPerResult"
    }

    incident_configuration {
        create_incident = true
        grouping {
            enabled                = true
            entity_matching_method = "AllEntities"
            lookback_duration      = "P1D"
        }
    }

    query_period      = "PT5M"
    query_frequency   = "PT5M"
    trigger_operator  = "GreaterThan"
    trigger_threshold = 0
    tactics           = [
        "Impact",
    ]

    entity_mapping {
        entity_type = "IP"
        field_mapping {
            column_name = "PublicIPAddress"
            identifier  = "Address"
        }
    }
    entity_mapping {
        entity_type = "AzureResource"
        field_mapping {
            column_name = "_ResourceId"
            identifier  = "ResourceId"
        }
    }


    query = <<QUERY
AzureDiagnostics
| where Category == "DDoSProtectionNotifications"
    QUERY

    log_analytics_workspace_id = var.log_analytics_workspace_id
}

The error I got was the following:

Error in EntityMappings: The given column ‘PublicIPAddress’ does not exist

What is happening is that the reference to PublicIPAddress in the IP entity must refer to a valid column. However, I am deploying the same configuration to loads of Sentinel instances, and want to use the same code, so I needed a solution.

In order to fix this issue, the column_ifexists function is very useful, as it will replace your error when a default value instead. So with a simple fix to our code, using extend to add an additional column IPCustomEntity with the empty string as default value, and using that column in the entity mapping, everything worked just fine:

resource "azurerm_sentinel_alert_rule_scheduled" "ar" {
    name                       = "ed38f228-0c0f-41ef-9078-54465b2b1589"
    display_name               = "Azure DDoS Protection Detected an attack"
    severity                   = "High"
    description                = "The Azure DDoS Protection Standard service has detected an active attack towards a public IP address."

    event_grouping {
        aggregation_method = "AlertPerResult"
    }

    incident_configuration {
        create_incident = true
        grouping {
            enabled                = true
            entity_matching_method = "AllEntities"
            lookback_duration      = "P1D"
        }
    }

    query_period      = "PT5M"
    query_frequency   = "PT5M"
    trigger_operator  = "GreaterThan"
    trigger_threshold = 0
    tactics           = [
        "Impact",
    ]

    entity_mapping {
        entity_type = "IP"
        field_mapping {
            column_name = "IPCustomEntity"
            identifier  = "Address"
        }
    }
    entity_mapping {
        entity_type = "AzureResource"
        field_mapping {
            column_name = "_ResourceId"
            identifier  = "ResourceId"
        }
    }


    query = <<QUERY
AzureDiagnostics
| where Category == "DDoSProtectionNotifications"
| extend IPCustomEntity = column_ifexists("PublicIPAddress", "")
    QUERY

    log_analytics_workspace_id = var.log_analytics_workspace_id
}

Hope it saves someone some time at some point!

Terraform module for automatically maintaining Azure DevOps variable group with app secret

Dropping a quick Terraform module that automatically rotates the password of an Azure AD application, outputting the value into an Azure DevOps variable group. This can be super handy when maintaining a tenant by code, allowing developers to “order” app secrets.

Continue reading “Terraform module for automatically maintaining Azure DevOps variable group with app secret”

Digging into Azure AD Certificate-Based Authentication

Azure AD Certificate-Based Authentication is now in public preview, with a surprisingly good documentation. Usually I have to guess how 50% of a feature actually works, but this time they have gone all-in with technical details of just about everything. What is a blogger to do? Well, let’s configure it and see if we can sneak a peek behind the scenes 🙂

Illustration with steps about how Azure AD certificate-based authentication works.
Continue reading “Digging into Azure AD Certificate-Based Authentication”

Checking out Azure AD cross tenant access policies

So, as one does, I was checking out the different Microsoft Graph AppRoles, which are the application scopes available. And then I found this:

Now, I now cross tenant access is something Microsoft has been working on for a while, and I have seen some preview stuff presented, but I have no access to any preview at all at this point. However, let’s see what we can find!

Disclaimer: Do not use this in production, as it is only a private preview feature I discovered.

Continue reading “Checking out Azure AD cross tenant access policies”

Another deep dive into Azure AD Workload Identity Federation using GitHub actions

I just had a previous blogpost about Workload Identity Federation, where I went into the details of how authentication works. This time, I want to use GitHub actions, which is the currently supported method. There are some limitations to the documentation today, but hopefully we will be able to do things like accessing KeyVaults and other services, not only using Azure CLI.

Continue reading “Another deep dive into Azure AD Workload Identity Federation using GitHub actions”

A deep dive into Azure AD Workload identity federation

Workload Identity Federation is a rather new concept in Azure AD, where service principals do not have keys in a directory, but in stead is federated to an external OpenID Connect (OIDC) provider, such as Okta, Ping, Github, GCP, AWS and – well – Azure AD.

A part of an earlier blogpost used a JWT in a client credential grant, signed by a KeyVault based certificate, to authenticate as an application. This time, this JWT will not be signed by a certificate, but instead by an OIDC provider.

Continue reading “A deep dive into Azure AD Workload identity federation”