From 9c01ad89051ff7e6cf910cd83402a6c7397e27ba Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Fri, 7 Aug 2026 14:23:33 -0400 Subject: [PATCH 1/6] feat(aws): add Kali attack box provisioning Provision an optional tagged Kali host on AWS so scoring and SSM workflows can discover it without inventory or public ingress. Bootstrap the SSM agent and required tooling from the official Marketplace image. --- cli/cmd/env_cmd.go | 3 + cli/cmd/infra_cmd.go | 21 ++++- cli/cmd/score.go | 45 ++++++++-- cli/cmd/score_test.go | 57 +++++++++++++ cli/cmd/ssm.go | 50 +++++++++-- cli/cmd/ssm_test.go | 82 ++++++++++++++++++ cli/internal/aws/ec2.go | 39 +++++---- cli/internal/aws/ec2_test.go | 35 ++++++++ cli/internal/aws/provider.go | 3 +- cli/internal/aws/provider_test.go | 18 ++++ cli/internal/azure/provider.go | 1 + cli/internal/doctor/checks.go | 3 + cli/internal/provider/factory.go | 5 +- cli/internal/provider/provider.go | 13 +++ cli/internal/provider/provider_test.go | 18 ++++ docs/mkdocs/docs/cli-reference.md | 1 + docs/mkdocs/docs/providers/aws.md | 24 ++++++ docs/scoring.md | 13 ++- infra/goad-deployment/staging/env.hcl | 3 + .../staging/us-west-1/kali/terragrunt.hcl | 52 ++++++++++++ infra/goad-deployment/test/env.hcl | 3 + .../test/us-east-2/kali/terragrunt.hcl | 52 ++++++++++++ .../terraform-aws-instance-factory/main.tf | 2 +- modules/terraform-aws-kali/README.md | 15 ++++ modules/terraform-aws-kali/main.tf | 83 +++++++++++++++++++ modules/terraform-aws-kali/outputs.tf | 24 ++++++ modules/terraform-aws-kali/user_data.sh.tpl | 38 +++++++++ modules/terraform-aws-kali/variables.tf | 71 ++++++++++++++++ modules/terraform-aws-kali/versions.tf | 10 +++ 29 files changed, 746 insertions(+), 38 deletions(-) create mode 100644 cli/cmd/score_test.go create mode 100644 cli/cmd/ssm_test.go create mode 100644 cli/internal/aws/ec2_test.go create mode 100644 cli/internal/aws/provider_test.go create mode 100644 cli/internal/provider/provider_test.go create mode 100644 infra/goad-deployment/staging/us-west-1/kali/terragrunt.hcl create mode 100644 infra/goad-deployment/test/us-east-2/kali/terragrunt.hcl create mode 100644 modules/terraform-aws-kali/README.md create mode 100644 modules/terraform-aws-kali/main.tf create mode 100644 modules/terraform-aws-kali/outputs.tf create mode 100644 modules/terraform-aws-kali/user_data.sh.tpl create mode 100644 modules/terraform-aws-kali/variables.tf create mode 100644 modules/terraform-aws-kali/versions.tf 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..70e34c0f 100644 --- a/cli/cmd/ssm.go +++ b/cli/cmd/ssm.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os/exec" "strings" "time" @@ -174,23 +175,50 @@ 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()) + // 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, _ := 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") } region, err := cfg.ResolveRegionWithInventory(inv) if err != nil { return err } - fmt.Printf("Starting SSM session to %s (%s) in %s...\n", host.Name, host.InstanceID, region) + fmt.Printf("Starting SSM session to %s (%s) in %s...\n", target.Name, target.ID, region) + + return shell.StartInteractiveShell(ctx, target.ID, region) +} + +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 + } + } + + if inst, err := prov.FindInstanceByHostname(ctx, env, hostName); err == nil && inst.ID != "" { + return inst, 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 { + if inst := provider.FindInstanceByRole(instances, "AttackBox"); inst != nil { + return inst, nil + } + } + } - return shell.StartInteractiveShell(ctx, host.InstanceID, region) + return nil, fmt.Errorf("host %q not found via AWS discovery or inventory", hostName) } func runSSMRun(cmd *cobra.Command, args []string) error { @@ -248,6 +276,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..1930a112 --- /dev/null +++ b/cli/cmd/ssm_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "context" + "fmt" + "testing" + + "github.com/dreadnode/dreadgoad/internal/inventory" + "github.com/dreadnode/dreadgoad/internal/provider" +) + +type ssmDiscoveryProvider struct { + provider.Provider + byName *provider.Instance + instances []provider.Instance +} + +func (p *ssmDiscoveryProvider) FindInstanceByHostname(context.Context, string, string) (*provider.Instance, error) { + if p.byName == nil { + return nil, fmt.Errorf("not found") + } + return p.byName, nil +} + +func (p *ssmDiscoveryProvider) DiscoverInstances(context.Context, string) ([]provider.Instance, error) { + return p.instances, nil +} + +func TestResolveSSMHostFallsBackToDiscovery(t *testing.T) { + want := &provider.Instance{ID: "i-kali", Name: "test-goad-dreadgoad-kali"} + 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 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) + } +} + +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) + } +} diff --git a/cli/internal/aws/ec2.go b/cli/internal/aws/ec2.go index 3a98f480..4935ab32 100644 --- a/cli/internal/aws/ec2.go +++ b/cli/internal/aws/ec2.go @@ -16,20 +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 their project and environment tags. // 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}, - }, + Filters: discoveryFilters(env, states), }) if err != nil { return nil, fmt.Errorf("describe instances: %w", err) @@ -42,10 +39,13 @@ func (c *Client) DiscoverInstances(ctx context.Context, env string, extraStates InstanceID: deref(i.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) @@ -90,11 +90,8 @@ 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}}, - }, + Filters: discoveryFilters(env, nil), }) if err != nil { return nil, fmt.Errorf("describe instances: %w", err) @@ -110,10 +107,13 @@ func (c *Client) DiscoverAllInstances(ctx context.Context, env string) ([]Instan InstanceID: deref(i.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) @@ -122,6 +122,17 @@ func (c *Client) DiscoverAllInstances(ctx context.Context, env string) ([]Instan return instances, nil } +func discoveryFilters(env string, states []string) []types.Filter { + filters := []types.Filter{ + {Name: Ptr("tag:Project"), Values: []string{"DreadGOAD"}}, + {Name: Ptr("tag:Environment"), Values: []string{env}}, + } + if len(states) > 0 { + filters = append(filters, types.Filter{Name: Ptr("instance-state-name"), Values: states}) + } + return filters +} + // FindInstanceByHostnameAll finds an instance (any state except terminated) whose Name tag contains the hostname. func (c *Client) FindInstanceByHostnameAll(ctx context.Context, env, hostname string) (*Instance, error) { instances, err := c.DiscoverAllInstances(ctx, env) diff --git a/cli/internal/aws/ec2_test.go b/cli/internal/aws/ec2_test.go new file mode 100644 index 00000000..3825503a --- /dev/null +++ b/cli/internal/aws/ec2_test.go @@ -0,0 +1,35 @@ +package aws + +import ( + "reflect" + "testing" +) + +func TestDiscoveryFiltersUseProjectAndEnvironmentTags(t *testing.T) { + filters := discoveryFilters("staging", []string{"running", "stopped"}) + got := make(map[string][]string, len(filters)) + for _, filter := range filters { + got[deref(filter.Name)] = filter.Values + } + + want := map[string][]string{ + "tag:Project": {"DreadGOAD"}, + "tag:Environment": {"staging"}, + "instance-state-name": {"running", "stopped"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("discoveryFilters() = %#v, want %#v", got, want) + } + if _, exists := got["tag:Name"]; exists { + t.Fatal("discoveryFilters() must not require a Name tag") + } +} + +func TestDiscoveryFiltersOmitStateWhenEmpty(t *testing.T) { + filters := discoveryFilters("test", nil) + for _, filter := range filters { + if deref(filter.Name) == "instance-state-name" { + t.Fatal("discoveryFilters() included an empty instance-state-name filter") + } + } +} 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-instance-factory/main.tf b/modules/terraform-aws-instance-factory/main.tf index 5e769167..2f8dd731 100644 --- a/modules/terraform-aws-instance-factory/main.tf +++ b/modules/terraform-aws-instance-factory/main.tf @@ -37,7 +37,7 @@ resource "aws_instance" "this" { monitoring = var.enable_monitoring user_data = var.user_data != "" ? var.user_data : null user_data_replace_on_change = true - associate_public_ip_address = var.assign_public_ip && !var.enable_ssm + associate_public_ip_address = var.assign_public_ip source_dest_check = var.source_dest_check root_block_device { diff --git a/modules/terraform-aws-kali/README.md b/modules/terraform-aws-kali/README.md new file mode 100644 index 00000000..deec15bb --- /dev/null +++ b/modules/terraform-aws-kali/README.md @@ -0,0 +1,15 @@ +# 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 is private by default. Set `assign_public_ip = true` only for a +standalone deployment in a public subnet that has no NAT gateway or SSM VPC +endpoints. The module still creates no public ingress rule. diff --git a/modules/terraform-aws-kali/main.tf b/modules/terraform-aws-kali/main.tf new file mode 100644 index 00000000..33edd903 --- /dev/null +++ b/modules/terraform-aws-kali/main.tf @@ -0,0 +1,83 @@ +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 + + # Private by default. A public IP can be explicitly enabled for standalone + # smoke tests in a public subnet without NAT or VPC endpoints. + assign_public_ip = var.assign_public_ip + + # 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..50bfdb35 --- /dev/null +++ b/modules/terraform-aws-kali/outputs.tf @@ -0,0 +1,24 @@ +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 "public_ip" { + description = "Public IPv4 address when assign_public_ip is enabled." + value = one(module.kali.instance_public_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 100644 index 00000000..397dca75 --- /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..00bb6b52 --- /dev/null +++ b/modules/terraform-aws-kali/variables.tf @@ -0,0 +1,71 @@ +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 "assign_public_ip" { + description = "Assign a public IP for standalone deployments in a public subnet. No public ingress is opened." + type = bool + default = false +} + +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" + } + } +} From 7b082516e3359d6771c09f4960001b397c2de236 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Fri, 7 Aug 2026 14:49:41 -0400 Subject: [PATCH 2/6] fix(aws): keep Kali attack box private AWS attack boxes use Systems Manager for access, so exposing a public-IP option adds unnecessary scope and weakens the intended private deployment model. --- modules/terraform-aws-instance-factory/main.tf | 2 +- modules/terraform-aws-kali/README.md | 5 ++--- modules/terraform-aws-kali/main.tf | 4 ---- modules/terraform-aws-kali/outputs.tf | 5 ----- modules/terraform-aws-kali/variables.tf | 6 ------ 5 files changed, 3 insertions(+), 19 deletions(-) diff --git a/modules/terraform-aws-instance-factory/main.tf b/modules/terraform-aws-instance-factory/main.tf index 2f8dd731..5e769167 100644 --- a/modules/terraform-aws-instance-factory/main.tf +++ b/modules/terraform-aws-instance-factory/main.tf @@ -37,7 +37,7 @@ resource "aws_instance" "this" { monitoring = var.enable_monitoring user_data = var.user_data != "" ? var.user_data : null user_data_replace_on_change = true - associate_public_ip_address = var.assign_public_ip + associate_public_ip_address = var.assign_public_ip && !var.enable_ssm source_dest_check = var.source_dest_check root_block_device { diff --git a/modules/terraform-aws-kali/README.md b/modules/terraform-aws-kali/README.md index deec15bb..8d7d430d 100644 --- a/modules/terraform-aws-kali/README.md +++ b/modules/terraform-aws-kali/README.md @@ -10,6 +10,5 @@ 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 is private by default. Set `assign_public_ip = true` only for a -standalone deployment in a public subnet that has no NAT gateway or SSM VPC -endpoints. The module still creates no public ingress rule. +The instance remains private and is managed through AWS Systems Manager; the +module creates no public ingress rule. diff --git a/modules/terraform-aws-kali/main.tf b/modules/terraform-aws-kali/main.tf index 33edd903..2f6e51de 100644 --- a/modules/terraform-aws-kali/main.tf +++ b/modules/terraform-aws-kali/main.tf @@ -35,10 +35,6 @@ module "kali" { vpc_id = var.vpc_id subnet_id = var.subnet_id - # Private by default. A public IP can be explicitly enabled for standalone - # smoke tests in a public subnet without NAT or VPC endpoints. - assign_public_ip = var.assign_public_ip - # 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. diff --git a/modules/terraform-aws-kali/outputs.tf b/modules/terraform-aws-kali/outputs.tf index 50bfdb35..90f43b4c 100644 --- a/modules/terraform-aws-kali/outputs.tf +++ b/modules/terraform-aws-kali/outputs.tf @@ -8,11 +8,6 @@ output "private_ip" { value = one(module.kali.instance_private_ips) } -output "public_ip" { - description = "Public IPv4 address when assign_public_ip is enabled." - value = one(module.kali.instance_public_ips) -} - output "security_group_id" { description = "Security group attached to the Kali attack box." value = module.kali.security_group_id diff --git a/modules/terraform-aws-kali/variables.tf b/modules/terraform-aws-kali/variables.tf index 00bb6b52..19a2c5ba 100644 --- a/modules/terraform-aws-kali/variables.tf +++ b/modules/terraform-aws-kali/variables.tf @@ -40,12 +40,6 @@ variable "root_volume_size" { } } -variable "assign_public_ip" { - description = "Assign a public IP for standalone deployments in a public subnet. No public ingress is opened." - type = bool - default = false -} - 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 From fce6eebbede38dec21ef7d0c4da93b0798cbc4a1 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Fri, 7 Aug 2026 15:04:31 -0400 Subject: [PATCH 3/6] fix(aws): surface SSM discovery errors Preserve inventory fallback while reporting parse failures, and return AWS discovery errors instead of masking credential or permission problems as missing hosts. --- cli/cmd/ssm.go | 27 ++++++++++++++++++--------- cli/cmd/ssm_test.go | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/cli/cmd/ssm.go b/cli/cmd/ssm.go index 70e34c0f..1b2138cd 100644 --- a/cli/cmd/ssm.go +++ b/cli/cmd/ssm.go @@ -178,7 +178,10 @@ func runSSMConnect(cmd *cobra.Command, args []string) error { // 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, _ := inventory.Parse(cfg.InventoryPath()) + inv, invErr := inventory.Parse(cfg.InventoryPath()) + if invErr != nil { + slog.Warn("inventory unavailable; falling back to AWS discovery", "path", cfg.InventoryPath(), "error", invErr) + } target, err := resolveSSMHost(ctx, prov, cfg.Env, inv, args[0]) if err != nil { return err @@ -203,19 +206,25 @@ func resolveSSMHost(ctx context.Context, prov provider.Provider, env string, inv } } - if inst, err := prov.FindInstanceByHostname(ctx, env, hostName); err == nil && inst.ID != "" { - return inst, 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 { - if inst := provider.FindInstanceByRole(instances, "AttackBox"); inst != nil { - return inst, nil - } + 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) + } + + 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 != "" { + return inst, nil } return nil, fmt.Errorf("host %q not found via AWS discovery or inventory", hostName) diff --git a/cli/cmd/ssm_test.go b/cli/cmd/ssm_test.go index 1930a112..e17babd9 100644 --- a/cli/cmd/ssm_test.go +++ b/cli/cmd/ssm_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "testing" @@ -11,11 +12,19 @@ import ( type ssmDiscoveryProvider struct { provider.Provider - byName *provider.Instance - instances []provider.Instance + 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") } @@ -23,7 +32,8 @@ func (p *ssmDiscoveryProvider) FindInstanceByHostname(context.Context, string, s } func (p *ssmDiscoveryProvider) DiscoverInstances(context.Context, string) ([]provider.Instance, error) { - return p.instances, nil + p.discoverCalls++ + return p.instances, p.discoverErr } func TestResolveSSMHostFallsBackToDiscovery(t *testing.T) { @@ -52,6 +62,9 @@ func TestResolveSSMHostAcceptsAttackBoxRole(t *testing.T) { 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) { @@ -80,3 +93,23 @@ func TestFilterProviderInstancesAllExcludesAttackBox(t *testing.T) { 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) + } +} From 4ce2ac29fd88314724738c5dac4f867b95cfe3a0 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Fri, 7 Aug 2026 15:36:25 -0400 Subject: [PATCH 4/6] fix(ci): satisfy Kali module pre-commit hooks Track the cloud-init template as executable and commit the generated Terraform module documentation expected by the repository hooks. --- modules/terraform-aws-kali/README.md | 52 +++++++++++++++++++++ modules/terraform-aws-kali/user_data.sh.tpl | 0 2 files changed, 52 insertions(+) mode change 100644 => 100755 modules/terraform-aws-kali/user_data.sh.tpl diff --git a/modules/terraform-aws-kali/README.md b/modules/terraform-aws-kali/README.md index 8d7d430d..bfb4c219 100644 --- a/modules/terraform-aws-kali/README.md +++ b/modules/terraform-aws-kali/README.md @@ -12,3 +12,55 @@ 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/user_data.sh.tpl b/modules/terraform-aws-kali/user_data.sh.tpl old mode 100644 new mode 100755 From 7e48c8a80e13cddf6155e1919fd30061ad464202 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Fri, 7 Aug 2026 16:23:03 -0400 Subject: [PATCH 5/6] fix(ci): format Kali bootstrap script Match the repository shfmt configuration so pre-commit does not rewrite the cloud-init template in CI. --- modules/terraform-aws-kali/user_data.sh.tpl | 28 ++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/modules/terraform-aws-kali/user_data.sh.tpl b/modules/terraform-aws-kali/user_data.sh.tpl index 397dca75..3850c9eb 100755 --- a/modules/terraform-aws-kali/user_data.sh.tpl +++ b/modules/terraform-aws-kali/user_data.sh.tpl @@ -10,15 +10,15 @@ ssm_url="https://s3.${aws_region}.amazonaws.com/amazon-ssm-${aws_region}/latest/ # 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" + 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" + 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" + 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" @@ -26,13 +26,13 @@ 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 + 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 From c2a4a3af32438183c2b33ab87cbd2fbab478233c Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Fri, 7 Aug 2026 16:53:09 -0400 Subject: [PATCH 6/6] fix(aws): harden attack box discovery --- cli/cmd/ssm.go | 35 +++++++++------ cli/cmd/ssm_test.go | 32 +++++++++++++- cli/internal/aws/ec2.go | 83 +++++++++++++++++------------------- cli/internal/aws/ec2_test.go | 74 ++++++++++++++++++++++++-------- 4 files changed, 150 insertions(+), 74 deletions(-) diff --git a/cli/cmd/ssm.go b/cli/cmd/ssm.go index 1b2138cd..cd91484f 100644 --- a/cli/cmd/ssm.go +++ b/cli/cmd/ssm.go @@ -166,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 } @@ -175,13 +186,6 @@ func runSSMConnect(cmd *cobra.Command, args []string) error { return fmt.Errorf("provider %s does not support interactive shells", prov.Name()) } - // 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) - } target, err := resolveSSMHost(ctx, prov, cfg.Env, inv, args[0]) if err != nil { return err @@ -190,13 +194,17 @@ func runSSMConnect(cmd *cobra.Command, args []string) error { 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 } - fmt.Printf("Starting SSM session to %s (%s) in %s...\n", target.Name, target.ID, region) - - return shell.StartInteractiveShell(ctx, target.ID, region) + return provider.ConstructorOpts{Region: region}, nil } func resolveSSMHost(ctx context.Context, prov provider.Provider, env string, inv *inventory.Inventory, hostName string) (*provider.Instance, error) { @@ -224,6 +232,9 @@ func resolveSSMHost(ctx context.Context, prov provider.Provider, env string, inv 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 } diff --git a/cli/cmd/ssm_test.go b/cli/cmd/ssm_test.go index e17babd9..0ecc0774 100644 --- a/cli/cmd/ssm_test.go +++ b/cli/cmd/ssm_test.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "strings" "testing" + "github.com/dreadnode/dreadgoad/internal/config" "github.com/dreadnode/dreadgoad/internal/inventory" "github.com/dreadnode/dreadgoad/internal/provider" ) @@ -37,7 +39,7 @@ func (p *ssmDiscoveryProvider) DiscoverInstances(context.Context, string) ([]pro } func TestResolveSSMHostFallsBackToDiscovery(t *testing.T) { - want := &provider.Instance{ID: "i-kali", Name: "test-goad-dreadgoad-kali"} + 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") @@ -49,6 +51,21 @@ func TestResolveSSMHostFallsBackToDiscovery(t *testing.T) { } } +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"}}, @@ -113,3 +130,16 @@ func TestResolveSSMHostPropagatesAttackBoxDiscoveryError(t *testing.T) { 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 4935ab32..253de8b0 100644 --- a/cli/internal/aws/ec2.go +++ b/cli/internal/aws/ec2.go @@ -19,39 +19,14 @@ type Instance struct { Tags map[string]string } -// DiscoverInstances finds DreadGOAD instances by their project and environment tags. +// 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) { states := []string{"running"} states = append(states, extraStates...) - out, err := c.EC2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ - Filters: discoveryFilters(env, 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), - Tags: make(map[string]string, len(i.Tags)), - } - for _, t := range i.Tags { - 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 c.discoverInstances(ctx, env, states) } // GetInstancePrivateIPs queries EC2 for private IPs of the given instance IDs. @@ -90,21 +65,36 @@ 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) { - out, err := c.EC2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ - Filters: discoveryFilters(env, nil), - }) - 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)), @@ -119,18 +109,25 @@ func (c *Client) DiscoverAllInstances(ctx context.Context, env string) ([]Instan instances = append(instances, inst) } } - return instances, nil + return instances } -func discoveryFilters(env string, states []string) []types.Filter { - filters := []types.Filter{ - {Name: Ptr("tag:Project"), Values: []string{"DreadGOAD"}}, - {Name: Ptr("tag:Environment"), Values: []string{env}}, +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 { - filters = append(filters, types.Filter{Name: Ptr("instance-state-name"), Values: states}) + for i := range filterSets { + filterSets[i] = append(filterSets[i], types.Filter{Name: Ptr("instance-state-name"), Values: states}) + } } - return filters + 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 index 3825503a..34e43b08 100644 --- a/cli/internal/aws/ec2_test.go +++ b/cli/internal/aws/ec2_test.go @@ -3,33 +3,71 @@ package aws import ( "reflect" "testing" + + "github.com/aws/aws-sdk-go-v2/service/ec2/types" ) -func TestDiscoveryFiltersUseProjectAndEnvironmentTags(t *testing.T) { - filters := discoveryFilters("staging", []string{"running", "stopped"}) - got := make(map[string][]string, len(filters)) - for _, filter := range filters { - got[deref(filter.Name)] = filter.Values +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)) } - want := map[string][]string{ - "tag:Project": {"DreadGOAD"}, - "tag:Environment": {"staging"}, - "instance-state-name": {"running", "stopped"}, + 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) } - if !reflect.DeepEqual(got, want) { - t.Fatalf("discoveryFilters() = %#v, want %#v", got, want) + + 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 _, exists := got["tag:Name"]; exists { - t.Fatal("discoveryFilters() must not require a Name tag") + if !reflect.DeepEqual(got, want) { + t.Fatalf("discoveryFilterSets() = %#v, want %#v", got, want) } } -func TestDiscoveryFiltersOmitStateWhenEmpty(t *testing.T) { - filters := discoveryFilters("test", nil) - for _, filter := range filters { - if deref(filter.Name) == "instance-state-name" { - t.Fatal("discoveryFilters() included an empty instance-state-name filter") +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]) + } +}