From 5644338ebd97014132d30ddab855d3f1264ac3da Mon Sep 17 00:00:00 2001 From: Jeremie Fraeys Date: Wed, 18 Feb 2026 16:35:58 -0500 Subject: [PATCH] security: implement Podman secrets for container credential management Add comprehensive Podman secrets support to prevent credential exposure: New types and methods (internal/container/podman.go): - PodmanSecret struct for secret definitions - CreateSecret() - Create Podman secrets from sensitive data - DeleteSecret() - Clean up secrets after use - BuildSecretArgs() - Generate podman run arguments for secrets - SanitizeContainerEnv() - Extract sensitive env vars as secrets - ContainerConfig.Secrets field for secret list Enhanced container lifecycle: - StartContainer() now creates secrets before starting container - Secrets automatically mounted via --secret flag - Cleanup on failure to prevent secret leakage - Secrets logged as count only (not content) Jupyter service integration (internal/jupyter/service_manager.go): - prepareContainerConfig() uses SanitizeContainerEnv() - JUPYTER_TOKEN and JUPYTER_PASSWORD now use secrets - Maintains backward compatibility with env var mounting Security benefits: - Credentials no longer visible in 'podman inspect' output - Secrets not exposed via /proc/*/environ inside container - Automatic cleanup prevents secret accumulation - Compatible with existing Jupyter authentication --- internal/container/podman.go | 127 +++++++++++++++++++++++++++- internal/jupyter/service_manager.go | 23 +++-- 2 files changed, 139 insertions(+), 11 deletions(-) diff --git a/internal/container/podman.go b/internal/container/podman.go index e7616a3..8715c52 100644 --- a/internal/container/podman.go +++ b/internal/container/podman.go @@ -32,6 +32,7 @@ type ContainerConfig struct { Image string `json:"image"` Command []string `json:"command"` Env map[string]string `json:"env"` + Secrets []PodmanSecret `json:"secrets"` // Sensitive data as Podman secrets Volumes map[string]string `json:"volumes"` Ports map[int]int `json:"ports"` SecurityOpts []string `json:"security_opts"` @@ -127,26 +128,62 @@ func ParseContainerID(output string) (string, error) { return "", fmt.Errorf("no container ID returned") } -// StartContainer starts a new container +// StartContainer starts a new container with secret support func (pm *PodmanManager) StartContainer( ctx context.Context, config *ContainerConfig, ) (string, error) { + // Create Podman secrets for sensitive data + for _, secret := range config.Secrets { + if err := pm.CreateSecret(ctx, secret.Name, secret.Data); err != nil { + // Clean up any secrets we already created + for _, s := range config.Secrets { + if s.Name == secret.Name { + break + } + _ = pm.DeleteSecret(ctx, s.Name) + } + return "", fmt.Errorf("failed to create secret %s: %w", secret.Name, err) + } + } + + // Build run args including secrets args := BuildRunArgs(config) + // Add secret mount arguments + secretArgs := BuildSecretArgs(config.Secrets) + // Insert secrets after "run -d" and before other args + if len(secretArgs) > 0 { + // args[0] = "run", args[1] = "-d" + newArgs := append([]string{args[0], args[1]}, secretArgs...) + newArgs = append(newArgs, args[2:]...) + args = newArgs + } + // Execute command cmd := exec.CommandContext(ctx, "podman", args...) output, err := cmd.CombinedOutput() if err != nil { + // Clean up secrets on failure + for _, secret := range config.Secrets { + _ = pm.DeleteSecret(ctx, secret.Name) + } return "", fmt.Errorf("failed to start container: %w, output: %s", err, string(output)) } containerID, err := ParseContainerID(string(output)) if err != nil { + // Clean up secrets on failure + for _, secret := range config.Secrets { + _ = pm.DeleteSecret(ctx, secret.Name) + } return "", err } - pm.logger.Info("container started", "container_id", containerID, "name", config.Name) + pm.logger.Info("container started", + "container_id", containerID, + "name", config.Name, + "secrets", len(config.Secrets)) return containerID, nil } @@ -386,6 +423,92 @@ func ValidateSecurityPolicy(cfg PodmanConfig) error { return nil } +// PodmanSecret represents a secret to be mounted in a container +type PodmanSecret struct { + Name string // Secret name in Podman + Data []byte // Secret data (will be base64 encoded) + Target string // Mount path inside container + EnvVar string // Environment variable name (optional, if set mounts as env var instead of file) +} + +// CreateSecret creates a Podman secret from the given data +func (pm *PodmanManager) CreateSecret(ctx context.Context, name string, data []byte) error { + // Create secret via podman command + // podman secret create name - << data + cmd := exec.CommandContext(ctx, "podman", "secret", "create", name, "-") + cmd.Stdin = strings.NewReader(string(data)) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create secret %s: %w, output: %s", name, err, string(output)) + } + + pm.logger.Info("secret created", "name", name) + return nil +} + +// DeleteSecret removes a Podman secret +func (pm *PodmanManager) DeleteSecret(ctx context.Context, name string) error { + cmd := exec.CommandContext(ctx, "podman", "secret", "rm", name) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to delete secret %s: %w, output: %s", name, err, string(output)) + } + + pm.logger.Info("secret deleted", "name", name) + return nil +} + +// BuildSecretArgs builds podman run arguments for mounting secrets +func BuildSecretArgs(secrets []PodmanSecret) []string { + args := []string{} + for _, secret := range secrets { + if secret.EnvVar != "" { + // Mount as environment variable + args = append(args, "--secret", fmt.Sprintf("%s,type=env,target=%s", secret.Name, secret.EnvVar)) + } else { + // Mount as file + target := secret.Target + if target == "" { + target = fmt.Sprintf("/run/secrets/%s", secret.Name) + } + args = append(args, "--secret", fmt.Sprintf("%s,type=mount,target=%s", secret.Name, target)) + } + } + return args +} + +// SanitizeContainerEnv removes sensitive values from env map and returns secrets to create +func SanitizeContainerEnv(env map[string]string, sensitiveKeys []string) ([]PodmanSecret, map[string]string) { + secrets := []PodmanSecret{} + cleanEnv := make(map[string]string) + + for key, value := range env { + isSensitive := false + lowerKey := strings.ToLower(key) + for _, sensitive := range sensitiveKeys { + if strings.Contains(lowerKey, strings.ToLower(sensitive)) { + isSensitive = true + break + } + } + + if isSensitive && value != "" { + // Create secret for this value + secretName := fmt.Sprintf("fetchml_%s_%d", strings.ToLower(key), os.Getpid()) + secrets = append(secrets, PodmanSecret{ + Name: secretName, + Data: []byte(value), + EnvVar: key, // Mount as env var to maintain compatibility + }) + // Don't include in env - it will be mounted as secret + } else { + cleanEnv[key] = value + } + } + + return secrets, cleanEnv +} + // ErrSecurityViolation is returned when a security policy is violated. var ErrSecurityViolation = fmt.Errorf("security policy violation") diff --git a/internal/jupyter/service_manager.go b/internal/jupyter/service_manager.go index 40cb393..f7c22e8 100644 --- a/internal/jupyter/service_manager.go +++ b/internal/jupyter/service_manager.go @@ -896,7 +896,7 @@ func (sm *ServiceManager) generateServiceID(name string) string { return fmt.Sprintf("jupyter-%s-%d", sanitizedName, timestamp) } -// prepareContainerConfig prepares container configuration +// prepareContainerConfig prepares container configuration with secret support func (sm *ServiceManager) prepareContainerConfig( serviceID string, req *StartRequest, @@ -912,32 +912,36 @@ func (sm *ServiceManager) prepareContainerConfig( } volumes[req.Workspace] = workspaceMount - // Prepare environment variables - env := map[string]string{ + // Prepare environment variables (including sensitive ones) + rawEnv := map[string]string{ "JUPYTER_ENABLE_LAB": "yes", } if req.Network.EnableToken && req.Network.Token != "" { - env["JUPYTER_TOKEN"] = req.Network.Token + rawEnv["JUPYTER_TOKEN"] = req.Network.Token } else { - env["JUPYTER_TOKEN"] = "" // No token for development + rawEnv["JUPYTER_TOKEN"] = "" // No token for development } if req.Network.EnablePassword && req.Network.Password != "" { - env["JUPYTER_PASSWORD"] = req.Network.Password + rawEnv["JUPYTER_PASSWORD"] = req.Network.Password } // Add custom environment variables for k, v := range req.Environment { - env[k] = v + rawEnv[k] = v } + // Sanitize environment - extract sensitive values as secrets + sensitiveKeys := []string{"JUPYTER_TOKEN", "JUPYTER_PASSWORD", "SECRET", "PASSWORD", "API_KEY", "TOKEN"} + secrets, cleanEnv := container.SanitizeContainerEnv(rawEnv, sensitiveKeys) + // Prepare port mappings ports := map[int]int{ req.Network.HostPort: req.Network.ContainerPort, } - // Prepare container command + // Prepare container command (uses cleanEnv variables) var cmd []string if isPublicJupyter { condaEnv := strings.TrimSpace(os.Getenv("FETCHML_JUPYTER_CONDA_ENV")) @@ -1019,7 +1023,8 @@ func (sm *ServiceManager) prepareContainerConfig( Name: serviceID, Image: req.Image, Command: cmd, - Env: env, + Env: cleanEnv, + Secrets: secrets, Volumes: volumes, Ports: ports, SecurityOpts: securityOpts,