diff --git a/cmd/myf5/main.go b/cmd/myf5/main.go index c816dbc..a92b6c2 100644 --- a/cmd/myf5/main.go +++ b/cmd/myf5/main.go @@ -625,7 +625,8 @@ func handleDownloads(ctx context.Context, args []string) { fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err) exitFunc(1) } - token, err := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "myf5_scope") + defer creds.Zero() + token, err := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "myf5_scope") checkErr(ctx, err, "Authentication failed") return downloads.NewClientWithKey(token, "", creds.APIKey) } @@ -633,6 +634,7 @@ func handleDownloads(ctx context.Context, args []string) { switch action { case "families", "products", "list-products", "list-families": client := getDownloadsClient() + defer client.Zero() meta, err := client.GetMetadata(ctx) checkErr(ctx, err, "Failed to load product families") fmt.Println("Available Product Families:") @@ -651,6 +653,7 @@ func handleDownloads(ctx context.Context, args []string) { } targetFamily := remaining[0] client := getDownloadsClient() + defer client.Zero() meta, err := client.GetMetadata(ctx) checkErr(ctx, err, "Failed to load product lines") for _, fam := range meta.Data.ProductFamilies { @@ -675,6 +678,7 @@ func handleDownloads(ctx context.Context, args []string) { exitFunc(1) } client := getDownloadsClient() + defer client.Zero() resp, err := client.GetProductVersions(ctx, remaining[0], remaining[1]) checkErr(ctx, err, "Failed to load versions") versions := resp.GetVersions() @@ -692,6 +696,7 @@ func handleDownloads(ctx context.Context, args []string) { exitFunc(1) } client := getDownloadsClient() + defer client.Zero() resp, err := client.GetProductVersions(ctx, remaining[0], remaining[1]) checkErr(ctx, err, "Failed to load containers") targetVer := "" @@ -723,6 +728,7 @@ func handleDownloads(ctx context.Context, args []string) { exitFunc(1) } client := getDownloadsClient() + defer client.Zero() resp, err := client.GetProductVersions(ctx, remaining[0], remaining[1]) checkErr(ctx, err, "Failed to load files") targetVer := "" @@ -776,6 +782,7 @@ func handleDownloads(ctx context.Context, args []string) { } client := getDownloadsClient() + defer client.Zero() fmt.Printf("Fetching download links for %s...\n", fileName) linksResp, err := client.GetFileLinks(ctx, family, line, ver, cont, fileName, "english") checkErr(ctx, err, "Failed to retrieve download link") @@ -824,7 +831,9 @@ func handleDownloads(ctx context.Context, args []string) { } case "browse": - runInteractiveBrowser(ctx, getDownloadsClient()) + client := getDownloadsClient() + defer client.Zero() + runInteractiveBrowser(ctx, client) default: printDownloadsUsage() @@ -1348,10 +1357,11 @@ func handleCases(ctx context.Context, args []string) { if action == "wizard" || action == "new" || (action == "create" && len(args) == 1) { creds, err := auth.ResolveCredentials("default") if err == nil { - token, aErr := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "myf5_scope") + token, aErr := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "myf5_scope") if aErr == nil { client = cases.NewClientWithKey(token, "", creds.APIKey) } + creds.Zero() } } else { creds, err := auth.ResolveCredentials("default") @@ -1359,10 +1369,14 @@ func handleCases(ctx context.Context, args []string) { fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err) exitFunc(1) } - token, err := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "myf5_scope") + defer creds.Zero() + token, err := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "myf5_scope") checkErr(ctx, err, "Authentication failed") client = cases.NewClientWithKey(token, "", creds.APIKey) } + if client != nil { + defer client.Zero() + } switch action { case "wizard", "new": @@ -1508,8 +1522,9 @@ func printCaseDetail(d *cases.CaseDetail) { if n := d.AlternateContact.DisplayName(); n != "" { fmt.Printf(" Alternate : %s <%s>\n", n, d.AlternateContact.Email) } - // dateOpened is deliberately not printed: the detail endpoint returns the - // priority string in that field. See CaseDetail. + if d.DateOpened != "" { + fmt.Printf(" Opened : %s\n", d.DateOpened) + } for _, l := range d.Links { if l.Rel == "case-detail" { fmt.Printf(" Web : %s\n", l.Href) @@ -1737,9 +1752,11 @@ func handleIHealth(ctx context.Context, args []string) { fmt.Fprintf(os.Stderr, "[ERROR] %v\n", err) exitFunc(1) } - token, err := auth.Authenticate(ctx, "", "", creds.ClientID, creds.ClientSecret, "ihealth") + defer creds.Zero() + token, err := auth.AuthenticateWithSecretBytes(ctx, nil, "", "", creds.ClientID, creds.SecretBytes, "ihealth") checkErr(ctx, err, "Authentication failed") client := ihealth.NewClient(token, "") + defer client.Zero() switch action { case "list": @@ -2240,16 +2257,22 @@ func handleAppliance(ctx context.Context, targetKind string, args []string) { exitFunc(1) } + var pwBytes []byte + if pw != "" { + pwBytes = []byte(pw) + } client, err := bigip.NewClientWithOptions(bigip.ClientOptions{ Host: host, Port: port, Username: user, Password: pw, + PasswordBytes: pwBytes, SkipTLSVerify: true, ClientCertPath: certPath, ClientKeyPath: certKeyPath, }) checkErr(ctx, err, "Failed to initialize appliance client") + defer client.Zero() ready, err := client.CheckReady(ctx) checkErr(ctx, err, "Ready check failed") if ready { @@ -2367,16 +2390,22 @@ func handleAppliance(ctx context.Context, targetKind string, args []string) { spinner.Start() } + var pwBytes []byte + if pw != "" { + pwBytes = []byte(pw) + } geoOpts := bigip.GeoIPOptions{ Port: port, Username: user, Password: pw, + PasswordBytes: pwBytes, IdentityFile: keyPath, ClientCertPath: certPath, ClientKeyPath: certKeyPath, Method: bigip.TransferMethod(method), KeepExtracted: keepExtracted, } + defer geoOpts.Zero() statusCb := func(status string) { if spinner != nil { @@ -2477,17 +2506,23 @@ func handleAppliance(ctx context.Context, targetKind string, args []string) { transferMethod = bigip.MethodAuto } + var pwBytes []byte + if pw != "" { + pwBytes = []byte(pw) + } qkOpts := bigip.TransferOptions{ TargetType: targetKind, Method: transferMethod, Username: user, Password: pw, + PasswordBytes: pwBytes, Port: port, IdentityFile: keyPath, ClientCertPath: certPath, ClientKeyPath: certKeyPath, Timeout: 30 * time.Second, } + defer qkOpts.Zero() spinner := progress.NewSpinner(fmt.Sprintf("Generating diagnostic QKView on %s", host)) spinner.Start() @@ -2636,21 +2671,28 @@ func getSystemInfoTarget(ctx context.Context, target, targetKind, user string, p } } - info, err := resolveApplianceSystemInfo(ctx, host, port, user, pw, keyPath, method, targetKind, certPath, certKeyPath) + var pwBytes []byte + if pw != "" { + pwBytes = []byte(pw) + } + + info, err := resolveApplianceSystemInfo(ctx, host, port, user, pw, pwBytes, keyPath, method, targetKind, certPath, certKeyPath) checkErr(ctx, err, fmt.Sprintf("Failed to retrieve system info from %s", host)) return info } -func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user, pw, keyPath, method, targetKind, certPath, certKeyPath string) (*bigip.SystemInfo, error) { +func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user, pw string, pwBytes []byte, keyPath, method, targetKind, certPath, certKeyPath string) (*bigip.SystemInfo, error) { if method == "scp" || method == "ssh" { opts := bigip.SCPOptions{ - Host: host, - Port: port, - Username: user, - Password: pw, - KeyPath: keyPath, - Timeout: 30 * time.Second, - } + Host: host, + Port: port, + Username: user, + Password: pw, + PasswordBytes: pwBytes, + KeyPath: keyPath, + Timeout: 30 * time.Second, + } + defer opts.Zero() return bigip.GetSystemInfoSSH(ctx, opts) } @@ -2659,11 +2701,13 @@ func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user Port: port, Username: user, Password: pw, + PasswordBytes: pwBytes, SkipTLSVerify: true, ClientCertPath: certPath, ClientKeyPath: certKeyPath, }) if err == nil { + defer client.Zero() var info *bigip.SystemInfo var restErr error info, restErr = client.GetSystemInfo(ctx) @@ -2684,13 +2728,15 @@ func resolveApplianceSystemInfo(ctx context.Context, host string, port int, user sshPort = port } opts := bigip.SCPOptions{ - Host: host, - Port: sshPort, - Username: user, - Password: pw, - KeyPath: keyPath, - Timeout: 30 * time.Second, - } + Host: host, + Port: sshPort, + Username: user, + Password: pw, + PasswordBytes: pwBytes, + KeyPath: keyPath, + Timeout: 30 * time.Second, + } + defer opts.Zero() sshInfo, sshErr := bigip.GetSystemInfoSSH(ctx, opts) if sshErr == nil { return sshInfo, nil @@ -2807,11 +2853,17 @@ func executePushToAppliance( } } + var pwBytes []byte + if pw != "" { + pwBytes = []byte(pw) + } + opts := bigip.TransferOptions{ TargetType: targetKind, Method: bigip.TransferMethod(method), Username: user, Password: pw, + PasswordBytes: pwBytes, Port: port, IdentityFile: keyPath, ClientCertPath: certPath, @@ -2819,6 +2871,7 @@ func executePushToAppliance( DestDir: destPath, Timeout: 30 * time.Second, } + defer opts.Zero() var bar *progress.Bar var renderProgress func(transferred, total int64) @@ -2900,16 +2953,22 @@ func executePushToAppliance( spinner.Start() } + var geoPwBytes []byte + if pw != "" { + geoPwBytes = []byte(pw) + } geoOpts := bigip.GeoIPOptions{ Port: port, Username: user, Password: pw, + PasswordBytes: geoPwBytes, IdentityFile: keyPath, ClientCertPath: certPath, ClientKeyPath: certKeyPath, Method: bigip.TransferMethod(method), KeepExtracted: keepExtracted, } + defer geoOpts.Zero() statusCb := func(status string) { if spinner != nil { @@ -3047,17 +3106,24 @@ func executeDownloadFromAppliance( } } + var pwBytes []byte + if pw != "" { + pwBytes = []byte(pw) + } + opts := bigip.TransferOptions{ TargetType: targetKind, Method: bigip.TransferMethod(method), Username: user, Password: pw, + PasswordBytes: pwBytes, Port: port, IdentityFile: keyPath, ClientCertPath: certPath, ClientKeyPath: certKeyPath, Timeout: 30 * time.Second, } + defer opts.Zero() fmt.Printf("Downloading %s from %s (%s) via %s...\n", remotePath, targetKind, host, strings.ToUpper(method)) @@ -3129,6 +3195,7 @@ func handleDoctor(ctx context.Context, args []string) { fmt.Printf(" [FAIL] Credentials: %v\n", err) hasError = true } else { + defer creds.Zero() fmt.Printf(" [PASS] Found Client ID: %s\n", creds.ClientID) fmt.Println(" [PASS] Found Client Secret: [REDACTED] (Zero CLI Exposure)") } diff --git a/docs/cli.md b/docs/cli.md index 922421e..fcbe03a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -144,23 +144,14 @@ myf5 cases list --all ``` #### `metadata` -Retrieves the authoritative schema metadata and valid values for case creation. +Retrieves the authoritative schema metadata and valid taxonomy values for case creation from the live MyF5 API. ```bash myf5 cases metadata ``` -> **Known outage (re-verified 2026-10-06):** this endpoint returns -> `500 "Malformed JSON: Expected '{' at the beginning of object"` for every -> request, including a plain `GET` with no body. The fault is server-side at -> F5 and there is no client-side workaround. It is the documented source of -> valid `productFamily`, `priority`, `timezone` and `reasonForContact` values, -> so those values currently cannot be enumerated from the API. -> -> Until it recovers, copy the values from an existing case with -> `myf5 cases get `, use `myf5 cases wizard` (which incorporates -> known-valid schemas offline), or use `--dry-run` on `cases create`, which -> validates your field values and reports what the API would reject without -> opening a case. +Returns the full taxonomy schema containing 10 categories: `prodFamilies` (with nested `products` and supported `versions`), `priorities`, `reasonsForContact`, `preferredContactMethods`, `timeZones`, `accounts`, `cloudProviders`, `linuxDistributions`, `nginxPlusVersions`, and `status`. + +> **Status:** Operational. A server-side 500 deserialization outage observed from 2026-10-05 was resolved by F5 Engineering on 2026-10-08. #### `comment [note]` Appends a status comment or investigation update to an open support case. If `[note]` is omitted, launches an interactive multiline text buffer with editing, undo/redo (`Ctrl+Z`), and word wrapping. @@ -185,16 +176,12 @@ myf5 wizard ``` #### `get ` -Shows one case in full: status, priority, product, account, contacts, the -description, and a list of the messages on the email thread. +Shows one case in full: status, priority, product, version, opened timestamp (`dateOpened`), +account, contacts, the description, and a list of the messages on the email thread. ```bash myf5 cases get 01287164 ``` -> `dateOpened` is deliberately not shown. The detail endpoint returns the -> priority string in that field (a server-side defect); `cases list` reports -> the real timestamp. - #### `create [flags]` Opens a support case. Run with no flags for the full list. ```bash diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go index dbf636b..0c16505 100644 --- a/pkg/auth/credentials.go +++ b/pkg/auth/credentials.go @@ -72,6 +72,9 @@ type Credentials struct { ClientID string ClientSecret string + // SecretBytes holds the client secret as a mutable byte slice for in-place memory scrubbing. + SecretBytes []byte + // APIKey is the "API usage key" sent as the required `k` query parameter // on support.apis.f5.com endpoints. It is a SECOND credential, distinct // from the OAuth bearer token built from ClientID/ClientSecret: the F5 @@ -84,11 +87,32 @@ type Credentials struct { // a single shared constant. Empty means "fall back to the package default". APIKey string + // APIKeyBytes holds the API usage key as a mutable byte slice for in-place memory scrubbing. + APIKeyBytes []byte + // Proxy specifies an HTTP, HTTPS, or SOCKS5 proxy URL for outbound egress. // Loaded from environment (MYF5_PROXY, F5_PROXY, HTTPS_PROXY, etc.) or credentials file. Proxy string } +// Zero securely overwrites all secret byte slices with zeros to scrub them from RAM, +// then clears secret string fields. +func (c *Credentials) Zero() { + if c == nil { + return + } + if len(c.SecretBytes) > 0 { + ZeroBytes(c.SecretBytes) + c.SecretBytes = nil + } + if len(c.APIKeyBytes) > 0 { + ZeroBytes(c.APIKeyBytes) + c.APIKeyBytes = nil + } + c.ClientSecret = "" + c.APIKey = "" +} + // String implements fmt.Stringer to ensure secrets are never leaked into logs or stdout. func (c Credentials) String() string { // APIKey is a credential in its own right and must be redacted alongside @@ -166,9 +190,13 @@ func ResolveCredentialsForService(profile, service string) (*Credentials, error) creds := &Credentials{ ClientID: clientID, ClientSecret: clientSecret, + SecretBytes: []byte(clientSecret), APIKey: apiKey, Proxy: envProxy, } + if apiKey != "" { + creds.APIKeyBytes = []byte(apiKey) + } if envProxy != "" && client.GetGlobalProxy() == nil { _ = client.SetGlobalProxy(envProxy) } @@ -203,6 +231,10 @@ func ResolveCredentialsForService(profile, service string) (*Credentials, error) // for the OAuth pair above. if apiKey != "" { creds.APIKey = apiKey + if creds.APIKeyBytes != nil { + ZeroBytes(creds.APIKeyBytes) + } + creds.APIKeyBytes = []byte(apiKey) } if envProxy != "" { creds.Proxy = envProxy @@ -259,6 +291,16 @@ func readCredentialsFile(filePath, profile, servicePrefix string) (*Credentials, currentSection := "" kvStore := make(map[string]string) profileStore := make(map[string]map[string]string) + defer func() { + for k := range kvStore { + delete(kvStore, k) + } + for _, m := range profileStore { + for k := range m { + delete(m, k) + } + } + }() for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) @@ -328,7 +370,17 @@ func readCredentialsFile(filePath, profile, servicePrefix string) (*Credentials, if prx == "" { prx = lookupKey(kvStore, "proxy", "f5_proxy", "https_proxy", "http_proxy") } - return &Credentials{ClientID: cid, ClientSecret: csec, APIKey: ak, Proxy: prx}, nil + creds := &Credentials{ + ClientID: cid, + ClientSecret: csec, + SecretBytes: []byte(csec), + APIKey: ak, + Proxy: prx, + } + if ak != "" { + creds.APIKeyBytes = []byte(ak) + } + return creds, nil } } @@ -338,7 +390,17 @@ func readCredentialsFile(filePath, profile, servicePrefix string) (*Credentials, if cid != "" && csec != "" { ak := lookupKey(kvStore, "api_key", "apikey", "k", "f5_api_key") prx := lookupKey(kvStore, "proxy", "f5_proxy", "https_proxy", "http_proxy") - return &Credentials{ClientID: cid, ClientSecret: csec, APIKey: ak, Proxy: prx}, nil + creds := &Credentials{ + ClientID: cid, + ClientSecret: csec, + SecretBytes: []byte(csec), + APIKey: ak, + Proxy: prx, + } + if ak != "" { + creds.APIKeyBytes = []byte(ak) + } + return creds, nil } return nil, fmt.Errorf("no credentials found in %s for profile %s", filePath, profile) diff --git a/pkg/auth/credentials_test.go b/pkg/auth/credentials_test.go index 515fc9d..8631fa8 100644 --- a/pkg/auth/credentials_test.go +++ b/pkg/auth/credentials_test.go @@ -311,3 +311,49 @@ func TestResolveCredentialsCustomFile(t *testing.T) { t.Errorf("got %v, want custom-id/custom-sec", creds) } } + +func TestCredentialsZero(t *testing.T) { + t.Run("scrubs byte slices and clears strings", func(t *testing.T) { + sec := []byte("my-client-secret-12345") + key := []byte("my-api-key-67890") + c := &Credentials{ + ClientID: "my-client-id", + ClientSecret: string(sec), + SecretBytes: sec, + APIKey: string(key), + APIKeyBytes: key, + } + + c.Zero() + + if c.ClientSecret != "" { + t.Errorf("ClientSecret not cleared: %q", c.ClientSecret) + } + if c.APIKey != "" { + t.Errorf("APIKey not cleared: %q", c.APIKey) + } + if c.SecretBytes != nil { + t.Errorf("SecretBytes should be nil") + } + if c.APIKeyBytes != nil { + t.Errorf("APIKeyBytes should be nil") + } + + // Verify underlying backing arrays were zeroed out + for i, v := range sec { + if v != 0 { + t.Errorf("sec byte %d not zeroed: %d", i, v) + } + } + for i, v := range key { + if v != 0 { + t.Errorf("key byte %d not zeroed: %d", i, v) + } + } + }) + + t.Run("handles nil safely", func(t *testing.T) { + var nilCreds *Credentials + nilCreds.Zero() // Must not panic + }) +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 478d7ed..bbadabc 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -59,6 +59,13 @@ func Authenticate(ctx context.Context, authFQDN, appID, clientID, clientSecret, // AuthenticateWithClient allows passing a custom HTTP client (useful for tests or custom proxy). func AuthenticateWithClient(ctx context.Context, httpClient *http.Client, authFQDN, appID, clientID, clientSecret, scope string) (string, error) { + secBytes := []byte(clientSecret) + defer ZeroBytes(secBytes) + return AuthenticateWithSecretBytes(ctx, httpClient, authFQDN, appID, clientID, secBytes, scope) +} + +// AuthenticateWithSecretBytes authenticates using a mutable clientSecret byte slice that is scrubbed from memory. +func AuthenticateWithSecretBytes(ctx context.Context, httpClient *http.Client, authFQDN, appID, clientID string, clientSecret []byte, scope string) (string, error) { if authFQDN == "" { authFQDN = DefaultAuthFQDN } @@ -122,8 +129,11 @@ func AuthenticateWithClient(ctx context.Context, httpClient *http.Client, authFQ return "", fmt.Errorf("failed to create auth request: %w", err) } - // Basic Auth credentials - rawCreds := []byte(clientID + ":" + clientSecret) + // Basic Auth credentials constructed in a mutable byte buffer + rawCreds := make([]byte, len(clientID)+1+len(clientSecret)) + copy(rawCreds, clientID) + rawCreds[len(clientID)] = ':' + copy(rawCreds[len(clientID)+1:], clientSecret) authHeader := base64.StdEncoding.EncodeToString(rawCreds) // Zero plaintext credential byte buffer immediately after base64 encoding ZeroBytes(rawCreds) diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 4e3eb30..3ecc4d8 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -86,3 +86,27 @@ func TestAuthenticateAudienceSelection(t *testing.T) { }) } } + +func TestAuthenticateWithSecretBytes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader != "Basic Y2xpZW50X2lkOnNlY3JldF9ieXRlcw==" { // base64 of "client_id:secret_bytes" + t.Errorf("unexpected Authorization header: %s", authHeader) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(TokenResponse{ + AccessToken: "bytes-token-xyz", + TokenType: "Bearer", + }) + })) + defer server.Close() + + sec := []byte("secret_bytes") + tok, err := AuthenticateWithSecretBytes(context.Background(), server.Client(), server.URL, "app", "client_id", sec, "myf5_scope") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tok != "bytes-token-xyz" { + t.Errorf("got token %s, want bytes-token-xyz", tok) + } +} diff --git a/pkg/bigip/client.go b/pkg/bigip/client.go index 807673b..ddb5afa 100644 --- a/pkg/bigip/client.go +++ b/pkg/bigip/client.go @@ -19,17 +19,31 @@ import ( "strings" "time" + "myf5-cli/pkg/auth" "myf5-cli/pkg/client" ) // Client interacts with BIG-IP iControl REST APIs. type Client struct { - Scheme string - Host string - Port int - Username string - Password string - HTTPClient *http.Client + Scheme string + Host string + Port int + Username string + Password string + PasswordBytes []byte + HTTPClient *http.Client +} + +// Zero securely scrubs appliance password bytes from RAM and clears password string fields. +func (c *Client) Zero() { + if c == nil { + return + } + if len(c.PasswordBytes) > 0 { + auth.ZeroBytes(c.PasswordBytes) + c.PasswordBytes = nil + } + c.Password = "" } // String implements fmt.Stringer to ensure appliance credentials are never leaked in logs. @@ -38,7 +52,7 @@ func (c *Client) String() string { return "" } pw := "[REDACTED]" - if c.Password == "" { + if c.Password == "" && len(c.PasswordBytes) == 0 { pw = "[EMPTY]" } return fmt.Sprintf("bigip.Client{Host: %q, Port: %d, Scheme: %q, Username: %q, Password: %s}", c.Host, c.Port, c.Scheme, c.Username, pw) @@ -189,6 +203,7 @@ type ClientOptions struct { Port int Username string Password string + PasswordBytes []byte SkipTLSVerify bool ClientCertPath string ClientKeyPath string @@ -226,13 +241,19 @@ func NewClientWithOptions(opts ClientOptions) (*Client, error) { } } + pwBytes := opts.PasswordBytes + if len(pwBytes) == 0 && opts.Password != "" { + pwBytes = []byte(opts.Password) + } + return &Client{ - Scheme: scheme, - Host: cleanHost, - Port: port, - Username: opts.Username, - Password: opts.Password, - HTTPClient: httpClient, + Scheme: scheme, + Host: cleanHost, + Port: port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: pwBytes, + HTTPClient: httpClient, }, nil } @@ -823,7 +844,17 @@ func (c *Client) GetEndpointExample(ctx context.Context, endpoint string) ([]byt } func (c *Client) setAuth(req *http.Request) { - auth := base64.StdEncoding.EncodeToString([]byte(c.Username + ":" + c.Password)) - req.Header.Set("Authorization", "Basic "+auth) + pwBytes := c.PasswordBytes + if len(pwBytes) == 0 && c.Password != "" { + pwBytes = []byte(c.Password) + } + buf := make([]byte, len(c.Username)+1+len(pwBytes)) + copy(buf, c.Username) + buf[len(c.Username)] = ':' + copy(buf[len(c.Username)+1:], pwBytes) + authHeader := base64.StdEncoding.EncodeToString(buf) + auth.ZeroBytes(buf) + + req.Header.Set("Authorization", "Basic "+authHeader) req.Header.Set("User-Agent", "myf5-cli-go/1.0.0") } diff --git a/pkg/bigip/client_test.go b/pkg/bigip/client_test.go index 2778e85..730a53f 100644 --- a/pkg/bigip/client_test.go +++ b/pkg/bigip/client_test.go @@ -428,3 +428,126 @@ func TestGetEndpointExample(t *testing.T) { t.Errorf("expected error on empty endpoint, got nil") } } + +func TestClientZero(t *testing.T) { + t.Run("scrubs PasswordBytes and clears Password", func(t *testing.T) { + pw := []byte("super-secret-password-xyz") + client, err := NewClientWithOptions(ClientOptions{ + Host: "192.0.2.1", + Port: 443, + Username: "admin", + Password: string(pw), + PasswordBytes: pw, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + client.Zero() + + if client.Password != "" { + t.Errorf("Password was not cleared: %q", client.Password) + } + if client.PasswordBytes != nil { + t.Errorf("PasswordBytes was not cleared to nil") + } + for i, v := range pw { + if v != 0 { + t.Errorf("byte %d was not zeroed: %d", i, v) + } + } + }) + + t.Run("handles nil safely", func(t *testing.T) { + var nilClient *Client + nilClient.Zero() // Must not panic + }) +} + +func TestSCPOptionsZero(t *testing.T) { + pw := []byte("ssh-pass-123") + passphrase := []byte("passphrase-456") + + opts := &SCPOptions{ + Host: "192.0.2.1", + Port: 22, + Username: "admin", + Password: string(pw), + PasswordBytes: pw, + Passphrase: string(passphrase), + PassphraseBytes: passphrase, + } + + opts.Zero() + + if opts.Password != "" { + t.Errorf("Password not cleared: %q", opts.Password) + } + if opts.Passphrase != "" { + t.Errorf("Passphrase not cleared: %q", opts.Passphrase) + } + if opts.PasswordBytes != nil { + t.Errorf("PasswordBytes not nil") + } + if opts.PassphraseBytes != nil { + t.Errorf("PassphraseBytes not nil") + } + + for i, v := range pw { + if v != 0 { + t.Errorf("pw byte %d not zeroed: %d", i, v) + } + } + for i, v := range passphrase { + if v != 0 { + t.Errorf("passphrase byte %d not zeroed: %d", i, v) + } + } + + var nilOpts *SCPOptions + nilOpts.Zero() // Must not panic +} + +func TestTransferOptionsZero(t *testing.T) { + pw := []byte("secret_pw_transfer") + passphrase := []byte("secret_pass_transfer") + + opts := &TransferOptions{ + TargetType: "bigip", + Method: MethodAuto, + Username: "admin", + Password: string(pw), + PasswordBytes: pw, + SSHPassphrase: string(passphrase), + SSHPassphraseBytes: passphrase, + } + + opts.Zero() + + if opts.Password != "" { + t.Errorf("Password not cleared: %q", opts.Password) + } + if opts.SSHPassphrase != "" { + t.Errorf("SSHPassphrase not cleared: %q", opts.SSHPassphrase) + } + if opts.PasswordBytes != nil { + t.Errorf("PasswordBytes not nil") + } + if opts.SSHPassphraseBytes != nil { + t.Errorf("SSHPassphraseBytes not nil") + } + + for i, v := range pw { + if v != 0 { + t.Errorf("pw byte %d not zeroed: %d", i, v) + } + } + for i, v := range passphrase { + if v != 0 { + t.Errorf("passphrase byte %d not zeroed: %d", i, v) + } + } + + var nilOpts *TransferOptions + nilOpts.Zero() // Must not panic +} diff --git a/pkg/bigip/failover.go b/pkg/bigip/failover.go index ad703c8..88bcd42 100644 --- a/pkg/bigip/failover.go +++ b/pkg/bigip/failover.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" "time" + + "myf5-cli/pkg/auth" ) // TransferMethod specifies the network protocol used for file transfers to/from appliances. @@ -27,17 +29,36 @@ const ( // TransferOptions configures connection, authentication, and destination parameters for appliance file transfers. type TransferOptions struct { - TargetType string // "bigip" or "bigiq" (default: "bigip") - Method TransferMethod // "auto", "icontrol", "scp" (default: "auto") - Username string // Username for appliance authentication - Password string // Password for appliance authentication - Port int // Port override (0 for default: 443 for REST, 22 for SCP) - IdentityFile string // SSH private key file path for public key authentication - SSHPassphrase string // Passphrase for encrypted private key - DestDir string // Remote destination directory for upload (default: "/shared/images") - ClientCertPath string // Path to TLS client certificate file (PEM) for mTLS - ClientKeyPath string // Path to TLS client certificate private key file (PEM) - Timeout time.Duration // Connection timeout + TargetType string // "bigip" or "bigiq" (default: "bigip") + Method TransferMethod // "auto", "icontrol", "scp" (default: "auto") + Username string // Username for appliance authentication + Password string // Password for appliance authentication + PasswordBytes []byte // Mutable byte buffer for password (cleared on Zero) + Port int // Port override (0 for default: 443 for REST, 22 for SCP) + IdentityFile string // SSH private key file path for public key authentication + SSHPassphrase string // Passphrase for encrypted private key + SSHPassphraseBytes []byte // Mutable byte buffer for SSH passphrase (cleared on Zero) + DestDir string // Remote destination directory for upload (default: "/shared/images") + ClientCertPath string // Path to TLS client certificate file (PEM) for mTLS + ClientKeyPath string // Path to TLS client certificate private key file (PEM) + Timeout time.Duration // Connection timeout +} + +// Zero scrubs sensitive password and passphrase bytes and clears string fields from memory. +func (opts *TransferOptions) Zero() { + if opts == nil { + return + } + if len(opts.PasswordBytes) > 0 { + auth.ZeroBytes(opts.PasswordBytes) + opts.PasswordBytes = nil + } + if len(opts.SSHPassphraseBytes) > 0 { + auth.ZeroBytes(opts.SSHPassphraseBytes) + opts.SSHPassphraseBytes = nil + } + opts.Password = "" + opts.SSHPassphrase = "" } // FailoverLogger is a callback invoked when a primary transfer method fails and failover occurs. @@ -232,6 +253,7 @@ func newClientFromOptions(host string, opts TransferOptions, defaultPort int) *C Port: port, Username: user, Password: opts.Password, + PasswordBytes: opts.PasswordBytes, SkipTLSVerify: true, ClientCertPath: opts.ClientCertPath, ClientKeyPath: opts.ClientKeyPath, @@ -254,6 +276,7 @@ func pushViaIControl( progressCb func(transferred, total int64), ) (string, error) { client := newClientFromOptions(host, opts, 443) + defer client.Zero() remoteUploaded, err := client.UploadFile(ctx, localFile, "", progressCb) if err != nil { @@ -279,14 +302,17 @@ func pushViaSCP( progressCb func(transferred, total int64), ) (string, error) { scpOpts := SCPOptions{ - Host: host, - Port: opts.Port, - Username: opts.Username, - Password: opts.Password, - KeyPath: opts.IdentityFile, - Passphrase: opts.SSHPassphrase, - Timeout: opts.Timeout, + Host: host, + Port: opts.Port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: opts.PasswordBytes, + KeyPath: opts.IdentityFile, + Passphrase: opts.SSHPassphrase, + PassphraseBytes: opts.SSHPassphraseBytes, + Timeout: opts.Timeout, } + defer scpOpts.Zero() return SCPUpload(ctx, localFile, destDir, scpOpts, progressCb) } @@ -297,6 +323,7 @@ func downloadViaIControl( progressCb func(transferred, total int64), ) (string, error) { client := newClientFromOptions(host, opts, 443) + defer client.Zero() cleanLocal := filepath.Clean(localDest) base := path.Base(path.Clean(remotePath)) @@ -321,14 +348,17 @@ func downloadViaSCP( progressCb func(transferred, total int64), ) (string, error) { scpOpts := SCPOptions{ - Host: host, - Port: opts.Port, - Username: opts.Username, - Password: opts.Password, - KeyPath: opts.IdentityFile, - Passphrase: opts.SSHPassphrase, - Timeout: opts.Timeout, + Host: host, + Port: opts.Port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: opts.PasswordBytes, + KeyPath: opts.IdentityFile, + Passphrase: opts.SSHPassphrase, + PassphraseBytes: opts.SSHPassphraseBytes, + Timeout: opts.Timeout, } + defer scpOpts.Zero() return SCPDownload(ctx, remotePath, localDest, scpOpts, progressCb) } @@ -351,6 +381,7 @@ func GenerateQKViewWithFailover( switch method { case MethodIControl: client := newClientFromOptions(host, opts, 443) + defer client.Zero() filename, err := client.GenerateQKViewWithStatus(ctx, requestedFilename, complete, statusCb) if err != nil { return "", MethodIControl, fmt.Errorf("iControl REST QKView generation failed: %w", err) @@ -359,14 +390,17 @@ func GenerateQKViewWithFailover( case MethodSCP: scpOpts := SCPOptions{ - Host: host, - Port: opts.Port, - Username: opts.Username, - Password: opts.Password, - KeyPath: opts.IdentityFile, - Passphrase: opts.SSHPassphrase, - Timeout: opts.Timeout, + Host: host, + Port: opts.Port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: opts.PasswordBytes, + KeyPath: opts.IdentityFile, + Passphrase: opts.SSHPassphrase, + PassphraseBytes: opts.SSHPassphraseBytes, + Timeout: opts.Timeout, } + defer scpOpts.Zero() filename, err := GenerateQKViewSSH(ctx, scpOpts, requestedFilename, complete, statusCb) if err != nil { return "", MethodSCP, fmt.Errorf("SSH QKView generation failed: %w", err) @@ -375,11 +409,12 @@ func GenerateQKViewWithFailover( case MethodAuto: // If password or client certificate is provided and no key, try iControl REST first - tryRestFirst := opts.Password != "" || opts.ClientCertPath != "" || (opts.IdentityFile == "" && os.Getenv("BIGIP_PASSWORD") != "") + tryRestFirst := opts.Password != "" || len(opts.PasswordBytes) > 0 || opts.ClientCertPath != "" || (opts.IdentityFile == "" && os.Getenv("BIGIP_PASSWORD") != "") if tryRestFirst { client := newClientFromOptions(host, opts, 443) filename, restErr := client.GenerateQKViewWithStatus(ctx, requestedFilename, complete, statusCb) + client.Zero() if restErr == nil { return filename, MethodIControl, nil } @@ -392,14 +427,17 @@ func GenerateQKViewWithFailover( } scpOpts := SCPOptions{ - Host: host, - Port: opts.Port, - Username: opts.Username, - Password: opts.Password, - KeyPath: opts.IdentityFile, - Passphrase: opts.SSHPassphrase, - Timeout: opts.Timeout, + Host: host, + Port: opts.Port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: opts.PasswordBytes, + KeyPath: opts.IdentityFile, + Passphrase: opts.SSHPassphrase, + PassphraseBytes: opts.SSHPassphraseBytes, + Timeout: opts.Timeout, } + defer scpOpts.Zero() filename, sshErr := GenerateQKViewSSH(ctx, scpOpts, requestedFilename, complete, statusCb) if sshErr == nil { return filename, MethodSCP, nil @@ -410,15 +448,18 @@ func GenerateQKViewWithFailover( // Try SSH first scpOpts := SCPOptions{ - Host: host, - Port: opts.Port, - Username: opts.Username, - Password: opts.Password, - KeyPath: opts.IdentityFile, - Passphrase: opts.SSHPassphrase, - Timeout: opts.Timeout, + Host: host, + Port: opts.Port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: opts.PasswordBytes, + KeyPath: opts.IdentityFile, + Passphrase: opts.SSHPassphrase, + PassphraseBytes: opts.SSHPassphraseBytes, + Timeout: opts.Timeout, } filename, sshErr := GenerateQKViewSSH(ctx, scpOpts, requestedFilename, complete, statusCb) + scpOpts.Zero() if sshErr == nil { return filename, MethodSCP, nil } @@ -431,6 +472,7 @@ func GenerateQKViewWithFailover( } client := newClientFromOptions(host, opts, 443) + defer client.Zero() filename, restErr := client.GenerateQKViewWithStatus(ctx, requestedFilename, complete, statusCb) if restErr == nil { return filename, MethodIControl, nil @@ -446,8 +488,9 @@ func GenerateQKViewWithFailover( // DeleteQKViewWithFailover deletes a generated QKView archive on the appliance via REST or SSH. func DeleteQKViewWithFailover(ctx context.Context, host, remoteFilename string, opts TransferOptions) error { method := opts.Method - if method == MethodIControl || (method == MethodAuto && (opts.Password != "" || opts.ClientCertPath != "")) { + if method == MethodIControl || (method == MethodAuto && (opts.Password != "" || len(opts.PasswordBytes) > 0 || opts.ClientCertPath != "")) { client := newClientFromOptions(host, opts, 443) + defer client.Zero() if err := client.DeleteQKView(ctx, remoteFilename); err == nil { return nil } else if errors.Is(err, context.Canceled) || ctx.Err() != nil { @@ -456,13 +499,16 @@ func DeleteQKViewWithFailover(ctx context.Context, host, remoteFilename string, } scpOpts := SCPOptions{ - Host: host, - Port: opts.Port, - Username: opts.Username, - Password: opts.Password, - KeyPath: opts.IdentityFile, - Passphrase: opts.SSHPassphrase, - Timeout: opts.Timeout, + Host: host, + Port: opts.Port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: opts.PasswordBytes, + KeyPath: opts.IdentityFile, + Passphrase: opts.SSHPassphrase, + PassphraseBytes: opts.SSHPassphraseBytes, + Timeout: opts.Timeout, } + defer scpOpts.Zero() return DeleteQKViewSSH(ctx, scpOpts, remoteFilename) } diff --git a/pkg/bigip/geoip.go b/pkg/bigip/geoip.go index 3290541..aa78925 100644 --- a/pkg/bigip/geoip.go +++ b/pkg/bigip/geoip.go @@ -18,23 +18,43 @@ import ( "golang.org/x/crypto/ssh" + "myf5-cli/pkg/auth" "myf5-cli/pkg/client" ) // GeoIPOptions configures connection, execution, and cleanup parameters for appliance GeoIP updates. type GeoIPOptions struct { - TargetType string // Appliance kind: must be "BIG-IP" (BIG-IQ is unsupported) - Method TransferMethod // "auto", "icontrol", "scp", or "ssh" - Username string // Appliance username (default: "admin") - Password string // Appliance password - Port int // Management port override (default: 443 for REST, 22 for SSH) - IdentityFile string // SSH private key path - SSHPassphrase string // SSH private key passphrase - ClientCertPath string // mTLS client certificate path - ClientKeyPath string // mTLS client private key path - Timeout time.Duration // Execution timeout (default: 3 minutes) - Force bool // Pass --force to rpm via geoip_update_data -f (default: true) - KeepExtracted bool // Retain extracted files on appliance (default: false, cleans up temp dir) + TargetType string // Appliance kind: must be "BIG-IP" (BIG-IQ is unsupported) + Method TransferMethod // "auto", "icontrol", "scp", or "ssh" + Username string // Appliance username (default: "admin") + Password string // Appliance password + PasswordBytes []byte // Mutable appliance password bytes for memory scrubbing + Port int // Management port override (default: 443 for REST, 22 for SSH) + IdentityFile string // SSH private key path + SSHPassphrase string // SSH private key passphrase + SSHPassphraseBytes []byte // Mutable SSH passphrase bytes for memory scrubbing + ClientCertPath string // mTLS client certificate path + ClientKeyPath string // mTLS client private key path + Timeout time.Duration // Execution timeout (default: 3 minutes) + Force bool // Pass --force to rpm via geoip_update_data -f (default: true) + KeepExtracted bool // Retain extracted files on appliance (default: false, cleans up temp dir) +} + +// Zero securely scrubs password and passphrase bytes from RAM and clears secret fields. +func (opts *GeoIPOptions) Zero() { + if opts == nil { + return + } + if len(opts.PasswordBytes) > 0 { + auth.ZeroBytes(opts.PasswordBytes) + opts.PasswordBytes = nil + } + if len(opts.SSHPassphraseBytes) > 0 { + auth.ZeroBytes(opts.SSHPassphraseBytes) + opts.SSHPassphraseBytes = nil + } + opts.Password = "" + opts.SSHPassphrase = "" } // GeoIPResult captures the output, packages, and verification status of a GeoIP database update. @@ -314,14 +334,17 @@ exit $SCRIPT_EXIT_CODE func applyGeoIPSSH(ctx context.Context, host, scriptB64 string, opts GeoIPOptions) (string, error) { scpOpts := SCPOptions{ - Host: host, - Port: opts.Port, - Username: opts.Username, - Password: opts.Password, - KeyPath: opts.IdentityFile, - Passphrase: opts.SSHPassphrase, - Timeout: opts.Timeout, - } + Host: host, + Port: opts.Port, + Username: opts.Username, + Password: opts.Password, + PasswordBytes: opts.PasswordBytes, + KeyPath: opts.IdentityFile, + Passphrase: opts.SSHPassphrase, + PassphraseBytes: opts.SSHPassphraseBytes, + Timeout: opts.Timeout, + } + defer scpOpts.Zero() clientConfig, err := BuildSSHClientConfig(scpOpts) if err != nil { @@ -404,6 +427,7 @@ func applyGeoIPREST(ctx context.Context, host, scriptB64 string, opts GeoIPOptio Port: port, Username: user, Password: opts.Password, + PasswordBytes: opts.PasswordBytes, SkipTLSVerify: true, ClientCertPath: opts.ClientCertPath, ClientKeyPath: opts.ClientKeyPath, @@ -412,6 +436,7 @@ func applyGeoIPREST(ctx context.Context, host, scriptB64 string, opts GeoIPOptio if err != nil { return "", fmt.Errorf("failed to initialize appliance REST client: %w", err) } + defer client.Zero() payload := map[string]string{ "command": "run", diff --git a/pkg/bigip/scp.go b/pkg/bigip/scp.go index d4ebc3a..58ee692 100644 --- a/pkg/bigip/scp.go +++ b/pkg/bigip/scp.go @@ -25,13 +25,32 @@ import ( // SCPOptions specifies the SSH connection and authentication configuration for SCP file transfers. type SCPOptions struct { - Host string - Port int - Username string - Password string - KeyPath string - Passphrase string - Timeout time.Duration + Host string + Port int + Username string + Password string + PasswordBytes []byte + KeyPath string + Passphrase string + PassphraseBytes []byte + Timeout time.Duration +} + +// Zero securely scrubs password and passphrase bytes from RAM and clears secret string fields. +func (opts *SCPOptions) Zero() { + if opts == nil { + return + } + if len(opts.PasswordBytes) > 0 { + auth.ZeroBytes(opts.PasswordBytes) + opts.PasswordBytes = nil + } + if len(opts.PassphraseBytes) > 0 { + auth.ZeroBytes(opts.PassphraseBytes) + opts.PassphraseBytes = nil + } + opts.Password = "" + opts.Passphrase = "" } // DefaultSCPTimeout is the default connection timeout for SSH dial operations. @@ -76,14 +95,18 @@ func BuildSSHClientConfig(opts SCPOptions) (*ssh.ClientConfig, error) { var authMethods []ssh.AuthMethod // 1. Password authentication if provided - if opts.Password != "" { - authMethods = append(authMethods, ssh.Password(opts.Password)) + pw := opts.Password + if pw == "" && len(opts.PasswordBytes) > 0 { + pw = string(opts.PasswordBytes) + } + if pw != "" { + authMethods = append(authMethods, ssh.Password(pw)) // Also support keyboard-interactive (PAM / TACACS+ / RADIUS) authMethods = append(authMethods, ssh.KeyboardInteractive( func(user, instruction string, questions []string, echos []bool) ([]string, error) { answers := make([]string, len(questions)) for i := range answers { - answers[i] = opts.Password + answers[i] = pw } return answers, nil }, @@ -97,8 +120,17 @@ func BuildSSHClientConfig(opts SCPOptions) (*ssh.ClientConfig, error) { return nil, fmt.Errorf("failed to read private key at %s: %w", opts.KeyPath, err) } var signer ssh.Signer - if opts.Passphrase != "" { - signer, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(opts.Passphrase)) + var passBytes []byte + if len(opts.PassphraseBytes) > 0 { + passBytes = make([]byte, len(opts.PassphraseBytes)) + copy(passBytes, opts.PassphraseBytes) + } else if opts.Passphrase != "" { + passBytes = []byte(opts.Passphrase) + } + + if len(passBytes) > 0 { + signer, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, passBytes) + auth.ZeroBytes(passBytes) } else { signer, err = ssh.ParsePrivateKey(keyBytes) } @@ -111,7 +143,7 @@ func BuildSSHClientConfig(opts SCPOptions) (*ssh.ClientConfig, error) { } // 3. Fallback to default SSH keys / agent only if no explicit password or key was given - if opts.Password == "" && opts.KeyPath == "" { + if pw == "" && opts.KeyPath == "" { if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { if agentConn, err := net.Dial("unix", sock); err == nil { ag := agent.NewClient(agentConn) diff --git a/pkg/cases/client.go b/pkg/cases/client.go index b956739..fcdda89 100644 --- a/pkg/cases/client.go +++ b/pkg/cases/client.go @@ -66,6 +66,15 @@ func (c *Client) GoString() string { return c.String() } +// Zero clears bearer tokens and API keys from memory. +func (c *Client) Zero() { + if c == nil { + return + } + c.Token = "" + c.KParam = "" +} + // NewClient initializes a new MyF5 Support Case client using the built-in // API usage key, which was accepted by the Case endpoints when last verified // (2026-10-02 — see DefaultKParam). Prefer NewClientWithKey where the caller diff --git a/pkg/cases/models.go b/pkg/cases/models.go index 606610e..63bf551 100644 --- a/pkg/cases/models.go +++ b/pkg/cases/models.go @@ -102,11 +102,10 @@ type CaseEmailActivity struct { // CaseDetail is a single case from GET /case-management/v1/cases/{caseNumber}. // -// WARNING: `dateOpened` is unreliable on this endpoint. The live API returns -// the PRIORITY string in that field on case detail (observed 2026-10-05: -// "4 - General Assistance" where the list endpoint correctly returns -// "09/29/2026 17:59:42" for the same case). The fault is server-side. Prefer -// the list endpoint's value when a real timestamp is needed. +// NOTE: `dateOpened` previously returned the priority string due to an +// Apigee transform defect (observed 2026-10-05). F5 resolved this on +// 2026-10-08; it now reliably returns the case creation timestamp (e.g., +// "10/05/2026 22:03:28") matching the list endpoint. type CaseDetail struct { CaseNumber string `json:"caseNumber"` Subject string `json:"subject"` diff --git a/pkg/client/proxy.go b/pkg/client/proxy.go index ba6185c..970073a 100644 --- a/pkg/client/proxy.go +++ b/pkg/client/proxy.go @@ -320,7 +320,12 @@ func dialHTTPConnect(ctx context.Context, proxyURL *url.URL, targetAddr string) if proxyURL.User != nil { user := proxyURL.User.Username() pass, _ := proxyURL.User.Password() - auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) + rawAuth := make([]byte, len(user)+1+len(pass)) + copy(rawAuth, user) + rawAuth[len(user)] = ':' + copy(rawAuth[len(user)+1:], pass) + auth := base64.StdEncoding.EncodeToString(rawAuth) + zeroBytes(rawAuth) connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", auth) } connectReq += "\r\n" diff --git a/pkg/client/tls.go b/pkg/client/tls.go index ee98f7a..b4fc3ef 100644 --- a/pkg/client/tls.go +++ b/pkg/client/tls.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "runtime" "time" ) @@ -80,6 +81,7 @@ func zeroBytes(b []byte) { for i := range b { b[i] = 0 } + runtime.KeepAlive(b) } // LoadClientCertificate loads a TLS client certificate and private key from file paths. diff --git a/pkg/downloads/client.go b/pkg/downloads/client.go index 00082b4..122a900 100644 --- a/pkg/downloads/client.go +++ b/pkg/downloads/client.go @@ -113,6 +113,15 @@ func (c *Client) GoString() string { return c.String() } +// Zero clears bearer tokens and API keys from memory. +func (c *Client) Zero() { + if c == nil { + return + } + c.Token = "" + c.APIKey = "" +} + // NewClient initializes a MyF5 Downloads API client without an explicit API // usage key. The key matters only on the legacy fallback path; prefer // NewClientWithKey. diff --git a/pkg/ihealth/client.go b/pkg/ihealth/client.go index 658c252..56fa8bc 100644 --- a/pkg/ihealth/client.go +++ b/pkg/ihealth/client.go @@ -77,6 +77,14 @@ func (c *Client) GoString() string { return c.String() } +// Zero clears bearer token from memory. +func (c *Client) Zero() { + if c == nil { + return + } + c.Token = "" +} + // NewClient initializes a new iHealth API client. func NewClient(token string, baseFQDN string) *Client { if baseFQDN == "" {