-
Notifications
You must be signed in to change notification settings - Fork 39
fix: escape shell metacharacters in expanded env files #1311
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import ( | |
| "net/http" | ||
| "net/http/httptest" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "runtime" | ||
| "strings" | ||
|
|
@@ -430,6 +431,209 @@ func TestEnvToList_Sorted(t *testing.T) { | |
| assert.Equal(t, "Z_VAR=z", list[2]) | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM — Missing test for All test templates use 3/10 review agents flagged this |
||
| func TestShellSafeExpandEnv(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| template string | ||
| env map[string]string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "simple value", | ||
| template: `export FOO="${FOO}"`, | ||
| env: map[string]string{"FOO": "bar"}, | ||
| want: `export FOO="bar"`, | ||
| }, | ||
| { | ||
| name: "value with double quotes", | ||
| template: `export MSG="${MSG}"`, | ||
| env: map[string]string{"MSG": `say "hello"`}, | ||
| want: `export MSG="say \"hello\""`, | ||
| }, | ||
| { | ||
| name: "value with parentheses", | ||
| template: `export MSG="${MSG}"`, | ||
| env: map[string]string{"MSG": "fix (example) thing"}, | ||
| want: `export MSG="fix (example) thing"`, | ||
| }, | ||
| { | ||
| name: "value with single quotes", | ||
| template: `export MSG="${MSG}"`, | ||
| env: map[string]string{"MSG": "it's broken"}, | ||
| want: `export MSG="it's broken"`, | ||
| }, | ||
| { | ||
| name: "value with dollar sign", | ||
| template: `export V="${V}"`, | ||
| env: map[string]string{"V": "$HOME"}, | ||
| want: `export V="\$HOME"`, | ||
| }, | ||
| { | ||
| name: "value with backticks", | ||
| template: `export CMD="${CMD}"`, | ||
| env: map[string]string{"CMD": "use `grep` here"}, | ||
| want: "export CMD=\"use \\`grep\\` here\"", | ||
| }, | ||
| { | ||
| name: "value with backslashes", | ||
| template: `export P="${P}"`, | ||
| env: map[string]string{"P": `C:\Users\test`}, | ||
| want: `export P="C:\\Users\\test"`, | ||
| }, | ||
| { | ||
| name: "value with all four special chars", | ||
| template: `export V="${V}"`, | ||
| env: map[string]string{"V": "a]\" $x `y` \\z"}, | ||
| want: `export V="a]\" \$x ` + "\\`y\\`" + ` \\z"`, | ||
| }, | ||
| { | ||
| name: "value with shell metacharacters safe inside double quotes", | ||
| template: `export CMD="${CMD}"`, | ||
| env: map[string]string{"CMD": "foo || true && bar; baz > /dev/null"}, | ||
| want: `export CMD="foo || true && bar; baz > /dev/null"`, | ||
| }, | ||
| { | ||
| name: "empty value", | ||
| template: `export FOO="${FOO}"`, | ||
| env: map[string]string{"FOO": ""}, | ||
| want: `export FOO=""`, | ||
| }, | ||
| { | ||
| name: "undefined variable", | ||
| template: `export FOO="${UNDEFINED}"`, | ||
| env: map[string]string{}, | ||
| want: `export FOO=""`, | ||
| }, | ||
| { | ||
| name: "static lines unchanged", | ||
| template: "export STATIC='hello world'", | ||
| env: map[string]string{}, | ||
| want: "export STATIC='hello world'", | ||
| }, | ||
| { | ||
| name: "multiple variables", | ||
| template: "export A=\"${A}\"\nexport B=\"${B}\"", | ||
| env: map[string]string{"A": "1", "B": "two (2)"}, | ||
| want: "export A=\"1\"\nexport B=\"two (2)\"", | ||
| }, | ||
| { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM — Misleading test name This test uses Suggest renaming to 5/10 review agents flagged this |
||
| name: "unquoted template still safe", | ||
| template: "export FOO=${FOO}", | ||
| env: map[string]string{"FOO": "bar"}, | ||
| want: "export FOO=bar", | ||
| }, | ||
| { | ||
| name: "real-world HUMAN_INSTRUCTION from issue 615", | ||
| template: `export HUMAN_INSTRUCTION="${HUMAN_INSTRUCTION}"`, | ||
| env: map[string]string{"HUMAN_INSTRUCTION": `replacing --search "$ISSUE_NUMBER in:body,title" with timeline API || true`}, | ||
| want: `export HUMAN_INSTRUCTION="replacing --search \"\$ISSUE_NUMBER in:body,title\" with timeline API || true"`, | ||
| }, | ||
| { | ||
| name: "real-world instruction with parentheses from failing run", | ||
| template: `export HUMAN_INSTRUCTION="${HUMAN_INSTRUCTION}"`, | ||
| env: map[string]string{"HUMAN_INSTRUCTION": `An administrator with elevated access to the GCP project (for example, with the ability to set IAM policy) can grant all required roles`}, | ||
| want: `export HUMAN_INSTRUCTION="An administrator with elevated access to the GCP project (for example, with the ability to set IAM policy) can grant all required roles"`, | ||
| }, | ||
| { | ||
| name: "injection attempt: break out of double quotes", | ||
| template: `export V="${V}"`, | ||
| env: map[string]string{"V": `"; rm -rf /; echo "`}, | ||
| want: `export V="\"; rm -rf /; echo \""`, | ||
| }, | ||
| { | ||
| name: "injection attempt: command substitution", | ||
| template: `export V="${V}"`, | ||
| env: map[string]string{"V": `$(cat /etc/passwd)`}, | ||
| want: `export V="\$(cat /etc/passwd)"`, | ||
| }, | ||
| { | ||
| name: "injection attempt: backtick substitution", | ||
| template: `export V="${V}"`, | ||
| env: map[string]string{"V": "`cat /etc/passwd`"}, | ||
| want: "export V=\"\\`cat /etc/passwd\\`\"", | ||
| }, | ||
| { | ||
| name: "newlines in value", | ||
| template: `export V="${V}"`, | ||
| env: map[string]string{"V": "line1\nline2\nline3"}, | ||
| want: "export V=\"line1\nline2\nline3\"", | ||
| }, | ||
| { | ||
| name: "tabs and special whitespace", | ||
| template: `export V="${V}"`, | ||
| env: map[string]string{"V": "col1\tcol2"}, | ||
| want: "export V=\"col1\tcol2\"", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| for k, v := range tt.env { | ||
| t.Setenv(k, v) | ||
| } | ||
| got := shellSafeExpandEnv(tt.template) | ||
| assert.Equal(t, tt.want, got) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestShellSafeExpandEnv_ShellRoundtrip verifies that expanded env files | ||
| // produce the original value when sourced by a real shell. This is the | ||
| // definitive safety test: if the value survives a roundtrip through | ||
| // sh -c '. file && printf "%s" "$VAR"', the escaping is correct. | ||
| func TestShellSafeExpandEnv_ShellRoundtrip(t *testing.T) { | ||
| values := []struct { | ||
| name string | ||
| value string | ||
| }{ | ||
| {"simple", "hello world"}, | ||
| {"double quotes", `say "hello" to "world"`}, | ||
| {"single quotes", "it's a test"}, | ||
| {"parentheses", "fix (example) thing"}, | ||
| {"pipes and logic", "foo || true && bar"}, | ||
| {"dollar sign", "cost is $100 or $HOME"}, | ||
| {"command substitution", "$(rm -rf /)"}, | ||
| {"backtick substitution", "`rm -rf /`"}, | ||
| {"backslashes", `path\to\file`}, | ||
| {"semicolons", "cmd1; cmd2; cmd3"}, | ||
| {"redirects", "echo foo > /tmp/evil"}, | ||
| {"glob chars", "match *.go and file?.txt"}, | ||
| {"mixed injection", `"; $(evil) ` + "`more`" + ` && rm -rf / #`}, | ||
| {"all four special chars", `quote" dollar$ tick` + "`" + ` slash\`}, | ||
| {"newlines", "line1\nline2\nline3"}, | ||
| {"tabs", "col1\tcol2"}, | ||
| {"empty", ""}, | ||
| {"unicode", "こんにちは 🎉"}, | ||
| {"real issue 615", `replacing --search "$ISSUE_NUMBER in:body,title" with timeline API || true`}, | ||
| {"real failing run", `An administrator with elevated access to the GCP project (for example, with the ability to set IAM policy) can grant all required roles with a single script:`}, | ||
| {"already escaped backslash", `already \" escaped`}, | ||
| {"nested quotes", `He said "she said 'hello'" today`}, | ||
| {"hash comment char", "value # not a comment"}, | ||
| {"exclamation mark", "hello! world!"}, | ||
| {"curly braces", "use ${VAR} syntax"}, | ||
| {"square brackets", "array[0] = value"}, | ||
| {"tilde", "~user/path"}, | ||
| {"ampersand", "Tom & Jerry"}, | ||
| } | ||
|
|
||
| for _, tt := range values { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| t.Setenv("TEST_VAL", tt.value) | ||
| expanded := shellSafeExpandEnv(`export TEST_VAL="${TEST_VAL}"`) | ||
|
|
||
| // Write expanded content to a temp file and source it in sh. | ||
| envFile := filepath.Join(t.TempDir(), "test.env") | ||
| require.NoError(t, os.WriteFile(envFile, []byte(expanded+"\n"), 0o644)) | ||
|
|
||
| // Use printf "%s" (not echo) to avoid interpretation of \n etc. | ||
| cmd := exec.Command("sh", "-c", fmt.Sprintf(`. %s && printf '%%s' "$TEST_VAL"`, envFile)) | ||
| out, err := cmd.Output() | ||
| require.NoError(t, err, "shell failed to source expanded env file; expanded content:\n%s", expanded) | ||
| assert.Equal(t, tt.value, string(out), "value did not survive shell roundtrip") | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestNeedsCrossCompilation(t *testing.T) { | ||
| result := needsCrossCompilation() | ||
| if runtime.GOOS == "linux" { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
HIGH — Unquoted env templates are not protected by this escaping
The doc comment says "Templates use the standard
export FOO="${FOO}"pattern" but 9 template lines across 5 files use unquoted${VAR}:code-agent.env:export ISSUE_NUMBER=${ISSUE_NUMBER},GITHUB_ISSUE_URL,GH_TOKENreview.env,triage.env,prioritize.env:export GH_TOKEN=${GH_TOKEN}gcp-vertex.env:ANTHROPIC_VERTEX_PROJECT_ID,CLOUD_ML_REGION,GOOGLE_CLOUD_PROJECTIn an unquoted context, characters like spaces,
;,|,&&,>are shell operators — the escaper does NOT protect against them. Currently safe because these are machine-generated values (tokens, integers, project IDs), but the function nameshellSafeExpandEnvimplies broader safety than it provides.Suggestion (follow-up PR): Standardize all env templates to
export VAR="${VAR}"— a one-line change per template, zero regression risk (double-quoting simple values is a no-op in shell), eliminates an entire class of future bugs.7/10 review agents flagged this (strong consensus)