Skip to main content

Clipboard Security Monitoring in Go: Detecting and Redacting Secrets

·8 mins
Table of Contents
Accidentally typing a password into the wrong field and having it sit in your clipboard is a real security risk. A clipboard monitor can detect common secret patterns and redact them automatically before you paste them somewhere they should not go.

This post builds a production-quality clipboard watcher in Go: regex-based secret detection, thread-safe polling with context cancellation, desktop notifications on redaction, and OS-level background service setup for macOS and Linux.

The Approach: Polling with Pattern Matching
#

The original version of this program used a fixed string prefix ("password: ") and a 1-second fixed sleep. Both choices are fragile: the prefix misses everything that does not use that exact format, and the fixed sleep creates a race condition where the clipboard can be overwritten between the detection interval.

The improved approach:

  1. Poll on a configurable interval (500ms is a reasonable default)
  2. Detect secrets using a set of compiled regexes covering common secret formats
  3. Redact by replacing most characters with asterisks, keeping the last N chars for verification
  4. Notify the user via a desktop notification so the redaction is not silent
  5. Run as a per-user background service, not as root

Secret Detection Patterns
#

A robust set of patterns covers JWTs, AWS keys, private keys, and generic password fields – not just the word “password” followed by a colon.

patterns.go
package main

import "regexp"

// secretPatterns is a list of compiled regexes. Each regex must have a named
// capture group called "secret" that isolates the sensitive value to redact.
var secretPatterns = []*regexp.Regexp{
    // AWS Access Key ID
    regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`),
    // AWS Secret Access Key (heuristic: 40 alphanumeric+/+ chars after known prefix)
    regexp.MustCompile(`(?i)aws_secret_access_key\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=]{40})`),
    // JWT token (three base64url segments separated by dots)
    regexp.MustCompile(`(?P<secret>eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)`),
    // PEM private key block
    regexp.MustCompile(`(?P<secret>-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)`),
    // Generic password= or password: assignment (covers config files, .env)
    regexp.MustCompile(`(?i)(?:password|passwd|secret|api_key|token)\s*[=:]\s*(?P<secret>[^\s"']{8,})`),
    // GitHub personal access token
    regexp.MustCompile(`(?P<secret>gh[ps]_[A-Za-z0-9]{36})`),
}

// findSecret returns the matched secret substring and the full match start/end,
// or empty string and -1 if no pattern matches.
func findSecret(content string) (fullMatch, secret string, start, end int) {
    for _, re := range secretPatterns {
        loc := re.FindStringIndex(content)
        if loc == nil {
            continue
        }
        match := content[loc[0]:loc[1]]
        // Extract the "secret" named group if present, else use the full match.
        names := re.SubexpNames()
        sub := re.FindStringSubmatch(content)
        secretVal := match
        for i, name := range names {
            if name == "secret" && i < len(sub) {
                secretVal = sub[i]
            }
        }
        return match, secretVal, loc[0], loc[1]
    }
    return "", "", -1, -1
}

The Redaction Logic
#

Redact the secret, keeping the last few characters so the user can verify it matched the right value. Preserve surrounding context in the clipboard so copy-pasted config blocks remain usable.

redact.go
package main

import "strings"

// redactSecret replaces most of the secret with asterisks, keeping up to 3
// trailing characters for user verification. Returns the modified content.
func redactSecret(content, secret string) string {
    if len(secret) == 0 {
        return content
    }

    visibleSuffix := 0
    switch {
    case len(secret) >= 12:
        visibleSuffix = 3
    case len(secret) >= 9:
        visibleSuffix = 2
    case len(secret) >= 6:
        visibleSuffix = 1
    }

    redacted := strings.Repeat("*", len(secret)-visibleSuffix) +
        secret[len(secret)-visibleSuffix:]

    return strings.Replace(content, secret, redacted, 1)
}

Thread-Safe Polling Loop with Context Cancellation
#

The polling loop runs in a goroutine. Context cancellation provides clean shutdown (important for graceful reload when the service restarts).

watcher.go
package main

import (
    "context"
    "log"
    "time"

    "github.com/atotto/clipboard"
    "github.com/gen2brain/beeep"
)

type Watcher struct {
    interval        time.Duration
    previousContent string
}

func NewWatcher(interval time.Duration) *Watcher {
    return &Watcher{interval: interval}
}

// Run polls the clipboard until ctx is cancelled.
// It is safe to call from a single goroutine only; clipboard access is not
// concurrent. Multiple watchers must not run simultaneously.
func (w *Watcher) Run(ctx context.Context) {
    ticker := time.NewTicker(w.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            log.Println("clipboard watcher: shutting down")
            return
        case <-ticker.C:
            w.check()
        }
    }
}

func (w *Watcher) check() {
    content, err := clipboard.ReadAll()
    if err != nil {
        // ReadAll can fail on Wayland/X11 if no owner; treat as empty.
        return
    }
    if content == w.previousContent {
        return
    }
    w.previousContent = content

    _, secret, _, _ := findSecret(content)
    if secret == "" {
        return
    }

    redacted := redactSecret(content, secret)
    if err := clipboard.WriteAll(redacted); err != nil {
        log.Printf("clipboard watcher: failed to redact: %v", err)
        return
    }
    w.previousContent = redacted

    log.Printf("clipboard watcher: redacted a secret (%d chars)", len(secret))
    _ = beeep.Notify(
        "Clipboard Watcher",
        "A potential secret was detected and redacted from your clipboard.",
        "",
    )
}
main.go
package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // Trap SIGINT and SIGTERM for clean shutdown.
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
    go func() {
        <-sigs
        cancel()
    }()

    log.Println("clipboard watcher: starting (interval=500ms)")
    watcher := NewWatcher(500 * time.Millisecond)
    watcher.Run(ctx)
}

macOS: Running as a launchd User Agent
#

On macOS, launchd user agents start on login without requiring a full system service or root privileges. Create a plist at ~/Library/LaunchAgents/com.manuelfedele.clipboard-watcher.plist:

com.manuelfedele.clipboard-watcher.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.manuelfedele.clipboard-watcher</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/clipboard-watcher</string>
    </array>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/tmp/clipboard-watcher.log</string>

    <key>StandardErrorPath</key>
    <string>/tmp/clipboard-watcher.err</string>

    <!-- Required for clipboard access on macOS Sonoma+ -->
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

Load and start it:

terminal
# Copy binary to a stable location
go build -o clipboard-watcher .
sudo cp clipboard-watcher /usr/local/bin/clipboard-watcher

# Load the launch agent (runs as the current user, not root)
launchctl load ~/Library/LaunchAgents/com.manuelfedele.clipboard-watcher.plist

# Check it is running
launchctl list | grep clipboard-watcher

Linux: systemd User Unit
#

~/.config/systemd/user/clipboard-watcher.service
[Unit]
Description=Clipboard secret watcher
After=graphical-session.target

[Service]
Type=simple
ExecStart=/usr/local/bin/clipboard-watcher
Restart=on-failure
RestartSec=5s
# Required for clipboard access via X11 or Wayland
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority

[Install]
WantedBy=default.target

Enable and start:

terminal
systemctl --user enable clipboard-watcher
systemctl --user start clipboard-watcher
systemctl --user status clipboard-watcher

Building and Code-Signing on macOS
#

build.sh
# Build for the current architecture
go build -o clipboard-watcher -ldflags="-s -w" .

# On macOS, ad-hoc sign for local use (no developer account required)
codesign --sign - --force --preserve-metadata=entitlements ./clipboard-watcher

# For distribution, sign with your Apple Developer ID
# codesign --sign "Developer ID Application: Your Name (TEAM_ID)" ./clipboard-watcher
# xcrun notarytool submit clipboard-watcher --apple-id ... --wait

Security Considerations
#

Important

A clipboard monitor reads the contents of every item you copy: passwords, credit card numbers, personal messages, SSH keys, and anything else that passes through the clipboard. This is precisely the same access a malicious clipboard hijacker would have. The trust model is critical.

Key constraints to enforce:

  • Run as the current user, never as root or a system service. A system-level service could read clipboard content from all users on the machine.
  • Do not log clipboard content. The log lines in the example above log only the length of the redacted secret, not the value.
  • Open source the binary you run. If you install a pre-built binary from an unknown source, you have no way to verify it is not exfiltrating everything you copy.
  • Keep the binary in a user-writable location only you control (~/bin or /usr/local/bin with restricted write permissions).
Starting with macOS Sonoma (14), apps accessing the clipboard programmatically may trigger a permission prompt. If the watcher does not receive clipboard content, check System Settings > Privacy & Security > Pasteboard and verify the binary has access. Sandboxed apps (from the Mac App Store) require explicit com.apple.security.temporary-exception.pbselect entitlements to read the clipboard.
On Wayland, clipboard access outside of an active window is restricted by default. The wl-clipboard tool (wl-paste) provides a workaround for headless clipboard reading, but the atotto/clipboard package transparently uses xclip or xsel on X11 and may require wl-clipboard compatibility shims on Wayland sessions.

Common Mistakes
#

Polling too fast: CPU waste
A 100ms poll interval on macOS causes noticeable CPU usage because clipboard reads involve IPC with the pasteboard server. 500ms is the sweet spot: fast enough to catch a secret before an accidental paste, slow enough to be invisible in Activity Monitor.
Polling too slow: race condition
The original post used a 1-second sleep. That interval is too long. A user who copies a secret and immediately pastes it (common in terminal workflows) will have pasted the unredacted value before the watcher fires. 500ms reduces this window significantly, though no polling-based approach can eliminate it entirely.
Running as root
Installing the watcher as a system-level launchd daemon (/Library/LaunchDaemons) or a systemd system service (/etc/systemd/system) gives it root privileges and access to all users’ clipboard content. Always use ~/Library/LaunchAgents (macOS) or ~/.config/systemd/user (Linux) to scope it to the current user session.
Logging clipboard content
Logging the detected or redacted secret value to a file creates a secondary exfiltration risk. The log file has weaker access controls than the clipboard itself and may be picked up by log aggregation tools (Splunk, CloudWatch agent, etc.). Log only metadata: that a redaction occurred, the pattern that matched, and the length of the secret.

If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related