|
#!/usr/bin/env bash |
|
# github-security-init - Apply security settings to a GitHub repository |
|
# Version: 1.2.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="" |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case $1 in |
|
--dry-run) |
|
DRY_RUN=true |
|
shift |
|
;; |
|
-*) |
|
echo "Unknown option: $1" |
|
echo "Usage: $0 [owner/repo | .] [--dry-run]" |
|
exit 1 |
|
;; |
|
*) |
|
REPO="$1" |
|
shift |
|
;; |
|
esac |
|
done |
|
|
|
if [ -z "$REPO" ]; then |
|
echo "Usage: $0 [owner/repo | .] [--dry-run]" |
|
echo "Example: $0 shrwnsan/dotfiles" |
|
echo " $0 . --dry-run (auto-detect from git remote)" |
|
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 <repo-url>" |
|
echo " 2. Specify owner/repo explicitly: $0 owner/repo" |
|
exit 1 |
|
fi |
|
|
|
# Parse remote URL to extract owner/repo |
|
# Handle SSH format: [email protected]:owner/repo.git |
|
# Handle HTTPS format: https://github.com/owner/repo.git |
|
if [[ "$REMOTE_URL" =~ ^git@github\.com: ]]; then |
|
# SSH format: [email protected]:owner/repo.git |
|
REPO_PART="${REMOTE_URL#[email protected]:}" |
|
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: [email protected]: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 |
|
} |
|
|
|
# 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 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 |
|
|
|
# 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 |