diff --git a/cli/cmd/env_cmd.go b/cli/cmd/env_cmd.go index baba713f..f10856bc 100644 --- a/cli/cmd/env_cmd.go +++ b/cli/cmd/env_cmd.go @@ -315,6 +315,9 @@ locals { aws_account_id = get_aws_account_id() env = %q vpc_cidr = %q + + # Optional Kali attack box. Enable with --with-kali on infra commands. + kali_instance_type = "t3.medium" } `, envName, vpcCIDR) return os.WriteFile(filepath.Join(envDir, "env.hcl"), []byte(content), 0o644) diff --git a/cli/cmd/infra_cmd.go b/cli/cmd/infra_cmd.go index ff5eade3..800979b0 100644 --- a/cli/cmd/infra_cmd.go +++ b/cli/cmd/infra_cmd.go @@ -87,13 +87,13 @@ func init() { infraApplyCmd.Flags().Bool("individual", false, "Apply each subdirectory individually (for module groups like goad/)") infraDestroyCmd.Flags().Bool("auto-approve", false, "Skip confirmation prompt") - // Azure-only opt-in flags. The matching DREADGOAD_ENABLE_* env vars are - // what the terragrunt exclude{} blocks check; these flags just set them - // for the child process so users don't have to. + // Optional-module flags. The matching DREADGOAD_ENABLE_* env vars are what + // the Terragrunt exclude{} blocks check; these flags set them for the child + // process so users don't have to. for _, cmd := range []*cobra.Command{infraApplyCmd, infraDestroyCmd, infraPlanCmd} { cmd.Flags().Bool("with-bastion", false, "(Azure) Include the optional Azure Bastion module") cmd.Flags().Bool("with-controller", false, "(Azure) Include the optional in-VNet Ansible controller module") - cmd.Flags().Bool("with-kali", false, "(Azure) Include the optional Kali Linux attack box") + cmd.Flags().Bool("with-kali", false, "Include the optional Kali Linux attack box") } infraCmd.PersistentFlags().StringP("deployment", "d", "", "Deployment name (default: from config)") @@ -280,6 +280,19 @@ func runInfraActionAWS(cmd *cobra.Command, cfg *config.Config, action string) er Debug: cfg.Debug, } + withKali, _ := cmd.Flags().GetBool("with-kali") + // On destroy, always include the optional unit so an existing attack box + // is not orphaned when the user omits --with-kali. + if !withKali && action == "destroy" { + kaliDir := filepath.Join(cfg.ProjectRoot, "infra", deployment, cfg.Env, region, "kali") + if _, err := os.Stat(kaliDir); err == nil { + withKali = true + } + } + if withKali { + opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AWS_KALI=true") + } + if action == "apply" || action == "destroy" { autoApprove, _ := cmd.Flags().GetBool("auto-approve") opts.AutoApprove = autoApprove diff --git a/cli/cmd/score.go b/cli/cmd/score.go index dab0d276..31c2b5ca 100644 --- a/cli/cmd/score.go +++ b/cli/cmd/score.go @@ -11,6 +11,7 @@ import ( "github.com/dreadnode/dreadgoad/internal/azure" "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/provider" "github.com/dreadnode/dreadgoad/internal/scoreboard" "github.com/spf13/cobra" ) @@ -147,20 +148,54 @@ func buildShellRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Confi return buildAWSRunner(ctx, cmd, cfg, attackBox) } - // No --attack-box: auto-detect provider. - if cfg.Provider == "azure" { + // No --attack-box: auto-detect provider and locate the tagged attack box. + if cfg.ResolvedProvider() == provider.NameAzure { return buildAzureRunner(ctx, cmd, cfg, "") } - return nil, fmt.Errorf("--attack-box is required with --live-verify (or set -p azure for auto-discovery)") + if cfg.ResolvedProvider() == provider.NameAWS { + region, profile, err := resolveAWSConnectionConfig(cmd, cfg) + if err != nil { + return nil, err + } + prov, err := provider.New(ctx, provider.NameAWS, provider.ConstructorOpts{ + Region: region, + AWSProfile: profile, + }) + if err != nil { + return nil, fmt.Errorf("create AWS provider: %w", err) + } + instances, err := prov.DiscoverInstances(ctx, cfg.Env) + if err != nil { + return nil, fmt.Errorf("discover AWS instances: %w", err) + } + kali := provider.FindInstanceByRole(instances, "AttackBox") + if kali == nil { + return nil, fmt.Errorf("no running Kali attack box (Role=AttackBox) found for env=%s; pass --attack-box to override", cfg.Env) + } + return scoreboard.NewSSMShellRunner(ctx, kali.ID, region, profile) + } + return nil, fmt.Errorf("--attack-box is required with --live-verify for provider %s", cfg.ResolvedProvider()) } func buildAWSRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Config, instanceID string) (scoreboard.ShellRunner, error) { + region, profile, err := resolveAWSConnectionConfig(cmd, cfg) + if err != nil { + return nil, err + } + return scoreboard.NewSSMShellRunner(ctx, instanceID, region, profile) +} + +func resolveAWSConnectionConfig(cmd *cobra.Command, cfg *config.Config) (string, string, error) { region, _ := cmd.Flags().GetString("region") if region == "" { - region = cfg.Region + var err error + region, err = cfg.ResolveRegion() + if err != nil { + return "", "", err + } } profile, _ := cmd.Flags().GetString("profile") - return scoreboard.NewSSMShellRunner(ctx, instanceID, region, profile) + return region, profile, nil } func buildAzureRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Config, vmResourceID string) (scoreboard.ShellRunner, error) { diff --git a/cli/cmd/score_test.go b/cli/cmd/score_test.go new file mode 100644 index 00000000..aaeee983 --- /dev/null +++ b/cli/cmd/score_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "testing" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/spf13/cobra" +) + +func TestResolveAWSConnectionConfigUsesEnvironmentRegionAndProfile(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("region", "", "") + cmd.Flags().String("profile", "", "") + if err := cmd.Flags().Set("profile", "lab"); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Env: "staging", + Region: "us-east-1", + Environments: map[string]config.EnvironmentConfig{ + "staging": {Region: "us-west-1"}, + }, + } + + region, profile, err := resolveAWSConnectionConfig(cmd, cfg) + if err != nil { + t.Fatalf("resolveAWSConnectionConfig() error = %v", err) + } + if region != "us-west-1" || profile != "lab" { + t.Fatalf("resolveAWSConnectionConfig() = (%q, %q), want (%q, %q)", region, profile, "us-west-1", "lab") + } +} + +func TestResolveAWSConnectionConfigPrefersFlagRegion(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("region", "", "") + cmd.Flags().String("profile", "", "") + if err := cmd.Flags().Set("region", "eu-west-1"); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Env: "staging", + Environments: map[string]config.EnvironmentConfig{ + "staging": {Region: "us-west-1"}, + }, + } + + region, _, err := resolveAWSConnectionConfig(cmd, cfg) + if err != nil { + t.Fatalf("resolveAWSConnectionConfig() error = %v", err) + } + if region != "eu-west-1" { + t.Fatalf("resolveAWSConnectionConfig() region = %q, want eu-west-1", region) + } +} diff --git a/cli/cmd/ssm.go b/cli/cmd/ssm.go index a922e7c3..cd91484f 100644 --- a/cli/cmd/ssm.go +++ b/cli/cmd/ssm.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os/exec" "strings" "time" @@ -165,7 +166,18 @@ func runSSMConnect(cmd *cobra.Command, args []string) error { } ctx := context.Background() - prov, err := cfg.NewProvider(ctx) + // The optional attack box is intentionally absent from the Ansible + // inventory. Prefer inventory for the Windows hosts, then fall back to + // provider discovery for tagged or otherwise out-of-inventory instances. + inv, invErr := inventory.Parse(cfg.InventoryPath()) + if invErr != nil { + slog.Warn("inventory unavailable; falling back to AWS discovery", "path", cfg.InventoryPath(), "error", invErr) + } + opts, err := resolveSSMProviderOptions(cfg, inv) + if err != nil { + return err + } + prov, err := provider.New(ctx, provider.NameAWS, opts) if err != nil { return err } @@ -174,23 +186,59 @@ func runSSMConnect(cmd *cobra.Command, args []string) error { return fmt.Errorf("provider %s does not support interactive shells", prov.Name()) } - inv, err := inventory.Parse(cfg.InventoryPath()) + target, err := resolveSSMHost(ctx, prov, cfg.Env, inv, args[0]) if err != nil { - return fmt.Errorf("parse inventory: %w", err) + return err } - - host := inv.HostByName(args[0]) - if host == nil || host.InstanceID == "" { - return fmt.Errorf("host %q not found in inventory", args[0]) + if _, err := exec.LookPath("session-manager-plugin"); err != nil { + return fmt.Errorf("AWS Session Manager plugin not found in PATH; install it before running ssm connect") } + fmt.Printf("Starting SSM session to %s (%s) in %s...\n", target.Name, target.ID, opts.Region) + + return shell.StartInteractiveShell(ctx, target.ID, opts.Region) +} + +func resolveSSMProviderOptions(cfg *config.Config, inv *inventory.Inventory) (provider.ConstructorOpts, error) { region, err := cfg.ResolveRegionWithInventory(inv) if err != nil { - return err + return provider.ConstructorOpts{}, err + } + return provider.ConstructorOpts{Region: region}, nil +} + +func resolveSSMHost(ctx context.Context, prov provider.Provider, env string, inv *inventory.Inventory, hostName string) (*provider.Instance, error) { + if inv != nil { + if host := inv.HostByName(hostName); host != nil && host.InstanceID != "" { + return &provider.Instance{ID: host.InstanceID, Name: host.Name}, nil + } + } + + // Also accept the stable role name so callers do not need to know whether + // the attack box resource is named "kali", "attacker", or something else. + if strings.EqualFold(hostName, "attack-box") || strings.EqualFold(hostName, "attackbox") { + instances, err := prov.DiscoverInstances(ctx, env) + if err != nil { + return nil, fmt.Errorf("discover AWS attack box: %w", err) + } + if inst := provider.FindInstanceByRole(instances, "AttackBox"); inst != nil { + return inst, nil + } + return nil, fmt.Errorf("no running AWS attack box (Role=AttackBox) found for env=%s", env) } - fmt.Printf("Starting SSM session to %s (%s) in %s...\n", host.Name, host.InstanceID, region) - return shell.StartInteractiveShell(ctx, host.InstanceID, region) + inst, err := prov.FindInstanceByHostname(ctx, env, hostName) + if err != nil { + return nil, fmt.Errorf("discover AWS host %q: %w", hostName, err) + } + if inst != nil && inst.ID != "" { + if inst.State != "" && !strings.EqualFold(inst.State, "running") { + return nil, fmt.Errorf("AWS host %q is %s; it must be running before ssm connect", hostName, inst.State) + } + return inst, nil + } + + return nil, fmt.Errorf("host %q not found via AWS discovery or inventory", hostName) } func runSSMRun(cmd *cobra.Command, args []string) error { @@ -248,6 +296,12 @@ func filterProviderInstances(instances []provider.Instance, hostsFlag string) ([ var ids, names []string if hostsFlag == "all" { for _, inst := range instances { + // The run verb executes PowerShell and is intended for the Windows + // lab hosts. A tagged Linux attack box is managed separately through + // score/SSM shell commands. + if strings.EqualFold(inst.Tags["Role"], "AttackBox") { + continue + } ids = append(ids, inst.ID) names = append(names, inst.Name) } diff --git a/cli/cmd/ssm_test.go b/cli/cmd/ssm_test.go new file mode 100644 index 00000000..0ecc0774 --- /dev/null +++ b/cli/cmd/ssm_test.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/inventory" + "github.com/dreadnode/dreadgoad/internal/provider" +) + +type ssmDiscoveryProvider struct { + provider.Provider + byName *provider.Instance + byNameErr error + instances []provider.Instance + discoverErr error + findCalls int + discoverCalls int +} + +func (p *ssmDiscoveryProvider) FindInstanceByHostname(context.Context, string, string) (*provider.Instance, error) { + p.findCalls++ + if p.byNameErr != nil { + return nil, p.byNameErr + } + if p.byName == nil { + return nil, fmt.Errorf("not found") + } + return p.byName, nil +} + +func (p *ssmDiscoveryProvider) DiscoverInstances(context.Context, string) ([]provider.Instance, error) { + p.discoverCalls++ + return p.instances, p.discoverErr +} + +func TestResolveSSMHostFallsBackToDiscovery(t *testing.T) { + want := &provider.Instance{ID: "i-kali", Name: "test-goad-dreadgoad-kali", State: "running"} + prov := &ssmDiscoveryProvider{byName: want} + + got, err := resolveSSMHost(context.Background(), prov, "test", nil, "kali") + if err != nil { + t.Fatalf("resolveSSMHost() error = %v", err) + } + if got.ID != want.ID { + t.Fatalf("resolveSSMHost() ID = %q, want %q", got.ID, want.ID) + } +} + +func TestResolveSSMProviderOptionsPrefersInventoryRegion(t *testing.T) { + cfg := &config.Config{Region: "us-west-2"} + inv := &inventory.Inventory{Vars: map[string]string{ + "ansible_aws_ssm_region": "us-east-2", + }} + + opts, err := resolveSSMProviderOptions(cfg, inv) + if err != nil { + t.Fatalf("resolveSSMProviderOptions() error = %v", err) + } + if opts.Region != "us-east-2" { + t.Fatalf("resolveSSMProviderOptions() region = %q, want us-east-2", opts.Region) + } +} + +func TestResolveSSMHostAcceptsAttackBoxRole(t *testing.T) { + prov := &ssmDiscoveryProvider{instances: []provider.Instance{ + {ID: "i-dc", Tags: map[string]string{"Role": "DomainController"}}, + {ID: "i-kali", Name: "custom-attacker", Tags: map[string]string{"Role": "AttackBox"}}, + }} + + got, err := resolveSSMHost(context.Background(), prov, "test", nil, "attack-box") + if err != nil { + t.Fatalf("resolveSSMHost() error = %v", err) + } + if got.ID != "i-kali" { + t.Fatalf("resolveSSMHost() ID = %q, want i-kali", got.ID) + } + if prov.findCalls != 0 || prov.discoverCalls != 1 { + t.Fatalf("resolveSSMHost() calls = find:%d discover:%d, want find:0 discover:1", prov.findCalls, prov.discoverCalls) + } +} + +func TestResolveSSMHostPrefersInventory(t *testing.T) { + inv := &inventory.Inventory{Hosts: map[string]*inventory.Host{ + "dc01": {Name: "dc01", InstanceID: "i-inventory"}, + }} + prov := &ssmDiscoveryProvider{byName: &provider.Instance{ID: "i-discovery", Name: "dc01"}} + + got, err := resolveSSMHost(context.Background(), prov, "test", inv, "dc01") + if err != nil { + t.Fatalf("resolveSSMHost() error = %v", err) + } + if got.ID != "i-inventory" { + t.Fatalf("resolveSSMHost() ID = %q, want i-inventory", got.ID) + } +} + +func TestFilterProviderInstancesAllExcludesAttackBox(t *testing.T) { + instances := []provider.Instance{ + {ID: "i-dc", Name: "dc01", Tags: map[string]string{"Role": "DomainController"}}, + {ID: "i-kali", Name: "kali", Tags: map[string]string{"Role": "AttackBox"}}, + } + + ids, names := filterProviderInstances(instances, "all") + if len(ids) != 1 || ids[0] != "i-dc" || len(names) != 1 || names[0] != "dc01" { + t.Fatalf("filterProviderInstances(all) = ids=%v names=%v, want only dc01", ids, names) + } +} + +func TestResolveSSMHostPropagatesHostnameDiscoveryError(t *testing.T) { + wantErr := errors.New("describe instances: access denied") + prov := &ssmDiscoveryProvider{byNameErr: wantErr} + + _, err := resolveSSMHost(context.Background(), prov, "test", nil, "kali") + if !errors.Is(err, wantErr) { + t.Fatalf("resolveSSMHost() error = %v, want wrapped %v", err, wantErr) + } +} + +func TestResolveSSMHostPropagatesAttackBoxDiscoveryError(t *testing.T) { + wantErr := errors.New("describe instances: access denied") + prov := &ssmDiscoveryProvider{discoverErr: wantErr} + + _, err := resolveSSMHost(context.Background(), prov, "test", nil, "attack-box") + if !errors.Is(err, wantErr) { + t.Fatalf("resolveSSMHost() error = %v, want wrapped %v", err, wantErr) + } +} + +func TestResolveSSMHostRejectsStoppedDiscoveryTarget(t *testing.T) { + prov := &ssmDiscoveryProvider{byName: &provider.Instance{ + ID: "i-kali", + Name: "test-goad-dreadgoad-kali", + State: "stopped", + }} + + _, err := resolveSSMHost(context.Background(), prov, "test", nil, "kali") + if err == nil || !strings.Contains(err.Error(), "is stopped") { + t.Fatalf("resolveSSMHost() error = %v, want actionable stopped-state error", err) + } +} diff --git a/cli/internal/aws/ec2.go b/cli/internal/aws/ec2.go index 3a98f480..253de8b0 100644 --- a/cli/internal/aws/ec2.go +++ b/cli/internal/aws/ec2.go @@ -16,42 +16,17 @@ type Instance struct { Name string PrivateIP string State string + Tags map[string]string } -// DiscoverInstances finds GOAD instances by tag pattern. +// DiscoverInstances finds DreadGOAD instances by project/environment tags or +// the legacy environment-scoped Name pattern. // By default only running instances are returned. Pass additional states // (e.g. "stopped") to include them. func (c *Client) DiscoverInstances(ctx context.Context, env string, extraStates ...string) ([]Instance, error) { - pattern := fmt.Sprintf("*%s*dreadgoad*", env) states := []string{"running"} states = append(states, extraStates...) - out, err := c.EC2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ - Filters: []types.Filter{ - {Name: Ptr("tag:Name"), Values: []string{pattern}}, - {Name: Ptr("instance-state-name"), Values: states}, - }, - }) - if err != nil { - return nil, fmt.Errorf("describe instances: %w", err) - } - - var instances []Instance - for _, r := range out.Reservations { - for _, i := range r.Instances { - inst := Instance{ - InstanceID: deref(i.InstanceId), - PrivateIP: deref(i.PrivateIpAddress), - State: string(i.State.Name), - } - for _, t := range i.Tags { - if deref(t.Key) == "Name" { - inst.Name = deref(t.Value) - } - } - instances = append(instances, inst) - } - } - return instances, nil + return c.discoverInstances(ctx, env, states) } // GetInstancePrivateIPs queries EC2 for private IPs of the given instance IDs. @@ -90,36 +65,69 @@ func (c *Client) StopInstances(ctx context.Context, instanceIDs []string) error // DiscoverAllInstances finds GOAD instances in any state (including stopped). func (c *Client) DiscoverAllInstances(ctx context.Context, env string) ([]Instance, error) { - pattern := fmt.Sprintf("*%s*dreadgoad*", env) - out, err := c.EC2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ - Filters: []types.Filter{ - {Name: Ptr("tag:Name"), Values: []string{pattern}}, - }, - }) - if err != nil { - return nil, fmt.Errorf("describe instances: %w", err) - } + return c.discoverInstances(ctx, env, nil) +} +func (c *Client) discoverInstances(ctx context.Context, env string, states []string) ([]Instance, error) { var instances []Instance - for _, r := range out.Reservations { + seen := make(map[string]struct{}) + for _, filters := range discoveryFilterSets(env, states) { + out, err := c.EC2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: filters}) + if err != nil { + return nil, fmt.Errorf("describe instances: %w", err) + } + instances = appendDiscoveredInstances(instances, seen, out.Reservations) + } + return instances, nil +} + +func appendDiscoveredInstances(instances []Instance, seen map[string]struct{}, reservations []types.Reservation) []Instance { + for _, r := range reservations { for _, i := range r.Instances { if i.State.Name == types.InstanceStateNameTerminated { continue } + instanceID := deref(i.InstanceId) + if _, exists := seen[instanceID]; exists { + continue + } + seen[instanceID] = struct{}{} + inst := Instance{ - InstanceID: deref(i.InstanceId), + InstanceID: instanceID, PrivateIP: deref(i.PrivateIpAddress), State: string(i.State.Name), + Tags: make(map[string]string, len(i.Tags)), } for _, t := range i.Tags { - if deref(t.Key) == "Name" { - inst.Name = deref(t.Value) + key, value := deref(t.Key), deref(t.Value) + inst.Tags[key] = value + if key == "Name" { + inst.Name = value } } instances = append(instances, inst) } } - return instances, nil + return instances +} + +func discoveryFilterSets(env string, states []string) [][]types.Filter { + filterSets := [][]types.Filter{ + { + {Name: Ptr("tag:Project"), Values: []string{"DreadGOAD"}}, + {Name: Ptr("tag:Environment"), Values: []string{env}}, + }, + { + {Name: Ptr("tag:Name"), Values: []string{fmt.Sprintf("*%s*dreadgoad*", env)}}, + }, + } + if len(states) > 0 { + for i := range filterSets { + filterSets[i] = append(filterSets[i], types.Filter{Name: Ptr("instance-state-name"), Values: states}) + } + } + return filterSets } // FindInstanceByHostnameAll finds an instance (any state except terminated) whose Name tag contains the hostname. diff --git a/cli/internal/aws/ec2_test.go b/cli/internal/aws/ec2_test.go new file mode 100644 index 00000000..34e43b08 --- /dev/null +++ b/cli/internal/aws/ec2_test.go @@ -0,0 +1,73 @@ +package aws + +import ( + "reflect" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/ec2/types" +) + +func TestDiscoveryFilterSetsSupportTagsAndLegacyNames(t *testing.T) { + filterSets := discoveryFilterSets("staging", []string{"running", "stopped"}) + if len(filterSets) != 2 { + t.Fatalf("discoveryFilterSets() returned %d sets, want 2", len(filterSets)) + } + + got := make([]map[string][]string, 0, len(filterSets)) + for _, filters := range filterSets { + set := make(map[string][]string, len(filters)) + for _, filter := range filters { + set[deref(filter.Name)] = filter.Values + } + got = append(got, set) + } + + want := []map[string][]string{ + { + "tag:Project": {"DreadGOAD"}, + "tag:Environment": {"staging"}, + "instance-state-name": {"running", "stopped"}, + }, + { + "tag:Name": {"*staging*dreadgoad*"}, + "instance-state-name": {"running", "stopped"}, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("discoveryFilterSets() = %#v, want %#v", got, want) + } +} + +func TestDiscoveryFilterSetsOmitStateWhenEmpty(t *testing.T) { + for _, filters := range discoveryFilterSets("test", nil) { + for _, filter := range filters { + if deref(filter.Name) == "instance-state-name" { + t.Fatal("discoveryFilterSets() included an empty instance-state-name filter") + } + } + } +} + +func TestAppendDiscoveredInstancesDeduplicatesAndPreservesTags(t *testing.T) { + reservation := types.Reservation{Instances: []types.Instance{ + { + InstanceId: Ptr("i-kali"), + State: &types.InstanceState{Name: types.InstanceStateNameRunning}, + Tags: []types.Tag{ + {Key: Ptr("Name"), Value: Ptr("test-goad-dreadgoad-kali")}, + {Key: Ptr("Role"), Value: Ptr("AttackBox")}, + }, + }, + }} + seen := make(map[string]struct{}) + + instances := appendDiscoveredInstances(nil, seen, []types.Reservation{reservation}) + instances = appendDiscoveredInstances(instances, seen, []types.Reservation{reservation}) + + if len(instances) != 1 { + t.Fatalf("appendDiscoveredInstances() returned %d instances, want 1", len(instances)) + } + if instances[0].Name != "test-goad-dreadgoad-kali" || instances[0].Tags["Role"] != "AttackBox" { + t.Fatalf("appendDiscoveredInstances() = %#v, want Name and Role tags preserved", instances[0]) + } +} diff --git a/cli/internal/aws/provider.go b/cli/internal/aws/provider.go index 33d4acff..4677c8ad 100644 --- a/cli/internal/aws/provider.go +++ b/cli/internal/aws/provider.go @@ -18,7 +18,7 @@ func init() { if opts.Region == "" { return nil, fmt.Errorf("AWS region is required") } - client, err := NewClient(ctx, opts.Region, "") + client, err := NewClient(ctx, opts.Region, opts.AWSProfile) if err != nil { return nil, err } @@ -200,6 +200,7 @@ func toProviderInstance(i Instance) provider.Instance { Name: i.Name, PrivateIP: i.PrivateIP, State: i.State, + Tags: i.Tags, } } diff --git a/cli/internal/aws/provider_test.go b/cli/internal/aws/provider_test.go new file mode 100644 index 00000000..0e397882 --- /dev/null +++ b/cli/internal/aws/provider_test.go @@ -0,0 +1,18 @@ +package aws + +import "testing" + +func TestToProviderInstancePreservesTags(t *testing.T) { + tags := map[string]string{"Role": "AttackBox", "Environment": "test"} + got := toProviderInstance(Instance{ + InstanceID: "i-kali", + Name: "test-goad-dreadgoad-kali", + PrivateIP: "10.8.4.10", + State: "running", + Tags: tags, + }) + + if got.Tags["Role"] != "AttackBox" || got.Tags["Environment"] != "test" { + t.Fatalf("toProviderInstance() tags = %#v, want Role and Environment", got.Tags) + } +} diff --git a/cli/internal/azure/provider.go b/cli/internal/azure/provider.go index b8a22ebe..92451677 100644 --- a/cli/internal/azure/provider.go +++ b/cli/internal/azure/provider.go @@ -178,6 +178,7 @@ func toProviderInstance(i Instance) provider.Instance { Name: i.Name, PrivateIP: i.PrivateIP, State: i.State, + Tags: i.Tags, } } diff --git a/cli/internal/doctor/checks.go b/cli/internal/doctor/checks.go index 5bf33baa..d62be04e 100644 --- a/cli/internal/doctor/checks.go +++ b/cli/internal/doctor/checks.go @@ -67,6 +67,9 @@ func RunChecks(opts Options) []CheckResult { // AWS is the historical default; proxmox currently uses the same // terraform/terragrunt toolchain so it falls through here too. results = append(results, checkCommand("aws", "AWS CLI")) + if opts.Provider == "" || opts.Provider == "aws" { + results = append(results, checkCommand("session-manager-plugin", "AWS Session Manager plugin")) + } results = append(results, checkAWSCredentials()) results = append(results, checkTerragrunt()) results = append(results, checkTerraformOrTofu()) diff --git a/cli/internal/provider/factory.go b/cli/internal/provider/factory.go index 43b6f4e0..df9cf237 100644 --- a/cli/internal/provider/factory.go +++ b/cli/internal/provider/factory.go @@ -18,8 +18,9 @@ type Constructor func(ctx context.Context, opts ConstructorOpts) (Provider, erro // ConstructorOpts holds the parameters needed to construct a provider. type ConstructorOpts struct { - Region string // AWS region or empty for non-AWS providers - Env string // dreadgoad env name (used by providers that need to scope side-channel state — Azure WinRM tunnel + inventory lookup) + Region string // AWS region or empty for non-AWS providers + AWSProfile string // optional AWS shared-config profile + Env string // dreadgoad env name (used by providers that need to scope side-channel state — Azure WinRM tunnel + inventory lookup) // Proxmox-specific ProxmoxAPIURL string diff --git a/cli/internal/provider/provider.go b/cli/internal/provider/provider.go index a5b19430..4f1869fd 100644 --- a/cli/internal/provider/provider.go +++ b/cli/internal/provider/provider.go @@ -2,6 +2,7 @@ package provider import ( "context" + "strings" "time" ) @@ -11,6 +12,18 @@ type Instance struct { Name string PrivateIP string State string // "running", "stopped", etc. + Tags map[string]string +} + +// FindInstanceByRole returns the first instance whose Role tag matches role. +// Providers that do not expose resource tags simply leave Instance.Tags nil. +func FindInstanceByRole(instances []Instance, role string) *Instance { + for i := range instances { + if strings.EqualFold(instances[i].Tags["Role"], role) { + return &instances[i] + } + } + return nil } // CommandResult holds the output of a remote command execution. diff --git a/cli/internal/provider/provider_test.go b/cli/internal/provider/provider_test.go new file mode 100644 index 00000000..50a01a5b --- /dev/null +++ b/cli/internal/provider/provider_test.go @@ -0,0 +1,18 @@ +package provider + +import "testing" + +func TestFindInstanceByRole(t *testing.T) { + instances := []Instance{ + {ID: "i-domain", Tags: map[string]string{"Role": "DomainController"}}, + {ID: "i-kali", Tags: map[string]string{"Role": "AttackBox"}}, + } + + got := FindInstanceByRole(instances, "attackbox") + if got == nil || got.ID != "i-kali" { + t.Fatalf("FindInstanceByRole() = %#v, want i-kali", got) + } + if got := FindInstanceByRole(instances, "missing"); got != nil { + t.Fatalf("FindInstanceByRole() = %#v, want nil", got) + } +} diff --git a/docs/mkdocs/docs/cli-reference.md b/docs/mkdocs/docs/cli-reference.md index e357b490..00f9a366 100644 --- a/docs/mkdocs/docs/cli-reference.md +++ b/docs/mkdocs/docs/cli-reference.md @@ -127,6 +127,7 @@ Manage DreadGOAD infrastructure via Terragrunt. Operates on the `infra/` directo | Flag | Description | |------|-------------| | `-d, --deployment string` | Deployment name | +| `--with-kali` | Include the optional Kali attack box (AWS or Azure; plan/apply/destroy) | #### `infra init` diff --git a/docs/mkdocs/docs/providers/aws.md b/docs/mkdocs/docs/providers/aws.md index d159d1e7..36cce08c 100644 --- a/docs/mkdocs/docs/providers/aws.md +++ b/docs/mkdocs/docs/providers/aws.md @@ -21,6 +21,14 @@ The architecture is quite the same than the Azure deployment. - [Terraform](https://www.terraform.io/downloads.html) - [AWS CLI](https://aws.amazon.com/cli/?nc1=h_ls) +- [AWS Session Manager plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html) + +To use the optional attack box, subscribe to the official +[Kali Linux AWS Marketplace image](https://aws.amazon.com/marketplace/pp/prodview-fznsw3f7mq7to) +once in the target AWS account. DreadGOAD selects the latest +`*kali-last-snapshot-amd64-*-804fcc46-63fc-4eb6-85a1-50e66d6c7215` +image from `aws-marketplace`, constraining discovery to Kali's official +Marketplace product ID. ## AWS configuration @@ -99,6 +107,20 @@ dreadgoad inventory sync dreadgoad provision ``` +Include the optional private Kali attack box with: + +```bash +dreadgoad infra apply --with-kali +``` + +The attack box is tagged `Role=AttackBox`, bootstraps the SSM agent and live +scoring tools, and has no public IP or inbound SSH rule. Connect without adding +it to the Ansible inventory: + +```bash +dreadgoad ssm connect attack-box +``` + ## start/stop/status - You can see the status of the lab with `dreadgoad lab status` @@ -142,4 +164,6 @@ dreadgoad provision # run Ansible provisioning via jumpbox ## Tips - To connect to a host via SSM you can use `dreadgoad ssm connect ` +- `dreadgoad doctor` checks both the AWS CLI and Session Manager plugin so a + missing local plugin is reported before an interactive session is attempted. - All AWS elements are tagged with `-` diff --git a/docs/scoring.md b/docs/scoring.md index e5d23c2f..c4cbf395 100644 --- a/docs/scoring.md +++ b/docs/scoring.md @@ -115,8 +115,15 @@ Commands run on the Kali attack box via SSM (AWS) or Bastion SSH ### AWS -Requires the Kali attack box EC2 instance ID. Commands run via SSM -(`AWS-RunShellScript`). +With an attack box provisioned by `dreadgoad infra apply --with-kali`, the CLI +discovers the running instance from its `Role=AttackBox` tag. Commands run via +SSM (`AWS-RunShellScript`). + +```bash +dreadgoad score --report ./report.jsonl --live-verify +``` + +You can still override discovery with an explicit instance ID: ```bash dreadgoad score --report ./report.jsonl \ @@ -128,7 +135,7 @@ dreadgoad score --report ./report.jsonl \ | Flag | Description | |----------------|------------------------------------------| -| `--attack-box` | EC2 instance ID of the Kali box | +| `--attack-box` | EC2 instance ID override (normally auto-discovered) | | `--region` | AWS region (falls back to config) | | `--profile` | AWS named profile | diff --git a/infra/goad-deployment/staging/env.hcl b/infra/goad-deployment/staging/env.hcl index 131462c2..84faa3ed 100644 --- a/infra/goad-deployment/staging/env.hcl +++ b/infra/goad-deployment/staging/env.hcl @@ -5,4 +5,7 @@ locals { aws_account_id = get_aws_account_id() env = "staging" # Environment name (dev, staging, prod) vpc_cidr = "10.1.0.0/16" # VPC CIDR block for this environment + + # Optional Kali attack box. Enable with --with-kali on infra commands. + kali_instance_type = "t3.medium" } diff --git a/infra/goad-deployment/staging/us-west-1/kali/terragrunt.hcl b/infra/goad-deployment/staging/us-west-1/kali/terragrunt.hcl new file mode 100644 index 00000000..07c0da99 --- /dev/null +++ b/infra/goad-deployment/staging/us-west-1/kali/terragrunt.hcl @@ -0,0 +1,52 @@ +# ============================================================================= +# Optional Kali Linux Attack Box +# +# Deploys a headless Kali VM in a private subnet. Access is through AWS Systems +# Manager Session Manager; no public IP or inbound SSH rule is created. +# Enable with `dreadgoad infra apply --with-kali`. +# ============================================================================= + +exclude { + if = lower(get_env("DREADGOAD_ENABLE_AWS_KALI", "false")) != "true" + actions = ["all"] +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + + env = local.env_vars.locals.env + deployment_name = local.env_vars.locals.deployment_name + kali_instance_type = try(local.env_vars.locals.kali_instance_type, "t3.medium") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-kali" +} + +dependency "network" { + config_path = "../network" + mock_outputs = { + vpc_id = "vpc-mock" + vpc_cidr = "10.0.0.0/16" + private_subnet_ids = ["subnet-mock"] + } + mock_outputs_allowed_terraform_commands = ["init", "validate", "plan"] +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + deployment_name = local.deployment_name + instance_type = local.kali_instance_type + vpc_id = dependency.network.outputs.vpc_id + vpc_cidr = dependency.network.outputs.vpc_cidr + subnet_id = dependency.network.outputs.private_subnet_ids[0] + + additional_tags = { + Project = "DreadGOAD" + Lab = "${local.deployment_name}-goad" + } +} diff --git a/infra/goad-deployment/test/env.hcl b/infra/goad-deployment/test/env.hcl index 2712a77d..daa970b0 100644 --- a/infra/goad-deployment/test/env.hcl +++ b/infra/goad-deployment/test/env.hcl @@ -5,4 +5,7 @@ locals { aws_account_id = get_aws_account_id() env = "test" vpc_cidr = "10.8.0.0/16" + + # Optional Kali attack box. Enable with --with-kali on infra commands. + kali_instance_type = "t3.medium" } diff --git a/infra/goad-deployment/test/us-east-2/kali/terragrunt.hcl b/infra/goad-deployment/test/us-east-2/kali/terragrunt.hcl new file mode 100644 index 00000000..07c0da99 --- /dev/null +++ b/infra/goad-deployment/test/us-east-2/kali/terragrunt.hcl @@ -0,0 +1,52 @@ +# ============================================================================= +# Optional Kali Linux Attack Box +# +# Deploys a headless Kali VM in a private subnet. Access is through AWS Systems +# Manager Session Manager; no public IP or inbound SSH rule is created. +# Enable with `dreadgoad infra apply --with-kali`. +# ============================================================================= + +exclude { + if = lower(get_env("DREADGOAD_ENABLE_AWS_KALI", "false")) != "true" + actions = ["all"] +} + +locals { + env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl")) + + env = local.env_vars.locals.env + deployment_name = local.env_vars.locals.deployment_name + kali_instance_type = try(local.env_vars.locals.kali_instance_type, "t3.medium") +} + +terraform { + source = "${get_repo_root()}/modules//terraform-aws-kali" +} + +dependency "network" { + config_path = "../network" + mock_outputs = { + vpc_id = "vpc-mock" + vpc_cidr = "10.0.0.0/16" + private_subnet_ids = ["subnet-mock"] + } + mock_outputs_allowed_terraform_commands = ["init", "validate", "plan"] +} + +include { + path = find_in_parent_folders("root.hcl") +} + +inputs = { + env = local.env + deployment_name = local.deployment_name + instance_type = local.kali_instance_type + vpc_id = dependency.network.outputs.vpc_id + vpc_cidr = dependency.network.outputs.vpc_cidr + subnet_id = dependency.network.outputs.private_subnet_ids[0] + + additional_tags = { + Project = "DreadGOAD" + Lab = "${local.deployment_name}-goad" + } +} diff --git a/modules/terraform-aws-kali/README.md b/modules/terraform-aws-kali/README.md new file mode 100644 index 00000000..bfb4c219 --- /dev/null +++ b/modules/terraform-aws-kali/README.md @@ -0,0 +1,66 @@ +# AWS Kali attack box + +Deploys an optional Kali Linux attack box into a private DreadGOAD subnet. The +module uses the official Kali AWS Marketplace image family constrained to +Kali's Marketplace product ID, attaches an +`AmazonSSMManagedInstanceCore` instance profile, and installs the SSM agent and +the tools used by live scoring during first boot. + +The instance is tagged `Role=AttackBox`, `Project=DreadGOAD`, and +`Environment=` so the CLI can discover it without adding it to the Ansible +inventory. + +The instance remains private and is managed through AWS Systems Manager; the +module creates no public ingress rule. + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.7 | +| [aws](#requirement\_aws) | ~> 6.0 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | ~> 6.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [kali](#module\_kali) | ../terraform-aws-instance-factory | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tags](#input\_additional\_tags) | Additional tags to merge into attack-box resources. | `map(string)` | `{}` | no | +| [ami\_id](#input\_ami\_id) | Optional explicit Kali AMI ID override. | `string` | `""` | no | +| [ami\_name\_pattern](#input\_ami\_name\_pattern) | Official Kali Marketplace AMI pattern, constrained to Kali product ID 804fcc46-63fc-4eb6-85a1-50e66d6c7215. Ignored when ami\_id is set. | `string` | `"*kali-last-snapshot-amd64-*-804fcc46-63fc-4eb6-85a1-50e66d6c7215"` | no | +| [ami\_owners](#input\_ami\_owners) | Allowed AMI owners. The default is further constrained by ami\_name\_pattern to the official Kali Marketplace product ID. | `list(string)` |
[
"aws-marketplace"
]
| no | +| [deployment\_name](#input\_deployment\_name) | Name of the deployment (for example, goad). | `string` | n/a | yes | +| [env](#input\_env) | Environment name (for example, test or staging). | `string` | n/a | yes | +| [instance\_type](#input\_instance\_type) | EC2 instance type for the Kali attack box. | `string` | `"t3.medium"` | no | +| [root\_volume\_size](#input\_root\_volume\_size) | Kali root volume size in GiB. | `number` | `80` | no | +| [subnet\_id](#input\_subnet\_id) | Private subnet in which to deploy the attack box. | `string` | n/a | yes | +| [vpc\_cidr](#input\_vpc\_cidr) | Lab VPC CIDR. Traffic from this range may reach the attack box. | `string` | n/a | yes | +| [vpc\_id](#input\_vpc\_id) | VPC in which to deploy the attack box. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [ami\_id](#output\_ami\_id) | Kali AMI selected for the attack box. | +| [instance\_id](#output\_instance\_id) | EC2 instance ID of the Kali attack box. | +| [private\_ip](#output\_private\_ip) | Private IPv4 address of the Kali attack box. | +| [security\_group\_id](#output\_security\_group\_id) | Security group attached to the Kali attack box. | + diff --git a/modules/terraform-aws-kali/main.tf b/modules/terraform-aws-kali/main.tf new file mode 100644 index 00000000..2f6e51de --- /dev/null +++ b/modules/terraform-aws-kali/main.tf @@ -0,0 +1,79 @@ +locals { + base_tags = { + Module = "terraform-aws-kali" + Project = "DreadGOAD" + Environment = var.env + Role = "AttackBox" + Lab = "${var.deployment_name}-goad" + } + + # Discovery-critical tags cannot be replaced by caller-supplied metadata. + tags = merge(var.additional_tags, local.base_tags) + + ami_filters = var.ami_id != "" ? [ + { + name = "image-id" + values = [var.ami_id] + } + ] : [ + { + name = "name" + values = [var.ami_name_pattern] + } + ] +} + +module "kali" { + source = "../terraform-aws-instance-factory" + + env = var.env + instance_name = "${var.deployment_name}-dreadgoad-kali" + instance_type = var.instance_type + os_type = "linux" + enable_asg = false + + vpc_id = var.vpc_id + subnet_id = var.subnet_id + + # The instance factory creates the IAM role/profile with + # AmazonSSMManagedInstanceCore. The Kali image needs the agent installed by + # user data before it can register with Systems Manager. + enable_ssm = true + user_data = templatefile("${path.module}/user_data.sh.tpl", { + aws_region = data.aws_region.current.region + }) + + linux_ami_owners = var.ami_owners + additional_linux_ami_filters = local.ami_filters + + ingress_rules = [ + { + description = "Allow lab traffic from the VPC" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [var.vpc_cidr] + } + ] + + egress_rules = [ + { + description = "Allow outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ] + + enable_monitoring = true + enable_metadata = true + require_imdsv2 = true + encrypt_volumes = true + root_volume_size = var.root_volume_size + volume_type = "gp3" + + tags = local.tags +} + +data "aws_region" "current" {} diff --git a/modules/terraform-aws-kali/outputs.tf b/modules/terraform-aws-kali/outputs.tf new file mode 100644 index 00000000..90f43b4c --- /dev/null +++ b/modules/terraform-aws-kali/outputs.tf @@ -0,0 +1,19 @@ +output "instance_id" { + description = "EC2 instance ID of the Kali attack box." + value = one(module.kali.instance_ids) +} + +output "private_ip" { + description = "Private IPv4 address of the Kali attack box." + value = one(module.kali.instance_private_ips) +} + +output "security_group_id" { + description = "Security group attached to the Kali attack box." + value = module.kali.security_group_id +} + +output "ami_id" { + description = "Kali AMI selected for the attack box." + value = module.kali.ami_id +} diff --git a/modules/terraform-aws-kali/user_data.sh.tpl b/modules/terraform-aws-kali/user_data.sh.tpl new file mode 100755 index 00000000..3850c9eb --- /dev/null +++ b/modules/terraform-aws-kali/user_data.sh.tpl @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +ssm_deb=/tmp/amazon-ssm-agent.deb +ssm_url="https://s3.${aws_region}.amazonaws.com/amazon-ssm-${aws_region}/latest/debian_amd64/amazon-ssm-agent.deb" + +# Bring up the only management path before installing the larger tool set. The +# official Kali cloud image normally includes curl or wget; retain an apt-based +# fallback so a sparse future image can still bootstrap itself. +if command -v curl >/dev/null 2>&1; then + curl --fail --silent --show-error --location --retry 5 --retry-all-errors \ + "$ssm_url" --output "$ssm_deb" +elif command -v wget >/dev/null 2>&1; then + wget --tries=5 --output-document="$ssm_deb" "$ssm_url" +else + apt-get -o Acquire::Retries=5 update + apt-get -o Acquire::Retries=5 install -y --no-install-recommends ca-certificates curl + curl --fail --silent --show-error --location --retry 5 --retry-all-errors \ + "$ssm_url" --output "$ssm_deb" +fi + +dpkg --install "$ssm_deb" +systemctl enable --now amazon-ssm-agent + +apt-get -o Acquire::Retries=5 update +apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ + ca-certificates \ + curl \ + dnsutils \ + impacket-scripts \ + netexec \ + python3-impacket \ + python3-pip + +printf '%s\n' '#!/bin/sh' 'exec impacket-secretsdump "$@"' >/usr/local/bin/secretsdump.py +chmod 0755 /usr/local/bin/secretsdump.py diff --git a/modules/terraform-aws-kali/variables.tf b/modules/terraform-aws-kali/variables.tf new file mode 100644 index 00000000..19a2c5ba --- /dev/null +++ b/modules/terraform-aws-kali/variables.tf @@ -0,0 +1,65 @@ +variable "deployment_name" { + description = "Name of the deployment (for example, goad)." + type = string +} + +variable "env" { + description = "Environment name (for example, test or staging)." + type = string +} + +variable "vpc_id" { + description = "VPC in which to deploy the attack box." + type = string +} + +variable "vpc_cidr" { + description = "Lab VPC CIDR. Traffic from this range may reach the attack box." + type = string +} + +variable "subnet_id" { + description = "Private subnet in which to deploy the attack box." + type = string +} + +variable "instance_type" { + description = "EC2 instance type for the Kali attack box." + type = string + default = "t3.medium" +} + +variable "root_volume_size" { + description = "Kali root volume size in GiB." + type = number + default = 80 + + validation { + condition = var.root_volume_size >= 25 + error_message = "root_volume_size must be at least 25 GiB for the official Kali Marketplace AMI." + } +} + +variable "ami_name_pattern" { + description = "Official Kali Marketplace AMI pattern, constrained to Kali product ID 804fcc46-63fc-4eb6-85a1-50e66d6c7215. Ignored when ami_id is set." + type = string + default = "*kali-last-snapshot-amd64-*-804fcc46-63fc-4eb6-85a1-50e66d6c7215" +} + +variable "ami_id" { + description = "Optional explicit Kali AMI ID override." + type = string + default = "" +} + +variable "ami_owners" { + description = "Allowed AMI owners. The default is further constrained by ami_name_pattern to the official Kali Marketplace product ID." + type = list(string) + default = ["aws-marketplace"] +} + +variable "additional_tags" { + description = "Additional tags to merge into attack-box resources." + type = map(string) + default = {} +} diff --git a/modules/terraform-aws-kali/versions.tf b/modules/terraform-aws-kali/versions.tf new file mode 100644 index 00000000..3f08554c --- /dev/null +++ b/modules/terraform-aws-kali/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +}