Skip to content

Secret Scanning Alert Notification #1337

Secret Scanning Alert Notification

Secret Scanning Alert Notification #1337

name: Secret Scanning Alert Notification
# Polls for new secret scanning alerts and sends private Slack notifications.
# GitHub does not support secret_scanning_alert as an Actions workflow trigger,
# so we poll on a schedule and track state via cache to avoid duplicates.
on:
schedule:
- cron: "*/10 * * * *"
workflow_dispatch:
inputs:
test_mode:
description: "Send mock test notification instead of scanning real alerts"
type: boolean
default: true
permissions:
contents: read
security-events: read
jobs:
check-alerts:
name: Check for new secret scanning alerts
runs-on: ubuntu-latest
steps:
- name: Restore last seen alert ID
uses: actions/cache/restore@v4
with:
path: .last-alert-id
key: secret-scanning-last-alert-${{ github.run_id }}
restore-keys: |
secret-scanning-last-alert-
- name: Check for new alerts and notify
id: scan
env:
GH_TOKEN: ${{ secrets.SECRET_SCANNING_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.H2O_OPS_SLACK_BOT_TOKEN }}
REPO: ${{ github.repository }}
IS_TEST: ${{ github.event_name == 'workflow_dispatch' && inputs.test_mode == true }}
run: |
if [[ "$IS_TEST" == "true" ]]; then
PAYLOAD=$(jq -n \
--arg repo "$REPO" \
--arg url "https://github.com/${REPO}/security/secret-scanning" \
'{
channel: "h2o-ghas-alerts",
text: "🧪 [TEST] Secret Scanning Alert",
blocks: [
{type: "header", text: {type: "plain_text", text: "🧪 [TEST] Secret Scanning Alert"}},
{type: "section", fields: [
{type: "mrkdwn", text: ("*Repository:*\n" + $repo)},
{type: "mrkdwn", text: "*Secret Type:*\nTest Secret (mock)"}
]},
{type: "section", fields: [
{type: "mrkdwn", text: "*Committed By:*\nTest User"},
{type: "mrkdwn", text: "*Commit Date:*\n2026-07-27T00:00:00Z"}
]},
{type: "section", fields: [
{type: "mrkdwn", text: "*File:*\n`config/test-file.yml`"},
{type: "mrkdwn", text: "*Commit:*\n`abc1234`"}
]},
{type: "section", fields: [
{type: "mrkdwn", text: "*Detected:*\nN/A (test run)"},
{type: "mrkdwn", text: "*Push Protection Bypassed:*\nNo"}
]},
{type: "divider"},
{type: "section", text: {type: "mrkdwn", text: ("🧪 *This is a test notification.* No action required.\n\n<" + $url + "|View Secret Scanning in GitHub>")}}
]
}')
RESPONSE=$(curl -s -X POST "https://slack.com/api/chat.postMessage" \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
OK=$(echo "$RESPONSE" | jq -r '.ok')
if [[ "$OK" != "true" ]]; then
echo "::error::Slack API error: $(echo "$RESPONSE" | jq -r '.error')"
exit 1
fi
echo "Test notification sent successfully"
exit 0
fi
LAST_SEEN_ID=0
if [[ -f .last-alert-id ]]; then
LAST_SEEN_ID=$(cat .last-alert-id)
fi
echo "Last seen alert ID: ${LAST_SEEN_ID}"
ALERTS=$(gh api "/repos/${REPO}/secret-scanning/alerts?state=open&sort=created&direction=desc&per_page=100" 2>&1) || {
echo "::error::Failed to fetch secret scanning alerts. Check token permissions."
echo "API response: ${ALERTS}"
exit 1
}
NEW_ALERTS=$(echo "$ALERTS" | jq --argjson last "$LAST_SEEN_ID" '[.[] | select(.number > $last)]')
ALERT_COUNT=$(echo "$NEW_ALERTS" | jq 'length')
echo "Found ${ALERT_COUNT} new alert(s)"
if [[ "$ALERT_COUNT" -eq 0 ]]; then
echo "No new secret scanning alerts"
exit 0
fi
HIGHEST_ID=$(echo "$NEW_ALERTS" | jq '[.[].number] | max')
echo "$HIGHEST_ID" > .last-alert-id
echo "updated=true" >> "$GITHUB_OUTPUT"
echo "Updated last seen alert ID to: ${HIGHEST_ID}"
echo "$NEW_ALERTS" | jq -c 'sort_by(.number) | .[]' | while read -r ALERT; do
ALERT_NUMBER=$(echo "$ALERT" | jq -r '.number')
SECRET_TYPE=$(echo "$ALERT" | jq -r '.secret_type_display_name')
CREATED_AT=$(echo "$ALERT" | jq -r '.created_at')
ALERT_URL=$(echo "$ALERT" | jq -r '.html_url')
PUSH_BYPASSED=$(echo "$ALERT" | jq -r '.push_protection_bypassed // false')
LOCATIONS=$(gh api "/repos/${REPO}/secret-scanning/alerts/${ALERT_NUMBER}/locations" 2>/dev/null) || LOCATIONS="[]"
COMMIT_SHA=$(echo "$LOCATIONS" | jq -r '.[0].details.commit_sha // "unknown"')
FILE_PATH=$(echo "$LOCATIONS" | jq -r '.[0].details.path // "unknown"')
COMMITTER="unknown"
COMMIT_DATE="unknown"
SHORT_SHA="unknown"
BRANCH="unknown"
if [[ "$COMMIT_SHA" != "unknown" && "$COMMIT_SHA" != "null" ]]; then
SHORT_SHA="${COMMIT_SHA:0:7}"
# Use GITHUB_TOKEN for commit/branch lookups (SECRET_SCANNING_TOKEN lacks contents:read)
COMMIT_DATA=$(GH_TOKEN="${GITHUB_TOKEN}" gh api "/repos/${REPO}/commits/${COMMIT_SHA}" 2>/dev/null) || COMMIT_DATA="{}"
COMMITTER=$(echo "$COMMIT_DATA" | jq -r '.commit.author.name // "unknown"')
COMMIT_DATE=$(echo "$COMMIT_DATA" | jq -r '.commit.author.date // "unknown"')
# Check if commit is in default branch history
DEFAULT_BRANCH=$(GH_TOKEN="${GITHUB_TOKEN}" gh api "/repos/${REPO}" --jq '.default_branch' 2>/dev/null) || DEFAULT_BRANCH="master"
COMPARE_STATUS=$(GH_TOKEN="${GITHUB_TOKEN}" gh api "/repos/${REPO}/compare/${COMMIT_SHA}...${DEFAULT_BRANCH}" --jq '.status' 2>/dev/null) || COMPARE_STATUS=""
if [[ "$COMPARE_STATUS" == "ahead" || "$COMPARE_STATUS" == "identical" ]]; then
BRANCH="${DEFAULT_BRANCH}"
else
# For feature branches, check branches where commit is HEAD
BRANCH=$(GH_TOKEN="${GITHUB_TOKEN}" gh api "/repos/${REPO}/commits/${COMMIT_SHA}/branches-where-head" --jq '[.[].name] | join(", ")' 2>/dev/null)
if [[ -z "$BRANCH" ]]; then
BRANCH="unknown"
fi
fi
fi
BYPASS_TEXT="No"
if [[ "$PUSH_BYPASSED" == "true" ]]; then
BYPASS_TEXT="Yes ⚠️"
fi
PAYLOAD=$(jq -n \
--arg repo "$REPO" \
--arg secret_type "$SECRET_TYPE" \
--arg committer "$COMMITTER" \
--arg commit_date "$COMMIT_DATE" \
--arg file_path "$FILE_PATH" \
--arg short_sha "$SHORT_SHA" \
--arg branch "$BRANCH" \
--arg created_at "$CREATED_AT" \
--arg bypass "$BYPASS_TEXT" \
--arg alert_url "$ALERT_URL" \
'{
channel: "h2o-ghas-alerts",
text: ("🔐 Secret Exposed in " + $repo),
blocks: [
{type: "header", text: {type: "plain_text", text: "🔐 Secret Scanning Alert"}},
{type: "section", fields: [
{type: "mrkdwn", text: ("*Repository:*\n" + $repo)},
{type: "mrkdwn", text: ("*Secret Type:*\n" + $secret_type)}
]},
{type: "section", fields: [
{type: "mrkdwn", text: ("*Committed By:*\n" + $committer)},
{type: "mrkdwn", text: ("*Commit Date:*\n" + $commit_date)}
]},
{type: "section", fields: [
{type: "mrkdwn", text: ("*File:*\n`" + $file_path + "`")},
{type: "mrkdwn", text: ("*Commit:*\n`" + $short_sha + "`")}
]},
{type: "section", fields: [
{type: "mrkdwn", text: ("*Branch:*\n" + $branch)},
{type: "mrkdwn", text: ("*Push Protection Bypassed:*\n" + $bypass)}
]},
{type: "section", fields: [
{type: "mrkdwn", text: ("*Detected:*\n" + $created_at)},
{type: "mrkdwn", text: " "}
]},
{type: "divider"},
{type: "section", text: {type: "mrkdwn", text: ("⚠️ *Action Required:* Rotate this credential immediately and revoke the exposed secret.\n\n<" + $alert_url + "|View Alert in GitHub>")}}
]
}')
curl -s -X POST "https://slack.com/api/chat.postMessage" \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD"
echo "Sent alert #${ALERT_NUMBER}: ${SECRET_TYPE} in ${FILE_PATH}"
done
- name: Save last seen alert ID
if: always()
uses: actions/cache/save@v4
with:
path: .last-alert-id
key: secret-scanning-last-alert-${{ github.run_id }}