-
Notifications
You must be signed in to change notification settings - Fork 0
feat: branch protection audit workflow and apply script #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,140 @@ | ||||||||||||||||||||||||||||
| #!/usr/bin/env bash | ||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||
| # apply-ruleset.sh — create or update a GitHub repository ruleset. | ||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||
| # Idempotent: looks up the ruleset by name. If it exists, updates it | ||||||||||||||||||||||||||||
| # (PUT); otherwise creates it (POST). | ||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||
| # Prerequisites: | ||||||||||||||||||||||||||||
| # - gh CLI authenticated with a token that has Administration: write | ||||||||||||||||||||||||||||
| # (e.g. a GitHub App installation token) | ||||||||||||||||||||||||||||
| # - jq | ||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||
| # Usage: | ||||||||||||||||||||||||||||
| # ./apply-ruleset.sh <owner/repo> <ruleset.json> | ||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||
| # Example (from repository root): | ||||||||||||||||||||||||||||
| # .github/branch-protection/apply-ruleset.sh stella/stella \ | ||||||||||||||||||||||||||||
| # .github/branch-protection/ruleset-main.json | ||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||
| # # With a GitHub App token: | ||||||||||||||||||||||||||||
| # GH_TOKEN="$(mint-token)" .github/branch-protection/apply-ruleset.sh \ | ||||||||||||||||||||||||||||
| # stella/stella .github/branch-protection/ruleset-main.json | ||||||||||||||||||||||||||||
| # | ||||||||||||||||||||||||||||
| # The script also supports adding the built-in GitHub Actions app as | ||||||||||||||||||||||||||||
| # a bypass actor via --github-actions-bypass. This looks up the | ||||||||||||||||||||||||||||
| # installation ID automatically and injects it into the payload. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| set -euo pipefail | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| usage() { | ||||||||||||||||||||||||||||
| echo "Usage: $0 [--github-actions-bypass] <owner/repo> <ruleset.json>" | ||||||||||||||||||||||||||||
| exit 1 | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| GITHUB_ACTIONS_BYPASS=false | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| while [[ "${1:-}" == --* ]]; do | ||||||||||||||||||||||||||||
| case "$1" in | ||||||||||||||||||||||||||||
| --github-actions-bypass) | ||||||||||||||||||||||||||||
| GITHUB_ACTIONS_BYPASS=true | ||||||||||||||||||||||||||||
| shift | ||||||||||||||||||||||||||||
| ;; | ||||||||||||||||||||||||||||
| *) | ||||||||||||||||||||||||||||
| echo "Unknown option: $1" | ||||||||||||||||||||||||||||
| usage | ||||||||||||||||||||||||||||
| ;; | ||||||||||||||||||||||||||||
| esac | ||||||||||||||||||||||||||||
| done | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if [ $# -ne 2 ]; then | ||||||||||||||||||||||||||||
| usage | ||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| REPO="$1" | ||||||||||||||||||||||||||||
| RULESET_FILE="$2" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if [ ! -f "${RULESET_FILE}" ]; then | ||||||||||||||||||||||||||||
| echo "Error: ruleset file not found: ${RULESET_FILE}" | ||||||||||||||||||||||||||||
| exit 1 | ||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Read the ruleset config. | ||||||||||||||||||||||||||||
| PAYLOAD=$(cat "${RULESET_FILE}") | ||||||||||||||||||||||||||||
| RULESET_NAME=$(echo "${PAYLOAD}" | jq -r '.name') | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if [ -z "${RULESET_NAME}" ] || [ "${RULESET_NAME}" = "null" ]; then | ||||||||||||||||||||||||||||
| echo "Error: ruleset JSON must have a 'name' field." | ||||||||||||||||||||||||||||
| exit 1 | ||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| echo "Ruleset: ${RULESET_NAME}" | ||||||||||||||||||||||||||||
| echo "Target repo: ${REPO}" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Optionally inject the GitHub Actions bypass actor. | ||||||||||||||||||||||||||||
| if [ "${GITHUB_ACTIONS_BYPASS}" = true ]; then | ||||||||||||||||||||||||||||
| echo "Looking up GitHub Actions app installation ID..." | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # The built-in GitHub Actions app slug is "github-actions". | ||||||||||||||||||||||||||||
| # We need the installation ID for the target repo. | ||||||||||||||||||||||||||||
| INSTALLATION_ID=$( | ||||||||||||||||||||||||||||
| gh api "repos/${REPO}/installations" \ | ||||||||||||||||||||||||||||
| --paginate \ | ||||||||||||||||||||||||||||
| --jq '.[] | select(.app_slug == "github-actions") | .id' \ | ||||||||||||||||||||||||||||
| 2>/dev/null || true | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
Comment on lines
+80
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Wrong jq filter for The Root Cause and ImpactAt But the API response shape is: {"total_count": 1, "installations": [{"id": 123, "app_slug": "github-actions"}]}So The correct filter should be Impact: The
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if [ -z "${INSTALLATION_ID}" ]; then | ||||||||||||||||||||||||||||
| echo "Warning: could not find GitHub Actions installation ID." | ||||||||||||||||||||||||||||
| echo "The github-actions[bot] bypass will NOT be added." | ||||||||||||||||||||||||||||
| echo "You may need to add it manually in the GitHub UI." | ||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||
| echo "GitHub Actions installation ID: ${INSTALLATION_ID}" | ||||||||||||||||||||||||||||
| PAYLOAD=$( | ||||||||||||||||||||||||||||
| echo "${PAYLOAD}" | jq \ | ||||||||||||||||||||||||||||
| --argjson id "${INSTALLATION_ID}" \ | ||||||||||||||||||||||||||||
| '.bypass_actors += [{ | ||||||||||||||||||||||||||||
| "actor_id": $id, | ||||||||||||||||||||||||||||
| "actor_type": "Integration", | ||||||||||||||||||||||||||||
| "bypass_mode": "always" | ||||||||||||||||||||||||||||
| }]' | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Check if the ruleset already exists. | ||||||||||||||||||||||||||||
| echo "Checking for existing ruleset..." | ||||||||||||||||||||||||||||
| EXISTING=$( | ||||||||||||||||||||||||||||
| gh api "repos/${REPO}/rulesets" \ | ||||||||||||||||||||||||||||
| --paginate | | ||||||||||||||||||||||||||||
| jq -r --arg name "${RULESET_NAME}" \ | ||||||||||||||||||||||||||||
| '.[] | select(.name == $name) | .id' | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
jan-kubica marked this conversation as resolved.
jan-kubica marked this conversation as resolved.
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| # Guard against multiple rulesets with the same name. | ||||||||||||||||||||||||||||
| if [ "$(echo "${EXISTING}" | grep -c .)" -gt 1 ]; then | ||||||||||||||||||||||||||||
| echo "Error: found multiple rulesets named '${RULESET_NAME}'." | ||||||||||||||||||||||||||||
| echo "Resolve duplicates manually before running this script." | ||||||||||||||||||||||||||||
| exit 1 | ||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if [ -n "${EXISTING}" ]; then | ||||||||||||||||||||||||||||
| RULESET_ID="${EXISTING}" | ||||||||||||||||||||||||||||
| echo "Found existing ruleset (ID: ${RULESET_ID}). Updating..." | ||||||||||||||||||||||||||||
|
jan-kubica marked this conversation as resolved.
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| gh api "repos/${REPO}/rulesets/${RULESET_ID}" \ | ||||||||||||||||||||||||||||
| --method PUT \ | ||||||||||||||||||||||||||||
| --input - <<< "${PAYLOAD}" \ | ||||||||||||||||||||||||||||
| --jq '{ id, name, enforcement, updated_at: .updated_at }' | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| echo "Ruleset updated." | ||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||
| echo "No existing ruleset found. Creating..." | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| gh api "repos/${REPO}/rulesets" \ | ||||||||||||||||||||||||||||
| --method POST \ | ||||||||||||||||||||||||||||
| --input - <<< "${PAYLOAD}" \ | ||||||||||||||||||||||||||||
| --jq '{ id, name, enforcement, created_at: .created_at }' | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| echo "Ruleset created." | ||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| # Reusable workflow: audit branch protection rulesets. | ||
| # | ||
| # Compares live GitHub rulesets against a checked-in expected | ||
| # config and uploads the result as compliance evidence. | ||
| # | ||
| # Called by individual repos via a thin caller workflow. | ||
|
|
||
| name: Audit Branch Protection | ||
|
|
||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| expected-config-path: | ||
| description: > | ||
| Path to the expected ruleset JSON in the calling repo. | ||
| type: string | ||
| default: .github/branch-protection/ruleset-main.json | ||
| secrets: | ||
| BRANCH_PROTECTION_APP_ID: | ||
| description: GitHub App ID for branch protection | ||
| required: true | ||
| BRANCH_PROTECTION_APP_KEY: | ||
| description: GitHub App private key (PEM) | ||
| required: true | ||
|
|
||
| jobs: | ||
| audit: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
| permissions: | ||
| contents: read | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | ||
|
|
||
| - name: Create GitHub App token | ||
| id: app-token | ||
| uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 | ||
| with: | ||
| app-id: ${{ secrets.BRANCH_PROTECTION_APP_ID }} | ||
| private-key: ${{ secrets.BRANCH_PROTECTION_APP_KEY }} | ||
|
|
||
| - name: Fetch live rulesets | ||
| env: | ||
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | ||
| REPO: ${{ github.repository }} | ||
| run: | | ||
| gh api "repos/${REPO}/rulesets" \ | ||
| --paginate \ | ||
| --slurp \ | ||
| --jq 'add // []' > live-rulesets.json | ||
|
|
||
| echo "::group::Live rulesets" | ||
| cat live-rulesets.json | jq . | ||
| echo "::endgroup::" | ||
|
|
||
| - name: Drift detection | ||
| env: | ||
| EXPECTED_PATH: ${{ inputs.expected-config-path }} | ||
| run: | | ||
| if [ ! -f "${EXPECTED_PATH}" ]; then | ||
| echo "::error::Expected config not found: ${EXPECTED_PATH}" | ||
| exit 1 | ||
| fi | ||
|
|
||
| EXPECTED_NAME=$(jq -r '.name' "${EXPECTED_PATH}") | ||
| echo "Looking for ruleset: ${EXPECTED_NAME}" | ||
|
|
||
| # Extract the matching ruleset from the live config. | ||
| # The live API returns an array; find by name. | ||
| jq --arg name "${EXPECTED_NAME}" \ | ||
| '.[] | select(.name == $name)' \ | ||
| live-rulesets.json > live-matched.json | ||
|
|
||
| if [ ! -s live-matched.json ]; then | ||
| echo "::error::Ruleset '${EXPECTED_NAME}' not found on GitHub." | ||
| echo "DRIFT_DETECTED=true" >> "$GITHUB_ENV" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Normalize both configs for comparison. | ||
| # Strip server-managed fields from the live config so we | ||
| # compare only the fields we control. | ||
| LIVE_FIELDS=( | ||
| name target enforcement conditions | ||
| bypass_actors rules | ||
| ) | ||
|
|
||
| LIVE_JQ_FILTER=$(printf ', %s' "${LIVE_FIELDS[@]}") | ||
| LIVE_JQ_FILTER="{ ${LIVE_JQ_FILTER:2} }" | ||
|
|
||
| jq "${LIVE_JQ_FILTER}" live-matched.json \ | ||
| > live-normalized.json | ||
| jq -S '.' "${EXPECTED_PATH}" > expected-sorted.json | ||
| jq -S '.' live-normalized.json > live-sorted.json | ||
|
|
||
| if diff expected-sorted.json live-sorted.json > drift.diff; then | ||
| echo "No drift detected." | ||
| echo "DRIFT_DETECTED=false" >> "$GITHUB_ENV" | ||
| else | ||
| echo "::warning::Drift detected in ruleset '${EXPECTED_NAME}'." | ||
| echo "::group::Diff (expected vs live)" | ||
| cat drift.diff | ||
| echo "::endgroup::" | ||
| echo "DRIFT_DETECTED=true" >> "$GITHUB_ENV" | ||
| fi | ||
|
|
||
| - name: Prepare evidence | ||
| run: | | ||
| mkdir -p evidence | ||
| TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") | ||
| REPO="${{ github.repository }}" | ||
|
|
||
| jq -n \ | ||
| --arg repo "${REPO}" \ | ||
| --arg ts "${TIMESTAMP}" \ | ||
| --arg sha "${{ github.sha }}" \ | ||
| --arg run "${{ github.run_id }}" \ | ||
| --arg drift "${DRIFT_DETECTED}" \ | ||
| --slurpfile live live-rulesets.json \ | ||
| '{ | ||
| repository: $repo, | ||
| timestamp: $ts, | ||
| commit_sha: $sha, | ||
| workflow_run_id: $run, | ||
| drift_detected: ($drift == "true"), | ||
| live_rulesets: $live[0] | ||
| }' > evidence/branch-protection-audit.json | ||
|
|
||
| if [ -f drift.diff ]; then | ||
| cp drift.diff evidence/ | ||
| fi | ||
|
|
||
| echo "::group::Evidence" | ||
| cat evidence/branch-protection-audit.json | jq . | ||
| echo "::endgroup::" | ||
|
|
||
| - name: Upload evidence | ||
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | ||
| with: | ||
| name: branch-protection-evidence-${{ github.run_id }} | ||
| path: evidence/ | ||
| retention-days: 365 | ||
|
|
||
| - name: Fail on drift | ||
| if: env.DRIFT_DETECTED == 'true' | ||
| run: | | ||
| echo "::error::Branch protection drift detected. Review the evidence artifact." | ||
| exit 1 |
Uh oh!
There was an error while loading. Please reload this page.