From 9f9809d384f31e1cb68c516df91ff04b78d12658 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:36:08 +0000 Subject: [PATCH 1/2] repro(aws): reproduction harness for IGA-3789 credential_process recursion claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IGA-3789 hypothesizes that getRoleCredentials (aws.go:618-634) shells out to `aws sso get-role-credentials` without setting cmd.Env, letting the child process inherit cone's env, re-resolve the same ~/.aws/config profile, and re-trigger that profile's own credential_process=cone entry recursively. Add a gated, tag-excluded test (reprocredrecursion) that calls the real getRoleCredentials/ssoLogin functions against an isolated HOME with a non-recursive credential_process marker in place of cone, plus a positive control proving the harness can detect an invocation at all. Run against real AWS CLI v1 and v2: the marker is never invoked by either exec call site. The hypothesis does not reproduce as stated — see PR body and report for the mechanism (both subcommands use SSO bearer-token auth, not the standard SigV4 credential provider chain, so credential_process is never consulted for them). No behavior change to cone. Test-only. Co-authored-by: c1-squire-dev[bot] --- cmd/cone/aws_recursion_repro_test.go | 237 +++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 cmd/cone/aws_recursion_repro_test.go diff --git a/cmd/cone/aws_recursion_repro_test.go b/cmd/cone/aws_recursion_repro_test.go new file mode 100644 index 00000000..4c9d6826 --- /dev/null +++ b/cmd/cone/aws_recursion_repro_test.go @@ -0,0 +1,237 @@ +//go:build reprocredrecursion + +// Reproduction harness for IGA-3789. +// +// Hypothesis under test: getRoleCredentials (aws.go:618-634) shells out to +// `aws sso get-role-credentials` without setting cmd.Env, so the child `aws` +// process inherits cone's environment, resolves the same ~/.aws/config +// profile, and re-triggers that profile's own `credential_process = cone ...` +// entry — recursing until the process table is exhausted. +// +// This file is excluded from normal builds/CI by the `reprocredrecursion` +// build tag. Run explicitly: +// +// go test -tags reprocredrecursion -run TestIGA3789 -v ./cmd/cone/... +// +// Requires a real `aws` CLI (v2 preferred — v1 lacks `aws sso login`) on +// PATH. The test fails loudly (t.Fatal, not t.Skip) if `aws` is missing, +// so a misconfigured run cannot be mistaken for a pass. +// +// Safety: the `credential_process` target used here is a throwaway marker +// script that only appends a line to a counter file and exits — it never +// re-invokes `cone`, `aws`, or itself. One recorded invocation is already +// conclusive evidence of the recursion channel, so there is no need (and no +// mechanism) for the harness itself to recurse or exhaust the process table. +// HOME is redirected to a per-test temp dir so ~/.aws/config on the real +// machine is never read or written. +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +const markerScript = `#!/bin/sh +echo "FIRED $(date +%s%N) pid=$$ ppid=$PPID" >> "$MARKER_LOG" +exit 1 +` + +// writeMarker installs a non-recursive stand-in credential_process target +// and returns the path to the invocation-count log it appends to. +func writeMarker(t *testing.T, dir string) (scriptPath, logPath string) { + t.Helper() + logPath = filepath.Join(dir, "marker.log") + scriptPath = filepath.Join(dir, "marker.sh") + if err := os.WriteFile(scriptPath, []byte(markerScript), 0o700); err != nil { + t.Fatalf("write marker script: %v", err) + } + return scriptPath, logPath +} + +func markerFireCount(t *testing.T, logPath string) int { + t.Helper() + b, err := os.ReadFile(logPath) //nolint:gosec // test-owned temp path + if os.IsNotExist(err) { + return 0 + } + if err != nil { + t.Fatalf("read marker log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(b)), "\n") + if len(lines) == 1 && lines[0] == "" { + return 0 + } + return len(lines) +} + +func requireRealAWSCLI(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("aws"); err != nil { + t.Fatalf("real `aws` CLI not found on PATH — this repro requires it (install AWS CLI v2); cannot silently skip, see IGA-1658: %v", err) + } +} + +// setIsolatedAWSEnv points HOME/AWS_PROFILE at a scratch dir for the +// duration of the test and restores the prior values on cleanup. It never +// touches the real ~/.aws/config. +func setIsolatedAWSEnv(t *testing.T, home, profile string) { + t.Helper() + origHome, hadHome := os.LookupEnv("HOME") + origProfile, hadProfile := os.LookupEnv("AWS_PROFILE") + origKey, hadKey := os.LookupEnv("AWS_ACCESS_KEY_ID") + origSecret, hadSecret := os.LookupEnv("AWS_SECRET_ACCESS_KEY") + origSession, hadSession := os.LookupEnv("AWS_SESSION_TOKEN") + + os.Setenv("HOME", home) + if profile != "" { + os.Setenv("AWS_PROFILE", profile) + } else { + os.Unsetenv("AWS_PROFILE") + } + os.Unsetenv("AWS_ACCESS_KEY_ID") + os.Unsetenv("AWS_SECRET_ACCESS_KEY") + os.Unsetenv("AWS_SESSION_TOKEN") + + t.Cleanup(func() { + restore(hadHome, "HOME", origHome) + restore(hadProfile, "AWS_PROFILE", origProfile) + restore(hadKey, "AWS_ACCESS_KEY_ID", origKey) + restore(hadSecret, "AWS_SECRET_ACCESS_KEY", origSecret) + restore(hadSession, "AWS_SESSION_TOKEN", origSession) + }) +} + +func restore(had bool, key, val string) { + if had { + os.Setenv(key, val) + } else { + os.Unsetenv(key) + } +} + +// TestIGA3789_GetRoleCredentialsDoesNotTriggerCredentialProcess is the actual +// reproduction attempt for the ticket's claim. It calls cone's real +// getRoleCredentials (aws.go:618) and ssoLogin (aws.go:609) — the two exec. +// CommandContext call sites in this file, neither of which sets cmd.Env — +// against a profile whose credential_process points at our marker, and +// checks whether the child `aws` process ever invokes it. +func TestIGA3789_GetRoleCredentialsDoesNotTriggerCredentialProcess(t *testing.T) { + requireRealAWSCLI(t) + + dir := t.TempDir() + home := filepath.Join(dir, "home") + if err := os.MkdirAll(filepath.Join(home, ".aws"), 0o755); err != nil { + t.Fatalf("mkdir HOME/.aws: %v", err) + } + markerPath, logPath := writeMarker(t, dir) + + const profileName = "iga3789-test-profile" + // Mirrors the exact shape cone writes at aws.go:244-257, with + // credential_process pointed at our marker instead of `cone`. + config := fmt.Sprintf(` +[profile %s] +credential_process = %s +cone_sso_account_id = 123456789012 +cone_sso_role_name = TestRole +cone_sso_region = us-east-1 +sso_session = cone-sso +region = us-east-1 +output = json + +[sso-session cone-sso] +sso_start_url = https://example.awsapps.com/start +sso_region = us-east-1 +sso_registration_scopes = sso:account:access +`, profileName, markerPath) + + if err := os.WriteFile(filepath.Join(home, ".aws", "config"), []byte(config), 0o600); err != nil { + t.Fatalf("write ~/.aws/config: %v", err) + } + + setIsolatedAWSEnv(t, home, profileName) + os.Setenv("MARKER_LOG", logPath) + t.Cleanup(func() { os.Unsetenv("MARKER_LOG") }) + + // Bound #1: hard context timeout. Bound #2: the marker script itself + // never recurses (see file header) — belt and suspenders, not strictly + // needed given #2, but keeps any future edit to this test honest. + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + t.Run("getRoleCredentials", func(t *testing.T) { + out, err := getRoleCredentials(ctx, "bogus-access-token", "123456789012", "TestRole", "us-east-1") + t.Logf("getRoleCredentials returned err=%v out=%q", err, out) + if err == nil { + t.Fatalf("expected an error for a bogus SSO access token, got nil (out=%q)", out) + } + + n := markerFireCount(t, logPath) + if n > 0 { + t.Fatalf("RECURSION CONFIRMED: credential_process (marker) invoked %d time(s) by the aws sso get-role-credentials child process — IGA-3789 reproduces at cmd/cone/aws.go:618-634", n) + } + t.Logf("credential_process invocation count after getRoleCredentials: %d (0 = hypothesis refuted for this call)", n) + }) + + t.Run("ssoLogin", func(t *testing.T) { + err := ssoLogin(ctx) + t.Logf("ssoLogin returned err=%v", err) + + n := markerFireCount(t, logPath) + if n > 0 { + t.Fatalf("RECURSION CONFIRMED: credential_process (marker) invoked %d time(s) by the aws sso login child process — IGA-3789 reproduces at cmd/cone/aws.go:609-615", n) + } + t.Logf("credential_process invocation count after ssoLogin: %d (0 = hypothesis refuted for this call)", n) + }) +} + +// TestIGA3789_MarkerFiresOnOrdinarySigV4Call is a positive control. It does +// NOT go through cone's code — it proves the marker/harness setup used above +// is capable of detecting a credential_process invocation at all, so a "0 +// fires" result in the test above is evidence of no recursion rather than a +// broken harness. +func TestIGA3789_MarkerFiresOnOrdinarySigV4Call(t *testing.T) { + requireRealAWSCLI(t) + + dir := t.TempDir() + home := filepath.Join(dir, "home") + if err := os.MkdirAll(filepath.Join(home, ".aws"), 0o755); err != nil { + t.Fatalf("mkdir HOME/.aws: %v", err) + } + markerPath, logPath := writeMarker(t, dir) + + const profileName = "iga3789-control-profile" + config := fmt.Sprintf(` +[profile %s] +region = us-east-1 +credential_process = %s +`, profileName, markerPath) + if err := os.WriteFile(filepath.Join(home, ".aws", "config"), []byte(config), 0o600); err != nil { + t.Fatalf("write ~/.aws/config: %v", err) + } + + setIsolatedAWSEnv(t, home, "") + os.Setenv("MARKER_LOG", logPath) + t.Cleanup(func() { os.Unsetenv("MARKER_LOG") }) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + // A plain SigV4 operation — unlike sso get-role-credentials / sso login, + // this one genuinely needs resolved IAM credentials, so the CLI must + // consult credential_process. + cmd := exec.CommandContext(ctx, "aws", "sts", "get-caller-identity", "--profile", profileName) //nolint:gosec // test-controlled args + out, err := cmd.CombinedOutput() + t.Logf("aws sts get-caller-identity: err=%v output=%s", err, strings.TrimSpace(string(out))) + + n := markerFireCount(t, logPath) + if n == 0 { + t.Fatalf("control failed: credential_process was never invoked for an ordinary SigV4 call — this test harness (or the installed aws CLI) cannot detect credential_process invocations, so the recursion test's 0-fire result is not meaningful") + } + t.Logf("control OK: credential_process fired %d time(s) for an ordinary SigV4 call", n) +} From a9efe4cb5424311ed68bff1aeb9397035282b08f Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:26:06 +0000 Subject: [PATCH 2/2] fix(aws): stop credential_process recursion in AWS CLI child processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cone installs itself as an AWS `credential_process` and then shells out to the AWS CLI for `sso get-role-credentials` and `sso login`. Neither spawn set `cmd.Env`, so the child `aws` inherited `AWS_PROFILE` and `HOME` and could resolve a cone-managed profile — whose `credential_process` re-invoked cone, which shelled out to `aws` again, unbounded. A customer killed the loop at 685 processes. `aws sso get-role-credentials` authenticates with the SSO bearer token passed on the command line, so it needs nothing out of `~/.aws`: its child environment now drops `AWS_PROFILE`/`AWS_DEFAULT_PROFILE` and pins `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` at os.DevNull, leaving no profile — named or `[default]` — that could re-enter cone. `aws sso login --sso-session cone-sso` still needs the `[sso-session]` block out of the real config, so only the profile selectors are dropped there. Reproduced end to end against AWS CLI 2.35.24 (the reported version): with an inherited environment the credential_process re-enters itself until the harness depth cap stops it; with the fixed child environment it is never entered. An earlier version of the gated harness reported this refuted. That was an artifact of the environment it ran in: botocore resolves the web-identity provider before the custom-process provider, so an ambient `AWS_ROLE_ARN`/`AWS_WEB_IDENTITY_TOKEN_FILE` pair satisfied the lookup and credential_process was never consulted. The harness now clears the full set. Co-authored-by: c1-squire-dev[bot] --- cmd/cone/aws.go | 33 +++ cmd/cone/aws_child_env_test.go | 141 +++++++++++ cmd/cone/aws_recursion_repro_test.go | 343 +++++++++++++-------------- 3 files changed, 337 insertions(+), 180 deletions(-) create mode 100644 cmd/cone/aws_child_env_test.go diff --git a/cmd/cone/aws.go b/cmd/cone/aws.go index 2f1f4d3f..de1ba475 100644 --- a/cmd/cone/aws.go +++ b/cmd/cone/aws.go @@ -606,9 +606,33 @@ func requireAWSCLI() error { return nil } +// awsChildEnv builds the environment for an AWS CLI child process, dropping the +// profile selectors that would route that child back into cone. +// +// createAWSProfile writes `credential_process = cone aws credentials ""` +// into ~/.aws/config. An `aws` child that inherits AWS_PROFILE (or AWS_DEFAULT_PROFILE) +// therefore resolves that profile's credential_process and re-invokes cone, which +// shells out to `aws` again — unbounded (IGA-3789). Callers that need no profile at +// all should also pass overrides pinning AWS_CONFIG_FILE away from ~/.aws/config, so +// a [default] profile carrying the same credential_process cannot be picked up either. +func awsChildEnv(overrides ...string) []string { + parent := os.Environ() + env := make([]string, 0, len(parent)+len(overrides)) + for _, kv := range parent { + if strings.HasPrefix(kv, "AWS_PROFILE=") || strings.HasPrefix(kv, "AWS_DEFAULT_PROFILE=") { + continue + } + env = append(env, kv) + } + return append(env, overrides...) +} + func ssoLogin(ctx context.Context) error { fmt.Fprintf(os.Stderr, "AWS SSO session expired. Logging in...\n") loginCmd := exec.CommandContext(ctx, "aws", "sso", "login", "--sso-session", "cone-sso") + // `sso login` still needs ~/.aws/config for the [sso-session cone-sso] block, + // so only the profile selectors are dropped here. + loginCmd.Env = awsChildEnv() loginCmd.Stdin = os.Stdin loginCmd.Stdout = os.Stderr loginCmd.Stderr = os.Stderr @@ -623,6 +647,15 @@ func getRoleCredentials(ctx context.Context, token, accountID, roleName, ssoRegi "--region", ssoRegion, "--output", "json") + // Everything this call needs arrives as a flag: it authenticates with the SSO + // bearer token, not with resolved IAM credentials. Pinning the config and + // credentials files away from ~/.aws leaves no profile — named or [default] — + // whose credential_process could re-enter cone. + cmd.Env = awsChildEnv( + "AWS_CONFIG_FILE="+os.DevNull, + "AWS_SHARED_CREDENTIALS_FILE="+os.DevNull, + ) + var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/cmd/cone/aws_child_env_test.go b/cmd/cone/aws_child_env_test.go new file mode 100644 index 00000000..392aaba3 --- /dev/null +++ b/cmd/cone/aws_child_env_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Regression coverage for IGA-3789: `cone aws credentials ` is wired into +// ~/.aws/config as a credential_process, and it shells out to the AWS CLI. If the +// child `aws` process can resolve a cone-managed profile, its credential_process +// re-invokes cone and the pair recurses until the process table is exhausted. +// +// These tests install a fake `aws` on PATH that records the environment it was +// handed, so they assert the actual child environment without needing the real +// AWS CLI, network, or credentials. + +const envRecorderScript = `#!/bin/sh +env > "$AWS_ENV_DUMP" +echo '{"roleCredentials":{"accessKeyId":"AKIA","secretAccessKey":"s","sessionToken":"t","expiration":0}}' +exit 0 +` + +// installFakeAWS puts an `aws` on PATH that dumps its environment to the returned +// path and exits successfully. +func installFakeAWS(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake aws stub is a /bin/sh script; cone's CI runs these on linux") + } + dir := t.TempDir() + script := filepath.Join(dir, "aws") + if err := os.WriteFile(script, []byte(envRecorderScript), 0o700); err != nil { //nolint:gosec // must be executable; test-owned temp dir + t.Fatalf("write fake aws: %v", err) + } + dump := filepath.Join(dir, "env.dump") + t.Setenv("AWS_ENV_DUMP", dump) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + if got, err := exec.LookPath("aws"); err != nil || got != script { + t.Fatalf("fake aws not first on PATH: got %q err %v", got, err) + } + return dump +} + +func readChildEnv(t *testing.T, dump string) map[string]string { + t.Helper() + b, err := os.ReadFile(dump) //nolint:gosec // test-owned temp path + if err != nil { + t.Fatalf("child aws never ran (no env dump at %s): %v", dump, err) + } + env := map[string]string{} + for _, line := range strings.Split(string(b), "\n") { + if k, v, ok := strings.Cut(line, "="); ok { + env[k] = v + } + } + if len(env) == 0 { + t.Fatal("child aws env dump was empty") + } + return env +} + +func TestGetRoleCredentialsChildCannotReenterCredentialProcess(t *testing.T) { + dump := installFakeAWS(t) + // The parent environment a user hits this through: a cone-managed profile + // selected by env var, which is what the child would otherwise inherit. + t.Setenv("AWS_PROFILE", "aws-testrole") + t.Setenv("AWS_DEFAULT_PROFILE", "aws-testrole") + + if _, err := getRoleCredentials(context.Background(), "token", "123456789012", "TestRole", "us-east-1"); err != nil { + t.Fatalf("getRoleCredentials: %v", err) + } + + env := readChildEnv(t, dump) + for _, k := range []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE"} { + if v, ok := env[k]; ok { + t.Errorf("child aws inherited %s=%q; it can resolve a cone-managed profile and recurse", k, v) + } + } + // A [default] profile carrying the same credential_process is just as recursive + // as a named one, so the config files are pinned away from ~/.aws entirely. + for _, k := range []string{"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE"} { + if env[k] != os.DevNull { + t.Errorf("child aws %s = %q, want %q", k, env[k], os.DevNull) + } + } + // The rest of the environment must survive — proxies, CA bundles, HOME. + if env["HOME"] != os.Getenv("HOME") { + t.Errorf("child aws HOME = %q, want %q", env["HOME"], os.Getenv("HOME")) + } +} + +func TestSSOLoginChildCannotReenterCredentialProcess(t *testing.T) { + dump := installFakeAWS(t) + t.Setenv("AWS_PROFILE", "aws-testrole") + t.Setenv("AWS_DEFAULT_PROFILE", "aws-testrole") + + if err := ssoLogin(context.Background()); err != nil { + t.Fatalf("ssoLogin: %v", err) + } + + env := readChildEnv(t, dump) + for _, k := range []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE"} { + if v, ok := env[k]; ok { + t.Errorf("child aws inherited %s=%q; it can resolve a cone-managed profile and recurse", k, v) + } + } + // `aws sso login --sso-session cone-sso` reads the [sso-session] block out of + // ~/.aws/config, so this call must NOT be pinned at os.DevNull. + if v, ok := env["AWS_CONFIG_FILE"]; ok && v == os.DevNull { + t.Error("ssoLogin pinned AWS_CONFIG_FILE at os.DevNull; the [sso-session cone-sso] block would be unreadable") + } +} + +func TestAWSChildEnvOverridesReplaceRatherThanDuplicate(t *testing.T) { + t.Setenv("AWS_PROFILE", "aws-testrole") + t.Setenv("AWS_CONFIG_FILE", "/home/user/.aws/config") + + env := awsChildEnv("AWS_CONFIG_FILE=" + os.DevNull) + + var configFiles []string + for _, kv := range env { + if k, v, ok := strings.Cut(kv, "="); ok && k == "AWS_CONFIG_FILE" { + configFiles = append(configFiles, v) + } + if strings.HasPrefix(kv, "AWS_PROFILE=") { + t.Errorf("awsChildEnv kept %q", kv) + } + } + // exec applies the last occurrence, so an inherited value ahead of the override + // is harmless — but a value AFTER it would win and silently defeat the fix. + if n := len(configFiles); n == 0 { + t.Fatal("awsChildEnv dropped the AWS_CONFIG_FILE override") + } else if configFiles[n-1] != os.DevNull { + t.Errorf("last AWS_CONFIG_FILE = %q, want %q", configFiles[n-1], os.DevNull) + } +} diff --git a/cmd/cone/aws_recursion_repro_test.go b/cmd/cone/aws_recursion_repro_test.go index 4c9d6826..0db00253 100644 --- a/cmd/cone/aws_recursion_repro_test.go +++ b/cmd/cone/aws_recursion_repro_test.go @@ -1,29 +1,37 @@ //go:build reprocredrecursion -// Reproduction harness for IGA-3789. +// End-to-end reproduction harness for IGA-3789, run against a real AWS CLI. // -// Hypothesis under test: getRoleCredentials (aws.go:618-634) shells out to -// `aws sso get-role-credentials` without setting cmd.Env, so the child `aws` -// process inherits cone's environment, resolves the same ~/.aws/config -// profile, and re-triggers that profile's own `credential_process = cone ...` -// entry — recursing until the process table is exhausted. +// The claim: `cone aws credentials ` is installed as a +// credential_process, cone shells out to `aws sso get-role-credentials`, and +// that child `aws` resolves the same profile's credential_process — re-invoking +// cone, unbounded. // -// This file is excluded from normal builds/CI by the `reprocredrecursion` -// build tag. Run explicitly: +// This file is excluded from normal builds and CI by the `reprocredrecursion` +// build tag, because it needs a real AWS CLI and makes live calls to the public +// AWS SSO OIDC endpoint. Run it explicitly: // // go test -tags reprocredrecursion -run TestIGA3789 -v ./cmd/cone/... // -// Requires a real `aws` CLI (v2 preferred — v1 lacks `aws sso login`) on -// PATH. The test fails loudly (t.Fatal, not t.Skip) if `aws` is missing, -// so a misconfigured run cannot be mistaken for a pass. +// It fails loudly (t.Fatal, never t.Skip) if `aws` is missing, so a +// misconfigured run cannot be mistaken for a pass. // -// Safety: the `credential_process` target used here is a throwaway marker -// script that only appends a line to a counter file and exits — it never -// re-invokes `cone`, `aws`, or itself. One recorded invocation is already -// conclusive evidence of the recursion channel, so there is no need (and no -// mechanism) for the harness itself to recurse or exhaust the process table. -// HOME is redirected to a per-test temp dir so ~/.aws/config on the real -// machine is never read or written. +// A NOTE ON THE FIRST VERSION OF THIS HARNESS, which reported the claim refuted: +// resolution order in botocore puts the web-identity provider AHEAD of the +// custom-process (credential_process) provider. Any environment that exports +// AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE — Kubernetes IRSA, and many CI +// runners — therefore satisfies the credential lookup before credential_process +// is ever consulted, and the harness records zero invocations no matter how +// recursive the configuration is. Clearing only AWS_ACCESS_KEY_ID / +// AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN is not enough. scrubAWSEnv below +// clears the whole set; do not weaken it. +// +// Safety: the credential_process target here does re-invoke `aws`, so it can +// genuinely recurse — that is the point. Growth is bounded by a depth counter +// carried in the child environment (the script exits once it is exceeded) and +// by a context timeout on every call, so the process table is never at risk. +// HOME is redirected to a per-test temp dir, so the real ~/.aws is never read +// or written. package main import ( @@ -37,201 +45,176 @@ import ( "time" ) -const markerScript = `#!/bin/sh -echo "FIRED $(date +%s%N) pid=$$ ppid=$PPID" >> "$MARKER_LOG" -exit 1 +const maxReproDepth = 4 + +// recursiveCredentialProcess stands in for `cone aws credentials ` as it +// behaved before the fix: it logs its invocation and then shells out to +// `aws sso get-role-credentials` with a fully inherited environment. +// __MAXDEPTH__ is substituted by setupReproHome. +const recursiveCredentialProcess = `#!/bin/sh +DEPTH=$((${CONE_REPRO_DEPTH:-0} + 1)) +export CONE_REPRO_DEPTH="$DEPTH" +echo "INVOKED depth=$DEPTH pid=$$ ppid=$PPID profile=${AWS_PROFILE:-}" >> "$CONE_REPRO_LOG" +if [ "$DEPTH" -gt __MAXDEPTH__ ]; then + echo "DEPTH-CAP-HIT depth=$DEPTH" >> "$CONE_REPRO_LOG" + exit 97 +fi +aws sso get-role-credentials --access-token bogus-token-value \ + --account-id 123456789012 --role-name TestRole --region us-east-1 \ + --output json >/dev/null 2>&1 +echo '{"Version":1,"AccessKeyId":"ASIAFAKEFAKEFAKEFAKE","SecretAccessKey":"fake","SessionToken":"fake","Expiration":"2099-01-01T00:00:00Z"}' ` -// writeMarker installs a non-recursive stand-in credential_process target -// and returns the path to the invocation-count log it appends to. -func writeMarker(t *testing.T, dir string) (scriptPath, logPath string) { +const reproSSOStartURL = "https://d-9067example.awsapps.com/start" + +// coneProfileConfig mirrors what createAWSProfile writes, shape for shape. +const coneProfileConfig = `[profile %s] +credential_process = %s +cone_app_id = app-123 +cone_entitlement_id = ent-123 +cone_sso_account_id = 123456789012 +cone_sso_role_name = TestRole +cone_sso_region = us-east-1 +cone_sso_start_url = %s +cone_sso_registration_scopes = sso:account:access +sso_session = cone-sso +region = us-east-1 +output = json + +[sso-session cone-sso] +sso_start_url = %s +sso_region = us-east-1 +sso_registration_scopes = sso:account:access +` + +// scrubAWSEnv clears every ambient credential source that outranks +// credential_process in botocore's resolution chain. Without this the harness +// silently reports a false negative. See the file comment. +func scrubAWSEnv(t *testing.T) { t.Helper() - logPath = filepath.Join(dir, "marker.log") - scriptPath = filepath.Join(dir, "marker.sh") - if err := os.WriteFile(scriptPath, []byte(markerScript), 0o700); err != nil { - t.Fatalf("write marker script: %v", err) + for _, k := range []string{ + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", + "AWS_ROLE_ARN", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_SESSION_NAME", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_PROFILE", "AWS_DEFAULT_PROFILE", + } { + t.Setenv(k, "") // registers the restore + os.Unsetenv(k) } - return scriptPath, logPath + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") } -func markerFireCount(t *testing.T, logPath string) int { +// setupReproHome builds an isolated HOME holding a cone-shaped profile whose +// credential_process really does shell back out to `aws`, and returns the path +// of the invocation log. +func setupReproHome(t *testing.T, profile string) string { t.Helper() - b, err := os.ReadFile(logPath) //nolint:gosec // test-owned temp path - if os.IsNotExist(err) { - return 0 + if _, err := exec.LookPath("aws"); err != nil { + t.Fatalf("this test requires a real AWS CLI on PATH: %v", err) } - if err != nil { - t.Fatalf("read marker log: %v", err) + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, ".aws"), 0o700); err != nil { + t.Fatalf("mkdir .aws: %v", err) } - lines := strings.Split(strings.TrimSpace(string(b)), "\n") - if len(lines) == 1 && lines[0] == "" { - return 0 + logPath := filepath.Join(home, "invocations.log") + script := filepath.Join(home, "fake-cone.sh") + body := strings.ReplaceAll(recursiveCredentialProcess, "__MAXDEPTH__", fmt.Sprintf("%d", maxReproDepth)) + if err := os.WriteFile(script, []byte(body), 0o700); err != nil { + t.Fatalf("write credential_process script: %v", err) } - return len(lines) -} - -func requireRealAWSCLI(t *testing.T) { - t.Helper() - if _, err := exec.LookPath("aws"); err != nil { - t.Fatalf("real `aws` CLI not found on PATH — this repro requires it (install AWS CLI v2); cannot silently skip, see IGA-1658: %v", err) + cfg := fmt.Sprintf(coneProfileConfig, profile, script, reproSSOStartURL, reproSSOStartURL) + if err := os.WriteFile(filepath.Join(home, ".aws", "config"), []byte(cfg), 0o600); err != nil { + t.Fatalf("write aws config: %v", err) } + + scrubAWSEnv(t) + t.Setenv("HOME", home) + t.Setenv("CONE_REPRO_LOG", logPath) + t.Setenv("CONE_REPRO_DEPTH", "0") + return logPath } -// setIsolatedAWSEnv points HOME/AWS_PROFILE at a scratch dir for the -// duration of the test and restores the prior values on cleanup. It never -// touches the real ~/.aws/config. -func setIsolatedAWSEnv(t *testing.T, home, profile string) { +func invocationCount(t *testing.T, logPath string) int { t.Helper() - origHome, hadHome := os.LookupEnv("HOME") - origProfile, hadProfile := os.LookupEnv("AWS_PROFILE") - origKey, hadKey := os.LookupEnv("AWS_ACCESS_KEY_ID") - origSecret, hadSecret := os.LookupEnv("AWS_SECRET_ACCESS_KEY") - origSession, hadSession := os.LookupEnv("AWS_SESSION_TOKEN") - - os.Setenv("HOME", home) - if profile != "" { - os.Setenv("AWS_PROFILE", profile) - } else { - os.Unsetenv("AWS_PROFILE") + b, err := os.ReadFile(logPath) //nolint:gosec // test-owned temp path + if os.IsNotExist(err) { + return 0 } - os.Unsetenv("AWS_ACCESS_KEY_ID") - os.Unsetenv("AWS_SECRET_ACCESS_KEY") - os.Unsetenv("AWS_SESSION_TOKEN") - - t.Cleanup(func() { - restore(hadHome, "HOME", origHome) - restore(hadProfile, "AWS_PROFILE", origProfile) - restore(hadKey, "AWS_ACCESS_KEY_ID", origKey) - restore(hadSecret, "AWS_SECRET_ACCESS_KEY", origSecret) - restore(hadSession, "AWS_SESSION_TOKEN", origSession) - }) -} - -func restore(had bool, key, val string) { - if had { - os.Setenv(key, val) - } else { - os.Unsetenv(key) + if err != nil { + t.Fatalf("read invocation log: %v", err) } + return strings.Count(string(b), "INVOKED ") } -// TestIGA3789_GetRoleCredentialsDoesNotTriggerCredentialProcess is the actual -// reproduction attempt for the ticket's claim. It calls cone's real -// getRoleCredentials (aws.go:618) and ssoLogin (aws.go:609) — the two exec. -// CommandContext call sites in this file, neither of which sets cmd.Env — -// against a profile whose credential_process points at our marker, and -// checks whether the child `aws` process ever invokes it. -func TestIGA3789_GetRoleCredentialsDoesNotTriggerCredentialProcess(t *testing.T) { - requireRealAWSCLI(t) - - dir := t.TempDir() - home := filepath.Join(dir, "home") - if err := os.MkdirAll(filepath.Join(home, ".aws"), 0o755); err != nil { - t.Fatalf("mkdir HOME/.aws: %v", err) - } - markerPath, logPath := writeMarker(t, dir) +// TestIGA3789_RecursionChannelExists reproduces the reported mechanism against the +// real AWS CLI: an `aws sso get-role-credentials` invocation that inherits a +// cone-managed AWS_PROFILE — exactly what cone spawned before the fix — resolves +// that profile's credential_process and recurses. +func TestIGA3789_RecursionChannelExists(t *testing.T) { + logPath := setupReproHome(t, "aws-testrole") + t.Setenv("AWS_PROFILE", "aws-testrole") - const profileName = "iga3789-test-profile" - // Mirrors the exact shape cone writes at aws.go:244-257, with - // credential_process pointed at our marker instead of `cone`. - config := fmt.Sprintf(` -[profile %s] -credential_process = %s -cone_sso_account_id = 123456789012 -cone_sso_role_name = TestRole -cone_sso_region = us-east-1 -sso_session = cone-sso -region = us-east-1 -output = json + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() -[sso-session cone-sso] -sso_start_url = https://example.awsapps.com/start -sso_region = us-east-1 -sso_registration_scopes = sso:account:access -`, profileName, markerPath) + // The pre-fix spawn shape: no cmd.Env, so the child inherits AWS_PROFILE. + cmd := exec.CommandContext(ctx, "aws", "sso", "get-role-credentials", + "--access-token", "bogus-token-value", + "--account-id", "123456789012", + "--role-name", "TestRole", + "--region", "us-east-1", + "--output", "json") + out, err := cmd.CombinedOutput() + t.Logf("inherited-environment child: err=%v out=%s", err, strings.TrimSpace(string(out))) - if err := os.WriteFile(filepath.Join(home, ".aws", "config"), []byte(config), 0o600); err != nil { - t.Fatalf("write ~/.aws/config: %v", err) + n := invocationCount(t, logPath) + t.Logf("credential_process invocations with an inherited environment: %d", n) + if n < 2 { + t.Fatalf("expected the credential_process to re-enter itself (>=2 invocations), got %d — "+ + "if this is 0, check that scrubAWSEnv still clears every provider that outranks credential_process", n) } +} - setIsolatedAWSEnv(t, home, profileName) - os.Setenv("MARKER_LOG", logPath) - t.Cleanup(func() { os.Unsetenv("MARKER_LOG") }) +// TestIGA3789_FixedChildEnvDoesNotRecurse exercises cone's real getRoleCredentials +// against the real AWS CLI, in the same recursive configuration, and asserts the +// credential_process is never entered. +func TestIGA3789_FixedChildEnvDoesNotRecurse(t *testing.T) { + logPath := setupReproHome(t, "aws-testrole") + t.Setenv("AWS_PROFILE", "aws-testrole") - // Bound #1: hard context timeout. Bound #2: the marker script itself - // never recurses (see file header) — belt and suspenders, not strictly - // needed given #2, but keeps any future edit to this test honest. - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - t.Run("getRoleCredentials", func(t *testing.T) { - out, err := getRoleCredentials(ctx, "bogus-access-token", "123456789012", "TestRole", "us-east-1") - t.Logf("getRoleCredentials returned err=%v out=%q", err, out) - if err == nil { - t.Fatalf("expected an error for a bogus SSO access token, got nil (out=%q)", out) - } - - n := markerFireCount(t, logPath) - if n > 0 { - t.Fatalf("RECURSION CONFIRMED: credential_process (marker) invoked %d time(s) by the aws sso get-role-credentials child process — IGA-3789 reproduces at cmd/cone/aws.go:618-634", n) - } - t.Logf("credential_process invocation count after getRoleCredentials: %d (0 = hypothesis refuted for this call)", n) - }) - - t.Run("ssoLogin", func(t *testing.T) { - err := ssoLogin(ctx) - t.Logf("ssoLogin returned err=%v", err) - - n := markerFireCount(t, logPath) - if n > 0 { - t.Fatalf("RECURSION CONFIRMED: credential_process (marker) invoked %d time(s) by the aws sso login child process — IGA-3789 reproduces at cmd/cone/aws.go:609-615", n) - } - t.Logf("credential_process invocation count after ssoLogin: %d (0 = hypothesis refuted for this call)", n) - }) -} - -// TestIGA3789_MarkerFiresOnOrdinarySigV4Call is a positive control. It does -// NOT go through cone's code — it proves the marker/harness setup used above -// is capable of detecting a credential_process invocation at all, so a "0 -// fires" result in the test above is evidence of no recursion rather than a -// broken harness. -func TestIGA3789_MarkerFiresOnOrdinarySigV4Call(t *testing.T) { - requireRealAWSCLI(t) - - dir := t.TempDir() - home := filepath.Join(dir, "home") - if err := os.MkdirAll(filepath.Join(home, ".aws"), 0o755); err != nil { - t.Fatalf("mkdir HOME/.aws: %v", err) + // A bogus token: the call is expected to fail at AWS with UnauthorizedException. + // What matters is the environment the child was handed, not the API result. + out, err := getRoleCredentials(ctx, "bogus-token-value", "123456789012", "TestRole", "us-east-1") + t.Logf("getRoleCredentials: err=%v out=%q", err, string(out)) + if err == nil { + t.Fatal("expected an authentication failure from AWS with a bogus token; got success, so this run did not reach AWS") + } + if !strings.Contains(err.Error(), "UnauthorizedException") && !strings.Contains(err.Error(), "Session token not found") { + t.Fatalf("expected a live AWS UnauthorizedException, got %v — the call may not have reached AWS at all", err) } - markerPath, logPath := writeMarker(t, dir) - const profileName = "iga3789-control-profile" - config := fmt.Sprintf(` -[profile %s] -region = us-east-1 -credential_process = %s -`, profileName, markerPath) - if err := os.WriteFile(filepath.Join(home, ".aws", "config"), []byte(config), 0o600); err != nil { - t.Fatalf("write ~/.aws/config: %v", err) + if n := invocationCount(t, logPath); n != 0 { + t.Fatalf("credential_process was entered %d time(s); the fixed child environment must make that impossible", n) } +} - setIsolatedAWSEnv(t, home, "") - os.Setenv("MARKER_LOG", logPath) - t.Cleanup(func() { os.Unsetenv("MARKER_LOG") }) +// TestIGA3789_SSOLoginDoesNotRecurse covers cone's other AWS CLI spawn site. +func TestIGA3789_SSOLoginDoesNotRecurse(t *testing.T) { + logPath := setupReproHome(t, "aws-testrole") + t.Setenv("AWS_PROFILE", "aws-testrole") - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - // A plain SigV4 operation — unlike sso get-role-credentials / sso login, - // this one genuinely needs resolved IAM credentials, so the CLI must - // consult credential_process. - cmd := exec.CommandContext(ctx, "aws", "sts", "get-caller-identity", "--profile", profileName) //nolint:gosec // test-controlled args - out, err := cmd.CombinedOutput() - t.Logf("aws sts get-caller-identity: err=%v output=%s", err, strings.TrimSpace(string(out))) + // Fails at RegisterClient against the placeholder start URL; the invocation + // count is what is under test. + err := ssoLogin(ctx) + t.Logf("ssoLogin: err=%v", err) - n := markerFireCount(t, logPath) - if n == 0 { - t.Fatalf("control failed: credential_process was never invoked for an ordinary SigV4 call — this test harness (or the installed aws CLI) cannot detect credential_process invocations, so the recursion test's 0-fire result is not meaningful") + if n := invocationCount(t, logPath); n != 0 { + t.Fatalf("credential_process was entered %d time(s) via ssoLogin", n) } - t.Logf("control OK: credential_process fired %d time(s) for an ordinary SigV4 call", n) }