From 9186124eb4ad1f08761f57603888bf484ddd86b1 Mon Sep 17 00:00:00 2001 From: Ash Davies <3853061+DrizzlyOwl@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:21:43 +0000 Subject: [PATCH] Initial commit --- .../check-kv-secret-expiry-development.yml | 28 ++ .../check-kv-secret-expiry-production.yml | 28 ++ .../workflows/check-kv-secret-expiry-test.yml | 28 ++ README.md | 43 +++ kv-secret-scan.sh | 284 ++++++++++++++++++ notify.sh | 89 ++++++ renovate.json | 6 + slack-webhook.json | 25 ++ 8 files changed, 531 insertions(+) create mode 100644 .github/workflows/check-kv-secret-expiry-development.yml create mode 100644 .github/workflows/check-kv-secret-expiry-production.yml create mode 100644 .github/workflows/check-kv-secret-expiry-test.yml create mode 100644 README.md create mode 100755 kv-secret-scan.sh create mode 100755 notify.sh create mode 100644 renovate.json create mode 100644 slack-webhook.json diff --git a/.github/workflows/check-kv-secret-expiry-development.yml b/.github/workflows/check-kv-secret-expiry-development.yml new file mode 100644 index 0000000..8885ae4 --- /dev/null +++ b/.github/workflows/check-kv-secret-expiry-development.yml @@ -0,0 +1,28 @@ +name: Key Vault / Development + +on: + workflow_dispatch: + schedule: + # At 20:00 every night + - cron: '0 20 * * *' + +jobs: + refresh: + runs-on: ubuntu-latest + environment: development + steps: + - name: Azure login with SP + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_SUBSCRIPTION_CREDENTIALS }} + + - name: Clone repo + uses: actions/checkout@v4 + + - name: Ensure script is executable + run: chmod +x ./kv-secret-scan.sh ./notify.sh + + - name: Execute task + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: bash ./kv-secret-scan.sh -s ${{ secrets.AZURE_SUBSCRIPTION_NAME }} -q diff --git a/.github/workflows/check-kv-secret-expiry-production.yml b/.github/workflows/check-kv-secret-expiry-production.yml new file mode 100644 index 0000000..50f4a4f --- /dev/null +++ b/.github/workflows/check-kv-secret-expiry-production.yml @@ -0,0 +1,28 @@ +name: Key Vault / Production + +on: + workflow_dispatch: + schedule: + # At 22:00 every night + - cron: '0 22 * * *' + +jobs: + refresh: + runs-on: ubuntu-latest + environment: production + steps: + - name: Azure login with SP + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_SUBSCRIPTION_CREDENTIALS }} + + - name: Clone repo + uses: actions/checkout@v4 + + - name: Ensure script is executable + run: chmod +x ./kv-secret-scan.sh ./notify.sh + + - name: Execute task + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: bash ./kv-secret-scan.sh -s ${{ secrets.AZURE_SUBSCRIPTION_NAME }} -q diff --git a/.github/workflows/check-kv-secret-expiry-test.yml b/.github/workflows/check-kv-secret-expiry-test.yml new file mode 100644 index 0000000..6ef576c --- /dev/null +++ b/.github/workflows/check-kv-secret-expiry-test.yml @@ -0,0 +1,28 @@ +name: Key Vault / Test + +on: + workflow_dispatch: + schedule: + # At 21:00 every night + - cron: '0 21 * * *' + +jobs: + refresh: + runs-on: ubuntu-latest + environment: test + steps: + - name: Azure login with SP + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_SUBSCRIPTION_CREDENTIALS }} + + - name: Clone repo + uses: actions/checkout@v4 + + - name: Ensure script is executable + run: chmod +x ./kv-secret-scan.sh ./notify.sh + + - name: Execute task + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: bash ./kv-secret-scan.sh -s ${{ secrets.AZURE_SUBSCRIPTION_NAME }} -q diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ecb7ff --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Azure Key Vault Secret Expiry Scanner + +## Set up + +You will need to do these steps for each Subscription in Azure. + +1) Create an App Registration in Entra ID +2) Grant `Key Vault Secrets User` Role to your Service Principal for any +Azure Key Vaults you want it to scan +3) Generate a client secret for your App Registration +4) Build a JSON credential string in the following format +```json +{ + "clientId": "", + "clientSecret": "", + "subscriptionId": "", + "tenantId": "" +} +``` +6) On GitHub, create an 'environment' (e.g. dev) and add the JSON string as an +environment secret with the secret name `AZURE_SUBSCRIPTION_CREDENTIALS`. +7) On GitHub, on the same environment, create a second secret with the name +`AZURE_SUBSCRIPTION_NAME` and set the value to the name of your subscription. + +## Notify + +This script supports notifying via Slack webhook. Set the GitHub secret +`SLACK_WEBHOOK_URL` in each environment and the script will POST the information + +## How this works: + +Service Principals: + +- s184d-kv-secret-monitor +- s184t-kv-secret-monitor +- s184p-kv-secret-monitor + +Each of the SP has the relevant role assigned to it + +The script held in the root of the repo (`kv-secret-scan.sh`) is executed +against each subscription on a nightly basis using a Cron triggered GitHub Action. + +The three workflows are staggered to avoid rate limiting. diff --git a/kv-secret-scan.sh b/kv-secret-scan.sh new file mode 100755 index 0000000..09198ca --- /dev/null +++ b/kv-secret-scan.sh @@ -0,0 +1,284 @@ +#! /bin/bash +set -e + +TZ=Europe/London +TODAY=$(date -Idate) +DATE_90=$(date -Iseconds -v+90d) +SILENT=0 + +NOTIFY=1 + +if [ -z "$SLACK_WEBHOOK_URL" ]; then + NOTIFY=0 +fi + +################################################################################ +# Author: +# Ash Davies <@DrizzlyOwl> +# Version: +# 0.1.0 +# Description: +# Search an Azure Subscription for Azure Key Vaults that have Secrets with +# expiry dates. If an expiry date is due within the next 90 days report it +# Usage: +# ./kv-secret-scan.sh [-s ] [-q] +# -s (optional) Azure Subscription +# -q (optional) Suppress output +# +# If you do not specify the subscription name, the script will prompt you to +# select one based on the current logged in Azure user +################################################################################ + +while getopts "s:q" opt; do + case $opt in + s) + AZ_SUBSCRIPTION_SCOPE=$OPTARG + ;; + q) + SILENT=1 + ;; + *) + ;; + esac +done + +# If a subscription scope has not been defined on the command line using '-e' +# then prompt the user to select a subscription from the account +if [ -z "${AZ_SUBSCRIPTION_SCOPE}" ]; then + AZ_SUBSCRIPTIONS=$( + az account list --output json | + jq -c '[.[] | select(.state == "Enabled").name]' + ) + + echo "🌐 Choose an option" + AZ_SUBSCRIPTIONS="$(echo "$AZ_SUBSCRIPTIONS" | jq -r '. | join(",")')" + + # Read from the list of available subscriptions and prompt them to the user + # with a numeric index for each one + if [ -n "$AZ_SUBSCRIPTIONS" ]; then + IFS=',' read -r -a array <<< "$AZ_SUBSCRIPTIONS" + + echo + cat -n < <(printf "%s\n" "${array[@]}") + echo + + n="" + + # Ask the user to select one of the indexes + while true; do + read -rp 'Select subscription to query: ' n + # If $n is an integer between one and $count... + if [ "$n" -eq "$n" ] && [ "$n" -gt 0 ]; then + break + fi + done + + i=$((n-1)) # Arrays are zero-indexed + AZ_SUBSCRIPTION_SCOPE="${array[$i]}" + fi +fi + +echo "🎯 Using subscription $AZ_SUBSCRIPTION_SCOPE" +echo + +echo "🔎 Looking for Azure Key Vaults..." + +# Find all Azure Key Vaults within the specified subscription +KV_LIST=$( + az keyvault list \ + --only-show-errors \ + --subscription "$AZ_SUBSCRIPTION_SCOPE" | + jq -rc '.[] | { "name": .name, "resourceGroup": .resourceGroup }' +) + +STATUS=0 + +for KEY_VAULT in $KV_LIST; do + BIN_EXPIRED="" + BIN_EXPIRING="" + RESOURCE_GROUP=$(echo "$KEY_VAULT" | jq -rc '.resourceGroup') + KV_NAME=$(echo "$KEY_VAULT" | jq -rc '.name') + + if [ $SILENT == 1 ]; then + echo " 🔐 Azure Key Vault found..." + else + echo " 🔐 Azure Key Vault $KV_NAME in Resource Group $RESOURCE_GROUP..." + fi + + echo " 🕵️ 🔎 Looking for Secrets..." + + SECRETS=$( + az keyvault secret list \ + --vault-name "$KV_NAME" \ + --output json \ + --only-show-errors \ + --subscription "$AZ_SUBSCRIPTION_SCOPE" | + jq '.[] | select(.attributes.enabled == true) | select(.attributes.expires != null) | { "secret_name": .name, "expiry_date": .attributes.expires }' + ) + + if [ -z "$SECRETS" ]; then + echo " ✅ No Secrets found!" + else + for SECRET in $(echo "$SECRETS" | jq -c); do + SECRET_NAME=$(echo "$SECRET" | jq -rc '.secret_name') + SECRET_EXPIRY=$(echo "$SECRET" | jq -rc '.expiry_date') + + if [ $SILENT == 1 ]; then + echo " 🔑 Checking Secret..." + else + echo " 🔑 Checking Secret: $SECRET_NAME..." + fi + + # Check expiry of existing token + SECRET_EXPIRY_EXPIRY_DATE=${SECRET_EXPIRY:0:10} + SECRET_EXPIRY_EXPIRY_DATE_COMP=${SECRET_EXPIRY_EXPIRY_DATE//-/} + DATE_90=${DATE_90:0:10} + DATE_90_COMP=${DATE_90//-/} + TODAY_COMP=${TODAY//-/} + + if [ $SILENT == 0 ]; then + echo " Expires on $SECRET_EXPIRY_EXPIRY_DATE" + fi + + if [[ "$SECRET_EXPIRY_EXPIRY_DATE_COMP" -lt "$TODAY_COMP" ]] || [[ "$SECRET_EXPIRY_EXPIRY_DATE_COMP" == "$TODAY_COMP" ]]; then + echo " ❌ Expired" + BIN_EXPIRED="$SECRET, $BIN_EXPIRED" + elif [[ "$SECRET_EXPIRY_EXPIRY_DATE_COMP" -lt "$DATE_90_COMP" ]]; then + echo " ⏳ Expiring in less than 90 days" + BIN_EXPIRING="$SECRET, $BIN_EXPIRING" + else + echo " ✅ Still valid" + fi + echo + done + fi + + if [ "$BIN_EXPIRING" == "" ] && [ "$BIN_EXPIRED" == "" ]; then + if [ $NOTIFY == 1 ]; then + BODY="" + export BODY + bash ./notify.sh \ + -t "Key Vault Scan finished for $KV_NAME" \ + -l "✅ No Secrets were found with expiry dates less than 90 days away" \ + -c "#50C878" \ + -d "*Key Vault:* $KV_NAME *Resource Group:* $RESOURCE_GROUP" + fi + else + STATUS=1 + + if [ "$BIN_EXPIRING" != "" ]; then + BIN_EXPIRING="[${BIN_EXPIRING/%, /}]" + + echo + echo "⚠️ Secrets were found that are close to expiry, you should renew them" + + if [ $SILENT == 0 ]; then + echo "Key Vault: $KV_NAME" + echo "$BIN_EXPIRING" | jq -c '.[].secret_name' + fi + + echo + + if [ $NOTIFY == 1 ]; then + JSON_SECRETS=$( + echo "$BIN_EXPIRING" | + jq -r \ + --arg kvn "${KV_NAME}" \ + '.[] | [ + { + text: (.secret_name | ""), + type: "mrkdwn" + }, + { + text: .expiry_date, + type: "plain_text" + } + ]' + ) + + BODY=$( + jq -n \ + --arg kvn "$KV_NAME" \ + --arg rg "$RESOURCE_GROUP" \ + --argjson secrets "$JSON_SECRETS" \ + '[ + { + text: "*Secret Name*", + type: "mrkdwn" + }, + { + text: "*Expiry Date*", + type: "mrkdwn" + } + ] | . += $secrets' + ) + + export BODY + + bash ./notify.sh \ + -t "Key Vault Scan finished for $KV_NAME" \ + -l "💣 These Secrets are close to expiry, you should renew them" \ + -c "#FFA500" \ + -d "*Key Vault:* $KV_NAME *Resource Group:* $RESOURCE_GROUP" + fi + fi + if [ "$BIN_EXPIRED" != "" ]; then + BIN_EXPIRED="[${BIN_EXPIRED/%, /}]" + + echo + echo "💣 Secrets were found that have expired, you should remove them if they are not in use" + + if [ $SILENT == 0 ]; then + echo "Key Vault: $KV_NAME" + echo "$BIN_EXPIRED" | jq -c '.[].secret_name' + fi + + echo + + if [ $NOTIFY == 1 ]; then + JSON_SECRETS=$( + echo "$BIN_EXPIRED" | + jq -r \ + --arg kvn "${KV_NAME}" \ + '.[] | [ + { + text: (.secret_name | ""), + type: "mrkdwn" + }, + { + text: .expiry_date, + type: "plain_text" + } + ]' + ) + + BODY=$( + jq -n \ + --arg kvn "$KV_NAME" \ + --arg rg "$RESOURCE_GROUP" \ + --argjson secrets "$JSON_SECRETS" \ + '[ + { + text: "*Secret Name*", + type: "mrkdwn" + }, + { + text: "*Expiry Date*", + type: "mrkdwn" + } + ] | . += $secrets' + ) + + export BODY + + bash ./notify.sh \ + -t "Key Vault Scan finished for $KV_NAME" \ + -l "💣 These Secrets have already expired, you should remove them if they are not in use" \ + -c "#FF0000" \ + -d "*Key Vault:* $KV_NAME *Resource Group:* $RESOURCE_GROUP" + fi + fi + fi +done + +exit $STATUS diff --git a/notify.sh b/notify.sh new file mode 100755 index 0000000..ce52a7b --- /dev/null +++ b/notify.sh @@ -0,0 +1,89 @@ +#! /bin/bash +set -e + +################################################################################ +# Author: +# Ash Davies <@DrizzlyOwl> +# Version: +# 0.1.0 +# Description: +# Dispatch a HTTP Webhook to Slack +# Usage: +# ./notify.sh +################################################################################ + + +usage() { + echo "Usage: $(basename "$0")" 1>&2 + echo " -t '' A short message to send" + echo " -l '' The heading for your text" + echo " -d '' Context for your message" + echo " -c '<#color>' Specify a colour hex (optional)" + exit 1 +} + +MORE_FIELDS=1 + +BODY=${BODY:-""} +SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL:-""} + +COLOR="#007fff" +TEXT="Hello, world!" +LABEL="A thing happened!" +DESCRIPTION="This is a general notification" + +if [ "$SLACK_WEBHOOK_URL" == "" ]; then + exit +fi + +if [ "$BODY" == "" ]; then + MORE_FIELDS=0 +fi + +while getopts "t:c:l:d:" opt; do + case $opt in + t) + TEXT=$OPTARG + ;; + c) + COLOR=$OPTARG + ;; + l) + LABEL=$OPTARG + ;; + d) + DESCRIPTION=$OPTARG + ;; + *) + usage + ;; + esac +done + +jq \ + --arg color "${COLOR}" \ + --arg text "${TEXT}" \ + --arg label "${LABEL}" \ + --arg desc "${DESCRIPTION}" \ + '.attachments[0].color = $color | + .attachments[0].blocks[0].text.text = $label | + .attachments[0].blocks[1].text.text = $desc | + .text = $text' ./slack-webhook.json > final.json + +if [ $MORE_FIELDS == 1 ]; then + BODY=$(echo "$BODY" | jq -cr) + + if [ "$BODY" != "" ]; then + mv final.json tmp.final.json + cat tmp.final.json | + jq \ + --argjson fields "$BODY" \ + '.attachments[0].blocks[2].fields = $fields | + .attachments[0].blocks[2].type = "section"' > final.json + fi +fi + +PAYLOAD=$(cat final.json) + +curl -X POST -H 'Content-type: application/json' \ + --data "$PAYLOAD" "$SLACK_WEBHOOK_URL" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/slack-webhook.json b/slack-webhook.json new file mode 100644 index 0000000..9414ef5 --- /dev/null +++ b/slack-webhook.json @@ -0,0 +1,25 @@ +{ + "text": "$TEXT", + "blocks": [], + "attachments": [ + { + "blocks": [ + { + "type": "section", + "text": { + "text": "$LABEL", + "type": "plain_text" + } + }, + { + "text": { + "text": "$DESCRIPTION", + "type": "mrkdwn" + }, + "type": "section" + } + ], + "color": "$COLOR" + } + ] +}