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
This commit is contained in:
Jeremie Fraeys 2026-02-18 16:35:58 -05:00
parent c9b6532dfb
commit 5644338ebd
No known key found for this signature in database
2 changed files with 139 additions and 11 deletions

View file

@ -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")

View file

@ -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,