feat: Add safe environment variable expansion
Co-authored-by: dylan <[email protected]>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// ExpandEnvSafe expands environment variables in s, while intentionally avoiding
|
||||
// shell-special expansions that are common in regex patterns and YAML comments.
|
||||
//
|
||||
// Supported expansions:
|
||||
// - $VAR
|
||||
// - ${VAR}
|
||||
//
|
||||
// Where VAR matches: [A-Za-z_][A-Za-z0-9_]*
|
||||
//
|
||||
// Notably, it does NOT expand:
|
||||
// - shell special parameters like $? / $@ / $1
|
||||
// - incomplete brace forms like "${VAR" (missing closing brace)
|
||||
// - non-identifier forms like "$(cmd)"
|
||||
//
|
||||
// If '$' is preceded by an odd number of backslashes (e.g. "\$FOO"), it is
|
||||
// treated as escaped and will not be expanded.
|
||||
func ExpandEnvSafe(s string) string {
|
||||
return expandEnvSafe(s, os.Getenv)
|
||||
}
|
||||
|
||||
func expandEnvSafe(s string, getenv func(string) string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// Build lazily to avoid allocations when no expansions happen.
|
||||
out := make([]byte, 0, len(s))
|
||||
changed := false
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '$' {
|
||||
out = append(out, s[i])
|
||||
continue
|
||||
}
|
||||
|
||||
// If '$' is escaped (odd number of backslashes immediately preceding),
|
||||
// keep it literal.
|
||||
if isEscapedDollar(s, i) {
|
||||
out = append(out, '$')
|
||||
continue
|
||||
}
|
||||
|
||||
// Need at least one character after '$' to expand.
|
||||
if i+1 >= len(s) {
|
||||
out = append(out, '$')
|
||||
continue
|
||||
}
|
||||
|
||||
// ${VAR} form (only if there is a closing brace).
|
||||
if s[i+1] == '{' {
|
||||
end := indexByteFrom(s, '}', i+2)
|
||||
if end == -1 {
|
||||
// Incomplete; treat as literal.
|
||||
out = append(out, '$')
|
||||
continue
|
||||
}
|
||||
name := s[i+2 : end]
|
||||
if isEnvVarName(name) {
|
||||
out = append(out, getenv(name)...)
|
||||
changed = true
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// Not a valid env var name; keep literal.
|
||||
out = append(out, s[i:end+1]...)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// $VAR form.
|
||||
if isEnvVarNameStart(s[i+1]) {
|
||||
j := i + 2
|
||||
for j < len(s) && isEnvVarNameChar(s[j]) {
|
||||
j++
|
||||
}
|
||||
name := s[i+1 : j]
|
||||
out = append(out, getenv(name)...)
|
||||
changed = true
|
||||
i = j - 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise treat '$' literally (covers $?, $@, $1, $(, $$, etc).
|
||||
out = append(out, '$')
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return s
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func indexByteFrom(s string, b byte, start int) int {
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isEnvVarNameStart(b byte) bool {
|
||||
return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '_'
|
||||
}
|
||||
|
||||
func isEnvVarNameChar(b byte) bool {
|
||||
return isEnvVarNameStart(b) || (b >= '0' && b <= '9')
|
||||
}
|
||||
|
||||
func isEnvVarName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if !isEnvVarNameStart(name[0]) {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(name); i++ {
|
||||
if !isEnvVarNameChar(name[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isEscapedDollar(s string, dollarIdx int) bool {
|
||||
// Count consecutive backslashes immediately before '$'.
|
||||
n := 0
|
||||
for i := dollarIdx - 1; i >= 0 && s[i] == '\\'; i-- {
|
||||
n++
|
||||
}
|
||||
return n%2 == 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandEnvSafe(t *testing.T) {
|
||||
t.Setenv("FOO", "BAR")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "expand $VAR",
|
||||
in: "x$FOO y",
|
||||
want: "xBAR y",
|
||||
},
|
||||
{
|
||||
name: "expand ${VAR}",
|
||||
in: "x${FOO}y",
|
||||
want: "xBARy",
|
||||
},
|
||||
{
|
||||
name: "do not expand $?",
|
||||
in: "$?",
|
||||
want: "$?",
|
||||
},
|
||||
{
|
||||
name: "do not expand $@",
|
||||
in: "$@",
|
||||
want: "$@",
|
||||
},
|
||||
{
|
||||
name: "do not expand $1",
|
||||
in: "$1",
|
||||
want: "$1",
|
||||
},
|
||||
{
|
||||
name: "do not mangle incomplete ${VAR",
|
||||
in: "${SOMETHING",
|
||||
want: "${SOMETHING",
|
||||
},
|
||||
{
|
||||
name: "do not expand $(...)",
|
||||
in: "$(",
|
||||
want: "$(",
|
||||
},
|
||||
{
|
||||
name: "do not expand invalid brace name",
|
||||
in: "${1}",
|
||||
want: "${1}",
|
||||
},
|
||||
{
|
||||
name: "escaped dollar does not expand",
|
||||
in: `\$FOO`,
|
||||
want: `\$FOO`,
|
||||
},
|
||||
{
|
||||
name: "regex example preserves $?, $@, and ${ without closing brace",
|
||||
in: `detectors:
|
||||
- name: Custom Detector
|
||||
regex:
|
||||
secret: |-
|
||||
(?i)password=([^ ]+)
|
||||
exclude_regexes_capture:
|
||||
- |-
|
||||
\\$?\\([A-Z$@0-9]
|
||||
- |- # ${SOMETHING
|
||||
^foo$`,
|
||||
want: `detectors:
|
||||
- name: Custom Detector
|
||||
regex:
|
||||
secret: |-
|
||||
(?i)password=([^ ]+)
|
||||
exclude_regexes_capture:
|
||||
- |-
|
||||
\\$?\\([A-Z$@0-9]
|
||||
- |- # ${SOMETHING
|
||||
^foo$`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExpandEnvSafe(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("ExpandEnvSafe mismatch:\n in: %q\n got: %q\n want: %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandEnvSafe_DoesNotMatchOsExpandEnvForShellSpecials(t *testing.T) {
|
||||
// This test documents the enterprise failure mode: os.ExpandEnv removes
|
||||
// shell-special parameters like $? and $@, which can appear in regexes.
|
||||
//
|
||||
// If Go changes os.ExpandEnv behavior in the future, this test can be
|
||||
// adjusted/removed; ExpandEnvSafe must continue to preserve them.
|
||||
in := `\\$?\\([A-Z$@0-9]`
|
||||
|
||||
got := ExpandEnvSafe(in)
|
||||
if got != in {
|
||||
t.Fatalf("expected ExpandEnvSafe to preserve input:\n got: %q\n in: %q", got, in)
|
||||
}
|
||||
|
||||
if os.ExpandEnv(in) == in {
|
||||
t.Fatalf("expected os.ExpandEnv to differ from input for this case; update this test if behavior changes")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user