diff --git a/docs/user-guide/remote-state.md b/docs/user-guide/remote-state.md new file mode 100644 index 000000000..1224a4168 --- /dev/null +++ b/docs/user-guide/remote-state.md @@ -0,0 +1,110 @@ +# Remote State (S3) + +By default, Stratus Red Team stores all state locally in `~/.stratus-red-team/`. This includes Terraform state files, technique lifecycle state, and outputs. + +For team usage or when running Stratus Red Team in ephemeral environments (CI/CD, containers), you can configure an S3 bucket as a remote state backend. This stores all state centrally, allowing multiple users or pods to share state for the same techniques. + +## Configuration + +Remote state requires a bucket name and region. These can be set via: + +1. **CLI flags** (hidden, for scripting): `--state-bucket` and `--state-bucket-region` +2. **Environment variables**: `STRATUS_STATE_BUCKET` and `STRATUS_STATE_BUCKET_REGION` +3. **Config file** (`~/.stratus-red-team/config.yaml` or `STRATUS_CONFIG_PATH`): + +```yaml +state: + bucket: myorg-stratus-state + region: us-east-1 +``` + +If no bucket is configured, Stratus Red Team uses local state (the default behavior). + +## Credentials + +The state bucket may live in a different AWS account than the one used for detonation. Credentials for the bucket are resolved separately from the target account credentials: + +| Priority | Method | Environment variable | +| -------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 1 | AWS named profile | `STRATUS_STATE_BUCKET_PROFILE` | +| 2 | Explicit static credentials | `STRATUS_STATE_AWS_ACCESS_KEY_ID`, `STRATUS_STATE_AWS_SECRET_ACCESS_KEY`, `STRATUS_STATE_AWS_SESSION_TOKEN` | +| 3 | Default credential chain | _(same as target account — a warning is logged)_ | + +!!! note + + The target account credentials used by Terraform to create resources and by the detonation code can be set "as usual" (AWS_* env var for instance) as we just wrap the AWS SDK + +## Bucket auto-creation + +If the bucket does not exist, Stratus Red Team creates it automatically with [versioning enabled](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) (recommended for Terraform state). + +!!! warning + + S3 bucket names are globally unique. Use a name specific to your organization to avoid collisions. + +## Example: team of pentesters sharing state + +```bash +# One-time setup: create a config file +cat > ~/.stratus-red-team/config.yaml < env > config. +func resolveStateBucket() string { + if flagStateBucket != "" { + return flagStateBucket + } + if env := os.Getenv(EnvVarStateBucket); env != "" { + return env + } + // Config file is loaded by the runner, but we need it here for + // the bucket name. Load it independently. + cfg := loadConfigForStateBucket() + if cfg != "" { + return cfg + } + return "" +} + +// resolveStateBucketRegion returns the region from flag > env > config. +func resolveStateBucketRegion() string { + if flagStateBucketRegion != "" { + return flagStateBucketRegion + } + if env := os.Getenv(EnvVarStateBucketRegion); env != "" { + return env + } + cfg := loadConfigForStateBucketRegion() + if cfg != "" { + return cfg + } + return "" +} + +// resolveStateBucketCredentials builds an aws.Config for the state bucket. +// Priority: named profile > explicit static creds > default chain (with warning). +func resolveStateBucketCredentials(region string) aws.Config { + opts := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(region), + } + + if profile := os.Getenv(EnvVarStateBucketProfile); profile != "" { + log.Printf("Using AWS profile %q for state bucket", profile) + opts = append(opts, awsconfig.WithSharedConfigProfile(profile)) + } else if accessKey := os.Getenv(EnvVarStateAWSAccessKeyID); accessKey != "" { + secretKey := os.Getenv(EnvVarStateAWSSecretKey) + sessionToken := os.Getenv(EnvVarStateAWSSessionToken) + log.Println("Using explicit credentials for state bucket") + opts = append(opts, awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKey, secretKey, sessionToken), + )) + } else { + log.Println("Warning: no dedicated credentials for state bucket, using default credential chain (same as target account)") + } + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...) + if err != nil { + log.Fatalf("Unable to load AWS config for state bucket: %v", err) + } + return cfg +} + +func ensureBucketExists(cfg *state.S3BackendConfig) { + s3Client := s3.NewFromConfig(cfg.AWSConfig, func(o *s3.Options) { + o.DisableLogOutputChecksumValidationSkipped = true + }) + + _, err := s3Client.HeadBucket(context.Background(), &s3.HeadBucketInput{ + Bucket: &cfg.BucketName, + }) + if err == nil { + return + } + + log.Printf("Creating state bucket s3://%s in %s", cfg.BucketName, cfg.Region) + createInput := &s3.CreateBucketInput{ + Bucket: &cfg.BucketName, + } + // LocationConstraint is required for all regions except us-east-1 + if cfg.Region != "us-east-1" { + createInput.CreateBucketConfiguration = &s3types.CreateBucketConfiguration{ + LocationConstraint: s3types.BucketLocationConstraint(cfg.Region), + } + } + + _, err = s3Client.CreateBucket(context.Background(), createInput) + if err != nil { + log.Fatalf("Unable to create state bucket: %v", err) + } + + // Enable versioning (recommended for Terraform S3 backend) + _, err = s3Client.PutBucketVersioning(context.Background(), &s3.PutBucketVersioningInput{ + Bucket: &cfg.BucketName, + VersioningConfiguration: &s3types.VersioningConfiguration{ + Status: s3types.BucketVersioningStatusEnabled, + }, + }) + if err != nil { + log.Printf("Warning: bucket created but versioning could not be enabled: %v", err) + } + + log.Printf("State bucket s3://%s created with versioning enabled", cfg.BucketName) +} + +// loadConfigForStateBucket reads state.bucket from the config file. +func loadConfigForStateBucket() string { + cfg, err := loadConfigQuiet() + if err != nil || cfg == nil { + return "" + } + return cfg.GetStateConfig().Bucket +} + +// loadConfigForStateBucketRegion reads state.region from the config file. +func loadConfigForStateBucketRegion() string { + cfg, err := loadConfigQuiet() + if err != nil || cfg == nil { + return "" + } + return cfg.GetStateConfig().Region +} + +// loadConfigQuiet loads the config without fataling on error. +func loadConfigQuiet() (config.Config, error) { + return config.LoadConfig() +} + +// buildRunnerOptions returns the RunnerOptions for S3 remote state, or nil if local state is configured. +func buildRunnerOptions() []runner.RunnerOption { + s3Cfg := resolveS3BackendConfig() + if s3Cfg == nil { + return nil + } + return []runner.RunnerOption{runner.WithS3Backend(*s3Cfg)} +} diff --git a/v2/cmd/stratus/cmd/status_cmd.go b/v2/cmd/stratus/cmd/status_cmd.go index 6c8638451..d01bc28f2 100644 --- a/v2/cmd/stratus/cmd/status_cmd.go +++ b/v2/cmd/stratus/cmd/status_cmd.go @@ -35,7 +35,7 @@ func doStatusCmd(techniques []*stratus.AttackTechnique) { t := GetDisplayTable() t.AppendHeader(table.Row{"ID", "Name", "Status"}) for i := range techniques { - stateManager := state.NewFileSystemStateManager(techniques[i]) + stateManager := resolveStateManager(techniques[i]) techniqueState := stateManager.GetTechniqueState() if techniqueState == "" { techniqueState = stratus.AttackTechniqueStatusCold @@ -45,6 +45,15 @@ func doStatusCmd(techniques []*stratus.AttackTechnique) { t.Render() } +// resolveStateManager returns the appropriate StateManager based on whether S3 remote state is configured +func resolveStateManager(technique *stratus.AttackTechnique) state.StateManager { + s3Cfg := resolveS3BackendConfig() + if s3Cfg != nil { + return state.NewS3StateManager(technique, *s3Cfg) + } + return state.NewFileSystemStateManager(technique) +} + func colorState(state stratus.AttackTechniqueState) string { stateString := string(state) switch state { diff --git a/v2/cmd/stratus/cmd/warmup_cmd.go b/v2/cmd/stratus/cmd/warmup_cmd.go index 0984bfed9..2022f7695 100644 --- a/v2/cmd/stratus/cmd/warmup_cmd.go +++ b/v2/cmd/stratus/cmd/warmup_cmd.go @@ -63,7 +63,7 @@ func doWarmupCmd(techniques []*stratus.AttackTechnique) { func warmupCmdWorker(techniques <-chan *stratus.AttackTechnique, errors chan<- error) { for technique := range techniques { - stratusRunner := runner.NewRunner(technique, forceWarmup) + stratusRunner := runner.NewRunner(technique, forceWarmup, buildRunnerOptions()...) _, err := stratusRunner.WarmUp() errors <- err } diff --git a/v2/internal/state/s3_state.go b/v2/internal/state/s3_state.go index 951b0af63..7c5577a26 100644 --- a/v2/internal/state/s3_state.go +++ b/v2/internal/state/s3_state.go @@ -45,7 +45,9 @@ func NewS3StateManager(technique *stratus.AttackTechnique, cfg S3BackendConfig) homeDirectory, _ := os.UserHomeDir() sm := &S3StateManager{ config: cfg, - s3Client: s3.NewFromConfig(cfg.AWSConfig), + s3Client: s3.NewFromConfig(cfg.AWSConfig, func(o *s3.Options) { + o.DisableLogOutputChecksumValidationSkipped = true + }), technique: technique, rootDirectory: filepath.Join(homeDirectory, config.StratusBaseDirectoryName), fileSystem: &LocalFileSystem{}, diff --git a/v2/pkg/stratus/config/config.go b/v2/pkg/stratus/config/config.go index 86d325744..72dce2927 100644 --- a/v2/pkg/stratus/config/config.go +++ b/v2/pkg/stratus/config/config.go @@ -21,8 +21,15 @@ const ( // // It is used to override techniques specifications. It can set variables in Terraform, or be called // directly in the technique code. +// StateConfig holds remote state backend configuration from the config file. +type StateConfig struct { + Bucket string `yaml:"bucket"` + Region string `yaml:"region"` +} + type Config interface { GetKubernetesConfig() KubernetesConfig + GetStateConfig() StateConfig GetTerraformVariables(techniqueID string) map[string]string } @@ -89,6 +96,16 @@ func (c *ConfigImpl) GetKubernetesConfig() KubernetesConfig { return c.kubernetes } +func (c *ConfigImpl) GetStateConfig() StateConfig { + if c == nil || c.v == nil { + return StateConfig{} + } + return StateConfig{ + Bucket: c.v.GetString("state.bucket"), + Region: c.v.GetString("state.region"), + } +} + // buildMergedViper produces a Viper containing the merged technique config. // Each provider populates its own subtree via populateViperOverride. func (c *ConfigImpl) buildMergedViper(techniqueID string) *viper.Viper { diff --git a/v2/pkg/stratus/config/config.schema.json b/v2/pkg/stratus/config/config.schema.json index ebc4dddde..b8663871e 100644 --- a/v2/pkg/stratus/config/config.schema.json +++ b/v2/pkg/stratus/config/config.schema.json @@ -5,7 +5,8 @@ "type": "object", "additionalProperties": false, "properties": { - "kubernetes": { "$ref": "#/$defs/kubernetes" } + "kubernetes": { "$ref": "#/$defs/kubernetes" }, + "state": { "$ref": "#/$defs/stateConfig" } }, "$defs": { "kubernetes": { @@ -27,6 +28,20 @@ "pod": { "$ref": "#/$defs/podConfig" } } }, + "stateConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "bucket": { + "type": "string", + "description": "S3 bucket name for remote state storage" + }, + "region": { + "type": "string", + "description": "AWS region of the state bucket" + } + } + }, "podConfig": { "type": "object", "additionalProperties": false, diff --git a/v2/pkg/stratus/config/mocks/Config.go b/v2/pkg/stratus/config/mocks/Config.go index 75949327f..b173fba88 100644 --- a/v2/pkg/stratus/config/mocks/Config.go +++ b/v2/pkg/stratus/config/mocks/Config.go @@ -32,6 +32,24 @@ func (_m *Config) GetKubernetesConfig() config.KubernetesConfig { return r0 } +// GetStateConfig provides a mock function with no fields +func (_m *Config) GetStateConfig() config.StateConfig { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetStateConfig") + } + + var r0 config.StateConfig + if rf, ok := ret.Get(0).(func() config.StateConfig); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(config.StateConfig) + } + + return r0 +} + // GetTerraformVariables provides a mock function with given fields: techniqueID func (_m *Config) GetTerraformVariables(techniqueID string) map[string]string { ret := _m.Called(techniqueID)