From 1061adc7511f7115e0dc63aee97a6d4dbbd9fe88 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 15 Feb 2026 10:11:06 +0100 Subject: [PATCH 1/3] feat: add branch protection audit workflow and apply script Adds a reusable workflow for drift detection on GitHub rulesets (comparing live config against a checked-in JSON) and an idempotent shell script for creating/updating rulesets via the API. Supports SOC 2 / ISO 27001 evidence collection with 365-day artifact retention. --- .github/branch-protection/apply-ruleset.sh | 128 +++++++++++++++ .github/workflows/audit-branch-protection.yml | 149 ++++++++++++++++++ README.md | 55 +++++++ 3 files changed, 332 insertions(+) create mode 100755 .github/branch-protection/apply-ruleset.sh create mode 100644 .github/workflows/audit-branch-protection.yml diff --git a/.github/branch-protection/apply-ruleset.sh b/.github/branch-protection/apply-ruleset.sh new file mode 100755 index 0000000..5290f3f --- /dev/null +++ b/.github/branch-protection/apply-ruleset.sh @@ -0,0 +1,128 @@ +#!/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 +# +# Examples: +# ./apply-ruleset.sh stella/stella .github/branch-protection/ruleset-main.json +# +# # With a GitHub App token: +# GH_TOKEN="$(mint-token)" ./apply-ruleset.sh stella/stella ruleset.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] " + 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}/installation" \ + --jq '.id' 2>/dev/null || true + ) + + 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 ".[] | select(.name == \"${RULESET_NAME}\") | .id" +) + +if [ -n "${EXISTING}" ]; then + RULESET_ID="${EXISTING}" + echo "Found existing ruleset (ID: ${RULESET_ID}). Updating..." + + 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 diff --git a/.github/workflows/audit-branch-protection.yml b/.github/workflows/audit-branch-protection.yml new file mode 100644 index 0000000..18e93e5 --- /dev/null +++ b/.github/workflows/audit-branch-protection.yml @@ -0,0 +1,149 @@ +# 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 \ + --jq '.' > 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 diff --git a/README.md b/README.md index 4bb6a76..ec9b130 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Organization-wide GitHub configurations, reusable workflows, and templates. |----------|-------------| | `pr-lint.yml` | PR linting: conventional commits, labels, auto-assign | | `base-checks.yml` | Composite workflow calling pr-lint | +| `audit-branch-protection.yml` | Drift detection for GitHub rulesets (compliance evidence) | ### Composite Actions @@ -75,6 +76,60 @@ jobs: secrets: inherit ``` +### Branch Protection Scripts + +| Script | Description | +|--------|-------------| +| `apply-ruleset.sh` | Idempotent create/update of a GitHub ruleset from JSON | + +--- + +### Audit Branch Protection + +Compares live GitHub rulesets against a checked-in expected config +and uploads the result as a compliance artifact (365-day retention). +Detects drift and fails if the live config diverges from the +expected state. + +```yaml +# .github/workflows/audit-branch-protection.yml +name: Audit Branch Protection + +on: + schedule: + - cron: "0 8 * * 1" # Monday 08:00 UTC + workflow_dispatch: + +jobs: + audit: + uses: stella/.github/.github/workflows/audit-branch-protection.yml@main + with: + expected-config-path: .github/branch-protection/ruleset-main.json + secrets: + BRANCH_PROTECTION_APP_ID: ${{ secrets.BRANCH_PROTECTION_APP_ID }} + BRANCH_PROTECTION_APP_KEY: ${{ secrets.BRANCH_PROTECTION_APP_KEY }} +``` + +**Secrets required:** +- `BRANCH_PROTECTION_APP_ID` — GitHub App ID with Administration: Read and write +- `BRANCH_PROTECTION_APP_KEY` — GitHub App private key (PEM) + +**Inputs:** +- `expected-config-path` — path to the expected ruleset JSON (default: `.github/branch-protection/ruleset-main.json`) + +### Apply Ruleset + +Create or update a GitHub ruleset from a JSON file. Idempotent: +looks up by name, creates if missing, updates if found. + +```bash +# Basic usage +./apply-ruleset.sh stella/stella .github/branch-protection/ruleset-main.json + +# With github-actions[bot] bypass (for SBOM workflow etc.) +./apply-ruleset.sh --github-actions-bypass stella/stella ruleset-main.json +``` + --- ## Composite Actions From bb08cce0f9393a164bc96975decaa61a9b89babf Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 15 Feb 2026 10:19:26 +0100 Subject: [PATCH 2/3] fix: address review comments - Fix GitHub Actions installation lookup (wrong API endpoint) - Use jq --arg for safe variable binding (prevent injection) - Add guard for duplicate ruleset names - Fix paginated API response with --slurp (prevents incomplete evidence) - Clarify example paths to run from repository root --- .github/branch-protection/apply-ruleset.sh | 24 ++++++++++++++----- .github/workflows/audit-branch-protection.yml | 3 ++- README.md | 8 ++++--- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.github/branch-protection/apply-ruleset.sh b/.github/branch-protection/apply-ruleset.sh index 5290f3f..0d9f913 100755 --- a/.github/branch-protection/apply-ruleset.sh +++ b/.github/branch-protection/apply-ruleset.sh @@ -13,11 +13,13 @@ # Usage: # ./apply-ruleset.sh # -# Examples: -# ./apply-ruleset.sh stella/stella .github/branch-protection/ruleset-main.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)" ./apply-ruleset.sh stella/stella ruleset.json +# 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 @@ -76,8 +78,10 @@ if [ "${GITHUB_ACTIONS_BYPASS}" = true ]; then # 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}/installation" \ - --jq '.id' 2>/dev/null || true + gh api "repos/${REPO}/installations" \ + --paginate \ + --jq '.[] | select(.app_slug == "github-actions") | .id' \ + 2>/dev/null || true ) if [ -z "${INSTALLATION_ID}" ]; then @@ -103,9 +107,17 @@ echo "Checking for existing ruleset..." EXISTING=$( gh api "repos/${REPO}/rulesets" \ --paginate \ - --jq ".[] | select(.name == \"${RULESET_NAME}\") | .id" + --jq --arg name "${RULESET_NAME}" \ + '.[] | select(.name == $name) | .id' ) +# 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..." diff --git a/.github/workflows/audit-branch-protection.yml b/.github/workflows/audit-branch-protection.yml index 18e93e5..9ef2fc9 100644 --- a/.github/workflows/audit-branch-protection.yml +++ b/.github/workflows/audit-branch-protection.yml @@ -48,7 +48,8 @@ jobs: run: | gh api "repos/${REPO}/rulesets" \ --paginate \ - --jq '.' > live-rulesets.json + --slurp \ + --jq 'add // []' > live-rulesets.json echo "::group::Live rulesets" cat live-rulesets.json | jq . diff --git a/README.md b/README.md index ec9b130..757d864 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,13 @@ Create or update a GitHub ruleset from a JSON file. Idempotent: looks up by name, creates if missing, updates if found. ```bash -# Basic usage -./apply-ruleset.sh stella/stella .github/branch-protection/ruleset-main.json +# Basic usage (from repository root) +.github/branch-protection/apply-ruleset.sh stella/stella \ + .github/branch-protection/ruleset-main.json # With github-actions[bot] bypass (for SBOM workflow etc.) -./apply-ruleset.sh --github-actions-bypass stella/stella ruleset-main.json +.github/branch-protection/apply-ruleset.sh --github-actions-bypass \ + stella/stella .github/branch-protection/ruleset-main.json ``` --- From 1005dd6a224b0bdea717f06e82749ba52089889e Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 15 Feb 2026 11:01:49 +0100 Subject: [PATCH 3/3] fix: pipe to jq for --arg support (gh api --jq doesn't accept it) --- .github/branch-protection/apply-ruleset.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/branch-protection/apply-ruleset.sh b/.github/branch-protection/apply-ruleset.sh index 0d9f913..aae98db 100755 --- a/.github/branch-protection/apply-ruleset.sh +++ b/.github/branch-protection/apply-ruleset.sh @@ -106,8 +106,8 @@ fi echo "Checking for existing ruleset..." EXISTING=$( gh api "repos/${REPO}/rulesets" \ - --paginate \ - --jq --arg name "${RULESET_NAME}" \ + --paginate | + jq -r --arg name "${RULESET_NAME}" \ '.[] | select(.name == $name) | .id' )