Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions docs/user-guide/remote-state.md
Original file line number Diff line number Diff line change
@@ -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 <<EOF
state:
bucket: myorg-stratus-state
region: us-east-1
EOF

# Authenticate against the state bucket account
export STRATUS_STATE_BUCKET_PROFILE=myorg-security-tooling

# Authenticate against the target account
aws-vault exec sso-myorg-test-account

# Use Stratus Red Team as usual — state goes to S3 transparently
stratus detonate aws.defense-evasion.cloudtrail-stop
stratus status aws.defense-evasion.cloudtrail-stop
stratus cleanup aws.defense-evasion.cloudtrail-stop
```

Any team member with access to the same bucket and target account can see technique state and clean up resources, even from a different machine.

## Example: ephemeral environments (CI/CD)

```bash
export STRATUS_STATE_BUCKET=myorg-stratus-state
export STRATUS_STATE_BUCKET_REGION=us-east-1
export STRATUS_STATE_AWS_ACCESS_KEY_ID=... # bucket account creds
export STRATUS_STATE_AWS_SECRET_ACCESS_KEY=...
export STRATUS_STATE_AWS_SESSION_TOKEN=...

# Target account creds (standard AWS env vars)
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_SESSION_TOKEN=...

stratus detonate aws.defense-evasion.cloudtrail-stop --cleanup
```

## Programmatic usage

When using Stratus Red Team as a Go library, use `WithS3Backend` to configure remote state:

```go
import (
"github.com/aws/aws-sdk-go-v2/config"
stratusrunner "github.com/datadog/stratus-red-team/v2/pkg/stratus/runner"
)

// Build an aws.Config for the bucket account
bucketCfg, _ := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"))

runner := stratusrunner.NewRunner(
technique,
stratusrunner.StratusRunnerNoForce,
stratusrunner.WithS3Backend(stratusrunner.S3BackendConfig{
BucketName: "myorg-stratus-state",
Region: "us-east-1",
AWSConfig: bucketCfg,
}),
)
```

See the [s3-remote-state example](https://github.com/DataDog/stratus-red-team/tree/main/examples/s3-remote-state) for a complete working example.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ nav:
- detonate: user-guide/commands/detonate.md
- revert: user-guide/commands/revert.md
- cleanup: user-guide/commands/cleanup.md
- Remote State (S3): user-guide/remote-state.md
- Troubleshooting: user-guide/troubleshooting.md
- Programmatic Usage: user-guide/programmatic-usage.md
- Attack Techniques Reference:
Expand Down
2 changes: 1 addition & 1 deletion v2/cmd/stratus/cmd/cleanup_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func doCleanupCmd(techniques []*stratus.AttackTechnique) {

func cleanupCmdWorker(techniques <-chan *stratus.AttackTechnique, errors chan<- error) {
for technique := range techniques {
stratusRunner := runner.NewRunner(technique, flagForceCleanup)
stratusRunner := runner.NewRunner(technique, flagForceCleanup, buildRunnerOptions()...)
err := stratusRunner.CleanUp()
errors <- err
}
Expand Down
2 changes: 1 addition & 1 deletion v2/cmd/stratus/cmd/detonate_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func doDetonateCmd(techniques []*stratus.AttackTechnique, cleanup bool) {

func detonateCmdWorker(techniques <-chan *stratus.AttackTechnique, errors chan<- error) {
for technique := range techniques {
stratusRunner := runner.NewRunner(technique, detonateForce)
stratusRunner := runner.NewRunner(technique, detonateForce, buildRunnerOptions()...)
detonateErr := stratusRunner.Detonate()
if detonateCleanup {
cleanupErr := stratusRunner.CleanUp()
Expand Down
2 changes: 1 addition & 1 deletion v2/cmd/stratus/cmd/revert_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ func revertCmdWorker(techniques <-chan *stratus.AttackTechnique, errors chan<- e
errors <- nil
continue
}
stratusRunner := runner.NewRunner(technique, revertForce)
stratusRunner := runner.NewRunner(technique, revertForce, buildRunnerOptions()...)
err := stratusRunner.Revert()
errors <- err
}
Expand Down
3 changes: 3 additions & 0 deletions v2/cmd/stratus/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ var RootCmd = &cobra.Command{
func init() {
setupLogging()

RootCmd.PersistentFlags().StringVar(&flagStateBucket, "state-bucket", "", "(optional) S3 bucket for remote state storage")
RootCmd.PersistentFlags().StringVar(&flagStateBucketRegion, "state-bucket-region", "", "(optional) AWS region of the S3 state bucket")

listCmd := buildListCmd()
showCmd := buildShowCmd()
warmupCmd := buildWarmupCmd()
Expand Down
190 changes: 190 additions & 0 deletions v2/cmd/stratus/cmd/state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package cmd

import (
"context"
"log"
"os"

"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/datadog/stratus-red-team/v2/internal/state"
"github.com/datadog/stratus-red-team/v2/pkg/stratus/config"
"github.com/datadog/stratus-red-team/v2/pkg/stratus/runner"
)

const (
EnvVarStateBucket = "STRATUS_STATE_BUCKET"
EnvVarStateBucketRegion = "STRATUS_STATE_BUCKET_REGION"
EnvVarStateBucketProfile = "STRATUS_STATE_BUCKET_PROFILE"
EnvVarStateAWSAccessKeyID = "STRATUS_STATE_AWS_ACCESS_KEY_ID"
EnvVarStateAWSSecretKey = "STRATUS_STATE_AWS_SECRET_ACCESS_KEY"
EnvVarStateAWSSessionToken = "STRATUS_STATE_AWS_SESSION_TOKEN"
)

var (
flagStateBucket string
flagStateBucketRegion string
)

// resolveS3BackendConfig builds an S3BackendConfig from flags, env vars, and the config file (in
// that priority order). Returns nil if no bucket is configured, meaning local state should be used.
func resolveS3BackendConfig() *state.S3BackendConfig {
bucket := resolveStateBucket()
if bucket == "" {
return nil
}

region := resolveStateBucketRegion()
if region == "" {
log.Fatal("S3 state bucket is configured but no region is set. " +
"Use --state-bucket-region, STRATUS_STATE_BUCKET_REGION, or state.region in config")
}

awsCfg := resolveStateBucketCredentials(region)

cfg := &state.S3BackendConfig{
BucketName: bucket,
Region: region,
AWSConfig: awsCfg,
}

ensureBucketExists(cfg)
return cfg
}

// resolveStateBucket returns the bucket name from flag > 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)}
}
11 changes: 10 additions & 1 deletion v2/cmd/stratus/cmd/status_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion v2/cmd/stratus/cmd/warmup_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion v2/internal/state/s3_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
Expand Down
Loading
Loading