Created
December 12, 2025 20:43
-
-
Save ariel-frischer/dbd5da3bce0cc73762f13f08f36f05f2 to your computer and use it in GitHub Desktop.
Cliphist secret cleaner - auto-remove passwords from Wayland clipboard history
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================= | |
| # CLIPHIST SECRET CLEANER | |
| # Auto-removes passwords/secrets from clipboard history after a configurable delay | |
| # ============================================================================= | |
| # | |
| # DESCRIPTION: | |
| # Monitors cliphist for high-entropy strings (likely passwords) and | |
| # automatically deletes them after a delay. Uses Shannon entropy and | |
| # character diversity to detect secrets. | |
| # | |
| # REQUIREMENTS: | |
| # - cliphist (clipboard history manager for Wayland) | |
| # - python3 (for entropy calculation) | |
| # - awk (standard on most systems) | |
| # - wl-paste watching cliphist: | |
| # wl-paste --type text --watch cliphist store | |
| # wl-paste --type image --watch cliphist store | |
| # | |
| # USAGE: | |
| # cliphist-secret-cleaner [delay_seconds] [--dry-run] | |
| # | |
| # OPTIONS: | |
| # delay_seconds Time to wait before deleting (default: 90) | |
| # --dry-run Log detections but don't actually delete | |
| # | |
| # INSTALLATION: | |
| # 1. Copy this script to ~/.local/bin/ and make executable: | |
| # chmod +x ~/.local/bin/cliphist-secret-cleaner | |
| # | |
| # 2. Create systemd user service (~/.config/systemd/user/cliphist-secret-cleaner.service): | |
| # [Unit] | |
| # Description=Cliphist secret/password cleaner | |
| # After=graphical-session.target | |
| # | |
| # [Service] | |
| # Type=simple | |
| # ExecStart=%h/.local/bin/cliphist-secret-cleaner 90 | |
| # Restart=on-failure | |
| # RestartSec=5 | |
| # | |
| # [Install] | |
| # WantedBy=default.target | |
| # | |
| # 3. Enable and start the service: | |
| # systemctl --user daemon-reload | |
| # systemctl --user enable --now cliphist-secret-cleaner.service | |
| # | |
| # COMMANDS: | |
| # View logs: journalctl --user -u cliphist-secret-cleaner -f | |
| # Restart: systemctl --user restart cliphist-secret-cleaner | |
| # Stop: systemctl --user stop cliphist-secret-cleaner | |
| # Disable: systemctl --user disable --now cliphist-secret-cleaner | |
| # | |
| # DETECTION LOGIC: | |
| # Flags as password if: | |
| # - Length 8-128 chars, no spaces/newlines | |
| # - Not a URL or file path | |
| # - Either: entropy > 4.0 OR (entropy > 3.2 AND 3+ char classes) | |
| # Character classes: uppercase, lowercase, digits, special chars | |
| # | |
| # ============================================================================= | |
| DELAY_SECONDS=${1:-90} | |
| DRY_RUN=false | |
| [[ "$*" == *"--dry-run"* ]] && DRY_RUN=true | |
| CHECK_INTERVAL=10 | |
| declare -A pending_deletions | |
| declare -A pending_info | |
| log() { | |
| echo "[$(date '+%H:%M:%S')] $1" | |
| } | |
| # Mask a string for safe logging (show first 3 and last 2 chars) | |
| mask_string() { | |
| local str="$1" | |
| local len=${#str} | |
| if [[ $len -le 8 ]]; then | |
| echo "${str:0:2}$( printf '*%.0s' $(seq 1 $((len-2))) )" | |
| else | |
| echo "${str:0:3}***${str: -2}" | |
| fi | |
| } | |
| # Calculate Shannon entropy of a string | |
| calc_entropy() { | |
| local str="$1" | |
| echo "$str" | python3 -c " | |
| import sys, math | |
| from collections import Counter | |
| s = sys.stdin.read().strip() | |
| if not s: print(0); sys.exit() | |
| freq = Counter(s) | |
| length = len(s) | |
| entropy = -sum((c/length) * math.log2(c/length) for c in freq.values()) | |
| print(f'{entropy:.2f}') | |
| " | |
| } | |
| # Check if string looks like a password/secret, returns info string if true | |
| check_secret() { | |
| local str="$1" | |
| local len=${#str} | |
| # Skip if too short, too long, or has newlines | |
| [[ $len -lt 8 || $len -gt 128 ]] && return 1 | |
| [[ "$str" == *$'\n'* ]] && return 1 | |
| # Skip if contains spaces (likely normal text) | |
| [[ "$str" == *" "* ]] && return 1 | |
| # Skip URLs | |
| [[ "$str" =~ ^https?:// ]] && return 1 | |
| # Skip file paths | |
| [[ "$str" =~ ^[/~] ]] && return 1 | |
| # Skip if it's just numbers | |
| [[ "$str" =~ ^[0-9]+$ ]] && return 1 | |
| # Calculate entropy | |
| local entropy=$(calc_entropy "$str") | |
| # Check character class diversity | |
| local has_upper=0 has_lower=0 has_digit=0 has_special=0 | |
| [[ "$str" =~ [A-Z] ]] && has_upper=1 | |
| [[ "$str" =~ [a-z] ]] && has_lower=1 | |
| [[ "$str" =~ [0-9] ]] && has_digit=1 | |
| [[ "$str" =~ [^a-zA-Z0-9] ]] && has_special=1 | |
| local diversity=$((has_upper + has_lower + has_digit + has_special)) | |
| local reason="" | |
| # Likely a password if: high entropy OR (moderate entropy + diverse chars) | |
| if awk "BEGIN {exit !($entropy > 4.0)}"; then | |
| reason="high entropy ($entropy)" | |
| elif awk "BEGIN {exit !($entropy > 3.2)}" && [[ $diversity -ge 3 ]]; then | |
| reason="entropy=$entropy, diversity=$diversity/4" | |
| else | |
| return 1 | |
| fi | |
| # Return info about why it was flagged | |
| echo "len=$len, $reason" | |
| return 0 | |
| } | |
| mode_str="LIVE" | |
| $DRY_RUN && mode_str="DRY-RUN" | |
| log "Starting cliphist secret cleaner [$mode_str] (delay: ${DELAY_SECONDS}s)" | |
| $DRY_RUN && log "Dry-run mode: will log but NOT delete. Remove --dry-run to enable deletion." | |
| while true; do | |
| current_time=$(date +%s) | |
| # Process pending deletions | |
| for entry_id in "${!pending_deletions[@]}"; do | |
| delete_time=${pending_deletions[$entry_id]} | |
| if [[ $current_time -ge $delete_time ]]; then | |
| info="${pending_info[$entry_id]}" | |
| if cliphist list | grep -q "^${entry_id} "; then | |
| if $DRY_RUN; then | |
| log "WOULD DELETE #$entry_id - $info" | |
| else | |
| echo "$entry_id" | cliphist delete | |
| log "DELETED #$entry_id - $info" | |
| fi | |
| fi | |
| unset "pending_deletions[$entry_id]" | |
| unset "pending_info[$entry_id]" | |
| fi | |
| done | |
| # Check recent entries for secrets (only check newest few) | |
| while IFS= read -r line; do | |
| entry_id=$(echo "$line" | cut -f1) | |
| content=$(echo "$line" | cut -f2-) | |
| # Skip if already pending | |
| [[ -n "${pending_deletions[$entry_id]}" ]] && continue | |
| reason=$(check_secret "$content") | |
| if [[ $? -eq 0 ]]; then | |
| delete_at=$((current_time + DELAY_SECONDS)) | |
| masked=$(mask_string "$content") | |
| pending_deletions[$entry_id]=$delete_at | |
| pending_info[$entry_id]="'$masked' ($reason)" | |
| log "QUEUED #$entry_id for deletion in ${DELAY_SECONDS}s: '$masked' ($reason)" | |
| fi | |
| done < <(cliphist list | head -5) | |
| sleep $CHECK_INTERVAL | |
| done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment