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 + } +}