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:
parent
c9b6532dfb
commit
5644338ebd
2 changed files with 139 additions and 11 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue