Salta al contenuto principale

Monitoraggio della clipboard in Go: rilevamento e redazione di segreti

·8 minuti
Indice dei contenuti
Digitare accidentalmente una password nel campo sbagliato e lasciarla nella clipboard è un rischio di sicurezza reale. Un monitor della clipboard può rilevare i pattern di segreti comuni e redigerli automaticamente prima che vengano incollati dove non dovrebbero andare.

Questo articolo costruisce un clipboard watcher di qualita produzione in Go: rilevamento di segreti basato su regex, polling thread-safe con context cancellation, notifiche desktop alla redazione e configurazione del servizio di background a livello OS per macOS e Linux.

L’approccio: polling con pattern matching
#

La versione originale di questo programma usava un prefisso fisso ("password: ") e un sleep fisso di 1 secondo. Entrambe le scelte sono fragili: il prefisso manca tutto cio che non usa quel formato esatto, e il sleep fisso crea una race condition dove la clipboard puo essere sovrascritta tra un intervallo di rilevamento e l’altro.

L’approccio migliorato:

  1. Polling su un intervallo configurabile (500ms è un buon default)
  2. Rilevamento di segreti usando un insieme di regex compilate che coprono i formati di segreti comuni
  3. Redazione sostituendo la maggior parte dei caratteri con asterischi, mantenendo gli ultimi N per verifica
  4. Notifica all’utente tramite notifica desktop in modo che la redazione non sia silenziosa
  5. Esecuzione come servizio di background per-utente, non come root

Pattern di rilevamento dei segreti
#

Un insieme robusto di pattern copre JWT, chiavi AWS, chiavi private e campi password generici, non solo la parola “password” seguita da due punti.

patterns.go
package main

import "regexp"

// secretPatterns è una lista di regex compilate. Ogni regex deve avere un gruppo
// nominato chiamato "secret" che isola il valore sensibile da redarre.
var secretPatterns = []*regexp.Regexp{
    // AWS Access Key ID
    regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`),
    // AWS Secret Access Key (euristica: 40 caratteri alfanumerici+/+ dopo prefisso noto)
    regexp.MustCompile(`(?i)aws_secret_access_key\s*[=:]\s*(?P<secret>[A-Za-z0-9/+=]{40})`),
    // JWT token (tre segmenti base64url separati da punti)
    regexp.MustCompile(`(?P<secret>eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)`),
    // Blocco PEM private key
    regexp.MustCompile(`(?P<secret>-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)`),
    // Assegnazione password= o password: generica (copre file di config, .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 restituisce la sottostringa del segreto trovato e la posizione
// del match completo, oppure stringa vuota e -1 se nessun pattern corrisponde.
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]]
        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
}

La logica di redazione
#

Redigere il segreto mantenendo gli ultimi pochi caratteri in modo che l’utente possa verificare che corrisponda al valore corretto. Preserva il contesto circostante nella clipboard in modo che i blocchi di configurazione copia-incollati rimangano utilizzabili.

redact.go
package main

import "strings"

// redactSecret sostituisce la maggior parte del segreto con asterischi, mantenendo
// fino a 3 caratteri finali per la verifica dell'utente.
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)
}

Ciclo di polling thread-safe con context cancellation
#

Il ciclo di polling gira in una goroutine. La context cancellation fornisce uno shutdown pulito (importante per il reload graceful quando il servizio si riavvia).

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 esegue il polling della clipboard finche ctx non viene annullato.
// È sicuro chiamarlo da una singola goroutine; l'accesso alla clipboard non è
// concorrente. Non devono essere in esecuzione più watcher contemporaneamente.
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 puo fallire su Wayland/X11 se non c'è un owner; tratta come vuoto.
        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",
        "Un potenziale segreto è stato rilevato e redatto dalla clipboard.",
        "",
    )
}
main.go
package main

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

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

    // Intercetta SIGINT e SIGTERM per uno shutdown pulito.
    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: esecuzione come launchd user agent
#

Su macOS, i launchd user agent si avviano al login senza richiedere un servizio di sistema completo o privilegi root. Crea un plist in ~/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>

    <!-- Richiesto per l'accesso alla clipboard su macOS Sonoma+ -->
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

Carica e avvia:

terminal
# Copia il binario in una posizione stabile
go build -o clipboard-watcher .
sudo cp clipboard-watcher /usr/local/bin/clipboard-watcher

# Carica il launch agent (gira come utente corrente, non come root)
launchctl load ~/Library/LaunchAgents/com.manuelfedele.clipboard-watcher.plist

# Verifica che sia in esecuzione
launchctl list | grep clipboard-watcher

Linux: unit systemd utente
#

~/.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
# Richiesto per l'accesso alla clipboard via X11 o Wayland
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority

[Install]
WantedBy=default.target

Abilita e avvia:

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

Build e code-signing su macOS
#

build.sh
# Build per l'architettura corrente
go build -o clipboard-watcher -ldflags="-s -w" .

# Su macOS, firma ad-hoc per uso locale (non richiede account developer)
codesign --sign - --force --preserve-metadata=entitlements ./clipboard-watcher

# Per la distribuzione, firma con il tuo Apple Developer ID
# codesign --sign "Developer ID Application: Your Name (TEAM_ID)" ./clipboard-watcher
# xcrun notarytool submit clipboard-watcher --apple-id ... --wait

Considerazioni di sicurezza
#

Importante

Un monitor della clipboard legge il contenuto di tutto cio che viene copiato: password, numeri di carta di credito, messaggi personali, chiavi SSH e qualsiasi altra cosa transiti attraverso la clipboard. Questo è esattamente lo stesso accesso che avrebbe un hijacker malevolo della clipboard. Il trust model è fondamentale.

Vincoli da rispettare:

  • Eseguire come utente corrente, mai come root o servizio di sistema. Un servizio a livello di sistema potrebbe leggere il contenuto della clipboard di tutti gli utenti della macchina.
  • Non loggare il contenuto della clipboard. Le righe di log nell’esempio sopra registrano solo la lunghezza del segreto redatto, non il valore.
  • Rendere open source il binario che si esegue. Se si installa un binario precompilato da una fonte sconosciuta, non c’è modo di verificare che non stia esfiltrando tutto cio che si copia.
  • Tenere il binario in una posizione scrivibile solo dall’utente (~/bin o /usr/local/bin con permessi di scrittura limitati).
A partire da macOS Sonoma (14), le app che accedono alla clipboard programmaticamente possono attivare un prompt di autorizzazione. Se il watcher non riceve il contenuto della clipboard, controllare in Impostazioni di Sistema > Privacy e Sicurezza > Pasteboard e verificare che il binario abbia accesso. Le app sandboxed (dal Mac App Store) richiedono entitlements espliciti com.apple.security.temporary-exception.pbselect per leggere la clipboard.
Su Wayland, l’accesso alla clipboard fuori da una finestra attiva è limitato per default. Lo strumento wl-clipboard (wl-paste) fornisce un workaround per la lettura headless della clipboard, ma il package atotto/clipboard usa trasparentemente xclip o xsel su X11 e potrebbe richiedere shim di compatibilita wl-clipboard sulle sessioni Wayland.

Errori comuni
#

Polling troppo veloce: spreco di CPU
Un intervallo di polling di 100ms su macOS causa un utilizzo della CPU notevole perché le letture della clipboard implicano IPC con il pasteboard server. 500ms è il punto di equilibrio: abbastanza veloce da catturare un segreto prima di un incolla accidentale, abbastanza lento da essere invisibile in Activity Monitor.
Polling troppo lento: race condition
Il post originale usava un sleep di 1 secondo. Quell’intervallo è troppo lungo. Un utente che copia un segreto e lo incolla immediatamente (comune nei workflow da terminale) avrà incollato il valore non redatto prima che il watcher intervenga. 500ms riduce significativamente questa finestra, anche se nessun approccio basato su polling puo eliminarla completamente.
Eseguire come root
Installare il watcher come daemon launchd a livello di sistema (/Library/LaunchDaemons) o come servizio systemd di sistema (/etc/systemd/system) gli conferisce privilegi root e accesso al contenuto della clipboard di tutti gli utenti. Usare sempre ~/Library/LaunchAgents (macOS) o ~/.config/systemd/user (Linux) per limitarlo alla sessione dell’utente corrente.
Loggare il contenuto della clipboard
Loggare il valore del segreto rilevato o redatto su un file crea un rischio di esfiltrazione secondario. Il file di log ha controlli di accesso piu deboli della clipboard stessa e potrebbe essere prelevato da strumenti di aggregazione dei log (Splunk, CloudWatch agent, ecc.). Loggare solo metadati: che è avvenuta una redazione, il pattern che ha corrisposto e la lunghezza del segreto.

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati