#!/usr/bin/env bash # ============================================================================ # Dotfiles Diff & Audit Tool # ============================================================================ # Compare configurations, audit for issues, and track changes. # # Usage: # dotfiles-diff.sh # Show uncommitted changes # dotfiles-diff.sh --symlinks # Verify symlink integrity # dotfiles-diff.sh --secrets # Audit for exposed secrets # dotfiles-diff.sh --permissions # Check file permissions # dotfiles-diff.sh --audit # Full security audit # ============================================================================ set -e # Source bootstrap source "${DOTFILES_HOME:-$HOME/.dotfiles}/zsh/lib/bootstrap.zsh" 2>/dev/null || { DF_GREEN=$'\033[0;32m' DF_YELLOW=$'\033[1;33m' DF_RED=$'\033[0;31m' DF_CYAN=$'\033[0;36m' DF_BLUE=$'\033[0;34m' DF_NC=$'\033[0m' DF_DIM=$'\033[2m' DOTFILES_HOME="${DOTFILES_HOME:-$HOME/.dotfiles}" df_print_header() { echo "=== $1 ==="; } df_print_success() { echo -e "${DF_GREEN}✓${DF_NC} $1"; } df_print_error() { echo -e "${DF_RED}✗${DF_NC} $1" >&2; } df_print_warning() { echo -e "${DF_YELLOW}⚠${DF_NC} $1"; } df_print_step() { echo -e "${DF_BLUE}==>${DF_NC} $1"; } df_print_section() { echo -e "${DF_CYAN}$1:${DF_NC}"; } df_print_indent() { echo " $1"; } } DOTFILES_DIR="${DOTFILES_HOME:-$HOME/.dotfiles}" # ============================================================================ # Diff Functions # ============================================================================ # Show git diff for uncommitted changes show_git_diff() { df_print_section "Uncommitted Changes" if [[ ! -d "$DOTFILES_DIR/.git" ]]; then df_print_warning "Not a git repository" return 1 fi cd "$DOTFILES_DIR" local changes=$(git status --porcelain 2>/dev/null) if [[ -z "$changes" ]]; then df_print_success "No uncommitted changes" return 0 fi echo "" echo "$changes" | while read -r status file; do case "$status" in M*|" M") echo -e " ${DF_YELLOW}modified:${DF_NC} $file" ;; A*|"A ") echo -e " ${DF_GREEN}added:${DF_NC} $file" ;; D*|" D") echo -e " ${DF_RED}deleted:${DF_NC} $file" ;; R*) echo -e " ${DF_BLUE}renamed:${DF_NC} $file" ;; \?\?) echo -e " ${DF_DIM}untracked:${DF_NC} $file" ;; *) echo -e " ${status}: $file" ;; esac done echo "" df_print_step "View full diff: git -C $DOTFILES_DIR diff" } # Show what's different between repo and installed files show_installed_diff() { df_print_section "Installed File Differences" echo "" local files_to_check=( "$HOME/.zshrc:$DOTFILES_DIR/zsh/.zshrc" "$HOME/.gitconfig:$DOTFILES_DIR/git/.gitconfig" "$HOME/.vimrc:$DOTFILES_DIR/vim/.vimrc" "$HOME/.tmux.conf:$DOTFILES_DIR/tmux/.tmux.conf" ) local has_diff=false for pair in "${files_to_check[@]}"; do local installed="${pair%%:*}" local source="${pair#*:}" local name=$(basename "$installed") if [[ ! -e "$installed" ]]; then echo -e " ${DF_YELLOW}⚠${DF_NC} $name: not installed" continue fi if [[ -L "$installed" ]]; then local target=$(readlink -f "$installed") if [[ "$target" == "$source" ]] || [[ "$target" == "$(readlink -f "$source")" ]]; then echo -e " ${DF_GREEN}✓${DF_NC} $name: symlink OK" else echo -e " ${DF_YELLOW}⚠${DF_NC} $name: symlink points elsewhere → $target" has_diff=true fi else # Regular file - check if different if diff -q "$installed" "$source" &>/dev/null; then echo -e " ${DF_GREEN}✓${DF_NC} $name: content matches (regular file)" else echo -e " ${DF_RED}✗${DF_NC} $name: differs from source" has_diff=true fi fi done if [[ "$has_diff" == true ]]; then echo "" df_print_warning "Some files differ. Run installer to sync: ./install.sh" fi } # ============================================================================ # Symlink Verification # ============================================================================ check_symlinks() { df_print_section "Symlink Integrity Check" echo "" local symlinks=( "$HOME/.zshrc" "$HOME/.gitconfig" "$HOME/.vimrc" "$HOME/.tmux.conf" "$HOME/.config/nvim" ) local broken=0 local missing=0 local ok=0 for link in "${symlinks[@]}"; do local name=$(basename "$link") if [[ ! -e "$link" && ! -L "$link" ]]; then echo -e " ${DF_DIM}○${DF_NC} $name: not installed" ((missing++)) elif [[ -L "$link" ]]; then if [[ -e "$link" ]]; then echo -e " ${DF_GREEN}✓${DF_NC} $name → $(readlink "$link")" ((ok++)) else echo -e " ${DF_RED}✗${DF_NC} $name: BROKEN → $(readlink "$link")" ((broken++)) fi else echo -e " ${DF_YELLOW}⚠${DF_NC} $name: regular file (not symlink)" fi done # Check bin scripts echo "" df_print_section "Bin Script Symlinks" if [[ -d "$HOME/.local/bin" ]]; then for script in "$HOME/.local/bin"/dotfiles-*.sh; do [[ -e "$script" ]] || continue local name=$(basename "$script") if [[ -L "$script" ]]; then if [[ -e "$script" ]]; then echo -e " ${DF_GREEN}✓${DF_NC} $name" ((ok++)) else echo -e " ${DF_RED}✗${DF_NC} $name: BROKEN" ((broken++)) fi fi done fi echo "" df_print_section "Summary" df_print_indent "OK: $ok | Missing: $missing | Broken: $broken" if (( broken > 0 )); then echo "" df_print_error "Found $broken broken symlinks!" df_print_indent "Fix with: dffix" fi } # ============================================================================ # Security Audit # ============================================================================ audit_secrets() { df_print_section "Secret Detection Audit" echo "" cd "$DOTFILES_DIR" local issues=0 # Patterns that might indicate secrets local patterns=( 'api[_-]?key\s*[:=]' 'secret[_-]?key\s*[:=]' 'password\s*[:=]' 'token\s*[:=]' 'private[_-]?key' 'BEGIN (RSA|DSA|EC|OPENSSH) PRIVATE KEY' 'aws_access_key_id' 'aws_secret_access_key' ) df_print_step "Scanning tracked files..." for pattern in "${patterns[@]}"; do local matches=$(git grep -l -i -E "$pattern" 2>/dev/null || true) if [[ -n "$matches" ]]; then echo "" df_print_warning "Pattern '$pattern' found in:" echo "$matches" | while read -r file; do df_print_indent " $file" ((issues++)) done fi done if (( issues == 0 )); then df_print_success "No obvious secrets found in tracked files" else echo "" df_print_error "Found potential secrets in $issues location(s)" df_print_indent "Review these files and use the vault for sensitive data" fi # Check git history (limited) echo "" df_print_step "Scanning recent git history (last 50 commits)..." local history_issues=$(git log -50 --all -p 2>/dev/null | grep -c -i -E 'password|secret|api.?key|token' || echo 0) if (( history_issues > 0 )); then df_print_warning "Found $history_issues potential matches in git history" df_print_indent "Consider: git filter-branch or BFG Repo Cleaner" else df_print_success "No obvious secrets in recent history" fi } audit_permissions() { df_print_section "File Permission Audit" echo "" cd "$DOTFILES_DIR" local issues=0 # Check for world-writable files df_print_step "Checking for world-writable files..." local world_writable=$(find . -type f -perm -o+w 2>/dev/null | grep -v ".git" || true) if [[ -n "$world_writable" ]]; then df_print_warning "World-writable files found:" echo "$world_writable" | while read -r file; do df_print_indent "$file" ((issues++)) done else df_print_success "No world-writable files" fi # Check bin scripts are executable echo "" df_print_step "Checking bin script permissions..." for script in "$DOTFILES_DIR/bin"/*.sh; do [[ -f "$script" ]] || continue local name=$(basename "$script") if [[ -x "$script" ]]; then echo -e " ${DF_GREEN}✓${DF_NC} $name" else echo -e " ${DF_RED}✗${DF_NC} $name: not executable" ((issues++)) fi done # Check sensitive directories echo "" df_print_step "Checking sensitive directories..." if [[ -d "$DOTFILES_DIR/vault" ]]; then local vault_perms=$(stat -c %a "$DOTFILES_DIR/vault" 2>/dev/null || stat -f %Lp "$DOTFILES_DIR/vault" 2>/dev/null) if [[ "$vault_perms" == "700" ]]; then df_print_success "vault/ directory: 700 (secure)" else df_print_warning "vault/ directory: $vault_perms (should be 700)" ((issues++)) fi fi echo "" if (( issues == 0 )); then df_print_success "All permission checks passed" else df_print_warning "Found $issues permission issues" fi } full_audit() { df_print_step "Running full security audit..." echo "" audit_secrets echo "" audit_permissions echo "" check_symlinks } # ============================================================================ # Machine Comparison # ============================================================================ compare_machines() { df_print_section "Machine Configuration Comparison" echo "" local machines_dir="$DOTFILES_DIR/machines" if [[ ! -d "$machines_dir" ]] || [[ -z "$(ls -A "$machines_dir" 2>/dev/null)" ]]; then df_print_info "No machine configs to compare" df_print_indent "Create with: df_machine_create" return fi local configs=("$machines_dir"/*.zsh(N)) if (( ${#configs[@]} < 2 )); then df_print_info "Need at least 2 machine configs to compare" return fi df_print_step "Available configs:" for config in "${configs[@]}"; do df_print_indent "$(basename "$config" .zsh)" done echo "" read -p "Compare which two? (e.g., 'laptop server'): " config1 config2 if [[ -f "$machines_dir/$config1.zsh" && -f "$machines_dir/$config2.zsh" ]]; then echo "" diff -u --color=always "$machines_dir/$config1.zsh" "$machines_dir/$config2.zsh" || true else df_print_error "Config not found" fi } # ============================================================================ # Help # ============================================================================ show_help() { cat << 'EOF' Dotfiles Diff & Audit Tool Usage: dotfiles-diff.sh [OPTIONS] Options: (none) Show uncommitted git changes --installed Compare installed files with source --symlinks Verify symlink integrity --secrets Scan for exposed secrets --permissions Check file permissions --audit Full security audit (secrets + permissions + symlinks) --machines Compare machine configurations --help Show this help Examples: dotfiles-diff.sh # Quick git status dotfiles-diff.sh --symlinks # Verify all symlinks are valid dotfiles-diff.sh --audit # Full security audit dotfiles-diff.sh --machines # Compare laptop vs server configs EOF } # ============================================================================ # Main # ============================================================================ main() { df_print_header "dotfiles-diff" case "${1:-}" in --installed|-i) show_installed_diff ;; --symlinks|-s) check_symlinks ;; --secrets) audit_secrets ;; --permissions|-p) audit_permissions ;; --audit|-a) full_audit ;; --machines|-m) compare_machines ;; --help|-h) show_help ;; *) show_git_diff echo "" show_installed_diff ;; esac } main "$@"