rootaws, lease: root scrubs AWS file and profile selectors at start; root Litestream gets off-cloud keys explicitly; environment keys win over the file; SDK quote and comment parsing (#267)
ScrubEnvironment (main, console) removes AWS_CONFIG_FILE, AWS_SHARED_CREDENTIALS_FILE, AWS_PROFILE, AWS_DEFAULT_PROFILE and AWS_SDK_LOAD_CONFIG for euid 0, and points the file selectors at an impossible path when root's HOME is not root-owned, so third-party SDK loads (Litestream's s3 replica client) cannot read service-writable config. Root replica clients get static keys from the validated off-cloud file; environment keys and instance profiles keep the default chain. Complete environment keys are used without opening the file; an unsafe file refuses on use rather than at config load. The static parser strips one quote pair and inline comments the way the SDK does.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 files changed,
+630
−25
cmd/console/main.go+2 −0internal/lease/checkpoint.go+4 −1internal/lease/claimgeneration.go+10 −2internal/lease/claimgeneration_test.go+8 −2internal/lease/endpoint.go+23 −3internal/lease/endpoint_test.go+133 −2internal/lease/lease.go+15 −5internal/lease/retention_window_test.go+8 −2internal/rootaws/config.go+43 −5internal/rootaws/config_test.go+142 −0internal/rootaws/credentials.go+20 −2internal/rootaws/credentials_linux_test.go+42 −1internal/rootaws/environment.go+37 −0internal/rootaws/environment_other.go+5 −0internal/rootaws/environment_test.go+111 −0internal/rootaws/environment_unix.go+25 −0main.go+2 −0
CI failed — run details
diff --git a/cmd/console/main.go b/cmd/console/main.go| index f44d65d5c..ab1ef043d 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -99,11 +99,13 @@ import ( |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| "github.com/carlosframework/platform/internal/status" |
| "github.com/carlosframework/platform/internal/steering" |
| ) |
| func main() { |
| + rootaws.ScrubEnvironment() |
| log.SetFlags(0) |
| if len(os.Args) < 2 { |
| usage() |
diff --git a/internal/lease/checkpoint.go b/internal/lease/checkpoint.go| index a7d7d1ec9..573208fdd 100644 |
| --- a/internal/lease/checkpoint.go |
| +++ b/internal/lease/checkpoint.go |
| @@ -26,7 +26,10 @@ func (h *Held) Checkpoint(ctx context.Context, dbPath string) (receipt Checkpoin |
| stop := context.AfterFunc(h.rn.ctx, cancel) |
| defer stop() |
| defer cancel() |
| - store, db := h.newStore(dbPath, 0) |
| + store, db, err := h.newStore(dbPath, 0) |
| + if err != nil { |
| + return receipt, err |
| + } |
| // Short checkpoints need only the explicit L0 sync. Background snapshots |
| // are unnecessary and v0.5.14 closes the DB before joining those monitors. |
| db.MonitorInterval = 0 |
diff --git a/internal/lease/claimgeneration.go b/internal/lease/claimgeneration.go| index d02f72154..003e848d5 100644 |
| --- a/internal/lease/claimgeneration.go |
| +++ b/internal/lease/claimgeneration.go |
| @@ -39,7 +39,11 @@ func (h *Held) BindGeneration(prefix string, check func(context.Context) error) |
| } |
| client := h.replicaClient |
| if client == nil { |
| - client = newReplicaClient(h.leaser.Bucket, clean, h.region) |
| + var err error |
| + client, err = newReplicaClient(h.leaser.Bucket, clean, h.region) |
| + if err != nil { |
| + return err |
| + } |
| } |
| h.replicaClient = &generationClient{client: client, check: check} |
| h.generationPrefix = clean |
| @@ -69,7 +73,11 @@ func (h *Held) BindRoot(check func(context.Context) error) error { |
| } |
| client := h.replicaClient |
| if client == nil { |
| - client = newReplicaClient(h.leaser.Bucket, base, h.region) |
| + var err error |
| + client, err = newReplicaClient(h.leaser.Bucket, base, h.region) |
| + if err != nil { |
| + return err |
| + } |
| } |
| h.replicaClient = &generationClient{client: client, check: check} |
| // The lease's own path marks the binding, so neither BindGeneration nor |
diff --git a/internal/lease/claimgeneration_test.go b/internal/lease/claimgeneration_test.go| index 68b87a421..65692522a 100644 |
| --- a/internal/lease/claimgeneration_test.go |
| +++ b/internal/lease/claimgeneration_test.go |
| @@ -154,7 +154,10 @@ func TestGenerationClientDisablesRemoteRetentionInRealStore(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - _, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + _, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if db.RetentionEnabled == bound { |
| t.Fatalf("bound=%v retention=%v", bound, db.RetentionEnabled) |
| } |
| @@ -169,7 +172,10 @@ func TestGenerationClientRealRetentionMonitorsDoNotDelete(t *testing.T) { |
| if err := h.BindGeneration(l.Path+"/g-one/", func(context.Context) error { return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if store.RetentionEnabled || db.RetentionEnabled { |
| t.Fatal("remote retention enabled") |
| } |
diff --git a/internal/lease/endpoint.go b/internal/lease/endpoint.go| index c115fdf47..37deb79a4 100644 |
| --- a/internal/lease/endpoint.go |
| +++ b/internal/lease/endpoint.go |
| @@ -16,8 +16,11 @@ package lease |
| import ( |
| "context" |
| + "errors" |
| "fmt" |
| + "os" |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| lss3 "github.com/benbjohnson/litestream/s3" |
| @@ -118,8 +121,25 @@ func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string, |
| // the non-AWS default region) from a non-empty Endpoint on its own, and the |
| // signed-payload and content-MD5 defaults NewReplicaClient already ships are |
| // the ones every compatible in litestream's provider table wants. |
| -func newReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient { |
| +func newReplicaClient(bucket, prefix, region string) (*lss3.ReplicaClient, error) { |
| + return newReplicaClientForUID(bucket, prefix, region, os.Geteuid(), rootaws.OffcloudCredentials) |
| +} |
| + |
| +var errRootReplicaSessionToken = errors.ErrUnsupported |
| + |
| +func newReplicaClientForUID(bucket, prefix, region string, euid int, offcloud func() (aws.Credentials, error)) (*lss3.ReplicaClient, error) { |
| c := lss3.NewReplicaClient() |
| + if euid == 0 { |
| + creds, err := offcloud() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if creds.SessionToken != "" { |
| + return nil, errRootReplicaSessionToken |
| + } |
| + c.AccessKeyID = creds.AccessKeyID |
| + c.SecretAccessKey = creds.SecretAccessKey |
| + } |
| c.Bucket = bucket |
| c.Path = prefix |
| c.Region = region |
| @@ -127,7 +147,7 @@ func newReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient { |
| c.Endpoint = ep |
| c.ForcePathStyle = true |
| } |
| - return c |
| + return c, nil |
| } |
| // ReadReplicaClient is newReplicaClient for READERS outside this package — |
| @@ -146,5 +166,5 @@ func ReadReplicaClient(ctx context.Context, bucket, prefix, region string) (*lss |
| if err != nil { |
| return nil, err |
| } |
| - return newReplicaClient(bucket, prefix, region), nil |
| + return newReplicaClient(bucket, prefix, region) |
| } |
diff --git a/internal/lease/endpoint_test.go b/internal/lease/endpoint_test.go| index 68346f032..dd341a561 100644 |
| --- a/internal/lease/endpoint_test.go |
| +++ b/internal/lease/endpoint_test.go |
| @@ -10,17 +10,21 @@ package lease |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| + "strings" |
| "sync" |
| "testing" |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| "github.com/benbjohnson/litestream" |
| lss3 "github.com/benbjohnson/litestream/s3" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // fakeS3 is just enough of S3 for the leaser's three verbs (GET/PUT/DELETE on |
| @@ -197,9 +201,13 @@ func TestNewLeaserClient_EndpointConfig(t *testing.T) { |
| // LTX files beside lock.json. A replica client that ignored the override while |
| // the leaser honoured it would split one lease across two stores. |
| func TestNewReplicaClient_EndpointConfig(t *testing.T) { |
| + fakeCreds(t) |
| t.Run("override", func(t *testing.T) { |
| t.Setenv("CARLOS_S3_ENDPOINT", "https://fsn1.your-objectstorage.com") |
| - c := newReplicaClient("b", "p", "eu-central-1") |
| + c, err := newReplicaClient("b", "p", "eu-central-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if c.Endpoint != "https://fsn1.your-objectstorage.com" { |
| t.Errorf("Endpoint = %q, want the override", c.Endpoint) |
| } |
| @@ -213,7 +221,10 @@ func TestNewReplicaClient_EndpointConfig(t *testing.T) { |
| t.Run("no override", func(t *testing.T) { |
| t.Setenv("CARLOS_S3_ENDPOINT", "") |
| - c := newReplicaClient("b", "p", "eu-west-1") |
| + c, err := newReplicaClient("b", "p", "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if c.Endpoint != "" || c.ForcePathStyle { |
| t.Errorf("Endpoint/ForcePathStyle = %q/%v, want empty/false without an override", c.Endpoint, c.ForcePathStyle) |
| } |
| @@ -338,3 +349,123 @@ func TestReadReplicaClientResolvesAnEmptyRegionLikeRestore(t *testing.T) { |
| t.Errorf("Endpoint = %q ForcePathStyle = %v, want the override applied", c.Endpoint, c.ForcePathStyle) |
| } |
| } |
| + |
| +func TestRootReplicaClientEnvironmentKeepsDefaultChain(t *testing.T) { |
| + for _, region := range []string{"eu-west-1", ""} { |
| + t.Run("region="+region, func(t *testing.T) { |
| + fakeCreds(t) |
| + t.Setenv("AWS_SESSION_TOKEN", "test-session-token") |
| + t.Setenv("AWS_PROFILE", "") |
| + t.Setenv("AWS_DEFAULT_PROFILE", "") |
| + dir := t.TempDir() |
| + marker := filepath.Join(dir, "executed") |
| + config := filepath.Join(dir, "config") |
| + if err := os.WriteFile(config, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("AWS_CONFIG_FILE", config) |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", config) |
| + var mu sync.Mutex |
| + var locations, listings int |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if !strings.Contains(r.Header.Get("Authorization"), "Credential=test-key/") || r.Header.Get("X-Amz-Security-Token") != "test-session-token" { |
| + t.Error("request did not carry the explicit keys and session token") |
| + } |
| + w.Header().Set("Content-Type", "application/xml") |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if r.URL.Query().Has("location") { |
| + locations++ |
| + fmt.Fprint(w, `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">eu-west-1</LocationConstraint>`) |
| + } else { |
| + listings++ |
| + fmt.Fprint(w, `<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><KeyCount>0</KeyCount><IsTruncated>false</IsTruncated></ListBucketResult>`) |
| + } |
| + })) |
| + defer srv.Close() |
| + // Keep Endpoint empty to exercise Litestream's bucket-region lookup. |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + t.Setenv("AWS_ENDPOINT_URL_S3", srv.URL) |
| + c, err := newReplicaClientForUID("b", "p", region, 0, rootaws.OffcloudCredentials) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c.ForcePathStyle = true |
| + if c.AccessKeyID != "" || c.SecretAccessKey != "" { |
| + t.Fatal("environment credentials must use the upstream default chain") |
| + } |
| + if _, err := c.GenerationsV3(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if listings == 0 || (region == "" && locations != 1) || (region != "" && locations != 0) { |
| + t.Fatalf("locations=%d listings=%d for region %q", locations, listings, region) |
| + } |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("credential_process executed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestNonRootReplicaClientKeepsDefaultProvider(t *testing.T) { |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 1001, func() (aws.Credentials, error) { |
| + t.Fatal("non-root read root credentials") |
| + return aws.Credentials{}, nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.AccessKeyID != "" || c.SecretAccessKey != "" { |
| + t.Fatal("non-root credential selection changed") |
| + } |
| +} |
| + |
| +func TestRootReplicaClientOffcloudCredentials(t *testing.T) { |
| + for _, token := range []string{"", "FILE_TOKEN"} { |
| + t.Run("token="+token, func(t *testing.T) { |
| + body := "[default]\naws_access_key_id=FILE_ACCESS\naws_secret_access_key=FILE_SECRET\n" |
| + if token != "" { |
| + body += "aws_session_token=" + token + "\n" |
| + } |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) { |
| + return rootaws.ParseStaticCredentials([]byte(body)) |
| + }) |
| + if token != "" { |
| + if !errors.Is(err, errRootReplicaSessionToken) || c != nil { |
| + t.Fatalf("token-bearing file: client=%v err=%v", c, err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.AccessKeyID != "FILE_ACCESS" || c.SecretAccessKey != "FILE_SECRET" { |
| + t.Fatal("off-cloud file keys were not set on the upstream client") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootReplicaClientWithoutOffcloudKeepsDefaultChain(t *testing.T) { |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) { |
| + return aws.Credentials{}, nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.AccessKeyID != "" || c.SecretAccessKey != "" { |
| + t.Fatal("instance-profile credentials must use the upstream default chain") |
| + } |
| +} |
| + |
| +func TestRootReplicaClientRefusesUnsafeOffcloudCredentials(t *testing.T) { |
| + refused := errors.New("unsafe file") |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) { |
| + return aws.Credentials{}, refused |
| + }) |
| + if !errors.Is(err, refused) || c != nil { |
| + t.Fatalf("unsafe file: client=%v err=%v", c, err) |
| + } |
| +} |
diff --git a/internal/lease/lease.go b/internal/lease/lease.go| index 1de6e5479..61788e219 100644 |
| --- a/internal/lease/lease.go |
| +++ b/internal/lease/lease.go |
| @@ -302,7 +302,10 @@ func (h *Held) Replicate(ctx context.Context, dbPath string, syncInterval time.D |
| stop := context.AfterFunc(h.rn.ctx, cancel) |
| defer stop() |
| defer cancel() |
| - store, db := h.newStore(dbPath, syncInterval) |
| + store, db, err := h.newStore(dbPath, syncInterval) |
| + if err != nil { |
| + return nil, err |
| + } |
| if err := store.Open(opCtx); err != nil { |
| cancel() |
| closeErr := store.Close(opCtx) |
| @@ -317,7 +320,7 @@ func (h *Held) Replicate(ctx context.Context, dbPath string, syncInterval time.D |
| return &Replicator{leaser: h.leaser, store: store, db: db, rn: h.rn}, nil |
| } |
| -func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.Store, *litestream.DB) { |
| +func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.Store, *litestream.DB, error) { |
| db := litestream.NewDB(dbPath) |
| // Preserve the activator namespace: sharing the default metadata directory |
| // with another replicator corrupts shadow state even across sequential runs. |
| @@ -325,7 +328,11 @@ func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream. |
| db.MonitorInterval = dbMonitorInterval |
| client := h.replicaClient |
| if client == nil { |
| - client = newReplicaClient(h.leaser.Bucket, h.leaser.Path, h.region) |
| + var err error |
| + client, err = newReplicaClient(h.leaser.Bucket, h.leaser.Path, h.region) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| } |
| db.Replica = litestream.NewReplicaWithClient(db, client) |
| if syncInterval > 0 { |
| @@ -340,7 +347,7 @@ func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream. |
| store.SetRetentionEnabled(false) |
| } |
| store.L0RetentionCheckInterval = l0RetentionCheckInterval |
| - return store, db |
| + return store, db, nil |
| } |
| // Replicator couples an exclusive S3 lease with a running litestream |
| @@ -453,7 +460,10 @@ func Restore(ctx context.Context, dbPath, bucket, prefix, region string) (bool, |
| if err != nil { |
| return false, err |
| } |
| - client := newReplicaClient(bucket, prefix, region) |
| + client, err := newReplicaClient(bucket, prefix, region) |
| + if err != nil { |
| + return false, err |
| + } |
| // A nested row's instance directory may not exist yet the first time it |
| // wakes: the directory is created by the spawn path (isolated-exec |
diff --git a/internal/lease/retention_window_test.go b/internal/lease/retention_window_test.go| index c5235c0e1..ac38c7884 100644 |
| --- a/internal/lease/retention_window_test.go |
| +++ b/internal/lease/retention_window_test.go |
| @@ -16,7 +16,10 @@ func TestNewStorePinsTheRecoveryWindow(t *testing.T) { |
| l := lss3.NewLeaser() |
| l.Path = "instances/x.example/inc" |
| h := &Held{leaser: l, replicaClient: &generationFake{}} |
| - store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if store.SnapshotRetention != SnapshotRetention || SnapshotRetention != 7*24*time.Hour { |
| t.Errorf("SnapshotRetention = %v (constant %v), want 7 days", store.SnapshotRetention, SnapshotRetention) |
| } |
| @@ -37,7 +40,10 @@ func TestNewStoreWindowLeavesAGenerationsRetentionOff(t *testing.T) { |
| if err := h.BindGeneration(l.Path+"/g-one/", func(context.Context) error { return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if store.RetentionEnabled || db.RetentionEnabled { |
| t.Fatal("a bound generation's store has retention on") |
| } |
diff --git a/internal/rootaws/config.go b/internal/rootaws/config.go| index 0990f3ee8..c8229d77e 100644 |
| --- a/internal/rootaws/config.go |
| +++ b/internal/rootaws/config.go |
| @@ -15,6 +15,23 @@ import ( |
| const credentialsPath = "/etc/carlos/credentials" |
| +// OffcloudCredentials returns the validated root file credentials only when |
| +// complete environment keys do not take precedence. It never contacts IMDS. |
| +func OffcloudCredentials() (aws.Credentials, error) { |
| + return offcloudCredentials(credentialsPath) |
| +} |
| + |
| +func offcloudCredentials(path string) (aws.Credentials, error) { |
| + if envAccessKey() != "" && envSecretKey() != "" { |
| + return aws.Credentials{}, nil |
| + } |
| + creds, err := readStaticCredentials(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return aws.Credentials{}, nil |
| + } |
| + return creds, err |
| +} |
| + |
| // LoadDefaultConfig preserves the SDK chain for non-root callers. Root disables |
| // both shared-file lists, including paths selected through HOME or AWS_* vars. |
| func LoadDefaultConfig(ctx context.Context, opts ...func(*awsconfig.LoadOptions) error) (aws.Config, error) { |
| @@ -32,15 +49,36 @@ func loadRootConfig(ctx context.Context, path string, opts ...func(*awsconfig.Lo |
| log.Printf("ALERT: %v", err) |
| return aws.Config{}, err |
| } |
| - static, err := readStaticCredentials(path) |
| - if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err) |
| - return aws.Config{}, fmt.Errorf("root AWS credentials: %w", err) |
| + // Complete environment keys are used as given and the fallback file is not |
| + // opened, so a file nobody uses cannot make loading fail. |
| + access, secret := envAccessKey(), envSecretKey() |
| + envKeys := access != "" && secret != "" |
| + var static aws.Credentials |
| + var refused error |
| + if !envKeys { |
| + var err error |
| + static, err = readStaticCredentials(path) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err) |
| + refused = fmt.Errorf("root AWS credentials: %w", err) |
| + } |
| + if err != nil { |
| + static = aws.Credentials{} |
| + } |
| } |
| // Append last so callers cannot restore file loading or select a profile. |
| opts = append(opts, awsconfig.WithSharedConfigFiles([]string{}), |
| awsconfig.WithSharedCredentialsFiles([]string{}), awsconfig.WithSharedConfigProfile("")) |
| - if err == nil && !(envAccessKey() != "" && envSecretKey() != "") { |
| + switch { |
| + case refused != nil: |
| + // Opening a store must not abort adoption before it uses AWS. Refuse |
| + // retrieval instead, without falling through to another identity. |
| + opts = append(opts, awsconfig.WithCredentialsProvider(aws.CredentialsProviderFunc( |
| + func(context.Context) (aws.Credentials, error) { return aws.Credentials{}, refused }))) |
| + case envKeys: |
| + opts = append(opts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( |
| + access, secret, os.Getenv("AWS_SESSION_TOKEN")))) |
| + case static.AccessKeyID != "": |
| opts = append(opts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( |
| static.AccessKeyID, static.SecretAccessKey, static.SessionToken))) |
| } |
diff --git a/internal/rootaws/config_test.go b/internal/rootaws/config_test.go| index 2af7c03bd..f54defb37 100644 |
| --- a/internal/rootaws/config_test.go |
| +++ b/internal/rootaws/config_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "testing" |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/credentials" |
| ) |
| func TestParseStaticCredentials(t *testing.T) { |
| @@ -38,6 +39,99 @@ func TestParseStaticCredentials(t *testing.T) { |
| } |
| } |
| +func TestParseStaticCredentialsSDKSemantics(t *testing.T) { |
| + parse := func(access, secret string) (string, string, error) { |
| + got, err := ParseStaticCredentials([]byte("[default]\naws_access_key_id = " + access + "\naws_secret_access_key = " + secret + "\n")) |
| + return got.AccessKeyID, got.SecretAccessKey, err |
| + } |
| + for name, tc := range map[string]struct{ access, secret, wantAccess, wantSecret string }{ |
| + "embedded-hash": {"ACC#ESS", "SEC#RET", "ACC#ESS", "SEC#RET"}, |
| + "embedded-semicolon": {"ACC;ESS", "SEC;RET", "ACC;ESS", "SEC;RET"}, |
| + "space-hash-comment": {"ACCESS # note", "SECRET ; note", "ACCESS", "SECRET"}, |
| + "tab-comment": {"ACCESS\t# note", "SECRET\t; note", "ACCESS", "SECRET"}, |
| + "double-quotes": {`"ACCESS"`, `"SEC#RET"`, "ACCESS", "SEC#RET"}, |
| + "single-quotes": {`'ACCESS'`, `'SEC;RET'`, "ACCESS", "SEC;RET"}, |
| + "quotes-and-comments": {`"ACCESS" # note`, `'SECRET' ; note`, "ACCESS", "SECRET"}, |
| + "one-pair-only": {`""ACCESS""`, `'"SECRET"'`, `"ACCESS"`, `"SECRET"`}, |
| + "mismatched-quotes": {`"ACCESS'`, `'SECRET`, `"ACCESS'`, `'SECRET`}, |
| + "lone-quote": {`"`, `'`, `"`, `'`}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + access, secret, err := parse(tc.access, tc.secret) |
| + if err != nil || access != tc.wantAccess || secret != tc.wantSecret { |
| + t.Fatalf("parsed %q %q, %v", access, secret, err) |
| + } |
| + }) |
| + } |
| + for name, body := range map[string]string{ |
| + "empty-double-quotes": "[default]\naws_access_key_id = \"\"\naws_secret_access_key = SECRET\n", |
| + "empty-single-quotes": "[default]\naws_access_key_id = ACCESS\naws_secret_access_key = ''\n", |
| + "comment-only-value": "[default]\naws_access_key_id = # note\naws_secret_access_key = SECRET\n", |
| + "quoted-spaces": "[default]\naws_access_key_id = \"AC CESS\"\naws_secret_access_key = SECRET\n", |
| + "process-with-quotes": "[default]\naws_access_key_id = ACCESS\naws_secret_access_key = SECRET\n\"credential_process\" = bad\n", |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := ParseStaticCredentials([]byte(body)); err == nil { |
| + t.Fatal("accepted invalid static credentials") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootConfigEnvironmentKeysIgnoreMalformedFile(t *testing.T) { |
| + for _, token := range []string{"", "ENV_TOKEN"} { |
| + t.Run("token="+token, func(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + t.Setenv("AWS_SESSION_TOKEN", token) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch /never-run\n[other]\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("malformed unused file made config fatal: %v", err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" || got.SessionToken != token { |
| + t.Fatalf("environment credentials failed: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootConfigIncompleteEnvironmentKeysStillValidateFile(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process = bad\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("unused unsafe credentials made config loading fatal: %v", err) |
| + } |
| + if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("malformed file accepted without a complete environment key pair") |
| + } |
| +} |
| + |
| +func TestRootConfigEnvironmentKeysBeatCallerCredentials(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + caller := awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("CALLER", "CALLER_SECRET", "")) |
| + cfg, err := loadRootConfig(context.Background(), filepath.Join(t.TempDir(), "absent"), caller) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" { |
| + t.Fatalf("caller credentials overrode the environment: %v", err) |
| + } |
| +} |
| + |
| func isolatedAWSEnv(t *testing.T) { |
| t.Helper() |
| for _, key := range []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY", "AWS_SECRET_KEY", "AWS_SESSION_TOKEN", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI"} { |
| @@ -153,3 +247,51 @@ func TestNonRootConfigKeepsSharedCredentials(t *testing.T) { |
| t.Fatalf("non-root shared credentials changed: %v", err) |
| } |
| } |
| + |
| +func TestRootConfigUnsafeFileRefusesOnUseWithoutFallback(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + fallback := credentials.NewStaticCredentialsProvider("OTHER_IDENTITY", "SECRET", "") |
| + cfg, err := loadRootConfig(context.Background(), path, awsconfig.WithCredentialsProvider(fallback)) |
| + if err != nil { |
| + t.Fatalf("opening an unused store would abort adoption: %v", err) |
| + } |
| + if got, err := cfg.Credentials.Retrieve(context.Background()); err == nil || got.AccessKeyID != "" { |
| + t.Fatal("unsafe file fell through to another identity") |
| + } |
| +} |
| + |
| +func TestRootCredentialsOffcloudSelection(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + got, err := offcloudCredentials(path) |
| + if err != nil || got.HasKeys() { |
| + t.Fatalf("absent file: credentials=%v err=%v", got.HasKeys(), err) |
| + } |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := offcloudCredentials(path); err == nil { |
| + t.Fatal("unsafe file was accepted") |
| + } |
| + t.Run("incomplete environment keys", func(t *testing.T) { |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + if _, err := offcloudCredentials(path); err == nil { |
| + t.Fatal("incomplete environment keys bypassed the unsafe file") |
| + } |
| + }) |
| + for _, names := range [][2]string{{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}, {"AWS_ACCESS_KEY", "AWS_SECRET_KEY"}} { |
| + t.Run(names[0], func(t *testing.T) { |
| + t.Setenv(names[0], "ENV_ACCESS") |
| + t.Setenv(names[1], "ENV_SECRET") |
| + t.Setenv("AWS_SESSION_TOKEN", "ENV_TOKEN") |
| + got, err := offcloudCredentials(path) |
| + if err != nil || got.HasKeys() || got.SessionToken != "" { |
| + t.Fatalf("environment keys must bypass file and leave static fields unset: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/rootaws/credentials.go b/internal/rootaws/credentials.go| index e3f374376..d5993a946 100644 |
| --- a/internal/rootaws/credentials.go |
| +++ b/internal/rootaws/credentials.go |
| @@ -31,8 +31,10 @@ func ParseStaticCredentials(body []byte) (aws.Credentials, error) { |
| profile = true |
| continue |
| } |
| - key, value, found := strings.Cut(line, "=") |
| - key, value = strings.TrimSpace(key), strings.TrimSpace(value) |
| + // Same order as the SDK: cut the inline comment, then trim, then |
| + // remove one matching pair of quotes. |
| + key, value, found := strings.Cut(trimInlineComment(line), "=") |
| + key, value = strings.TrimSpace(key), unquote(strings.TrimSpace(value)) |
| if !profile || !found || value == "" { |
| return empty, fmt.Errorf("invalid static credentials entry") |
| } |
| @@ -51,3 +53,19 @@ func ParseStaticCredentials(body []byte) (aws.Credentials, error) { |
| } |
| return aws.Credentials{AccessKeyID: values["aws_access_key_id"], SecretAccessKey: values["aws_secret_access_key"], SessionToken: values["aws_session_token"], Source: "CarlosStaticCredentials"}, nil |
| } |
| + |
| +// trimInlineComment matches the SDK's property comments: only a # or ; after |
| +// a space or tab starts one, so values may contain either character. |
| +func trimInlineComment(s string) string { |
| + for _, marker := range []string{" #", " ;", "\t#", "\t;"} { |
| + s, _, _ = strings.Cut(s, marker) |
| + } |
| + return s |
| +} |
| + |
| +func unquote(s string) string { |
| + if len(s) >= 2 && (s[0] == '"' && s[len(s)-1] == '"' || s[0] == '\'' && s[len(s)-1] == '\'') { |
| + return s[1 : len(s)-1] |
| + } |
| + return s |
| +} |
diff --git a/internal/rootaws/credentials_linux_test.go b/internal/rootaws/credentials_linux_test.go| index d5f866848..80dcb739e 100644 |
| --- a/internal/rootaws/credentials_linux_test.go |
| +++ b/internal/rootaws/credentials_linux_test.go |
| @@ -63,7 +63,11 @@ func TestRootCredentialsRejectUnsafeFiles(t *testing.T) { |
| old := log.Writer() |
| log.SetOutput(&logs) |
| defer log.SetOutput(old) |
| - if _, err := loadRootConfig(context.Background(), path); err == nil { |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("unused unsafe credentials made config loading fatal: %v", err) |
| + } |
| + if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil { |
| t.Fatal("unsafe file accepted") |
| } |
| if !strings.Contains(logs.String(), "ALERT:") { |
| @@ -73,6 +77,39 @@ func TestRootCredentialsRejectUnsafeFiles(t *testing.T) { |
| } |
| } |
| +func TestRootCredentialsEnvironmentKeysIgnoreServiceOwnedFile(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=FILE\naws_secret_access_key=SECRET\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if os.Geteuid() == 0 { |
| + if err := os.Chown(path, 65534, 65534); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Without environment keys the same file is refused, so the file is unsafe. |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "") |
| + unsafeConfig, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := unsafeConfig.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("service-owned fixture was not unsafe") |
| + } |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("service-owned unused file made config fatal: %v", err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" { |
| + t.Fatalf("environment credentials failed: %v", err) |
| + } |
| +} |
| + |
| func TestRootCredentialsStaticProvider(t *testing.T) { |
| if os.Geteuid() != 0 { |
| t.Skip("requires root-owned credentials fixture") |
| @@ -82,6 +119,10 @@ func TestRootCredentialsStaticProvider(t *testing.T) { |
| if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=STATIC\naws_secret_access_key=SECRET\naws_session_token=TOKEN\n"), 0640); err != nil { |
| t.Fatal(err) |
| } |
| + fileCreds, err := offcloudCredentials(path) |
| + if err != nil || fileCreds.AccessKeyID != "STATIC" || fileCreds.SecretAccessKey != "SECRET" || fileCreds.SessionToken != "TOKEN" { |
| + t.Fatalf("off-cloud file credentials failed: %v", err) |
| + } |
| cfg, err := loadRootConfig(context.Background(), path) |
| if err != nil { |
| t.Fatal(err) |
diff --git a/internal/rootaws/environment.go b/internal/rootaws/environment.go| new file mode 100644 |
| index 000000000..f1b090fc8 |
| --- /dev/null |
| +++ b/internal/rootaws/environment.go |
| @@ -0,0 +1,37 @@ |
| +package rootaws |
| + |
| +import ( |
| + "log" |
| + "os" |
| +) |
| + |
| +// A child of a device cannot exist, even if a service user creates directories. |
| +const disabledSharedFile = "/dev/null/carlos-root-aws" |
| + |
| +// ScrubEnvironment must run before dispatch or goroutines in each executable. |
| +// It also protects SDK loads in dependencies that do not call this package. |
| +func ScrubEnvironment() { |
| + scrubEnvironment(os.Geteuid(), rootOwnedHome) |
| +} |
| + |
| +func scrubEnvironment(euid int, trustedHome func(string) bool) { |
| + if euid != 0 { |
| + return |
| + } |
| + changed := false |
| + for _, key := range []string{"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "AWS_DEFAULT_PROFILE"} { |
| + if _, set := os.LookupEnv(key); set { |
| + changed = true |
| + } |
| + _ = os.Unsetenv(key) |
| + } |
| + // SDK v2 ignores this switch; older SDKs must not opt into shared config. |
| + _ = os.Unsetenv("AWS_SDK_LOAD_CONFIG") |
| + if home := os.Getenv("HOME"); home == "" || !trustedHome(home) { |
| + _ = os.Setenv("AWS_CONFIG_FILE", disabledSharedFile) |
| + _ = os.Setenv("AWS_SHARED_CREDENTIALS_FILE", disabledSharedFile) |
| + } |
| + if changed { |
| + log.Print("ALERT: root AWS shared-file and profile environment overrides ignored") |
| + } |
| +} |
diff --git a/internal/rootaws/environment_other.go b/internal/rootaws/environment_other.go| new file mode 100644 |
| index 000000000..0d3aebf40 |
| --- /dev/null |
| +++ b/internal/rootaws/environment_other.go |
| @@ -0,0 +1,5 @@ |
| +//go:build !linux && !darwin |
| + |
| +package rootaws |
| + |
| +func rootOwnedHome(string) bool { return false } |
diff --git a/internal/rootaws/environment_test.go b/internal/rootaws/environment_test.go| new file mode 100644 |
| index 000000000..f6c8ad386 |
| --- /dev/null |
| +++ b/internal/rootaws/environment_test.go |
| @@ -0,0 +1,111 @@ |
| +package rootaws |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| +) |
| + |
| +func TestScrubEnvironment(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + euid int |
| + home string |
| + trusted bool |
| + }{ |
| + {"root-home", 0, "/root", true}, |
| + {"service-home", 0, "/home/carlos", false}, |
| + {"missing-home", 0, "", true}, |
| + {"non-root", 1001, "/home/carlos", false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + t.Setenv("HOME", tc.home) |
| + keys := []string{"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "AWS_DEFAULT_PROFILE"} |
| + for _, key := range keys { |
| + t.Setenv(key, "untrusted") |
| + } |
| + t.Setenv("AWS_SDK_LOAD_CONFIG", "1") |
| + t.Setenv("AWS_ACCESS_KEY_ID", "KEEP") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "KEEP_SECRET") |
| + t.Setenv("AWS_SESSION_TOKEN", "KEEP_TOKEN") |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + scrubEnvironment(tc.euid, func(home string) bool { |
| + if home != tc.home { |
| + t.Fatal("wrong home checked") |
| + } |
| + return tc.trusted |
| + }) |
| + for _, key := range keys { |
| + got, set := os.LookupEnv(key) |
| + want := "" |
| + if tc.euid != 0 { |
| + want = "untrusted" |
| + } else if (key == "AWS_CONFIG_FILE" || key == "AWS_SHARED_CREDENTIALS_FILE") && (tc.home == "" || !tc.trusted) { |
| + want = disabledSharedFile |
| + } |
| + if got != want || set != (want != "") { |
| + t.Errorf("%s = %q, set=%v; want %q", key, got, set, want) |
| + } |
| + } |
| + wantLoad, wantLogs := "", 1 |
| + if tc.euid != 0 { |
| + wantLoad, wantLogs = "1", 0 |
| + } |
| + load, set := os.LookupEnv("AWS_SDK_LOAD_CONFIG") |
| + if load != wantLoad || set != (tc.euid != 0) || strings.Count(logs.String(), "ALERT:") != wantLogs { |
| + t.Fatal("load-config switch or log count differs") |
| + } |
| + if os.Getenv("AWS_ACCESS_KEY_ID") != "KEEP" || os.Getenv("AWS_SECRET_ACCESS_KEY") != "KEEP_SECRET" || os.Getenv("AWS_SESSION_TOKEN") != "KEEP_TOKEN" { |
| + t.Fatal("environment credentials changed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestScrubEnvironmentBlocksDependencySharedConfig(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + dir := t.TempDir() |
| + marker := filepath.Join(dir, "executed") |
| + path := filepath.Join(dir, "config") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("HOME", dir) |
| + t.Setenv("AWS_CONFIG_FILE", path) |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path) |
| + t.Setenv("AWS_PROFILE", "default") |
| + t.Setenv("AWS_DEFAULT_PROFILE", "default") |
| + t.Setenv("AWS_SDK_LOAD_CONFIG", "1") |
| + scrubEnvironment(0, func(string) bool { return false }) |
| + // This deliberately bypasses rootaws, as a third-party SDK call would. |
| + cfg, err := awsconfig.LoadDefaultConfig(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("unexpected credentials") |
| + } |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("credential_process executed") |
| + } |
| + if _, err := os.Stat(disabledSharedFile); err == nil { |
| + t.Fatal("disabled path exists") |
| + } |
| +} |
| + |
| +func TestRootOwnedHomeRejectsUntrustedPaths(t *testing.T) { |
| + for _, path := range []string{"", ".", filepath.Join(t.TempDir(), "absent"), t.TempDir()} { |
| + if rootOwnedHome(path) { |
| + t.Errorf("trusted replaceable or missing HOME %q", path) |
| + } |
| + } |
| +} |
diff --git a/internal/rootaws/environment_unix.go b/internal/rootaws/environment_unix.go| new file mode 100644 |
| index 000000000..e339df0e8 |
| --- /dev/null |
| +++ b/internal/rootaws/environment_unix.go |
| @@ -0,0 +1,25 @@ |
| +//go:build linux || darwin |
| + |
| +package rootaws |
| + |
| +import ( |
| + "path/filepath" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func rootOwnedHome(home string) bool { |
| + if !filepath.IsAbs(home) { |
| + return false |
| + } |
| + // Check the parents too: a service user must not be able to replace HOME. |
| + for path := filepath.Clean(home); ; path = filepath.Dir(path) { |
| + var st unix.Stat_t |
| + if unix.Lstat(path, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Uid != 0 || st.Mode&0o022 != 0 { |
| + return false |
| + } |
| + if path == "/" { |
| + return true |
| + } |
| + } |
| +} |
diff --git a/main.go b/main.go| index 90efe073b..17d8cfcfc 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -11,11 +11,13 @@ import ( |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/host" |
| "github.com/carlosframework/platform/internal/instanceexec" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| "github.com/carlosframework/platform/internal/version" |
| "github.com/carlosframework/platform/internal/vet" |
| ) |
| func main() { |
| + rootaws.ScrubEnvironment() |
| // Failure is reported through log.Fatal everywhere in this binary, so |
| // the logger is where the "this went wrong" colour goes — once, rather |
| // than at a few hundred call sites. Off unless stderr is a terminal, so |