|
name: PAT Security Monitor |
|
|
|
# Purpose: Enterprise-grade Personal Access Token (PAT) security monitoring |
|
# This workflow performs automated security audits of GitHub Personal Access Tokens |
|
# to enforce organizational security policies and best practices. |
|
# |
|
# Features: |
|
# - Daily automated scans |
|
# - Risk scoring and categorization |
|
# - Multi-channel alerting (Issues, Slack) |
|
# - GHAS integration |
|
# - Detailed security reporting |
|
# |
|
# Requirements: |
|
# - GitHub Enterprise Cloud |
|
# - Repository permissions: |
|
# - issues: write |
|
# - security-events: write |
|
# - Optional integrations: |
|
# - Slack webhook (for notifications) |
|
# - GitHub Advanced Security |
|
# |
|
# Compliance: |
|
# - SOC2: Meets AC-2 (Account Management) |
|
# - ISO27001: Meets A.9.2.5 (Review of user access rights) |
|
# - NIST: Meets IA-4 (Identifier Management) |
|
# |
|
# Usage: |
|
# 1. Copy this workflow to .github/workflows/ |
|
# 2. Configure secrets: |
|
# - SLACK_WEBHOOK (optional) |
|
# 3. Adjust MAX_AGE_DAYS if needed (default: 90) |
|
# |
|
# Author: [Your Organization] |
|
# Version: 1.0.0 |
|
# Last Updated: 2024 |
|
|
|
on: |
|
schedule: |
|
- cron: '0 9 * * *' # Daily at 9 AM UTC |
|
workflow_dispatch: # Manual trigger |
|
inputs: |
|
max-age-days: |
|
description: 'Maximum allowed age for tokens in days' |
|
required: false |
|
type: number |
|
default: 90 |
|
notification-channels: |
|
description: 'Comma-separated list of notification channels (issues,slack)' |
|
required: false |
|
type: string |
|
default: 'issues' |
|
push: |
|
branches: [ main ] |
|
paths: |
|
- '.github/workflows/pat-monitor.yml' |
|
|
|
# Security hardening |
|
permissions: |
|
contents: read |
|
|
|
env: |
|
MAX_AGE_DAYS: ${{ inputs.max-age-days || 90 }} |
|
NODE_VERSION: '20' |
|
NOTIFICATION_CHANNELS: ${{ inputs.notification-channels || 'issues' }} |
|
RETRY_ATTEMPTS: 3 # Number of API call retry attempts |
|
LOG_LEVEL: 'info' # Logging level (debug, info, warn, error) |
|
|
|
jobs: |
|
check-pats: |
|
name: π Scan Personal Access Tokens |
|
runs-on: ubuntu-latest |
|
timeout-minutes: 10 |
|
|
|
# Use environment for additional protection |
|
environment: security-monitoring |
|
|
|
permissions: |
|
contents: read |
|
issues: write |
|
actions: read # For GITHUB_TOKEN introspection |
|
security-events: write # For GHAS integration |
|
|
|
steps: |
|
- name: π Checkout |
|
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 |
|
with: |
|
persist-credentials: false |
|
|
|
- name: π§ Setup Node.js |
|
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 |
|
with: |
|
node-version: ${{ env.NODE_VERSION }} |
|
|
|
- name: π Initialize Job Summary |
|
run: | |
|
echo "# π PAT Security Scan Results" >> $GITHUB_STEP_SUMMARY |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
echo "π
**Scan Date:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> $GITHUB_STEP_SUMMARY |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
|
|
- name: π Check PATs via GitHub API |
|
id: check-pats |
|
env: |
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
run: | |
|
#!/bin/bash |
|
set -euo pipefail |
|
|
|
# Logging function |
|
log() { |
|
local level=$1 |
|
local message=$2 |
|
local timestamp=$(date -u '+%Y-%m-%d %H:%M:%S UTC') |
|
|
|
# Only log if level meets minimum LOG_LEVEL |
|
case $LOG_LEVEL in |
|
debug) [[ $level =~ ^(DEBUG|INFO|WARN|ERROR)$ ]] && echo "[$timestamp] $level: $message" ;; |
|
info) [[ $level =~ ^(INFO|WARN|ERROR)$ ]] && echo "[$timestamp] $level: $message" ;; |
|
warn) [[ $level =~ ^(WARN|ERROR)$ ]] && echo "[$timestamp] $level: $message" ;; |
|
error) [[ $level =~ ^(ERROR)$ ]] && echo "[$timestamp] $level: $message" ;; |
|
esac |
|
} |
|
|
|
# Retry function with exponential backoff |
|
retry_command() { |
|
local cmd=$1 |
|
local attempt=1 |
|
local max_attempts=$RETRY_ATTEMPTS |
|
local timeout=2 |
|
local result |
|
|
|
while true; do |
|
log "DEBUG" "Attempt $attempt of $max_attempts: $cmd" |
|
|
|
if eval "$cmd"; then |
|
log "DEBUG" "Command succeeded on attempt $attempt" |
|
return 0 |
|
fi |
|
|
|
if ((attempt >= max_attempts)); then |
|
log "ERROR" "Command failed after $max_attempts attempts" |
|
return 1 |
|
fi |
|
|
|
log "WARN" "Command failed, retrying in $timeout seconds..." |
|
sleep $timeout |
|
attempt=$((attempt + 1)) |
|
timeout=$((timeout * 2)) |
|
done |
|
} |
|
|
|
# Initialize tracking |
|
ALERTS_FOUND=false |
|
ALERT_COUNT=0 |
|
TOTAL_PATS=0 |
|
|
|
# Pretty print helpers |
|
print_header() { |
|
log "INFO" "$1" |
|
echo "" |
|
echo "ββββββββββββββββββββββββββββββββββββββββ" |
|
echo " $1" |
|
echo "ββββββββββββββββββββββββββββββββββββββββ" |
|
} |
|
|
|
print_header "π Fetching Personal Access Tokens" |
|
|
|
# Get all PATs with error handling and retry |
|
if ! retry_command "gh api /user/personal-access-tokens --paginate > pats.json 2>/dev/null"; then |
|
log "ERROR" "Failed to fetch PATs after multiple attempts. Check permissions." |
|
exit 1 |
|
fi |
|
|
|
PATS=$(cat pats.json) |
|
|
|
# Count total PATs |
|
TOTAL_PATS=$(echo "$PATS" | jq '. | length') |
|
log "INFO" "π Found ${TOTAL_PATS} Personal Access Tokens" |
|
|
|
# Create findings file for structured output |
|
FINDINGS_FILE=$(mktemp) |
|
echo "[]" > "$FINDINGS_FILE" |
|
|
|
# Metrics file for historical tracking |
|
METRICS_FILE="pat_metrics.json" |
|
echo "{\"scan_date\": \"$(date -u '+%Y-%m-%d')\", \"metrics\": {}}" > "$METRICS_FILE" |
|
|
|
# Check each PAT |
|
echo "$PATS" | jq -c '.[]' | while read -r pat; do |
|
TOKEN_ID=$(echo "$pat" | jq -r '.id') |
|
TOKEN_NAME=$(echo "$pat" | jq -r '.name // "Unnamed Token"') |
|
CREATED_AT=$(echo "$pat" | jq -r '.created_at') |
|
EXPIRES_AT=$(echo "$pat" | jq -r '.expires_at // empty') |
|
LAST_USED=$(echo "$pat" | jq -r '.last_used_at // empty') |
|
SCOPES=$(echo "$pat" | jq -r '.scopes[]' 2>/dev/null | tr '\n' ',' | sed 's/,$//') |
|
|
|
# Calculate token age |
|
CREATED_TIMESTAMP=$(date -d "$CREATED_AT" +%s) |
|
CURRENT_TIMESTAMP=$(date +%s) |
|
AGE_DAYS=$(( ($CURRENT_TIMESTAMP - $CREATED_TIMESTAMP) / 86400 )) |
|
|
|
# Risk scoring |
|
RISK_SCORE=0 |
|
ISSUES=() |
|
|
|
echo "" |
|
echo "π Checking: ${TOKEN_NAME}" |
|
|
|
# Check 1: Token age |
|
if [ $AGE_DAYS -gt $MAX_AGE_DAYS ]; then |
|
ISSUES+=("β° **Age Alert**: Token is ${AGE_DAYS} days old (limit: ${MAX_AGE_DAYS} days)") |
|
RISK_SCORE=$((RISK_SCORE + 3)) |
|
echo " β οΈ Token exceeds age limit" |
|
fi |
|
|
|
# Check 2: Missing expiration |
|
if [ -z "$EXPIRES_AT" ]; then |
|
ISSUES+=("π¨ **No Expiration**: Token never expires") |
|
RISK_SCORE=$((RISK_SCORE + 5)) |
|
echo " β No expiration date set" |
|
else |
|
# Check if expiring soon (within 7 days) |
|
EXPIRES_TIMESTAMP=$(date -d "$EXPIRES_AT" +%s) |
|
DAYS_UNTIL_EXPIRY=$(( ($EXPIRES_TIMESTAMP - $CURRENT_TIMESTAMP) / 86400 )) |
|
if [ $DAYS_UNTIL_EXPIRY -lt 7 ] && [ $DAYS_UNTIL_EXPIRY -gt 0 ]; then |
|
ISSUES+=("β³ **Expiring Soon**: Token expires in ${DAYS_UNTIL_EXPIRY} days") |
|
RISK_SCORE=$((RISK_SCORE + 2)) |
|
echo " β³ Token expiring soon" |
|
fi |
|
fi |
|
|
|
# Check 3: Wildcard repo access |
|
if echo "$SCOPES" | grep -q "^repo$\|^repo,\|,repo,\|,repo$"; then |
|
ISSUES+=("π **Full Repo Access**: Token has unrestricted repository access") |
|
RISK_SCORE=$((RISK_SCORE + 4)) |
|
echo " π Full repository access detected" |
|
fi |
|
|
|
# Check 4: Admin scopes |
|
if echo "$SCOPES" | grep -qE "admin:|delete_repo|delete:packages"; then |
|
ISSUES+=("β‘ **Admin Powers**: Token has dangerous administrative scopes") |
|
RISK_SCORE=$((RISK_SCORE + 5)) |
|
echo " β‘ Administrative scopes detected" |
|
fi |
|
|
|
# Check 5: Unused tokens (over 30 days) |
|
if [ -n "$LAST_USED" ]; then |
|
LAST_USED_TIMESTAMP=$(date -d "$LAST_USED" +%s) |
|
DAYS_SINCE_USE=$(( ($CURRENT_TIMESTAMP - $LAST_USED_TIMESTAMP) / 86400 )) |
|
if [ $DAYS_SINCE_USE -gt 30 ]; then |
|
ISSUES+=("π€ **Dormant Token**: Not used in ${DAYS_SINCE_USE} days") |
|
RISK_SCORE=$((RISK_SCORE + 2)) |
|
echo " π€ Token appears dormant" |
|
fi |
|
fi |
|
|
|
# Add to findings if issues found |
|
if [ ${#ISSUES[@]} -gt 0 ]; then |
|
ALERTS_FOUND=true |
|
ALERT_COUNT=$((ALERT_COUNT + 1)) |
|
|
|
# Create finding object |
|
FINDING=$(jq -n \ |
|
--arg name "$TOKEN_NAME" \ |
|
--arg created "$CREATED_AT" \ |
|
--arg expires "$EXPIRES_AT" \ |
|
--arg age "$AGE_DAYS" \ |
|
--arg risk "$RISK_SCORE" \ |
|
--arg scopes "$SCOPES" \ |
|
--argjson issues "$(printf '%s\n' "${ISSUES[@]}" | jq -R . | jq -s .)" \ |
|
'{ |
|
name: $name, |
|
created: $created, |
|
expires: $expires, |
|
age_days: $age | tonumber, |
|
risk_score: $risk | tonumber, |
|
scopes: $scopes, |
|
issues: $issues |
|
}') |
|
|
|
# Append to findings |
|
jq ". += [$FINDING]" "$FINDINGS_FILE" > "$FINDINGS_FILE.tmp" && mv "$FINDINGS_FILE.tmp" "$FINDINGS_FILE" |
|
else |
|
echo " β
Token passed all checks" |
|
fi |
|
done |
|
|
|
# Sort findings by risk score |
|
jq 'sort_by(.risk_score) | reverse' "$FINDINGS_FILE" > "$FINDINGS_FILE.sorted" |
|
mv "$FINDINGS_FILE.sorted" "$FINDINGS_FILE" |
|
|
|
# Generate summary |
|
print_header "π Scan Summary" |
|
echo "Total Tokens: ${TOTAL_PATS}" |
|
echo "Issues Found: ${ALERT_COUNT}" |
|
echo "" |
|
|
|
# Output for other steps |
|
echo "alerts_found=${ALERTS_FOUND}" >> $GITHUB_OUTPUT |
|
echo "alert_count=${ALERT_COUNT}" >> $GITHUB_OUTPUT |
|
echo "total_pats=${TOTAL_PATS}" >> $GITHUB_OUTPUT |
|
echo "findings_file=${FINDINGS_FILE}" >> $GITHUB_OUTPUT |
|
|
|
# Add to job summary |
|
echo "## π Scan Statistics" >> $GITHUB_STEP_SUMMARY |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY |
|
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY |
|
echo "| π Total PATs | ${TOTAL_PATS} |" >> $GITHUB_STEP_SUMMARY |
|
echo "| β οΈ Tokens with Issues | ${ALERT_COUNT} |" >> $GITHUB_STEP_SUMMARY |
|
echo "| β
Healthy Tokens | $((TOTAL_PATS - ALERT_COUNT)) |" >> $GITHUB_STEP_SUMMARY |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
|
|
- name: π Generate Detailed Report |
|
if: steps.check-pats.outputs.alerts_found == 'true' |
|
run: | |
|
FINDINGS_FILE="${{ steps.check-pats.outputs.findings_file }}" |
|
|
|
echo "## π¨ Security Findings" >> $GITHUB_STEP_SUMMARY |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
|
|
# Create detailed findings table |
|
jq -r '.[] | |
|
"### π \(.name)\n" + |
|
"- **Risk Score**: " + ( |
|
if .risk_score >= 10 then "π΄ Critical (" + (.risk_score|tostring) + ")" |
|
elif .risk_score >= 7 then "π High (" + (.risk_score|tostring) + ")" |
|
elif .risk_score >= 4 then "π‘ Medium (" + (.risk_score|tostring) + ")" |
|
else "π’ Low (" + (.risk_score|tostring) + ")" |
|
end |
|
) + "\n" + |
|
"- **Created**: \(.created)\n" + |
|
"- **Expires**: \(.expires // "Never")\n" + |
|
"- **Age**: \(.age_days) days\n" + |
|
"\n**Issues Found:**\n" + |
|
(.issues | map("- " + .) | join("\n")) + |
|
"\n" |
|
' "$FINDINGS_FILE" >> $GITHUB_STEP_SUMMARY |
|
|
|
# Create annotations |
|
jq -r '.[] | |
|
.issues[] | |
|
if contains("Critical") or contains("No Expiration") then "error" |
|
elif contains("Age Alert") or contains("Full Repo") then "warning" |
|
else "notice" |
|
end + "::" + . |
|
' "$FINDINGS_FILE" || true |
|
|
|
- name: π« Create GitHub Issue |
|
if: steps.check-pats.outputs.alerts_found == 'true' |
|
env: |
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
run: | |
|
FINDINGS_FILE="${{ steps.check-pats.outputs.findings_file }}" |
|
|
|
# Generate issue body with findings |
|
ISSUE_BODY=$(cat <<EOF |
|
## π PAT Security Alert |
|
|
|
The automated security scan detected **${{ steps.check-pats.outputs.alert_count }}** Personal Access Tokens requiring attention. |
|
|
|
### π Summary |
|
- **Total PATs Scanned**: ${{ steps.check-pats.outputs.total_pats }} |
|
- **Tokens with Issues**: ${{ steps.check-pats.outputs.alert_count }} |
|
- **Scan Date**: $(date -u '+%Y-%m-%d %H:%M:%S UTC') |
|
|
|
### π¨ Detailed Findings |
|
|
|
$(jq -r '.[] | |
|
"#### π " + .name + "\n" + |
|
"> **Risk Level**: " + ( |
|
if .risk_score >= 10 then "π΄ Critical" |
|
elif .risk_score >= 7 then "π High" |
|
elif .risk_score >= 4 then "π‘ Medium" |
|
else "π’ Low" |
|
end |
|
) + " (Score: " + (.risk_score|tostring) + ")\n\n" + |
|
"**Details:**\n" + |
|
"- Created: `" + .created + "`\n" + |
|
"- Expires: `" + (.expires // "Never") + "`\n" + |
|
"- Age: " + (.age_days|tostring) + " days\n" + |
|
"- Scopes: `" + .scopes + "`\n\n" + |
|
"**Issues:**\n" + |
|
(.issues | map("- " + .) | join("\n")) + |
|
"\n\n---\n" |
|
' "$FINDINGS_FILE") |
|
|
|
### π‘οΈ Recommended Actions |
|
|
|
1. **π Rotate Old Tokens**: Replace tokens older than 90 days |
|
2. **β° Set Expiration Dates**: All tokens should have expiration dates |
|
3. **π― Use Minimal Scopes**: Follow principle of least privilege |
|
4. **ποΈ Remove Unused Tokens**: Delete dormant tokens |
|
5. **π Document Token Usage**: Keep track of what each token is for |
|
|
|
### π Resources |
|
|
|
- [Managing Personal Access Tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) |
|
- [Token Security Best Practices](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions) |
|
- [GitHub Security Features](https://docs.github.com/en/code-security) |
|
|
|
--- |
|
*π€ This issue was automatically created by the PAT Security Monitor workflow.* |
|
EOF |
|
) |
|
|
|
# Check for existing open issues |
|
EXISTING_ISSUE=$(gh issue list \ |
|
--label "security,pat-monitor" \ |
|
--state open \ |
|
--json number \ |
|
--jq '.[0].number // empty') |
|
|
|
if [ -n "$EXISTING_ISSUE" ]; then |
|
# Update existing issue |
|
gh issue comment "$EXISTING_ISSUE" --body "$ISSUE_BODY" |
|
echo "π Updated existing issue #${EXISTING_ISSUE}" |
|
else |
|
# Create new issue |
|
gh issue create \ |
|
--title "π PAT Security Alert - ${{ steps.check-pats.outputs.alert_count }} Tokens Need Review" \ |
|
--body "$ISSUE_BODY" \ |
|
--label "security,pat-monitor,automation" \ |
|
--assignee "@me" |
|
echo "π« Created new security issue" |
|
fi |
|
|
|
- name: π¬ Send Slack Notification |
|
if: steps.check-pats.outputs.alerts_found == 'true' && env.SLACK_WEBHOOK != '' |
|
env: |
|
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} |
|
run: | |
|
FINDINGS_FILE="${{ steps.check-pats.outputs.findings_file }}" |
|
|
|
# Generate risk summary |
|
CRITICAL_COUNT=$(jq '[.[] | select(.risk_score >= 10)] | length' "$FINDINGS_FILE") |
|
HIGH_COUNT=$(jq '[.[] | select(.risk_score >= 7 and .risk_score < 10)] | length' "$FINDINGS_FILE") |
|
MEDIUM_COUNT=$(jq '[.[] | select(.risk_score >= 4 and .risk_score < 7)] | length' "$FINDINGS_FILE") |
|
|
|
# Build Slack blocks |
|
SLACK_PAYLOAD=$(jq -n \ |
|
--arg repo "${{ github.repository }}" \ |
|
--arg total "${{ steps.check-pats.outputs.total_pats }}" \ |
|
--arg alerts "${{ steps.check-pats.outputs.alert_count }}" \ |
|
--arg critical "$CRITICAL_COUNT" \ |
|
--arg high "$HIGH_COUNT" \ |
|
--arg medium "$MEDIUM_COUNT" \ |
|
'{ |
|
"text": "π GitHub PAT Security Alert", |
|
"blocks": [ |
|
{ |
|
"type": "header", |
|
"text": { |
|
"type": "plain_text", |
|
"text": "π PAT Security Alert", |
|
"emoji": true |
|
} |
|
}, |
|
{ |
|
"type": "context", |
|
"elements": [ |
|
{ |
|
"type": "mrkdwn", |
|
"text": ("π *Repository:* " + $repo + " | π
*Date:* " + (now | strftime("%Y-%m-%d %H:%M UTC"))) |
|
} |
|
] |
|
}, |
|
{ |
|
"type": "section", |
|
"text": { |
|
"type": "mrkdwn", |
|
"text": ("*Security scan detected " + $alerts + " tokens requiring attention*\n\n" + |
|
"*Risk Breakdown:*\n" + |
|
(if ($critical | tonumber) > 0 then "β’ π΄ Critical: " + $critical + "\n" else "" end) + |
|
(if ($high | tonumber) > 0 then "β’ π High: " + $high + "\n" else "" end) + |
|
(if ($medium | tonumber) > 0 then "β’ π‘ Medium: " + $medium + "\n" else "" end)) |
|
}, |
|
"accessory": { |
|
"type": "button", |
|
"text": { |
|
"type": "plain_text", |
|
"text": "View Tokens", |
|
"emoji": true |
|
}, |
|
"url": "https://github.com/settings/tokens", |
|
"style": "danger" |
|
} |
|
}, |
|
{ |
|
"type": "divider" |
|
}, |
|
{ |
|
"type": "actions", |
|
"elements": [ |
|
{ |
|
"type": "button", |
|
"text": { |
|
"type": "plain_text", |
|
"text": "π View Issue" |
|
}, |
|
"url": ("https://github.com/" + $repo + "/issues") |
|
}, |
|
{ |
|
"type": "button", |
|
"text": { |
|
"type": "plain_text", |
|
"text": "π View Workflow" |
|
}, |
|
"url": ("https://github.com/" + $repo + "/actions/runs/" + ($ENV.GITHUB_RUN_ID // "")) |
|
} |
|
] |
|
} |
|
] |
|
}') |
|
|
|
curl -X POST -H 'Content-type: application/json' \ |
|
--data "$SLACK_PAYLOAD" \ |
|
"$SLACK_WEBHOOK" |
|
|
|
- name: π Upload SARIF (GHAS Integration) |
|
if: always() |
|
uses: github/codeql-action/upload-sarif@v3 |
|
continue-on-error: true |
|
with: |
|
sarif_file: pat-security.sarif |
|
category: pat-monitor |
|
|
|
- name: π Upload Metrics |
|
if: always() |
|
env: |
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
run: | |
|
# Calculate metrics |
|
METRICS=$(jq -n \ |
|
--arg total "${{ steps.check-pats.outputs.total_pats }}" \ |
|
--arg alerts "${{ steps.check-pats.outputs.alert_count }}" \ |
|
--argjson findings "$(cat ${{ steps.check-pats.outputs.findings_file }})" \ |
|
'{ |
|
"total_tokens": ($total | tonumber), |
|
"tokens_with_issues": ($alerts | tonumber), |
|
"risk_levels": { |
|
"critical": ([$findings[] | select(.risk_score >= 10)] | length), |
|
"high": ([$findings[] | select(.risk_score >= 7 and .risk_score < 10)] | length), |
|
"medium": ([$findings[] | select(.risk_score >= 4 and .risk_score < 7)] | length), |
|
"low": ([$findings[] | select(.risk_score < 4)] | length) |
|
}, |
|
"issue_types": { |
|
"no_expiration": ([$findings[] | select(.issues[] | contains("No Expiration"))] | length), |
|
"age_alert": ([$findings[] | select(.issues[] | contains("Age Alert"))] | length), |
|
"admin_scope": ([$findings[] | select(.issues[] | contains("Admin Powers"))] | length), |
|
"full_repo_access": ([$findings[] | select(.issues[] | contains("Full Repo Access"))] | length), |
|
"dormant": ([$findings[] | select(.issues[] | contains("Dormant Token"))] | length) |
|
}, |
|
"compliance_score": ( |
|
if ($total | tonumber) > 0 then |
|
(1 - (($alerts | tonumber) / ($total | tonumber))) * 100 |
|
else |
|
100 |
|
end |
|
) |
|
}') |
|
|
|
# Store metrics in artifact |
|
echo "$METRICS" > pat_security_metrics.json |
|
|
|
- name: π¦ Upload Metrics Artifact |
|
if: always() |
|
uses: actions/upload-artifact@v4 |
|
with: |
|
name: pat-security-metrics |
|
path: pat_security_metrics.json |
|
retention-days: 90 |
|
|
|
- name: β
Final Summary |
|
if: always() |
|
run: | |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
echo "## π― Next Steps" >> $GITHUB_STEP_SUMMARY |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
|
|
if [ "${{ steps.check-pats.outputs.alerts_found }}" == "true" ]; then |
|
echo "1. π Review the generated issue for detailed findings" >> $GITHUB_STEP_SUMMARY |
|
echo "2. π Rotate tokens older than 90 days" >> $GITHUB_STEP_SUMMARY |
|
echo "3. β° Set expiration dates on all tokens" >> $GITHUB_STEP_SUMMARY |
|
echo "4. π― Audit token scopes and reduce where possible" >> $GITHUB_STEP_SUMMARY |
|
else |
|
echo "β¨ **All tokens passed security checks!**" >> $GITHUB_STEP_SUMMARY |
|
echo "" >> $GITHUB_STEP_SUMMARY |
|
echo "Keep up the great security hygiene! π‘οΈ" >> $GITHUB_STEP_SUMMARY |
|
fi |
|
|
|
# Reusable workflow support |
|
notify-complete: |
|
name: π’ Completion Notification |
|
needs: [check-pats] |
|
runs-on: ubuntu-latest |
|
if: always() |
|
|
|
steps: |
|
- name: π Success Notification |
|
if: needs.check-pats.result == 'success' |
|
run: echo "β
PAT security scan completed successfully!" |
|
|
|
- name: π₯ Failure Notification |
|
if: needs.check-pats.result == 'failure' |
|
run: echo "β PAT security scan failed. Check logs for details." |