From 18eb06c876cefa0a99af41d049960558a152c5e2 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Sat, 31 Oct 2026 12:01:33 +0100 Subject: [PATCH] extractor-plugin: strip secret env vars from plugin subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/parser/languages/extractor_plugin.go launches the configured plugin with cmd.Env = append(os.Environ(), "GORTEX_FILE_PATH=…"), inheriting the daemon's whole environment — OPENAI_API_KEY, GOOGLE_API_KEY, GORTEX_LLM_API_KEY, GITHUB_TOKEN, AWS_SECRET_ACCESS_KEY, and the like — into the plugin process. A plugin command is config a repo-local .gortex.yaml can declare, so it could read os.Environ() and exfiltrate those secrets. Strip secret-shaped entries before launching: stripSecretEnv keeps only vars whose name is not secret-shaped (matching the shapes the LLM/embedding/network backends use: *_API_KEY, APIKEY, _TOKEN, _SECRET, _BEARER, Authorization), so no future key escapes by an unknown name. PATH and other non-secret vars are inherited so the command still resolves; GORTEX_FILE_PATH is added after filtering. --- internal/parser/languages/extractor_plugin.go | 42 ++++++++++++++++- .../parser/languages/extractor_plugin_test.go | 46 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/internal/parser/languages/extractor_plugin.go b/internal/parser/languages/extractor_plugin.go index 755e8fd41..788507c33 100644 --- a/internal/parser/languages/extractor_plugin.go +++ b/internal/parser/languages/extractor_plugin.go @@ -440,6 +440,41 @@ func (e *SubprocessExtractor) Extract(filePath string, src []byte) (*parser.Extr // raw-string literal; the `"` and `\` are escaped. const shellUnsafePathChars = " ;|&<>()\"'\\$" + "`=" +// secretEnvName reports whether an env var name is secret-shaped and must be +// stripped from the plugin subprocess environment, so an extractor command — +// potentially declared in a repo-local .gortex.yaml — can't read it and +// exfiltrate it. It matches the shapes the LLM/embedding/network backends +// actually use (e.g. OPENAI_API_KEY, GORTEX_LLM_API_KEY, GITHUB_TOKEN, +// AWS_SECRET_ACCESS_KEY) rather than an incomplete hardcoded list, so no +// future key escapes by an unknown name. PATH and other non-secret vars are +// inherited normally. +func secretEnvName(name string) bool { + u := strings.ToUpper(name) + if u == "AUTHORIZATION" { + return true + } + return strings.Contains(u, "AUTHORIZATION") || + strings.Contains(u, "API_KEY") || + strings.Contains(u, "APIKEY") || + strings.Contains(u, "_TOKEN") || + strings.Contains(u, "_SECRET") || + strings.Contains(u, "_BEARER") +} + +// stripSecretEnv returns env without secret-shaped entries. The result is a +// fresh slice; callers append non-secret extras (e.g. GORTEX_FILE_PATH) +// afterwards. +func stripSecretEnv(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + key, _, ok := strings.Cut(kv, "=") + if !ok || !secretEnvName(key) { + out = append(out, kv) + } + } + return out +} + // ExtractBounded runs the configured plugin with hard wire and result limits. // It is an all-or-nothing capability: caller cancellation, limit violations, // command failures, timeouts and malformed output return no partial result. @@ -486,7 +521,12 @@ func (e *SubprocessExtractor) ExtractBounded(ctx context.Context, filePath strin cmd.Stdin = bytes.NewReader(src) cmd.Stdout = stdout cmd.Stderr = stderr - cmd.Env = append(os.Environ(), "GORTEX_FILE_PATH="+filePath) + // The plugin runs a command declared in the operator's / repo's + // .gortex.yaml and inherits this environment. Start from os.Environ() + // with the secret-shaped entries removed, then add GORTEX_FILE_PATH: + // otherwise an extractor command could read them (e.g. os.Environ()) + // and exfiltrate them. PATH is inherited so the command resolves. + cmd.Env = append(stripSecretEnv(os.Environ()), "GORTEX_FILE_PATH="+filePath) runErr := cmd.Run() if err := ctx.Err(); err != nil { diff --git a/internal/parser/languages/extractor_plugin_test.go b/internal/parser/languages/extractor_plugin_test.go index 334cc219f..e57aa8ffe 100644 --- a/internal/parser/languages/extractor_plugin_test.go +++ b/internal/parser/languages/extractor_plugin_test.go @@ -483,3 +483,49 @@ func TestRegisterExtractorPlugins_SkipsInvalidAndCollisions(t *testing.T) { require.True(t, ok) assert.Equal(t, "go", e.Language()) } + +// TestStripSecretEnv verifies the plugin subprocess environment drops +// secret-shaped entries (provider API keys, tokens, AWS secrets, bearer +// vars) while keeping PATH and other non-secret vars — so an extractor +// command can't read and exfiltrate them via os.Environ(). +func TestStripSecretEnv(t *testing.T) { + in := []string{ + "PATH=/usr/local/bin:/usr/bin", + "OPENAI_API_KEY=sk-abc", + "GOOGLE_API_KEY=AIza", + "API_KEY=barekey", + "GOOGLEAPIKEY=concatkey", + "GORTEX_LLM_API_KEY=glp-xyz", + "GORTEX_EMBEDDINGS_API_KEY=e-123", + "GITHUB_TOKEN=ghp_123", + "AUTH_TOKEN=tok", + "AWS_SECRET_ACCESS_KEY=wJalr", + "MY_SECRET=xyz", + "GITHUB_BEARER=b", + "HDR_AUTHORIZATION=Bearer abc", + "APIKEY=plainkey", + "MY_SEGMENTER=split", // not secret-shaped + "GORTEX_FILE_PATH=repo/a.go", + "GITHUB_ACTIONS", // no '=', kept + } + got := map[string]bool{} + for _, kv := range stripSecretEnv(in) { + key := kv + if i := strings.IndexByte(kv, '='); i >= 0 { + key = kv[:i] + } + got[key] = true + } + for _, keep := range []string{"PATH", "MY_SEGMENTER", "GORTEX_FILE_PATH", "GITHUB_ACTIONS"} { + assert.True(t, got[keep], "expected %q to be kept", keep) + } + dropped := []string{ + "OPENAI_API_KEY", "GOOGLE_API_KEY", "API_KEY", "GOOGLEAPIKEY", + "GORTEX_LLM_API_KEY", "GORTEX_EMBEDDINGS_API_KEY", + "GITHUB_TOKEN", "AUTH_TOKEN", "AWS_SECRET_ACCESS_KEY", "MY_SECRET", + "GITHUB_BEARER", "HDR_AUTHORIZATION", "APIKEY", + } + for _, name := range dropped { + assert.False(t, got[name], "expected %q to be dropped", name) + } +}