diff --git a/agent.go b/agent.go index 09fbd06..f75ea69 100644 --- a/agent.go +++ b/agent.go @@ -64,7 +64,7 @@ func runAgent(ctx context.Context, wsURL, token string) { // runSession runs a single WebSocket session. Returns when disconnected or ctx cancelled. func runSession(ctx context.Context, baseURL, token string) error { endpoint := baseURL + "/socket/agent/websocket" - slog.Info("connecting", "url", endpoint) + slog.Info("connecting", "url", sanitizeURL(endpoint)) ws, err := WSDial(endpoint) if err != nil { @@ -374,4 +374,15 @@ func nextBackoff(current, maxDelay time.Duration) time.Duration { return next + jitter } +// zeroBytes overwrites a byte slice with zeros. +// SECURITY: Go strings are immutable and cannot be zeroed in place. This utility +// is for zeroing byte slices (e.g., password buffers) to limit credential lifetime +// in memory. Credentials stored as Go strings (ssh.go, snmp.go, mikrotik.go) +// cannot benefit from this until the protocol layer supports []byte credentials. +func zeroBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} + func strPtr(s string) *string { return &s } diff --git a/agent_test.go b/agent_test.go index a9fe0d4..852a49c 100644 --- a/agent_test.go +++ b/agent_test.go @@ -336,6 +336,20 @@ func TestHandleMessage(t *testing.T) { }) } +func TestZeroBytes(t *testing.T) { + b := []byte{1, 2, 3, 4, 5} + zeroBytes(b) + for i, v := range b { + if v != 0 { + t.Errorf("byte[%d] = %d, want 0", i, v) + } + } + + // Nil/empty should not panic + zeroBytes(nil) + zeroBytes([]byte{}) +} + func TestNextBackoff(t *testing.T) { maxDelay := 60 * time.Second diff --git a/main.go b/main.go index fc5a180..a791ad1 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log/slog" + "net/url" "os" "os/signal" "strings" @@ -43,7 +44,7 @@ func main() { // Convert HTTP(S) to WebSocket URL wsURL := toWebSocketURL(*apiURL) - slog.Info("websocket url", "url", wsURL) + slog.Info("websocket url", "url", sanitizeURL(wsURL)) // Signal handling ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) @@ -69,6 +70,21 @@ func toWebSocketURL(url string) string { } } +// sanitizeURL masks query parameters to prevent credential leakage in logs. +func sanitizeURL(rawURL string) string { + if rawURL == "" { + return "" + } + u, err := url.Parse(rawURL) + if err != nil { + return "[invalid URL]" + } + if u.RawQuery != "" { + u.RawQuery = "***" + } + return u.String() +} + func envOrDefault(key, fallback string) string { if v := os.Getenv(key); v != "" { return v diff --git a/main_test.go b/main_test.go index 42c4709..e0595b9 100644 --- a/main_test.go +++ b/main_test.go @@ -21,6 +21,24 @@ func TestEnvOrDefault(t *testing.T) { } } +func TestSanitizeURL(t *testing.T) { + tests := []struct { + input, want string + }{ + {"wss://towerops.net/socket", "wss://towerops.net/socket"}, + {"wss://towerops.net/socket?token=secret", "wss://towerops.net/socket?***"}, + {"wss://towerops.net/socket?token=secret&key=abc", "wss://towerops.net/socket?***"}, + {"://invalid url", "[invalid URL]"}, + {"", ""}, + } + for _, tt := range tests { + got := sanitizeURL(tt.input) + if got != tt.want { + t.Errorf("sanitizeURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + func TestToWebSocketURL(t *testing.T) { tests := []struct { input, want string diff --git a/mikrotik.go b/mikrotik.go index 47f4da0..dcdd790 100644 --- a/mikrotik.go +++ b/mikrotik.go @@ -14,9 +14,9 @@ import ( ) const ( - mikrotikConnTimeout = 30 * time.Second - mikrotikReadTimeout = 30 * time.Second - maxMikrotikWordSize = 10 << 20 // 10 MB + mikrotikConnTimeout = 30 * time.Second + mikrotikReadTimeout = 30 * time.Second + maxMikrotikWordSize = 10 << 20 // 10 MB ) var mikrotikDial = mikrotikConnect @@ -42,6 +42,9 @@ func mikrotikConnect(ip string, port uint32, username, password string, useSSL b var err error if useSSL { + // SECURITY: InsecureSkipVerify is required because MikroTik devices use + // self-signed certificates. The agent connects to customer-configured IPs + // on private networks where CA-signed certs are not available. dialer := &tls.Dialer{ NetDialer: &net.Dialer{Timeout: mikrotikConnTimeout}, Config: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, diff --git a/ssh.go b/ssh.go index 976bbbe..a79cefe 100644 --- a/ssh.go +++ b/ssh.go @@ -15,6 +15,10 @@ var doPing = pingDevice // executeMikrotikBackup connects via SSH and runs /export compact. func executeMikrotikBackup(ip string, port uint16, username, password string) (string, error) { + // SECURITY: InsecureIgnoreHostKey is used because the agent connects to + // customer network devices with unknown host keys. These are typically + // MikroTik routers on private networks where host key pinning is not + // feasible due to dynamic device provisioning. config := &ssh.ClientConfig{ User: username, Auth: []ssh.AuthMethod{ssh.Password(password)},