#!/usr/bin/env bash # github-security-init - Apply security settings to a GitHub repository # Version: 1.3.0 # License: MIT # Source: https://gist.github.com/shrwnsan/c0a4eaa82e66a6e8c5ddcc0d00a8841f # Usage: github-security-init [owner/repo | .] [--dry-run] set -e # Parse arguments DRY_RUN=false REPO="" INTERACTION_LIMITS="" while [[ $# -gt 0 ]]; do case $1 in --dry-run) DRY_RUN=true shift ;; --interaction-limits) INTERACTION_LIMITS="prompt" shift ;; --no-interaction-limits) INTERACTION_LIMITS="skip" shift ;; -*) echo "Unknown option: $1" echo "Usage: $0 [owner/repo | .] [--dry-run] [--interaction-limits] [--no-interaction-limits]" exit 1 ;; *) REPO="$1" shift ;; esac done if [ -z "$REPO" ]; then echo "Usage: $0 [owner/repo | .] [--dry-run] [--interaction-limits] [--no-interaction-limits]" echo "Example: $0 shrwnsan/dotfiles" echo " $0 . --dry-run (auto-detect from git remote)" echo " $0 . --interaction-limits (prompt for interaction limits)" echo " $0 . --no-interaction-limits (skip interaction limits)" exit 1 fi # Handle current directory detection if [ "$REPO" = "." ]; then if ! git rev-parse --git-dir >/dev/null 2>&1; then echo "Error: Not in a git repository" echo "Run this command from within a git repository, or specify owner/repo explicitly" exit 1 fi REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) if [ -z "$REMOTE_URL" ]; then echo "Error: No 'origin' remote found" echo "Please either:" echo " 1. Add an origin remote: git remote add origin " echo " 2. Specify owner/repo explicitly: $0 owner/repo" exit 1 fi # Parse remote URL to extract owner/repo # Handle SSH format: git@github.com:owner/repo.git # Handle HTTPS format: https://github.com/owner/repo.git if [[ "$REMOTE_URL" =~ ^git@github\.com: ]]; then # SSH format: git@github.com:owner/repo.git REPO_PART="${REMOTE_URL#git@github.com:}" REPO_PART="${REPO_PART%.git}" REPO="$REPO_PART" elif [[ "$REMOTE_URL" =~ ^https://github\.com/ ]]; then # HTTPS format: https://github.com/owner/repo.git REPO_PART="${REMOTE_URL#https://github.com/}" REPO_PART="${REPO_PART%.git}" REPO="$REPO_PART" elif [[ "$REMOTE_URL" =~ ^https://gist\.github\.com/ ]]; then echo "Error: Gist repositories are not supported" exit 1 else echo "Error: Unable to parse remote URL: $REMOTE_URL" echo "Expected format: git@github.com:owner/repo.git or https://github.com/owner/repo.git" echo "Please specify owner/repo explicitly: $0 owner/repo" exit 1 fi echo "Detected repository from git remote: $REPO" fi # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' NC='\033[0m' # No Color # Track what features were applied APPLIED_FEATURES=() SKIPPED_FEATURES=() ALREADY_CONFIGURED=() DRY_RUN_PREFIX="" if [ "$DRY_RUN" = true ]; then DRY_RUN_PREFIX="${CYAN}[DRY-RUN]${NC} " fi log_info() { local prefix="$DRY_RUN_PREFIX" echo -e "${prefix}${BLUE}[INFO]${NC} $1" } log_success() { local prefix="$DRY_RUN_PREFIX" echo -e "${prefix}${GREEN}[SUCCESS]${NC} $1" APPLIED_FEATURES+=("$1") } log_skip() { local prefix="$DRY_RUN_PREFIX" echo -e "${prefix}${YELLOW}[SKIP]${NC} $1" SKIPPED_FEATURES+=("$1") } log_configured() { local prefix="$DRY_RUN_PREFIX" echo -e "${prefix}${CYAN}[CONFIGURED]${NC} $1" ALREADY_CONFIGURED+=("$1") } log_error() { local prefix="$DRY_RUN_PREFIX" echo -e "${prefix}${RED}[ERROR]${NC} $1" >&2 } # Check if gh is installed and authenticated if ! command -v gh &> /dev/null; then log_error "GitHub CLI (gh) is not installed" log_info "Install it from: https://cli.github.com/" exit 1 fi if ! gh auth status &> /dev/null; then log_error "GitHub CLI is not authenticated" log_info "Run: gh auth login" exit 1 fi echo "🔒 Analyzing repository: $REPO" if [ "$DRY_RUN" = true ]; then echo -e "${CYAN}[DRY-RUN MODE]${NC} No changes will be applied" echo fi # Get repository info - using set +e to handle potential failures temporarily set +e REPO_INFO=$(gh repo view "$REPO" --json visibility,defaultBranchRef --jq '.visibility,.defaultBranchRef.name' 2>/dev/null) REPO_EXIT_CODE=$? set -e if [ $REPO_EXIT_CODE -ne 0 ] || [ -z "$REPO_INFO" ]; then log_error "Could not retrieve repository information for '$REPO'" log_info "Please verify the repository exists and you have access to it" exit 1 fi # Parse repository info VISIBILITY=$(echo "$REPO_INFO" | head -n 1) DEFAULT_BRANCH=$(echo "$REPO_INFO" | tail -n 1) if [ -z "$DEFAULT_BRANCH" ]; then DEFAULT_BRANCH="main" fi log_info "Repository visibility: $VISIBILITY" log_info "Default branch: $DEFAULT_BRANCH" # Get secret scanning status via API set +e SECRET_SCANNING_STATUS=$(gh api "repos/$REPO" --jq '.security_and_analysis.secret_scanning.status' 2>/dev/null) set -e if [ "$SECRET_SCANNING_STATUS" = "enabled" ]; then SECRET_SCANNING_ENABLED=true log_info "Secret scanning: enabled" else SECRET_SCANNING_ENABLED=false log_info "Secret scanning: disabled" fi echo # Function to check if branch protection exists check_branch_protection() { local repo="$1" local branch="$2" set +e PROTECTION=$(gh api "repos/$repo/branches/$branch/protection" --silent 2>/dev/null) local exit_code=$? set -e if [ $exit_code -eq 0 ] && [ -n "$PROTECTION" ]; then return 0 # Protection exists fi return 1 # No protection } # Function to check if interaction limits are already set check_interaction_limits() { local repo="$1" set +e LIMITS=$(gh api "repos/$repo/interaction-limits" --silent 2>/dev/null) local exit_code=$? set -e if [ $exit_code -eq 0 ] && [ -n "$LIMITS" ] && [ "$LIMITS" != "null" ]; then return 0 # Limits exist fi return 1 # No limits } # Determine branches to protect (always main if different from default) BRANCHES_TO_PROTECT=("$DEFAULT_BRANCH") if [ "$DEFAULT_BRANCH" != "main" ]; then BRANCHES_TO_PROTECT+=("main") fi # Check current state before attempting changes log_info "Checking current security configuration..." # Check interaction limits state INTERACTION_LIMITS_ALREADY_SET=false if check_interaction_limits "$REPO"; then INTERACTION_LIMITS_ALREADY_SET=true fi # Check secret scanning state SECRET_SCANNING_ALREADY_ENABLED=false if [ "$SECRET_SCANNING_ENABLED" = "true" ]; then SECRET_SCANNING_ALREADY_ENABLED=true fi # Check branch protection state for all target branches # Use arrays in parallel to track branch names and their protection status PROTECTED_BRANCHES=() UNPROTECTED_BRANCHES=() for branch in "${BRANCHES_TO_PROTECT[@]}"; do if check_branch_protection "$REPO" "$branch"; then PROTECTED_BRANCHES+=("$branch") else UNPROTECTED_BRANCHES+=("$branch") fi done echo # Determine what features are available and apply if needed # Note: We attempt all features and handle failures gracefully since we can't # reliably determine account tier/feature availability via API # Try to enable secret scanning log_info "Checking secret scanning..." if [ "$SECRET_SCANNING_ALREADY_ENABLED" = true ]; then log_configured "Secret scanning is already enabled" elif [ "$DRY_RUN" = true ]; then log_info "Would enable secret scanning" APPLIED_FEATURES+=("Secret scanning (would be enabled)") else if gh repo edit "$REPO" --enable-secret-scanning 2>/dev/null; then log_success "Secret scanning enabled" else log_skip "Secret scanning (not available for this repository - requires public repo or GitHub Pro)" fi fi # Apply branch protection to all target branches for branch in "${BRANCHES_TO_PROTECT[@]}"; do log_info "Checking branch protection for '$branch'..." # Check if branch is already protected IS_PROTECTED=false for protected in "${PROTECTED_BRANCHES[@]}"; do if [ "$branch" = "$protected" ]; then IS_PROTECTED=true break fi done if [ "$IS_PROTECTED" = true ]; then log_configured "Branch protection already configured for '$branch'" elif [ "$DRY_RUN" = true ]; then log_info "Would configure branch protection for '$branch' (admin enforcement + force push disabled)" APPLIED_FEATURES+=("Branch protection for '$branch'") else BRANCH_PROTECTION_PAYLOAD='{ "required_status_checks": { "strict": false, "contexts": [] }, "enforce_admins": true, "required_pull_request_reviews": { "required_approving_review_count": 0, "dismiss_stale_reviews": false, "require_code_owner_reviews": false, "require_last_push_approval": false }, "restrictions": null }' if echo "$BRANCH_PROTECTION_PAYLOAD" | gh api "repos/$REPO/branches/$branch/protection" --method PUT --input - --silent 2>/dev/null; then log_success "Branch protection configured for '$branch' (admin enforcement + force push disabled)" else log_skip "Branch protection for '$branch' (could not apply - branch may not exist or requires additional permissions)" fi fi done # Interaction limits (opt-in, unless already set or explicitly skipped) if [ "$INTERACTION_LIMITS_ALREADY_SET" = true ]; then log_configured "Interaction limits already configured" elif [ "$INTERACTION_LIMITS" = "skip" ]; then log_info "Skipping interaction limits (explicitly disabled)" elif [ "$DRY_RUN" = true ]; then if [ "$INTERACTION_LIMITS" = "prompt" ]; then log_info "Would configure interaction limits (prompted)" fi elif [ "$INTERACTION_LIMITS" = "prompt" ] || [ -z "$INTERACTION_LIMITS" ]; then # Prompt for interaction limits echo echo -e "${BLUE}[PROMPT]${NC} Enable interaction limits (restrict PRs/issues/comments to collaborators only)?" echo " This helps prevent spam and unwanted contributions." echo " Duration options: 1 week, 1 month, or 6 months (default)" echo read -p " Enable? (y/N): " -n 1 -r ENABLE_LIMITS echo if [[ $ENABLE_LIMITS =~ ^[Yy]$ ]]; then read -p " Duration (1w/1m/6m, default: 6m): " -r DURATION_INPUT echo case "$DURATION_INPUT" in 1w|1W) EXPIRY="one_week" DURATION_DISPLAY="1 week" ;; 1m|1M) EXPIRY="one_month" DURATION_DISPLAY="1 month" ;; ""|6m|6M) EXPIRY="six_months" DURATION_DISPLAY="6 months" ;; *) log_error "Invalid duration. Using default (6 months)" EXPIRY="six_months" DURATION_DISPLAY="6 months" ;; esac log_info "Setting interaction limits (collaborators only, $DURATION_DISPLAY)..." if gh api "repos/$REPO/interaction-limits" \ -X PUT \ -H "Accept: application/vnd.github+json" \ -f limit='collaborators_only' \ -f expiry="$EXPIRY" \ --silent 2>/dev/null; then log_success "Interaction limits configured (collaborators only, expires in $DURATION_DISPLAY)" else log_skip "Interaction limits (could not apply - requires public repo)" fi else log_info "Skipping interaction limits" fi fi # Print summary echo echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" if [ ${#ALREADY_CONFIGURED[@]} -gt 0 ]; then echo -e "${CYAN}[CONFIGURED]${NC} Features already in place:" for feature in "${ALREADY_CONFIGURED[@]}"; do echo " ✓ $feature" done echo fi if [ ${#APPLIED_FEATURES[@]} -gt 0 ]; then if [ "$DRY_RUN" = true ]; then echo -e "${CYAN}[DRY-RUN]${NC} Changes that would be applied:" else echo -e "${GREEN}[APPLIED]${NC} Security configuration completed!" fi echo for feature in "${APPLIED_FEATURES[@]}"; do echo " ✓ $feature" done elif [ "$DRY_RUN" != true ]; then log_info "No new security features were applied" fi if [ ${#SKIPPED_FEATURES[@]} -gt 0 ]; then echo echo "Skipped features:" for feature in "${SKIPPED_FEATURES[@]}"; do echo " ○ $feature" done fi # Exit with appropriate code if [ ${#APPLIED_FEATURES[@]} -eq 0 ] && [ ${#SKIPPED_FEATURES[@]} -gt 0 ] && [ "$DRY_RUN" != true ]; then echo log_info "Repository '$REPO' may already have security configured, or lacks permissions for some features" exit 0 fi echo echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo