From f27399275b222cdfe8edc7237bb1e3f6d949b273 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Mon, 3 Aug 2026 13:16:46 -0700 Subject: [PATCH] fix: harden Windows reboots and AD group creation for aws_ssm reliability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Added:** - Boot-time baseline capture and block/rescue reboot verification across the `groups_domains`, `iis`, `mssql`, `trusts`, and `webdav` roles, so the expected aws_ssm reconnect timeout during a reboot no longer fails the play — reboots now verify completion via `LastBootUpTime` comparison and retry until the host is back - Async execution and `async_status` wait tasks for universal, global, and DomainLocal group creation in the `ad` role, with retries to tolerate transient failures during group provisioning - `sanitizeAWSEnv` helper in the ansible runner that drops `AWS_PROFILE` when explicit `AWS_ACCESS_KEY_ID`/`AWS_SESSION_TOKEN` are present, resolving the boto3 "profile and access tokens is not supported" error in the aws_ssm connection plugin - cli/internal/ansible/runner.go - Test coverage for the AWS env sanitization logic and for a fatal error rescued by block/rescue with a clean PLAY RECAP - cli/internal/ansible/runner_test.go, logparser_test.go **Changed:** - `CheckAnsibleSuccess` now treats an existing PLAY RECAP as authoritative, relying on ansible's failed/unreachable counters (which already discount rescued/ignored fatals) and only scanning for unignored fatals when no recap is present - cli/internal/ansible/logparser.go - Reduced reboot timeouts to standardized values (300s timeout, 60s post-reboot delay) now that verification is handled explicitly by the rescue logic - Regenerated architecture diagram and updated role README task listings to reflect the new baseline/reboot block structure - docs/architecture.svg --- ansible/roles/ad/README.md | 3 + ansible/roles/ad/tasks/groups.yml | 60 ++++++++++++++ ansible/roles/groups_domains/README.md | 4 +- ansible/roles/groups_domains/tasks/main.yml | 26 +++++- ansible/roles/iis/README.md | 4 +- ansible/roles/iis/tasks/main.yml | 23 +++++- ansible/roles/mssql/README.md | 8 +- ansible/roles/mssql/tasks/install.yml | 49 +++++++++++- ansible/roles/trusts/README.md | 4 +- ansible/roles/trusts/tasks/main.yml | 26 +++++- ansible/roles/webdav/README.md | 4 +- ansible/roles/webdav/tasks/main.yml | 23 +++++- cli/internal/ansible/logparser.go | 9 ++- cli/internal/ansible/logparser_test.go | 11 +++ cli/internal/ansible/runner.go | 40 ++++++++- cli/internal/ansible/runner_test.go | 89 +++++++++++++++++++++ docs/architecture.svg | 2 +- 17 files changed, 362 insertions(+), 23 deletions(-) create mode 100644 cli/internal/ansible/runner_test.go diff --git a/ansible/roles/ad/README.md b/ansible/roles/ad/README.md index 5e66b1d7..62c2371b 100644 --- a/ansible/roles/ad/README.md +++ b/ansible/roles/ad/README.md @@ -16,8 +16,11 @@ Configure Active Directory domain administrator membership and settings ### groups.yml - **Create Universal Groups** (microsoft.ad.group) - Conditional +- **Wait for Universal group creation to complete** (ansible.builtin.async_status) - Conditional - **Create Global Groups** (microsoft.ad.group) - Conditional +- **Wait for Global group creation to complete** (ansible.builtin.async_status) - Conditional - **Create DomainLocal Groups** (microsoft.ad.group) - Conditional +- **Wait for DomainLocal group creation to complete** (ansible.builtin.async_status) - Conditional ### main.yml diff --git a/ansible/roles/ad/tasks/groups.yml b/ansible/roles/ad/tasks/groups.yml index 220dfae7..bccdd6a1 100644 --- a/ansible/roles/ad/tasks/groups.yml +++ b/ansible/roles/ad/tasks/groups.yml @@ -12,6 +12,26 @@ loop_control: label: "Creating universal group: {{ item.key }}" when: ad_groups['universal'] is defined + register: universal_group_result + retries: 3 + delay: 10 + until: universal_group_result is not failed + async: 120 + poll: 0 + vars: + ansible_win_async_startup_timeout: 30 + +- name: Wait for Universal group creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: universal_group_status + until: universal_group_status.finished + retries: 30 + delay: 5 + loop: "{{ universal_group_result.results }}" + loop_control: + label: "Waiting for universal group: {{ item.item.key }}" + when: universal_group_result is not skipped and item.ansible_job_id is defined - name: Create Global Groups microsoft.ad.group: @@ -24,6 +44,26 @@ loop_control: label: "Creating global group: {{ item.key }}" when: ad_groups['global'] is defined + register: global_group_result + retries: 3 + delay: 10 + until: global_group_result is not failed + async: 120 + poll: 0 + vars: + ansible_win_async_startup_timeout: 30 + +- name: Wait for Global group creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: global_group_status + until: global_group_status.finished + retries: 30 + delay: 5 + loop: "{{ global_group_result.results }}" + loop_control: + label: "Waiting for global group: {{ item.item.key }}" + when: global_group_result is not skipped and item.ansible_job_id is defined - name: Create DomainLocal Groups microsoft.ad.group: @@ -36,3 +76,23 @@ loop_control: label: "Creating domainlocal group: {{ item.key }}" when: ad_groups['domainlocal'] is defined + register: domainlocal_group_result + retries: 3 + delay: 10 + until: domainlocal_group_result is not failed + async: 120 + poll: 0 + vars: + ansible_win_async_startup_timeout: 30 + +- name: Wait for DomainLocal group creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: domainlocal_group_status + until: domainlocal_group_status.finished + retries: 30 + delay: 5 + loop: "{{ domainlocal_group_result.results }}" + loop_control: + label: "Waiting for domainlocal group: {{ item.item.key }}" + when: domainlocal_group_result is not skipped and item.ansible_job_id is defined diff --git a/ansible/roles/groups_domains/README.md b/ansible/roles/groups_domains/README.md index 279ab55d..bdfe2032 100644 --- a/ansible/roles/groups_domains/README.md +++ b/ansible/roles/groups_domains/README.md @@ -15,7 +15,9 @@ Create and configure Active Directory groups across domains ### main.yml -- **Reboot and wait for the AD system to restart** (ansible.windows.win_reboot) +- **Record pre-reboot boot time baseline** (ansible.windows.win_powershell) +- **Reboot and wait for the AD system to restart** (block) +- **Trigger reboot via win_reboot** (ansible.windows.win_reboot) - **Synchronize all domains with proper credentials** (ansible.windows.win_powershell) - **Add cross-domain users/groups using PowerShell Direct** (ansible.windows.win_powershell) - Conditional diff --git a/ansible/roles/groups_domains/tasks/main.yml b/ansible/roles/groups_domains/tasks/main.yml index 3f2e8dbb..87668903 100644 --- a/ansible/roles/groups_domains/tasks/main.yml +++ b/ansible/roles/groups_domains/tasks/main.yml @@ -1,8 +1,28 @@ --- +- name: Record pre-reboot boot time baseline + ansible.windows.win_powershell: + script: | + (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + register: reboot_baseline + - name: Reboot and wait for the AD system to restart - ansible.windows.win_reboot: - test_command: "Get-ADUser -Identity Administrator -Properties *" - post_reboot_delay: 100 + block: + - name: Trigger reboot via win_reboot + ansible.windows.win_reboot: + test_command: "Get-ADUser -Identity Administrator -Properties *" + reboot_timeout: 300 + post_reboot_delay: 60 + rescue: + - name: Verify reboot completed (aws_ssm reconnect timeout is expected) + ansible.windows.win_powershell: + script: | + $current = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + if ($current -le {{ reboot_baseline.output[0] }}) { throw "host has not rebooted yet" } + Get-ADUser -Identity Administrator -Properties * | Out-Null + register: reboot_verify + until: reboot_verify is succeeded + retries: 30 + delay: 10 - name: Synchronize all domains with proper credentials ansible.windows.win_powershell: diff --git a/ansible/roles/iis/README.md b/ansible/roles/iis/README.md index 6d8afd9b..5cbbd409 100644 --- a/ansible/roles/iis/README.md +++ b/ansible/roles/iis/README.md @@ -21,7 +21,9 @@ Install and configure Internet Information Services web server - **Add SYSTEM allow rights to machine keys** (ansible.windows.win_acl) - **Create IIS directories** (ansible.windows.win_file) - **Deploy default website index** (ansible.windows.win_copy) -- **Reboot if required** (ansible.windows.win_reboot) - Conditional +- **Record pre-reboot boot time baseline (IIS install)** (ansible.windows.win_powershell) - Conditional +- **Reboot if required** (block) - Conditional +- **Trigger reboot via win_reboot** (ansible.windows.win_reboot) ## Example Playbook diff --git a/ansible/roles/iis/tasks/main.yml b/ansible/roles/iis/tasks/main.yml index 46c721c2..f4417493 100644 --- a/ansible/roles/iis/tasks/main.yml +++ b/ansible/roles/iis/tasks/main.yml @@ -76,6 +76,27 @@ src: files/index.html dest: "C:\\inetpub\\wwwroot\\index.html" +- name: Record pre-reboot boot time baseline (IIS install) + ansible.windows.win_powershell: + script: | + (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + register: reboot_baseline + when: win_feature.reboot_required | default(false) + - name: Reboot if required - ansible.windows.win_reboot: + block: + - name: Trigger reboot via win_reboot + ansible.windows.win_reboot: + reboot_timeout: 300 + post_reboot_delay: 60 + rescue: + - name: Verify reboot completed (aws_ssm reconnect timeout is expected) + ansible.windows.win_powershell: + script: | + $current = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + if ($current -le {{ reboot_baseline.output[0] }}) { throw "host has not rebooted yet" } + register: reboot_verify + until: reboot_verify is succeeded + retries: 30 + delay: 10 when: win_feature.reboot_required | default(false) diff --git a/ansible/roles/mssql/README.md b/ansible/roles/mssql/README.md index a471c413..d7d151fc 100644 --- a/ansible/roles/mssql/README.md +++ b/ansible/roles/mssql/README.md @@ -43,7 +43,9 @@ Install and configure Microsoft SQL Server Express ### install.yml - **Check if reboot is pending before install** (ansible.windows.win_shell) -- **Reboot before install if pending (long timeout in case of update)** (ansible.windows.win_reboot) - Conditional +- **Record pre-reboot boot time baseline (pre-MSSQL install)** (ansible.windows.win_powershell) - Conditional +- **Reboot before install if pending** (block) - Conditional +- **Trigger reboot via win_reboot** (ansible.windows.win_reboot) - **Create SQL Server installation directories** (ansible.windows.win_file) - **Create and load user profile** (ansible.windows.win_shell) - **Create SQL Server configuration file** (ansible.windows.win_template) @@ -58,7 +60,9 @@ Install and configure Microsoft SQL Server Express - **Install SQL Server** (ansible.windows.win_command) - Conditional - **Add or update registry for ip port (2022)** (ansible.windows.win_regedit) - Conditional - **Add or update registry for ip port (2019)** (ansible.windows.win_regedit) - Conditional -- **Reboot if registry was changed** (ansible.windows.win_reboot) - Conditional +- **Record pre-reboot boot time baseline (post-registry change)** (ansible.windows.win_powershell) - Conditional +- **Reboot if registry was changed** (block) - Conditional +- **Trigger reboot via win_reboot** (ansible.windows.win_reboot) - **Firewall ¦ Allow MSSQL through Firewall** (ansible.windows.win_dsc) - **Firewall ¦ Allow MSSQL discover through Firewall** (ansible.windows.win_dsc) - **Be sure service is started** (ansible.windows.win_service) diff --git a/ansible/roles/mssql/tasks/install.yml b/ansible/roles/mssql/tasks/install.yml index 7a84f475..4a841d58 100644 --- a/ansible/roles/mssql/tasks/install.yml +++ b/ansible/roles/mssql/tasks/install.yml @@ -34,9 +34,29 @@ failed_when: false changed_when: false -- name: Reboot before install if pending (long timeout in case of update) - ansible.windows.win_reboot: - reboot_timeout: 1200 +- name: Record pre-reboot boot time baseline (pre-MSSQL install) + ansible.windows.win_powershell: + script: | + (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + register: reboot_baseline + when: reboot_check.rc == 1 + +- name: Reboot before install if pending + block: + - name: Trigger reboot via win_reboot + ansible.windows.win_reboot: + reboot_timeout: 300 + post_reboot_delay: 60 + rescue: + - name: Verify reboot completed (aws_ssm reconnect timeout is expected) + ansible.windows.win_powershell: + script: | + $current = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + if ($current -le {{ reboot_baseline.output[0] }}) { throw "host has not rebooted yet" } + register: reboot_verify + until: reboot_verify is succeeded + retries: 60 + delay: 10 when: reboot_check.rc == 1 - name: Create SQL Server installation directories @@ -156,8 +176,29 @@ register: win_reg when: sql_version == "MSSQL_2019" +- name: Record pre-reboot boot time baseline (post-registry change) + ansible.windows.win_powershell: + script: | + (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + register: reboot_baseline + when: win_reg.changed + - name: Reboot if registry was changed - ansible.windows.win_reboot: + block: + - name: Trigger reboot via win_reboot + ansible.windows.win_reboot: + reboot_timeout: 300 + post_reboot_delay: 60 + rescue: + - name: Verify reboot completed (aws_ssm reconnect timeout is expected) + ansible.windows.win_powershell: + script: | + $current = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + if ($current -le {{ reboot_baseline.output[0] }}) { throw "host has not rebooted yet" } + register: reboot_verify + until: reboot_verify is succeeded + retries: 30 + delay: 10 when: win_reg.changed - name: Firewall | Allow MSSQL through Firewall diff --git a/ansible/roles/trusts/README.md b/ansible/roles/trusts/README.md index 941d8b82..eaa10e23 100644 --- a/ansible/roles/trusts/README.md +++ b/ansible/roles/trusts/README.md @@ -17,7 +17,9 @@ Configure Active Directory domain trust relationships - **Prepare to trust flush and renew dns** (ansible.windows.win_shell) - **Configure forest trust to remote domain** (ansible.windows.win_powershell) -- **Reboot and wait for the AD system to restart** (ansible.windows.win_reboot) - Conditional +- **Record pre-reboot boot time baseline** (ansible.windows.win_powershell) - Conditional +- **Reboot and wait for the AD system to restart** (block) - Conditional +- **Trigger reboot via win_reboot** (ansible.windows.win_reboot) ## Example Playbook diff --git a/ansible/roles/trusts/tasks/main.yml b/ansible/roles/trusts/tasks/main.yml index 96d0c1df..7089b00d 100644 --- a/ansible/roles/trusts/tasks/main.yml +++ b/ansible/roles/trusts/tasks/main.yml @@ -50,7 +50,29 @@ } register: trust_result +- name: Record pre-reboot boot time baseline + ansible.windows.win_powershell: + script: | + (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + register: reboot_baseline + when: trust_result.changed + - name: Reboot and wait for the AD system to restart - ansible.windows.win_reboot: - test_command: "Get-ADUser -Identity {{ admin_user }} -Properties *" + block: + - name: Trigger reboot via win_reboot + ansible.windows.win_reboot: + test_command: "Get-ADUser -Identity {{ admin_user }} -Properties *" + reboot_timeout: 300 + post_reboot_delay: 60 + rescue: + - name: Verify reboot completed (aws_ssm reconnect timeout is expected) + ansible.windows.win_powershell: + script: | + $current = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + if ($current -le {{ reboot_baseline.output[0] }}) { throw "host has not rebooted yet" } + Get-ADUser -Identity {{ admin_user }} -Properties * | Out-Null + register: reboot_verify + until: reboot_verify is succeeded + retries: 30 + delay: 10 when: trust_result.changed diff --git a/ansible/roles/webdav/README.md b/ansible/roles/webdav/README.md index 3ebd91e0..891d8b08 100644 --- a/ansible/roles/webdav/README.md +++ b/ansible/roles/webdav/README.md @@ -16,7 +16,9 @@ Install and configure WebDAV client on Windows hosts ### main.yml - **Ensure WebDAV client feature is installed** (ansible.windows.win_feature) -- **Reboot after installing WebDAV client feature** (ansible.windows.win_reboot) - Conditional +- **Record pre-reboot boot time baseline (WebDAV install)** (ansible.windows.win_powershell) - Conditional +- **Reboot after installing WebDAV client feature** (block) - Conditional +- **Trigger reboot via win_reboot** (ansible.windows.win_reboot) ## Example Playbook diff --git a/ansible/roles/webdav/tasks/main.yml b/ansible/roles/webdav/tasks/main.yml index 17e421de..cafc6318 100644 --- a/ansible/roles/webdav/tasks/main.yml +++ b/ansible/roles/webdav/tasks/main.yml @@ -5,6 +5,27 @@ state: present register: webdav_feature +- name: Record pre-reboot boot time baseline (WebDAV install) + ansible.windows.win_powershell: + script: | + (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + register: reboot_baseline + when: webdav_feature.reboot_required + - name: Reboot after installing WebDAV client feature - ansible.windows.win_reboot: + block: + - name: Trigger reboot via win_reboot + ansible.windows.win_reboot: + reboot_timeout: 300 + post_reboot_delay: 60 + rescue: + - name: Verify reboot completed (aws_ssm reconnect timeout is expected) + ansible.windows.win_powershell: + script: | + $current = (Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime).LastBootUpTime.ToFileTime() + if ($current -le {{ reboot_baseline.output[0] }}) { throw "host has not rebooted yet" } + register: reboot_verify + until: reboot_verify is succeeded + retries: 30 + delay: 10 when: webdav_feature.reboot_required diff --git a/cli/internal/ansible/logparser.go b/cli/internal/ansible/logparser.go index 3357890e..eb146f60 100644 --- a/cli/internal/ansible/logparser.go +++ b/cli/internal/ansible/logparser.go @@ -13,21 +13,22 @@ var ( ) // CheckAnsibleSuccess analyzes Ansible output to determine if the run succeeded. -// It reports whether no failures or unreachable hosts were detected in the -// PLAY RECAP and no unignored fatal errors appear in the output. +// When a PLAY RECAP is present it is treated as authoritative: ansible's own +// failed/unreachable counters already discount fatals that were rescued or +// explicitly ignored. If no recap exists (the play aborted before printing one) +// the output is scanned for unignored fatal errors as a fallback. func CheckAnsibleSuccess(output string) bool { if idx := strings.Index(output, "PLAY RECAP"); idx >= 0 { recap := output[idx:] if failedRe.MatchString(recap) || unreachableRe.MatchString(recap) { return false } + return !strings.Contains(output, "to retry, use:") } - // Secondary: check for fatal errors not followed by "...ignoring" lines := strings.Split(output, "\n") for i, line := range lines { if strings.HasPrefix(line, "fatal:") { - // Check next 20 lines for "...ignoring" (multi-line YAML output can be long) end := i + 21 if end > len(lines) { end = len(lines) diff --git a/cli/internal/ansible/logparser_test.go b/cli/internal/ansible/logparser_test.go index 2b51f3f5..1ed6a4a7 100644 --- a/cli/internal/ansible/logparser_test.go +++ b/cli/internal/ansible/logparser_test.go @@ -45,6 +45,17 @@ fatal: [DC01]: FAILED! => {"msg": "critical error"} NO MORE HOSTS LEFT *************************************************************`, want: false, }, + { + name: "fatal rescued by block/rescue with clean recap", + output: `TASK [Reboot] +fatal: [DC01]: FAILED! => {"msg": "Timed out waiting for last boot time check (timeout=600)"} + +TASK [Verify reboot completed] +ok: [DC01] +PLAY RECAP ********************************************************************* +DC01 : ok=12 changed=1 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0`, + want: true, + }, { name: "retry indicator present", output: `PLAY RECAP ********************************************************************* diff --git a/cli/internal/ansible/runner.go b/cli/internal/ansible/runner.go index c655d498..ab036917 100644 --- a/cli/internal/ansible/runner.go +++ b/cli/internal/ansible/runner.go @@ -228,7 +228,7 @@ func buildArgs(opts RunOptions, cfg *config.Config) []string { } func buildEnv(opts RunOptions, cfg *config.Config) ([]string, error) { - env := os.Environ() + env := sanitizeAWSEnv(os.Environ()) // On macOS, the ObjC runtime aborts forked child processes when class // initialisation is in progress at fork time. Ansible forks workers @@ -256,6 +256,44 @@ func buildEnv(opts RunOptions, cfg *config.Config) ([]string, error) { return env, nil } +// sanitizeAWSEnv resolves the boto3 "Passing both a profile and access tokens +// is not supported" error by dropping AWS_PROFILE when explicit access-key or +// session-token env vars are also present. This shows up when the shell has +// AWS_PROFILE set as a default while an SSO/1Password/assume-role helper has +// injected short-lived credentials — both are valid credential sources on +// their own, but the amazon.aws.aws_ssm connection plugin passes both to +// boto3 which then hard-errors on every host. +func sanitizeAWSEnv(env []string) []string { + var hasProfile, hasKeys bool + for _, kv := range env { + switch { + case strings.HasPrefix(kv, "AWS_PROFILE="): + hasProfile = kv != "AWS_PROFILE=" + case strings.HasPrefix(kv, "AWS_ACCESS_KEY_ID="), + strings.HasPrefix(kv, "AWS_SESSION_TOKEN="): + if !strings.HasSuffix(kv, "=") { + hasKeys = true + } + } + } + if !hasProfile || !hasKeys { + return env + } + + out := env[:0:len(env)] + var dropped string + for _, kv := range env { + if strings.HasPrefix(kv, "AWS_PROFILE=") { + dropped = strings.TrimPrefix(kv, "AWS_PROFILE=") + continue + } + out = append(out, kv) + } + slog.Warn("dropped AWS_PROFILE to avoid boto3 profile+access-key conflict; using explicit AWS_ACCESS_KEY_ID/SESSION_TOKEN instead", + "dropped_profile", dropped) + return out +} + func fileExists(path string) bool { _, err := os.Stat(path) return err == nil diff --git a/cli/internal/ansible/runner_test.go b/cli/internal/ansible/runner_test.go new file mode 100644 index 00000000..1d0684ad --- /dev/null +++ b/cli/internal/ansible/runner_test.go @@ -0,0 +1,89 @@ +package ansible + +import ( + "slices" + "testing" +) + +func TestSanitizeAWSEnv(t *testing.T) { + tests := []struct { + name string + in []string + wantHas []string + wantMissing []string + }{ + { + name: "profile and access key both set drops profile", + in: []string{ + "PATH=/usr/bin", + "AWS_PROFILE=personal", + "AWS_ACCESS_KEY_ID=AKIAEXAMPLE", + "AWS_SESSION_TOKEN=tok", + }, + wantHas: []string{"PATH=/usr/bin", "AWS_ACCESS_KEY_ID=AKIAEXAMPLE", "AWS_SESSION_TOKEN=tok"}, + wantMissing: []string{"AWS_PROFILE=personal"}, + }, + { + name: "profile alone is kept", + in: []string{ + "AWS_PROFILE=personal", + "HOME=/tmp", + }, + wantHas: []string{"AWS_PROFILE=personal", "HOME=/tmp"}, + wantMissing: nil, + }, + { + name: "access keys alone are kept", + in: []string{ + "AWS_ACCESS_KEY_ID=AKIAEXAMPLE", + "AWS_SESSION_TOKEN=tok", + }, + wantHas: []string{"AWS_ACCESS_KEY_ID=AKIAEXAMPLE", "AWS_SESSION_TOKEN=tok"}, + wantMissing: nil, + }, + { + name: "empty profile value with keys is a no-op", + in: []string{ + "AWS_PROFILE=", + "AWS_ACCESS_KEY_ID=AKIAEXAMPLE", + }, + wantHas: []string{"AWS_PROFILE=", "AWS_ACCESS_KEY_ID=AKIAEXAMPLE"}, + wantMissing: nil, + }, + { + name: "profile plus empty keys is a no-op", + in: []string{ + "AWS_PROFILE=personal", + "AWS_ACCESS_KEY_ID=", + "AWS_SESSION_TOKEN=", + }, + wantHas: []string{"AWS_PROFILE=personal", "AWS_ACCESS_KEY_ID=", "AWS_SESSION_TOKEN="}, + wantMissing: nil, + }, + { + name: "profile plus session token only still drops profile", + in: []string{ + "AWS_PROFILE=personal", + "AWS_SESSION_TOKEN=tok", + }, + wantHas: []string{"AWS_SESSION_TOKEN=tok"}, + wantMissing: []string{"AWS_PROFILE=personal"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeAWSEnv(slices.Clone(tt.in)) + for _, want := range tt.wantHas { + if !slices.Contains(got, want) { + t.Errorf("expected env to contain %q, got %v", want, got) + } + } + for _, missing := range tt.wantMissing { + if slices.Contains(got, missing) { + t.Errorf("expected env NOT to contain %q, got %v", missing, got) + } + } + }) + } +} diff --git a/docs/architecture.svg b/docs/architecture.svg index 9ea63f32..0148f120 100644 --- a/docs/architecture.svg +++ b/docs/architecture.svg @@ -1 +1 @@ -

dreadnode.goad
Ansible Collection

LAPS
4 roles

SCCM
14 roles

Vulnerabilities
30 roles

Security
8 roles

Settings
13 roles

Active Directory
21 roles

Server Roles
30 roles

dc • permissions • server
verify

config_accounts • config_boundary • config_client_install
config_client_push • config_discovery • config_naa
config_pxe • config_users • install_adk
install_iis • install_mecm • install_prerequisites
install_wsus • pxe

acls • adcs_esc10_case1 • adcs_esc10_case2
adcs_esc11 • adcs_esc13 • adcs_esc15
adcs_esc6 • adcs_esc7 • adcs_templates
administrator_folder • anonymous_enum • autologon
credentials • directory • disable_firewall
enable_credssp_client • enable_credssp_server • enable_llmnr
enable_nbt_ns • files • mssql
no_ldap_channel_binding • no_ldap_integrity • no_ldap_signing
ntlmdowngrade • openshares • permissions
schedule • shares • smbv1

dc_audit_sacl • ldap_diagnostic_logging • account_is_sensitive
asr • audit_policy • enable_run_as_ppl
ensure_kb_not_installed • powershell_restrict

adjust_rights • admin_password • copy_files
disable_nat_adapter • enable_nat_adapter • gpmc
gpo_remove • hostname • keyboard
no_updates • updates • user_rights
windows_defender

acl • ad • adcs
adcs_templates • child_domain • dc_dns_conditional_forwarder
disable_user • dns_conditional_forwarder • domain_controller
domain_controller_slave • enable_user • gmsa
gmsa_hosts • groups_domains • member_server
move_to_ou • onlyusers • parent_child_dns
password_policy • sync_domains • trusts

common • commonwkstn • dhcp
elk • fix_dns • iis
localusers • logs_windows • mssql
audit • link • reporting
ssms • ps • webdav
add_dns_record • exchange_bot • keepass
klink • linux • linux_add_linux_to_domain
linux_guacamole • linux_guacamole_create_connections • linux_tomcat
ludus_exchange • network_discovery • security
wazuh_agent • wazuh_agent_linux • wazuh_manager

Playbooks
41 playbooks

ad • ad-acl • ad-child_domain
ad-data • ad-gmsa • ad-members
ad-parent_domain • ad-relations • ad-servers
ad-trusts • adcs • build
dhcp • diagnose-dc01 • disable_vagrant
enable_vagrant • ext-elk • ext-exchange
ext-guacamole • ext-lx01 • ext-wazuh
ext-ws01 • fix_dns • fix_trust
interfaces • laps • load_network_mappings
localusers • main • network_setup
onlyusers • reboot • sccm-client
sccm-config • sccm-install • sccm-pxe
security • security_logging • servers
vulnerabilities • wait5m

+

dreadnode.goad
Ansible Collection

LAPS
4 roles

SCCM
14 roles

Vulnerabilities
30 roles

Security
8 roles

Settings
13 roles

Active Directory
21 roles

Server Roles
30 roles

dc • permissions • server
verify

config_accounts • config_boundary • config_client_install
config_client_push • config_discovery • config_naa
config_pxe • config_users • install_adk
install_iis • install_mecm • install_prerequisites
install_wsus • pxe

acls • adcs_esc10_case1 • adcs_esc10_case2
adcs_esc11 • adcs_esc13 • adcs_esc15
adcs_esc6 • adcs_esc7 • adcs_templates
administrator_folder • anonymous_enum • autologon
credentials • directory • disable_firewall
enable_credssp_client • enable_credssp_server • enable_llmnr
enable_nbt_ns • files • mssql
no_ldap_channel_binding • no_ldap_integrity • no_ldap_signing
ntlmdowngrade • openshares • permissions
schedule • shares • smbv1

dc_audit_sacl • ldap_diagnostic_logging • account_is_sensitive
asr • audit_policy • enable_run_as_ppl
ensure_kb_not_installed • powershell_restrict

adjust_rights • admin_password • copy_files
disable_nat_adapter • enable_nat_adapter • gpmc
gpo_remove • hostname • keyboard
no_updates • updates • user_rights
windows_defender

acl • ad • adcs
adcs_templates • child_domain • dc_dns_conditional_forwarder
disable_user • dns_conditional_forwarder • domain_controller
domain_controller_slave • enable_user • gmsa
gmsa_hosts • groups_domains • member_server
move_to_ou • onlyusers • parent_child_dns
password_policy • sync_domains • trusts

common • commonwkstn • dhcp
elk • fix_dns • iis
localusers • logs_windows • mssql
audit • link • reporting
ssms • ps • webdav
add_dns_record • exchange_bot • keepass
klink • linux • linux_add_linux_to_domain
linux_guacamole • linux_guacamole_create_connections • linux_tomcat
ludus_exchange • network_discovery • security
wazuh_agent • wazuh_agent_linux • wazuh_manager

Playbooks
41 playbooks

ad • ad-acl • ad-child_domain
ad-data • ad-gmsa • ad-members
ad-parent_domain • ad-relations • ad-servers
ad-trusts • adcs • build
dhcp • diagnose-dc01 • disable_vagrant
enable_vagrant • ext-elk • ext-exchange
ext-guacamole • ext-lx01 • ext-wazuh
ext-ws01 • fix_dns • fix_trust
interfaces • laps • load_network_mappings
localusers • main • network_setup
onlyusers • reboot • sccm-client
sccm-config • sccm-install • sccm-pxe
security • security_logging • servers
vulnerabilities • wait5m