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:
- Polling su un intervallo configurabile (500ms è un buon default)
- Rilevamento di segreti usando un insieme di regex compilate che coprono i formati di segreti comuni
- Redazione sostituendo la maggior parte dei caratteri con asterischi, mantenendo gli ultimi N per verifica
- Notifica all’utente tramite notifica desktop in modo che la redazione non sia silenziosa
- 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.
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.
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).
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.",
"",
)
}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:
<?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:
# 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-watcherLinux: unit systemd utente#
[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.targetAbilita e avvia:
systemctl --user enable clipboard-watcher
systemctl --user start clipboard-watcher
systemctl --user status clipboard-watcherBuild e code-signing su macOS#
# 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 ... --waitConsiderazioni di sicurezza#
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 (
~/bino/usr/local/bincon permessi di scrittura limitati).
com.apple.security.temporary-exception.pbselect per leggere la clipboard.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
Polling troppo lento: race condition
Eseguire come root
/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
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.