From ab1ae3b0336a11552048bdac8b852db4925bcf8a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Dec 2025 09:34:57 +0000 Subject: [PATCH] feat(security): filter sensitive environment variables from workflow execution Add protection against credential leakage by filtering sensitive environment variables (AWS keys, GitHub tokens, SSH agent sockets, database credentials, etc.) from being inherited by workflow commands. This mitigates the risk described in CVE-like scenario where malicious workflows could exfiltrate credentials via environment variable access. --- internal/executor/host.go | 6 +- internal/security/envvar.go | 123 ++++++++++++++++++ internal/security/envvar_test.go | 211 +++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+), 1 deletion(-) diff --git a/internal/executor/host.go b/internal/executor/host.go index 55a9e26..edf3365 100644 --- a/internal/executor/host.go +++ b/internal/executor/host.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "sync" + + "github.com/watany-dev/raptor/internal/security" ) // HostExecutor executes commands on the host system using a shell. @@ -21,9 +23,11 @@ func NewHostExecutor() *HostExecutor { // getCachedSysEnv returns the cached system environment variables. // The cache is populated on first call using sync.Once for thread safety. +// Sensitive environment variables (credentials, tokens, etc.) are filtered out +// to prevent credential leakage to workflow commands. func (h *HostExecutor) getCachedSysEnv() []string { h.once.Do(func() { - h.cachedSysEnv = os.Environ() + h.cachedSysEnv = security.FilterSensitiveEnvVars(os.Environ()) }) return h.cachedSysEnv } diff --git a/internal/security/envvar.go b/internal/security/envvar.go index eb190e0..d7291aa 100644 --- a/internal/security/envvar.go +++ b/internal/security/envvar.go @@ -60,6 +60,129 @@ func ValidateEnvVarName(name string) error { return nil } +// SensitiveEnvVarPatterns contains patterns for environment variables that may contain secrets. +// These are filtered from inherited environment to prevent credential leakage. +var SensitiveEnvVarPatterns = []string{ + // AWS credentials + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + + // Google Cloud + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "CLOUDSDK_AUTH_ACCESS_TOKEN", + + // Azure + "AZURE_CLIENT_ID", + "AZURE_CLIENT_SECRET", + "AZURE_TENANT_ID", + "AZURE_SUBSCRIPTION_ID", + + // Git/GitHub/GitLab + "GITHUB_TOKEN", + "GH_TOKEN", + "GITLAB_TOKEN", + "BITBUCKET_TOKEN", + "GIT_ASKPASS", + "GIT_CREDENTIAL_", + + // SSH/GPG agents + "SSH_AUTH_SOCK", + "SSH_AGENT_PID", + "GPG_AGENT_INFO", + "GPG_TTY", + + // Database credentials + "DATABASE_URL", + "DB_PASSWORD", + "POSTGRES_PASSWORD", + "MYSQL_PASSWORD", + "REDIS_PASSWORD", + "MONGODB_URI", + + // API keys and tokens (common patterns) + "API_KEY", + "API_SECRET", + "AUTH_TOKEN", + "ACCESS_TOKEN", + "REFRESH_TOKEN", + "BEARER_TOKEN", + + // NPM/Package managers + "NPM_TOKEN", + "NPM_AUTH_TOKEN", + "YARN_AUTH_TOKEN", + "NUGET_API_KEY", + "PYPI_TOKEN", + "RUBYGEMS_API_KEY", + + // Docker/Container registries + "DOCKER_PASSWORD", + "DOCKER_AUTH_CONFIG", + "REGISTRY_PASSWORD", + + // CI/CD + "CI_JOB_TOKEN", + "CIRCLE_TOKEN", + "TRAVIS_TOKEN", + + // Encryption keys + "ENCRYPTION_KEY", + "SIGNING_KEY", + "PRIVATE_KEY", + "SECRET_KEY", + + // General patterns (checked as suffix/contains) + "_SECRET", + "_PASSWORD", + "_TOKEN", + "_CREDENTIALS", + "_API_KEY", + "_PRIVATE_KEY", +} + +// FilterSensitiveEnvVars filters out sensitive environment variables from the given list. +// It returns a new slice with only non-sensitive variables. +func FilterSensitiveEnvVars(envVars []string) []string { + filtered := make([]string, 0, len(envVars)) + for _, env := range envVars { + // Split into key=value + idx := strings.Index(env, "=") + if idx == -1 { + continue // Invalid format, skip + } + key := strings.ToUpper(env[:idx]) + + if !isSensitiveEnvVar(key) { + filtered = append(filtered, env) + } + } + return filtered +} + +// isSensitiveEnvVar checks if an environment variable name matches sensitive patterns. +func isSensitiveEnvVar(name string) bool { + upperName := strings.ToUpper(name) + + for _, pattern := range SensitiveEnvVarPatterns { + // Exact match + if upperName == pattern { + return true + } + // Suffix match (for patterns like _SECRET, _PASSWORD) + if strings.HasPrefix(pattern, "_") && strings.HasSuffix(upperName, pattern) { + return true + } + // Prefix match (for patterns like GIT_CREDENTIAL_) + if strings.HasSuffix(pattern, "_") && strings.HasPrefix(upperName, pattern) { + return true + } + } + return false +} + // ValidateEnvVarValue validates that an environment variable value is safe. func ValidateEnvVarValue(name, value string) error { // Value length limit (DoS prevention) diff --git a/internal/security/envvar_test.go b/internal/security/envvar_test.go index a193fa6..2ee6417 100644 --- a/internal/security/envvar_test.go +++ b/internal/security/envvar_test.go @@ -161,3 +161,214 @@ func TestBlockedEnvVars_AllHaveReasons(t *testing.T) { } } } + +func TestFilterSensitiveEnvVars(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "empty input", + input: []string{}, + expected: []string{}, + }, + { + name: "no sensitive vars", + input: []string{ + "PATH=/usr/bin", + "HOME=/home/user", + "SHELL=/bin/bash", + }, + expected: []string{ + "PATH=/usr/bin", + "HOME=/home/user", + "SHELL=/bin/bash", + }, + }, + { + name: "filter AWS credentials", + input: []string{ + "PATH=/usr/bin", + "AWS_ACCESS_KEY_ID=AKIA...", + "AWS_SECRET_ACCESS_KEY=secret", + "HOME=/home/user", + }, + expected: []string{ + "PATH=/usr/bin", + "HOME=/home/user", + }, + }, + { + name: "filter GitHub tokens", + input: []string{ + "PATH=/usr/bin", + "GITHUB_TOKEN=ghp_xxxx", + "GH_TOKEN=ghp_yyyy", + }, + expected: []string{ + "PATH=/usr/bin", + }, + }, + { + name: "filter SSH agent", + input: []string{ + "SSH_AUTH_SOCK=/tmp/ssh-xxx/agent.123", + "SSH_AGENT_PID=12345", + "TERM=xterm", + }, + expected: []string{ + "TERM=xterm", + }, + }, + { + name: "filter suffix patterns", + input: []string{ + "MY_SECRET=hidden", + "DB_PASSWORD=pass123", + "AUTH_TOKEN=abc", + "NORMAL_VAR=value", + }, + expected: []string{ + "NORMAL_VAR=value", + }, + }, + { + name: "filter prefix patterns", + input: []string{ + "GIT_CREDENTIAL_HELPER=store", + "GIT_CREDENTIAL_CACHE=cache", + "GIT_AUTHOR_NAME=user", + }, + expected: []string{ + "GIT_AUTHOR_NAME=user", + }, + }, + { + name: "case insensitive filtering", + input: []string{ + "aws_access_key_id=key", + "Aws_Secret_Access_Key=secret", + "github_token=token", + }, + expected: []string{}, + }, + { + name: "skip invalid format", + input: []string{ + "VALID=value", + "INVALID_NO_EQUALS", + "ANOTHER=ok", + }, + expected: []string{ + "VALID=value", + "ANOTHER=ok", + }, + }, + { + name: "filter database URLs", + input: []string{ + "DATABASE_URL=postgres://user:pass@host/db", + "MONGODB_URI=mongodb://localhost", + "REDIS_URL=redis://localhost", + }, + expected: []string{ + "REDIS_URL=redis://localhost", + }, + }, + { + name: "filter API keys", + input: []string{ + "API_KEY=xxx", + "API_SECRET=yyy", + "STRIPE_API_KEY=sk_test", + "SERVICE_NAME=myapp", + }, + expected: []string{ + "SERVICE_NAME=myapp", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FilterSensitiveEnvVars(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("FilterSensitiveEnvVars() got %d items, want %d", len(result), len(tt.expected)) + t.Errorf("got: %v", result) + t.Errorf("want: %v", tt.expected) + return + } + for i, v := range result { + if v != tt.expected[i] { + t.Errorf("FilterSensitiveEnvVars()[%d] = %q, want %q", i, v, tt.expected[i]) + } + } + }) + } +} + +func TestIsSensitiveEnvVar(t *testing.T) { + tests := []struct { + name string + varName string + expected bool + }{ + // Exact matches + {"AWS_ACCESS_KEY_ID", "AWS_ACCESS_KEY_ID", true}, + {"AWS_SECRET_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY", true}, + {"GITHUB_TOKEN", "GITHUB_TOKEN", true}, + {"SSH_AUTH_SOCK", "SSH_AUTH_SOCK", true}, + {"DATABASE_URL", "DATABASE_URL", true}, + + // Case insensitive + {"lowercase aws key", "aws_access_key_id", true}, + {"mixed case github token", "GitHub_Token", true}, + + // Suffix patterns + {"custom secret", "MY_CUSTOM_SECRET", true}, + {"custom password", "DB_PASSWORD", true}, + {"custom token", "OAUTH_TOKEN", true}, + {"custom credentials", "APP_CREDENTIALS", true}, + {"custom api key", "STRIPE_API_KEY", true}, + + // Prefix patterns + {"git credential helper", "GIT_CREDENTIAL_HELPER", true}, + {"git credential cache", "GIT_CREDENTIAL_STORE", true}, + + // Non-sensitive + {"PATH", "PATH", false}, + {"HOME", "HOME", false}, + {"USER", "USER", false}, + {"SHELL", "SHELL", false}, + {"TERM", "TERM", false}, + {"LANG", "LANG", false}, + {"EDITOR", "EDITOR", false}, + {"PWD", "PWD", false}, + {"GOPATH", "GOPATH", false}, + + // Edge cases - not sensitive despite similar names + {"TOKENIZER", "TOKENIZER", false}, + {"PASSWORD_MIN_LENGTH", "PASSWORD_MIN_LENGTH", false}, + {"SECRET_MODE", "SECRET_MODE", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isSensitiveEnvVar(tt.varName) + if result != tt.expected { + t.Errorf("isSensitiveEnvVar(%q) = %v, want %v", tt.varName, result, tt.expected) + } + }) + } +} + +func TestSensitiveEnvVarPatterns_NoDuplicates(t *testing.T) { + seen := make(map[string]bool) + for _, pattern := range SensitiveEnvVarPatterns { + if seen[pattern] { + t.Errorf("Duplicate pattern found: %q", pattern) + } + seen[pattern] = true + } +}