From 20fad90848006950e66057a42973f127d1dc95ab Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 8 Apr 2026 09:27:50 -0600 Subject: [PATCH 1/5] feat: enable and validate xp_cmdshell for MSSQL role and update validator **Added:** - Introduced Ansible tasks to enable `xp_cmdshell` in MSSQL, including error handling and logging for failures in the role's configuration tasks - Documented the new `xp_cmdshell` enable and logging steps in the MSSQL role README **Changed:** - Updated MSSQL validator to treat `xp_cmdshell` not being enabled as a FAIL instead of a WARN, making the check stricter for compliance **Removed:** - No removals --- ansible/roles/acl/tasks/main.yml | 8 ++++++++ ansible/roles/mssql/README.md | 2 ++ ansible/roles/mssql/tasks/config.yml | 26 ++++++++++++++++++++++++++ cli/internal/validate/checks.go | 2 +- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/ansible/roles/acl/tasks/main.yml b/ansible/roles/acl/tasks/main.yml index 632ff599..da2f7a74 100644 --- a/ansible/roles/acl/tasks/main.yml +++ b/ansible/roles/acl/tasks/main.yml @@ -110,6 +110,14 @@ if (-not $forObj) { $withDollar = $for + '$' $forObj = Get-ADObject -Filter "SamAccountName -eq '$withDollar'" -Properties objectSID -ErrorAction SilentlyContinue + # Get-ADObject -Filter can choke on trailing $ in filter strings; + # fall back to type-specific cmdlets that use -Identity instead. + if (-not $forObj) { + $forObj = Get-ADServiceAccount -Identity $for -Properties objectSID -ErrorAction SilentlyContinue + } + if (-not $forObj) { + $forObj = Get-ADComputer -Identity $for -Properties objectSID -ErrorAction SilentlyContinue + } } if (-not $forObj) { throw "Cannot find object with SamAccountName: $for" diff --git a/ansible/roles/mssql/README.md b/ansible/roles/mssql/README.md index 7d562bad..595f1352 100644 --- a/ansible/roles/mssql/README.md +++ b/ansible/roles/mssql/README.md @@ -36,6 +36,8 @@ Install and configure Microsoft SQL Server Express - **Enable sa account** (ansible.windows.win_shell) - **Log sa account errors** (ansible.builtin.debug) - Conditional - **Enable MSSQL authentication and windows authent** (ansible.windows.win_shell) +- **Enable xp_cmdshell** (ansible.windows.win_shell) +- **Log xp_cmdshell errors** (ansible.builtin.debug) - Conditional - **Revoke ssm-user SQL sysadmin after config** (ansible.windows.win_shell) - **Restart service MSSQL** (ansible.windows.win_service) - Conditional diff --git a/ansible/roles/mssql/tasks/config.yml b/ansible/roles/mssql/tasks/config.yml index 492ee33d..0baab68e 100644 --- a/ansible/roles/mssql/tasks/config.yml +++ b/ansible/roles/mssql/tasks/config.yml @@ -172,6 +172,32 @@ become_user: SYSTEM register: auth_mode_result +- name: Enable xp_cmdshell + ansible.windows.win_shell: | + $ErrorActionPreference = "Continue" + $errors = @() + + $result1 = SqlCmd {{ connection_type }} -Q "EXEC sp_configure 'show advanced options', 1; RECONFIGURE" 2>&1 + if ($LASTEXITCODE -ne 0) { $errors += "show advanced options failed: $result1" } + + $result2 = SqlCmd {{ connection_type }} -Q "EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE" 2>&1 + if ($LASTEXITCODE -ne 0) { $errors += "xp_cmdshell enable failed: $result2" } + + if ($errors.Count -gt 0) { + Write-Error ($errors -join "`n") + exit 1 + } + Write-Output "Successfully enabled xp_cmdshell" + become: true + become_method: ansible.builtin.runas + become_user: SYSTEM + register: xp_cmdshell_result + +- name: Log xp_cmdshell errors + ansible.builtin.debug: + msg: "WARNING: xp_cmdshell config had stderr: {{ xp_cmdshell_result.stderr }}" + when: xp_cmdshell_result.stderr is defined and xp_cmdshell_result.stderr | length > 0 + - name: Revoke ssm-user SQL sysadmin after config ansible.windows.win_shell: | $ErrorActionPreference = "Continue" diff --git a/cli/internal/validate/checks.go b/cli/internal/validate/checks.go index 20358c7d..a3344c10 100644 --- a/cli/internal/validate/checks.go +++ b/cli/internal/validate/checks.go @@ -306,7 +306,7 @@ func (v *Validator) checkMSSQL(ctx context.Context) { if strings.TrimSpace(output) == "1" { v.addResult("PASS", "MSSQL", fmt.Sprintf("xp_cmdshell enabled on %s", hostLabel), "") } else { - v.addResult("WARN", "MSSQL", fmt.Sprintf("xp_cmdshell NOT enabled on %s", hostLabel), "") + v.addResult("FAIL", "MSSQL", fmt.Sprintf("xp_cmdshell NOT enabled on %s", hostLabel), "") } } } From b8657358946c182645d4814b61192903ebecdcb1 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 8 Apr 2026 11:54:21 -0600 Subject: [PATCH 2/5] refactor: simplify mssql config by persisting sysadmin and updating validator total **Changed:** - Updated mssql role to ensure BUILTIN\Administrators has SQL sysadmin, removing temporary ssm-user sysadmin grants and revokes for idempotency and simpler re-provisioning (config.yml, README.md) - Removed all unnecessary become/runas SYSTEM usage in mssql config tasks since ssm-user retains sysadmin - Improved documentation in mssql config tasks to clarify privilege rationale and bootstrap flow - Adjusted validator to compute report.Total as sum of Passed, Failed, and Warnings for accurate reporting **Removed:** - Removed tasks that granted and revoked ssm-user SQL sysadmin in mssql config for a simpler, persistent privilege model - Eliminated redundant become/runas SYSTEM parameters from mssql config tasks --- ansible/roles/mssql/README.md | 3 +- ansible/roles/mssql/tasks/config.yml | 83 ++++++++-------------------- cli/internal/validate/validator.go | 2 +- 3 files changed, 26 insertions(+), 62 deletions(-) diff --git a/ansible/roles/mssql/README.md b/ansible/roles/mssql/README.md index 595f1352..2f946734 100644 --- a/ansible/roles/mssql/README.md +++ b/ansible/roles/mssql/README.md @@ -26,7 +26,7 @@ Install and configure Microsoft SQL Server Express ### config.yml -- **Grant ssm-user SQL sysadmin for config run** (ansible.windows.win_shell) +- **Ensure BUILTIN\Administrators has SQL sysadmin** (ansible.windows.win_shell) - **Add MSSQL admin** (ansible.windows.win_shell) - **Log MSSQL admin errors** (ansible.builtin.debug) - Conditional - **Add IMPERSONATE on login** (ansible.windows.win_shell) @@ -38,7 +38,6 @@ Install and configure Microsoft SQL Server Express - **Enable MSSQL authentication and windows authent** (ansible.windows.win_shell) - **Enable xp_cmdshell** (ansible.windows.win_shell) - **Log xp_cmdshell errors** (ansible.builtin.debug) - Conditional -- **Revoke ssm-user SQL sysadmin after config** (ansible.windows.win_shell) - **Restart service MSSQL** (ansible.windows.win_service) - Conditional ### install.yml diff --git a/ansible/roles/mssql/tasks/config.yml b/ansible/roles/mssql/tasks/config.yml index 0baab68e..a6d033ad 100644 --- a/ansible/roles/mssql/tasks/config.yml +++ b/ansible/roles/mssql/tasks/config.yml @@ -3,33 +3,36 @@ # These tasks configure SQL Server after installation # They should ALWAYS run to ensure proper configuration (idempotent) # -# Note: Tasks use become/runas SYSTEM for SqlCmd. The golden AMI build also -# grants ssm-user an explicit SQL sysadmin login as defense-in-depth -# (BUILTIN\Administrators alone is not sufficient due to UAC token filtering -# on non-elevated SSM sessions). +# The golden AMI build grants ssm-user an explicit SQL sysadmin login +# (BUILTIN\Administrators alone is not sufficient due to UAC token +# filtering on non-elevated SSM sessions). Config tasks run as +# ssm-user with that explicit login — no become/runas SYSTEM needed. # -# ssm-user sysadmin is bracketed: granted at the start of config, revoked at -# the end. This keeps re-runs idempotent while minimizing residual privilege. +# The bootstrap task tries Windows auth first, then falls back to sa +# auth (available after the first provision) so that re-runs work even +# if ssm-user's SQL login was previously dropped. +# +# ssm-user's SQL sysadmin is intentionally kept across runs so that +# re-provisioning remains idempotent without a bootstrap problem. -- name: Grant ssm-user SQL sysadmin for config run +- name: Ensure BUILTIN\Administrators has SQL sysadmin ansible.windows.win_shell: | $ErrorActionPreference = "Continue" - $errors = @() - - $result1 = SqlCmd {{ connection_type }} -Q "IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = 'ssm-user') CREATE LOGIN [ssm-user] FROM WINDOWS WITH DEFAULT_DATABASE=[master]" 2>&1 - if ($LASTEXITCODE -ne 0) { $errors += "CREATE LOGIN failed: $result1" } - - $result2 = SqlCmd {{ connection_type }} -Q "IF IS_SRVROLEMEMBER('sysadmin', 'ssm-user') = 0 EXEC sp_addsrvrolemember 'ssm-user', 'sysadmin'" 2>&1 - if ($LASTEXITCODE -ne 0) { $errors += "sp_addsrvrolemember failed: $result2" } - if ($errors.Count -gt 0) { - Write-Error ($errors -join "`n") - exit 1 + # Test if current session already has SQL sysadmin via Windows auth + $test = SqlCmd {{ connection_type }} -Q "SET NOCOUNT ON; SELECT CASE WHEN IS_SRVROLEMEMBER('sysadmin')=1 THEN 'HAS_SYSADMIN' ELSE 'NO_SYSADMIN' END" 2>&1 + $hasSysadmin = ($LASTEXITCODE -eq 0) -and ($test -match 'HAS_SYSADMIN') + + if (-not $hasSysadmin) { + # Current session lacks sysadmin - bootstrap via sa auth (enabled by prior provision). + # Grant sysadmin to BUILTIN\Administrators so any admin user (ssm-user, ansible, etc.) works. + $saArgs = @("-b", "-U", "sa", "-P", "{{ sa_password }}", "-S", "localhost\{{ sql_instance_name }}") + $r1 = & SqlCmd @saArgs -Q "IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = 'BUILTIN\Administrators') CREATE LOGIN [BUILTIN\Administrators] FROM WINDOWS WITH DEFAULT_DATABASE=[master]" 2>&1 + if ($LASTEXITCODE -ne 0) { Write-Error "CREATE LOGIN BUILTIN\Administrators failed: $r1"; exit 1 } + $r2 = & SqlCmd @saArgs -Q "IF IS_SRVROLEMEMBER('sysadmin', 'BUILTIN\Administrators') = 0 EXEC sp_addsrvrolemember 'BUILTIN\Administrators', 'sysadmin'" 2>&1 + if ($LASTEXITCODE -ne 0) { Write-Error "sp_addsrvrolemember failed: $r2"; exit 1 } } - Write-Output "ssm-user granted SQL sysadmin" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM + Write-Output "SQL sysadmin access verified (had_sysadmin=$hasSysadmin)" - name: Add MSSQL admin ansible.windows.win_shell: | @@ -49,9 +52,6 @@ exit 1 } Write-Output "Successfully configured sysadmin: {{ item }}" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM loop: "{{ sql_sysadmins }}" register: admin_result @@ -81,9 +81,6 @@ exit 1 } Write-Output "Successfully granted IMPERSONATE ON LOGIN::[{{ item.value }}] TO [{{ item.key }}]" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM with_dict: "{{ executeaslogin }}" register: impersonate_login_result @@ -117,9 +114,6 @@ exit 1 } Write-Output "Successfully granted IMPERSONATE ON USER::[{{ item.value.impersonate }}] TO [{{ item.value.user }}] in {{ item.value.db }}" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM with_dict: "{{ executeasuser }}" register: impersonate_user_result @@ -149,9 +143,6 @@ exit 1 } Write-Output "Successfully enabled sa account" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM register: sa_result - name: Log sa account errors @@ -167,9 +158,6 @@ exit 1 } Write-Output "Successfully enabled mixed mode authentication (LoginMode=2)" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM register: auth_mode_result - name: Enable xp_cmdshell @@ -188,9 +176,6 @@ exit 1 } Write-Output "Successfully enabled xp_cmdshell" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM register: xp_cmdshell_result - name: Log xp_cmdshell errors @@ -198,26 +183,6 @@ msg: "WARNING: xp_cmdshell config had stderr: {{ xp_cmdshell_result.stderr }}" when: xp_cmdshell_result.stderr is defined and xp_cmdshell_result.stderr | length > 0 -- name: Revoke ssm-user SQL sysadmin after config - ansible.windows.win_shell: | - $ErrorActionPreference = "Continue" - $errors = @() - - $result1 = SqlCmd {{ connection_type }} -Q "IF IS_SRVROLEMEMBER('sysadmin', 'ssm-user') = 1 EXEC sp_dropsrvrolemember 'ssm-user', 'sysadmin'" 2>&1 - if ($LASTEXITCODE -ne 0) { $errors += "sp_dropsrvrolemember failed: $result1" } - - $result2 = SqlCmd {{ connection_type }} -Q "IF EXISTS (SELECT 1 FROM sys.server_principals WHERE name = 'ssm-user') DROP LOGIN [ssm-user]" 2>&1 - if ($LASTEXITCODE -ne 0) { $errors += "DROP LOGIN failed: $result2" } - - if ($errors.Count -gt 0) { - Write-Error ($errors -join "`n") - exit 1 - } - Write-Output "ssm-user SQL sysadmin revoked" - become: true - become_method: ansible.builtin.runas - become_user: SYSTEM - - name: Restart service MSSQL ansible.windows.win_service: name: "{{ mssql_service_name }}" diff --git a/cli/internal/validate/validator.go b/cli/internal/validate/validator.go index fb296d1d..1c4cf25d 100644 --- a/cli/internal/validate/validator.go +++ b/cli/internal/validate/validator.go @@ -131,7 +131,7 @@ func (v *Validator) RunAllChecks(ctx context.Context) { // GetReport returns the current report. func (v *Validator) GetReport() *Report { - v.report.Total = len(v.report.Results) + v.report.Total = v.report.Passed + v.report.Failed + v.report.Warnings return &v.report } From 349883aaae9df620ac94919e71e517ba419c31ec Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 8 Apr 2026 12:53:17 -0600 Subject: [PATCH 3/5] feat: add concurrent, ordered check execution and introduce test suite for validator **Added:** - Introduced `runChecks` method to execute validation checks concurrently with bounded parallelism, ensuring each check's output appears in submission order - Defined `checkFunc` type for uniform check function signatures - Added tests (`validator_test.go`) for concurrent check execution, output ordering, result collection, and semaphore limit enforcement **Changed:** - Refactored all check methods in `checks.go` and their invocations to accept an `io.Writer` parameter for flexible output (required by `runChecks`) - Updated `addResult` to write colored status lines to a provided writer, improving testability and output control - Modified `DiscoverHosts` to use `addResult` with `os.Stdout` - `RunQuickChecks` and `RunAllChecks` now delegate check execution to `runChecks`, replacing sequential calls with concurrent, ordered execution - Added a mutex to `Validator` to protect concurrent mutation of the report - Improved output handling for status messages (e.g., INFO, SKIP, WARN) for consistency and testability **Removed:** - Eliminated direct `fmt.Println` and `color.*` calls from check methods in favor of writer-based output and the `printHeader` helper --- cli/internal/validate/checks.go | 275 ++++++++++++------------ cli/internal/validate/validator.go | 117 ++++++---- cli/internal/validate/validator_test.go | 206 ++++++++++++++++++ 3 files changed, 427 insertions(+), 171 deletions(-) create mode 100644 cli/internal/validate/validator_test.go diff --git a/cli/internal/validate/checks.go b/cli/internal/validate/checks.go index a3344c10..9e5a7ed5 100644 --- a/cli/internal/validate/checks.go +++ b/cli/internal/validate/checks.go @@ -3,15 +3,20 @@ package validate import ( "context" "fmt" + "io" "strings" ) -func (v *Validator) checkCredentialDiscovery(ctx context.Context) { - fmt.Println("\n== Credential Discovery Vulnerabilities ==") +func printHeader(w io.Writer, header string) { + fmt.Fprintf(w, "\n== %s ==\n", header) +} + +func (v *Validator) checkCredentialDiscovery(ctx context.Context, w io.Writer) { + printHeader(w, "Credential Discovery Vulnerabilities") users := v.lab.UsersWithPasswordInDescription() if len(users) == 0 { - v.addResult("SKIP", "Credentials", "No users with password-in-description configured", "") + v.addResult(w, "SKIP", "Credentials", "No users with password-in-description configured", "") return } @@ -21,25 +26,25 @@ func (v *Validator) checkCredentialDiscovery(ctx context.Context) { `Get-ADUser -Identity '%s' -Properties Description | Select-Object -ExpandProperty Description`, uf.Username)) if strings.Contains(strings.ToLower(output), strings.ToLower(uf.User.Password)) { - v.addResult("PASS", "Credentials", fmt.Sprintf("%s has password in description", uf.Username), "") + v.addResult(w, "PASS", "Credentials", fmt.Sprintf("%s has password in description", uf.Username), "") } else { - v.addResult("FAIL", "Credentials", fmt.Sprintf("%s does NOT have password in description", uf.Username), "") + v.addResult(w, "FAIL", "Credentials", fmt.Sprintf("%s does NOT have password in description", uf.Username), "") } } } -func (v *Validator) checkKerberosAttacks(ctx context.Context) { - fmt.Println("\n== Kerberos Attack Vectors ==") +func (v *Validator) checkKerberosAttacks(ctx context.Context, w io.Writer) { + printHeader(w, "Kerberos Attack Vectors") - v.checkASREPRoasting(ctx) - v.checkKerberoasting(ctx) + v.checkASREPRoasting(ctx, w) + v.checkKerberoasting(ctx, w) } -func (v *Validator) checkASREPRoasting(ctx context.Context) { +func (v *Validator) checkASREPRoasting(ctx context.Context, w io.Writer) { // Find DCs that run AS-REP roasting scripts asrepHosts := v.lab.HostsWithScript("asrep_roasting") if len(asrepHosts) == 0 { - v.addResult("SKIP", "Kerberos", "No AS-REP roasting scripts configured", "") + v.addResult(w, "SKIP", "Kerberos", "No AS-REP roasting scripts configured", "") return } @@ -49,19 +54,19 @@ func (v *Validator) checkASREPRoasting(ctx context.Context) { `Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} -Properties DoesNotRequirePreAuth | Select-Object -ExpandProperty SamAccountName`) users := parseOutputLines(output) if len(users) > 0 { - v.addResult("PASS", "Kerberos", + v.addResult(w, "PASS", "Kerberos", fmt.Sprintf("AS-REP roastable users on %s: %s", dcRole, strings.Join(users, ", ")), "") } else { - v.addResult("FAIL", "Kerberos", + v.addResult(w, "FAIL", "Kerberos", fmt.Sprintf("No AS-REP roastable users found on %s", dcRole), "") } } } -func (v *Validator) checkKerberoasting(ctx context.Context) { +func (v *Validator) checkKerberoasting(ctx context.Context, w io.Writer) { spnUsers := v.lab.UsersWithSPNs() if len(spnUsers) == 0 { - v.addResult("SKIP", "Kerberos", "No users with SPNs configured", "") + v.addResult(w, "SKIP", "Kerberos", "No users with SPNs configured", "") return } @@ -71,22 +76,22 @@ func (v *Validator) checkKerberoasting(ctx context.Context) { `Get-ADUser -Identity '%s' -Properties ServicePrincipalName | Select-Object -ExpandProperty ServicePrincipalName`, uf.Username)) if strings.TrimSpace(output) != "" { - v.addResult("PASS", "Kerberos", + v.addResult(w, "PASS", "Kerberos", fmt.Sprintf("%s has SPNs configured (Kerberoastable)", uf.Username), "") } else { - v.addResult("FAIL", "Kerberos", + v.addResult(w, "FAIL", "Kerberos", fmt.Sprintf("%s does NOT have SPNs configured", uf.Username), "") } } } -func (v *Validator) checkNetworkMisconfigs(ctx context.Context) { - fmt.Println("\n== Network-Level Misconfigurations ==") +func (v *Validator) checkNetworkMisconfigs(ctx context.Context, w io.Writer) { + printHeader(w, "Network-Level Misconfigurations") // Check SMB signing on all Windows servers servers := v.lab.WindowsServers() if len(servers) == 0 { - v.addResult("SKIP", "Network", "No Windows servers configured", "") + v.addResult(w, "SKIP", "Network", "No Windows servers configured", "") return } @@ -102,17 +107,17 @@ func (v *Validator) checkNetworkMisconfigs(ctx context.Context) { switch { case strings.Contains(lower, "false") && strings.Count(lower, "false") >= 2: - v.addResult("PASS", "Network", fmt.Sprintf("%s has SMB signing disabled", hostLabel), "") + v.addResult(w, "PASS", "Network", fmt.Sprintf("%s has SMB signing disabled", hostLabel), "") case strings.Contains(lower, "false"): - v.addResult("WARN", "Network", fmt.Sprintf("%s has SMB signing enabled but not required", hostLabel), "") + v.addResult(w, "WARN", "Network", fmt.Sprintf("%s has SMB signing enabled but not required", hostLabel), "") default: - v.addResult("FAIL", "Network", fmt.Sprintf("%s has SMB signing enforced", hostLabel), "") + v.addResult(w, "FAIL", "Network", fmt.Sprintf("%s has SMB signing enforced", hostLabel), "") } } } -func (v *Validator) checkAnonymousSMB(ctx context.Context) { - fmt.Println("\n== Anonymous/Guest SMB Enumeration ==") +func (v *Validator) checkAnonymousSMB(ctx context.Context, w io.Writer) { + printHeader(w, "Anonymous/Guest SMB Enumeration") // Check RestrictAnonymous on each DC for _, role := range v.lab.DCs() { @@ -126,18 +131,18 @@ func (v *Validator) checkAnonymousSMB(ctx context.Context) { `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name RestrictAnonymous -ErrorAction SilentlyContinue | Select-Object -ExpandProperty RestrictAnonymous`) val := strings.TrimSpace(output) if val == "0" { - v.addResult("PASS", "SMB", fmt.Sprintf("RestrictAnonymous is 0 on %s (NULL sessions enabled)", hostLabel), "") + v.addResult(w, "PASS", "SMB", fmt.Sprintf("RestrictAnonymous is 0 on %s (NULL sessions enabled)", hostLabel), "") } else { - v.addResult("INFO", "SMB", fmt.Sprintf("RestrictAnonymous is %s on %s", val, hostLabel), "") + v.addResult(w, "INFO", "SMB", fmt.Sprintf("RestrictAnonymous is %s on %s", val, hostLabel), "") } output = v.runPS(ctx, host, `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name RestrictAnonymousSAM -ErrorAction SilentlyContinue | Select-Object -ExpandProperty RestrictAnonymousSAM`) val = strings.TrimSpace(output) if val == "0" { - v.addResult("PASS", "SMB", fmt.Sprintf("RestrictAnonymousSAM is 0 on %s (SAM enum enabled)", hostLabel), "") + v.addResult(w, "PASS", "SMB", fmt.Sprintf("RestrictAnonymousSAM is 0 on %s (SAM enum enabled)", hostLabel), "") } else { - v.addResult("INFO", "SMB", fmt.Sprintf("RestrictAnonymousSAM is %s on %s", val, hostLabel), "") + v.addResult(w, "INFO", "SMB", fmt.Sprintf("RestrictAnonymousSAM is %s on %s", val, hostLabel), "") } } @@ -151,9 +156,9 @@ func (v *Validator) checkAnonymousSMB(ctx context.Context) { output := v.runPS(ctx, host, `Get-LocalUser -Name Guest | Select-Object Name,Enabled | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "true") { - v.addResult("PASS", "SMB", fmt.Sprintf("Guest account enabled on %s", hostLabel), "") + v.addResult(w, "PASS", "SMB", fmt.Sprintf("Guest account enabled on %s", hostLabel), "") } else { - v.addResult("FAIL", "SMB", fmt.Sprintf("Guest account NOT enabled on %s", hostLabel), "") + v.addResult(w, "FAIL", "SMB", fmt.Sprintf("Guest account NOT enabled on %s", hostLabel), "") } } @@ -168,15 +173,15 @@ func (v *Validator) checkAnonymousSMB(ctx context.Context) { `Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue | Select-Object -ExpandProperty LmCompatibilityLevel`) val := strings.TrimSpace(output) if val == "0" || val == "1" || val == "2" { - v.addResult("PASS", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on %s (NTLM downgrade vulnerable)", val, hostLabel), "") + v.addResult(w, "PASS", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on %s (NTLM downgrade vulnerable)", val, hostLabel), "") } else { - v.addResult("FAIL", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on %s (expected 0-2)", val, hostLabel), "") + v.addResult(w, "FAIL", "SMB", fmt.Sprintf("LmCompatibilityLevel is %s on %s (expected 0-2)", val, hostLabel), "") } } } -func (v *Validator) checkDelegation(ctx context.Context) { - fmt.Println("\n== Delegation Configurations ==") +func (v *Validator) checkDelegation(ctx context.Context, w io.Writer) { + printHeader(w, "Delegation Configurations") // Find DCs with delegation scripts allHosts := v.lab.HostsWithScript("constrained_delegation") @@ -186,7 +191,7 @@ func (v *Validator) checkDelegation(ctx context.Context) { allHosts = v.lab.DCs() } if len(allHosts) == 0 { - v.addResult("SKIP", "Delegation", "No domain controllers configured", "") + v.addResult(w, "SKIP", "Delegation", "No domain controllers configured", "") return } @@ -203,7 +208,7 @@ func (v *Validator) checkDelegation(ctx context.Context) { `Get-ADUser -Filter {TrustedForDelegation -eq $true} -Properties TrustedForDelegation | Select-Object -ExpandProperty SamAccountName`) users := parseOutputLines(output) if len(users) > 0 { - v.addResult("PASS", "Delegation", + v.addResult(w, "PASS", "Delegation", fmt.Sprintf("Unconstrained delegation users on %s: %s", host, strings.Join(users, ", ")), "") } @@ -212,14 +217,14 @@ func (v *Validator) checkDelegation(ctx context.Context) { `Get-ADUser -Filter 'msDS-AllowedToDelegateTo -like "*"' -Properties msDS-AllowedToDelegateTo | Select-Object -ExpandProperty SamAccountName`) users = parseOutputLines(output) if len(users) > 0 { - v.addResult("PASS", "Delegation", + v.addResult(w, "PASS", "Delegation", fmt.Sprintf("Constrained delegation users on %s: %s", host, strings.Join(users, ", ")), "") } } } -func (v *Validator) checkMachineAccountQuota(ctx context.Context) { - fmt.Println("\n== Machine Account Quota ==") +func (v *Validator) checkMachineAccountQuota(ctx context.Context, w io.Writer) { + printHeader(w, "Machine Account Quota") for _, role := range v.lab.DCs() { host := strings.ToUpper(role) @@ -230,20 +235,20 @@ func (v *Validator) checkMachineAccountQuota(ctx context.Context) { `Get-ADObject -Identity ((Get-ADDomain).distinguishedname) -Properties ms-DS-MachineAccountQuota | Select-Object -ExpandProperty ms-DS-MachineAccountQuota`) val := strings.TrimSpace(output) if val == "10" { - v.addResult("PASS", "MachineQuota", "Machine Account Quota is 10 (allows RBCD)", "") + v.addResult(w, "PASS", "MachineQuota", "Machine Account Quota is 10 (allows RBCD)", "") } else { - v.addResult("WARN", "MachineQuota", fmt.Sprintf("Machine Account Quota is %s (default is 10)", val), "") + v.addResult(w, "WARN", "MachineQuota", fmt.Sprintf("Machine Account Quota is %s (default is 10)", val), "") } return // Only check first available DC } } -func (v *Validator) checkMSSQL(ctx context.Context) { - fmt.Println("\n== MSSQL Configurations ==") +func (v *Validator) checkMSSQL(ctx context.Context, w io.Writer) { + printHeader(w, "MSSQL Configurations") mssqlFacts := v.lab.HostsWithMSSQLConfig() if len(mssqlFacts) == 0 { - v.addResult("SKIP", "MSSQL", "No MSSQL configured for this lab", "") + v.addResult(w, "SKIP", "MSSQL", "No MSSQL configured for this lab", "") return } @@ -257,10 +262,10 @@ func (v *Validator) checkMSSQL(ctx context.Context) { output := v.runPS(ctx, host, `Get-Service 'MSSQL$SQLEXPRESS','MSSQLSERVER' -ErrorAction SilentlyContinue | Where-Object {$_.Status -eq 'Running'} | Select-Object -ExpandProperty Name`) if strings.TrimSpace(output) == "" { - v.addResult("FAIL", "MSSQL", fmt.Sprintf("MSSQL NOT running on %s", hostLabel), "") + v.addResult(w, "FAIL", "MSSQL", fmt.Sprintf("MSSQL NOT running on %s", hostLabel), "") continue } - v.addResult("PASS", "MSSQL", fmt.Sprintf("MSSQL running on %s", hostLabel), "") + v.addResult(w, "PASS", "MSSQL", fmt.Sprintf("MSSQL running on %s", hostLabel), "") sqlQuery := func(query string) string { return v.runPS(ctx, host, fmt.Sprintf( @@ -275,9 +280,9 @@ func (v *Validator) checkMSSQL(ctx context.Context) { "SELECT m.name FROM sys.server_role_members srm JOIN sys.server_principals r ON srm.role_principal_id = r.principal_id JOIN sys.server_principals m ON srm.member_principal_id = m.principal_id WHERE r.name = ''sysadmin'' AND m.name = ''%s''", admin)) if strings.TrimSpace(output) != "" { - v.addResult("PASS", "MSSQL", fmt.Sprintf("%s is sysadmin on %s", admin, hostLabel), "") + v.addResult(w, "PASS", "MSSQL", fmt.Sprintf("%s is sysadmin on %s", admin, hostLabel), "") } else { - v.addResult("FAIL", "MSSQL", fmt.Sprintf("%s is NOT sysadmin on %s", admin, hostLabel), "") + v.addResult(w, "FAIL", "MSSQL", fmt.Sprintf("%s is NOT sysadmin on %s", admin, hostLabel), "") } } @@ -286,9 +291,9 @@ func (v *Validator) checkMSSQL(ctx context.Context) { "SELECT pr.name FROM sys.server_permissions sp JOIN sys.server_principals pr ON sp.grantee_principal_id = pr.principal_id JOIN sys.server_principals pr2 ON sp.major_id = pr2.principal_id WHERE sp.permission_name = ''IMPERSONATE'' AND pr.name = ''%s'' AND pr2.name = ''%s''", grantee, target)) if strings.TrimSpace(output) != "" { - v.addResult("PASS", "MSSQL", fmt.Sprintf("%s can impersonate %s on %s", grantee, target, hostLabel), "") + v.addResult(w, "PASS", "MSSQL", fmt.Sprintf("%s can impersonate %s on %s", grantee, target, hostLabel), "") } else { - v.addResult("FAIL", "MSSQL", fmt.Sprintf("%s CANNOT impersonate %s on %s", grantee, target, hostLabel), "") + v.addResult(w, "FAIL", "MSSQL", fmt.Sprintf("%s CANNOT impersonate %s on %s", grantee, target, hostLabel), "") } } @@ -296,27 +301,27 @@ func (v *Validator) checkMSSQL(ctx context.Context) { output = sqlQuery(fmt.Sprintf( "SELECT name FROM sys.servers WHERE is_linked = 1 AND name = ''%s''", name)) if strings.TrimSpace(output) != "" { - v.addResult("PASS", "MSSQL", fmt.Sprintf("Linked server %s -> %s on %s", name, ls.DataSrc, hostLabel), "") + v.addResult(w, "PASS", "MSSQL", fmt.Sprintf("Linked server %s -> %s on %s", name, ls.DataSrc, hostLabel), "") } else { - v.addResult("FAIL", "MSSQL", fmt.Sprintf("Linked server %s NOT found on %s", name, hostLabel), "") + v.addResult(w, "FAIL", "MSSQL", fmt.Sprintf("Linked server %s NOT found on %s", name, hostLabel), "") } } output = sqlQuery("SELECT CONVERT(INT, ISNULL(value, value_in_use)) FROM sys.configurations WHERE name = ''xp_cmdshell''") if strings.TrimSpace(output) == "1" { - v.addResult("PASS", "MSSQL", fmt.Sprintf("xp_cmdshell enabled on %s", hostLabel), "") + v.addResult(w, "PASS", "MSSQL", fmt.Sprintf("xp_cmdshell enabled on %s", hostLabel), "") } else { - v.addResult("FAIL", "MSSQL", fmt.Sprintf("xp_cmdshell NOT enabled on %s", hostLabel), "") + v.addResult(w, "FAIL", "MSSQL", fmt.Sprintf("xp_cmdshell NOT enabled on %s", hostLabel), "") } } } -func (v *Validator) checkADCS(ctx context.Context) { - fmt.Println("\n== ADCS Configuration ==") +func (v *Validator) checkADCS(ctx context.Context, w io.Writer) { + printHeader(w, "ADCS Configuration") adcsHosts := v.lab.ADCSHosts() if len(adcsHosts) == 0 { - v.addResult("SKIP", "ADCS", "No ADCS configured for this lab", "") + v.addResult(w, "SKIP", "ADCS", "No ADCS configured for this lab", "") return } @@ -330,18 +335,18 @@ func (v *Validator) checkADCS(ctx context.Context) { output := v.runPS(ctx, host, `Get-WindowsFeature ADCS-Cert-Authority | Select-Object Name,InstallState | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "installed") { - v.addResult("PASS", "ADCS", fmt.Sprintf("ADCS installed on %s", hostLabel), "") + v.addResult(w, "PASS", "ADCS", fmt.Sprintf("ADCS installed on %s", hostLabel), "") } else { - v.addResult("FAIL", "ADCS", fmt.Sprintf("ADCS NOT installed on %s", hostLabel), "") + v.addResult(w, "FAIL", "ADCS", fmt.Sprintf("ADCS NOT installed on %s", hostLabel), "") } if v.lab.CAWebEnrollment() { output = v.runPS(ctx, host, `Get-WindowsFeature ADCS-Web-Enrollment | Select-Object Name,InstallState | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "installed") { - v.addResult("PASS", "ADCS", "ADCS Web Enrollment installed (ESC8 possible)", "") + v.addResult(w, "PASS", "ADCS", "ADCS Web Enrollment installed (ESC8 possible)", "") } else { - v.addResult("WARN", "ADCS", "ADCS Web Enrollment not installed", "") + v.addResult(w, "WARN", "ADCS", "ADCS Web Enrollment not installed", "") } } @@ -361,20 +366,20 @@ func (v *Validator) checkADCS(ctx context.Context) { } } if found { - v.addResult("PASS", "ADCS", fmt.Sprintf("Template %s published on %s CA", tmpl, hostLabel), "") + v.addResult(w, "PASS", "ADCS", fmt.Sprintf("Template %s published on %s CA", tmpl, hostLabel), "") } else { - v.addResult("FAIL", "ADCS", fmt.Sprintf("Template %s NOT published on %s CA", tmpl, hostLabel), "") + v.addResult(w, "FAIL", "ADCS", fmt.Sprintf("Template %s NOT published on %s CA", tmpl, hostLabel), "") } } } } -func (v *Validator) checkACLPermissions(ctx context.Context) { - fmt.Println("\n== ACL Permissions ==") +func (v *Validator) checkACLPermissions(ctx context.Context, w io.Writer) { + printHeader(w, "ACL Permissions") acls := v.lab.AllACLs() if len(acls) == 0 { - v.addResult("SKIP", "ACL", "No ACLs configured for this lab", "") + v.addResult(w, "SKIP", "ACL", "No ACLs configured for this lab", "") return } @@ -425,21 +430,21 @@ try { switch { case strings.Contains(output, "ACL_FOUND"): - v.addResult("PASS", "ACL", fmt.Sprintf("%s has %s on %s", source, af.ACL.Right, target), "") + v.addResult(w, "PASS", "ACL", fmt.Sprintf("%s has %s on %s", source, af.ACL.Right, target), "") case strings.Contains(output, "ACL_NOT_FOUND"): - v.addResult("FAIL", "ACL", fmt.Sprintf("%s does NOT have %s on %s", source, af.ACL.Right, target), "") + v.addResult(w, "FAIL", "ACL", fmt.Sprintf("%s does NOT have %s on %s", source, af.ACL.Right, target), "") default: - v.addResult("WARN", "ACL", fmt.Sprintf("Could not verify ACL: %s -> %s (%s)", source, target, af.ACL.Right), "") + v.addResult(w, "WARN", "ACL", fmt.Sprintf("Could not verify ACL: %s -> %s (%s)", source, target, af.ACL.Right), "") } } } -func (v *Validator) checkDomainTrusts(ctx context.Context) { - fmt.Println("\n== Domain Trusts ==") +func (v *Validator) checkDomainTrusts(ctx context.Context, w io.Writer) { + printHeader(w, "Domain Trusts") trusts := v.lab.DomainTrusts() if len(trusts) == 0 { - v.addResult("SKIP", "Trusts", "No domain trusts configured for this lab", "") + v.addResult(w, "SKIP", "Trusts", "No domain trusts configured for this lab", "") return } @@ -450,10 +455,10 @@ func (v *Validator) checkDomainTrusts(ctx context.Context) { output := v.runPS(ctx, srcHost, `Get-ADTrust -Filter * | Select-Object Name,Direction,TrustType | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), strings.ToLower(tf.TargetDomain)) { - v.addResult("PASS", "Trusts", + v.addResult(w, "PASS", "Trusts", fmt.Sprintf("Trust configured: %s -> %s", tf.SourceDomain, tf.TargetDomain), "") } else { - v.addResult("FAIL", "Trusts", + v.addResult(w, "FAIL", "Trusts", fmt.Sprintf("Trust NOT found: %s -> %s", tf.SourceDomain, tf.TargetDomain), "") } } @@ -465,10 +470,10 @@ func (v *Validator) checkDomainTrusts(ctx context.Context) { output := v.runPS(ctx, tgtHost, `Get-ADTrust -Filter * | Select-Object Name,Direction,TrustType | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), strings.ToLower(tf.SourceDomain)) { - v.addResult("PASS", "Trusts", + v.addResult(w, "PASS", "Trusts", fmt.Sprintf("Trust configured: %s -> %s", tf.TargetDomain, tf.SourceDomain), "") } else { - v.addResult("FAIL", "Trusts", + v.addResult(w, "FAIL", "Trusts", fmt.Sprintf("Trust NOT found: %s -> %s", tf.TargetDomain, tf.SourceDomain), "") } } @@ -476,8 +481,8 @@ func (v *Validator) checkDomainTrusts(ctx context.Context) { } } -func (v *Validator) checkServices(ctx context.Context) { - fmt.Println("\n== Additional Services ==") +func (v *Validator) checkServices(ctx context.Context, w io.Writer) { + printHeader(w, "Additional Services") // Print Spooler on all DCs for _, role := range v.lab.DCs() { @@ -488,9 +493,9 @@ func (v *Validator) checkServices(ctx context.Context) { output := v.runPS(ctx, host, `Get-Service Spooler | Select-Object Status | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "running") { - v.addResult("PASS", "Services", fmt.Sprintf("Print Spooler running on %s (coercion possible)", host), "") + v.addResult(w, "PASS", "Services", fmt.Sprintf("Print Spooler running on %s (coercion possible)", host), "") } else { - v.addResult("WARN", "Services", fmt.Sprintf("Print Spooler not running on %s", host), "") + v.addResult(w, "WARN", "Services", fmt.Sprintf("Print Spooler not running on %s", host), "") } } @@ -504,15 +509,15 @@ func (v *Validator) checkServices(ctx context.Context) { output := v.runPS(ctx, host, `Get-Service W3SVC -ErrorAction SilentlyContinue | Select-Object Name,Status | Format-Table -AutoSize | Out-String`) if strings.Contains(strings.ToLower(output), "running") { - v.addResult("PASS", "Services", fmt.Sprintf("IIS running on %s", hostLabel), "") + v.addResult(w, "PASS", "Services", fmt.Sprintf("IIS running on %s", hostLabel), "") } else if strings.TrimSpace(output) != "" { - v.addResult("WARN", "Services", fmt.Sprintf("IIS not running on %s", hostLabel), "") + v.addResult(w, "WARN", "Services", fmt.Sprintf("IIS not running on %s", hostLabel), "") } } } -func (v *Validator) checkScheduledTasks(ctx context.Context) { - fmt.Println("\n== Scheduled Tasks (Bots) ==") +func (v *Validator) checkScheduledTasks(ctx context.Context, w io.Writer) { + printHeader(w, "Scheduled Tasks (Bots)") botScripts := map[string]string{ "rdp_scheduler": "connect_bot", @@ -534,21 +539,21 @@ func (v *Validator) checkScheduledTasks(ctx context.Context) { state := strings.TrimSpace(output) switch { case strings.EqualFold(state, "Running") || strings.EqualFold(state, "Ready"): - v.addResult("PASS", "ScheduledTasks", fmt.Sprintf("%s is %s on %s", taskName, state, host), "") + v.addResult(w, "PASS", "ScheduledTasks", fmt.Sprintf("%s is %s on %s", taskName, state, host), "") case state != "": - v.addResult("WARN", "ScheduledTasks", fmt.Sprintf("%s state is %s on %s", taskName, state, host), "") + v.addResult(w, "WARN", "ScheduledTasks", fmt.Sprintf("%s state is %s on %s", taskName, state, host), "") default: - v.addResult("FAIL", "ScheduledTasks", fmt.Sprintf("%s NOT found on %s", taskName, host), "") + v.addResult(w, "FAIL", "ScheduledTasks", fmt.Sprintf("%s NOT found on %s", taskName, host), "") } } } if !found { - v.addResult("SKIP", "ScheduledTasks", "No bot scripts configured", "") + v.addResult(w, "SKIP", "ScheduledTasks", "No bot scripts configured", "") } } -func (v *Validator) checkLLMNR(ctx context.Context) { - fmt.Println("\n== LLMNR / NBT-NS ==") +func (v *Validator) checkLLMNR(ctx context.Context, w io.Writer) { + printHeader(w, "LLMNR / NBT-NS") llmnrHosts := v.lab.HostsWithVuln("enable_llmnr") for _, role := range llmnrHosts { @@ -561,9 +566,9 @@ func (v *Validator) checkLLMNR(ctx context.Context) { `$v = Get-ItemProperty -Path 'HKLM:\Software\policies\Microsoft\Windows NT\DNSClient' -Name EnableMulticast -ErrorAction SilentlyContinue; if ($v) { $v.EnableMulticast } else { 'NOT_SET' }`) val := strings.TrimSpace(output) if val == "1" || val == "NOT_SET" { - v.addResult("PASS", "LLMNR", fmt.Sprintf("LLMNR enabled on %s", hostLabel), "") + v.addResult(w, "PASS", "LLMNR", fmt.Sprintf("LLMNR enabled on %s", hostLabel), "") } else { - v.addResult("FAIL", "LLMNR", fmt.Sprintf("LLMNR disabled on %s (value=%s)", hostLabel, val), "") + v.addResult(w, "FAIL", "LLMNR", fmt.Sprintf("LLMNR disabled on %s (value=%s)", hostLabel, val), "") } } @@ -586,25 +591,25 @@ func (v *Validator) checkLLMNR(ctx context.Context) { } switch { case allZero: - v.addResult("PASS", "LLMNR", fmt.Sprintf("NBT-NS enabled on %s", hostLabel), "") + v.addResult(w, "PASS", "LLMNR", fmt.Sprintf("NBT-NS enabled on %s", hostLabel), "") case len(lines) == 0: - v.addResult("WARN", "LLMNR", fmt.Sprintf("NBT-NS status unknown on %s", hostLabel), "") + v.addResult(w, "WARN", "LLMNR", fmt.Sprintf("NBT-NS status unknown on %s", hostLabel), "") default: - v.addResult("FAIL", "LLMNR", fmt.Sprintf("NBT-NS disabled on %s", hostLabel), "") + v.addResult(w, "FAIL", "LLMNR", fmt.Sprintf("NBT-NS disabled on %s", hostLabel), "") } } if len(llmnrHosts) == 0 && len(nbtHosts) == 0 { - v.addResult("SKIP", "LLMNR", "No LLMNR/NBT-NS vulns configured", "") + v.addResult(w, "SKIP", "LLMNR", "No LLMNR/NBT-NS vulns configured", "") } } -func (v *Validator) checkGPOAbuse(ctx context.Context) { - fmt.Println("\n== GPO Abuse ==") +func (v *Validator) checkGPOAbuse(ctx context.Context, w io.Writer) { + printHeader(w, "GPO Abuse") hosts := v.lab.HostsWithScript("gpo_abuse") if len(hosts) == 0 { - v.addResult("SKIP", "GPO", "No GPO abuse scripts configured", "") + v.addResult(w, "SKIP", "GPO", "No GPO abuse scripts configured", "") return } @@ -617,19 +622,19 @@ func (v *Validator) checkGPOAbuse(ctx context.Context) { `Get-GPO -All | Where-Object { $_.DisplayName -notmatch 'Default Domain' } | Select-Object -ExpandProperty DisplayName`) gpos := parseOutputLines(output) if len(gpos) > 0 { - v.addResult("PASS", "GPO", fmt.Sprintf("Custom GPOs on %s: %s", host, strings.Join(gpos, ", ")), "") + v.addResult(w, "PASS", "GPO", fmt.Sprintf("Custom GPOs on %s: %s", host, strings.Join(gpos, ", ")), "") } else { - v.addResult("FAIL", "GPO", fmt.Sprintf("No custom GPOs found on %s", host), "") + v.addResult(w, "FAIL", "GPO", fmt.Sprintf("No custom GPOs found on %s", host), "") } } } -func (v *Validator) checkGMSA(ctx context.Context) { - fmt.Println("\n== gMSA Accounts ==") +func (v *Validator) checkGMSA(ctx context.Context, w io.Writer) { + printHeader(w, "gMSA Accounts") facts := v.lab.DomainsWithGMSA() if len(facts) == 0 { - v.addResult("SKIP", "gMSA", "No gMSA configured for this lab", "") + v.addResult(w, "SKIP", "gMSA", "No gMSA configured for this lab", "") return } @@ -641,19 +646,19 @@ func (v *Validator) checkGMSA(ctx context.Context) { output := v.runPS(ctx, host, fmt.Sprintf( `Get-ADServiceAccount -Identity '%s' -Properties Enabled | Select-Object -ExpandProperty Enabled`, gf.GMSA.Name)) if strings.Contains(strings.ToLower(output), "true") { - v.addResult("PASS", "gMSA", fmt.Sprintf("gMSA %s exists and enabled in %s", gf.GMSA.Name, gf.Domain), "") + v.addResult(w, "PASS", "gMSA", fmt.Sprintf("gMSA %s exists and enabled in %s", gf.GMSA.Name, gf.Domain), "") } else { - v.addResult("FAIL", "gMSA", fmt.Sprintf("gMSA %s NOT found or disabled in %s", gf.GMSA.Name, gf.Domain), "") + v.addResult(w, "FAIL", "gMSA", fmt.Sprintf("gMSA %s NOT found or disabled in %s", gf.GMSA.Name, gf.Domain), "") } } } -func (v *Validator) checkLAPS(ctx context.Context) { - fmt.Println("\n== LAPS ==") +func (v *Validator) checkLAPS(ctx context.Context, w io.Writer) { + printHeader(w, "LAPS") lapsHosts := v.lab.HostsWithLAPS() if len(lapsHosts) == 0 { - v.addResult("SKIP", "LAPS", "No LAPS hosts configured", "") + v.addResult(w, "SKIP", "LAPS", "No LAPS hosts configured", "") return } @@ -671,19 +676,19 @@ func (v *Validator) checkLAPS(ctx context.Context) { output := v.runPS(ctx, dc, fmt.Sprintf( `Get-ADComputer -Identity '%s' -Properties ms-Mcs-AdmPwd | Select-Object -ExpandProperty ms-Mcs-AdmPwd`, hostname)) if strings.TrimSpace(output) != "" { - v.addResult("PASS", "LAPS", fmt.Sprintf("LAPS password set for %s", hostname), "") + v.addResult(w, "PASS", "LAPS", fmt.Sprintf("LAPS password set for %s", hostname), "") } else { - v.addResult("FAIL", "LAPS", fmt.Sprintf("LAPS password NOT set for %s", hostname), "") + v.addResult(w, "FAIL", "LAPS", fmt.Sprintf("LAPS password NOT set for %s", hostname), "") } } } -func (v *Validator) checkSIDFiltering(ctx context.Context) { - fmt.Println("\n== SID Filtering ==") +func (v *Validator) checkSIDFiltering(ctx context.Context, w io.Writer) { + printHeader(w, "SID Filtering") trusts := v.lab.DomainTrusts() if len(trusts) == 0 { - v.addResult("SKIP", "SIDFiltering", "No domain trusts configured", "") + v.addResult(w, "SKIP", "SIDFiltering", "No domain trusts configured", "") return } @@ -700,21 +705,21 @@ func (v *Validator) checkSIDFiltering(ctx context.Context) { lower := strings.ToLower(output) switch { case strings.Contains(lower, "not enabled"): - v.addResult("PASS", "SIDFiltering", fmt.Sprintf("SID filtering disabled on %s -> %s (exploitation possible)", tf.SourceDomain, tf.TargetDomain), "") + v.addResult(w, "PASS", "SIDFiltering", fmt.Sprintf("SID filtering disabled on %s -> %s (exploitation possible)", tf.SourceDomain, tf.TargetDomain), "") case strings.Contains(lower, "enabled"): - v.addResult("WARN", "SIDFiltering", fmt.Sprintf("SID filtering enabled on %s -> %s", tf.SourceDomain, tf.TargetDomain), "") + v.addResult(w, "WARN", "SIDFiltering", fmt.Sprintf("SID filtering enabled on %s -> %s", tf.SourceDomain, tf.TargetDomain), "") default: - v.addResult("INFO", "SIDFiltering", fmt.Sprintf("Could not determine SID filtering: %s -> %s", tf.SourceDomain, tf.TargetDomain), "") + v.addResult(w, "INFO", "SIDFiltering", fmt.Sprintf("Could not determine SID filtering: %s -> %s", tf.SourceDomain, tf.TargetDomain), "") } } } -func (v *Validator) checkSMBShares(ctx context.Context) { - fmt.Println("\n== SMB Shares ==") +func (v *Validator) checkSMBShares(ctx context.Context, w io.Writer) { + printHeader(w, "SMB Shares") shareHosts := v.lab.HostsWithVuln("openshares") if len(shareHosts) == 0 { - v.addResult("SKIP", "Shares", "No openshares vulns configured", "") + v.addResult(w, "SKIP", "Shares", "No openshares vulns configured", "") return } @@ -728,19 +733,19 @@ func (v *Validator) checkSMBShares(ctx context.Context) { `Get-SmbShare | Where-Object { $_.Name -notmatch 'ADMIN\$|C\$|IPC\$' } | Select-Object -ExpandProperty Name`) shares := parseOutputLines(output) if len(shares) > 0 { - v.addResult("PASS", "Shares", fmt.Sprintf("Custom shares on %s: %s", hostLabel, strings.Join(shares, ", ")), "") + v.addResult(w, "PASS", "Shares", fmt.Sprintf("Custom shares on %s: %s", hostLabel, strings.Join(shares, ", ")), "") } else { - v.addResult("FAIL", "Shares", fmt.Sprintf("No custom shares found on %s", hostLabel), "") + v.addResult(w, "FAIL", "Shares", fmt.Sprintf("No custom shares found on %s", hostLabel), "") } } } -func (v *Validator) checkFirewallDisabled(ctx context.Context) { - fmt.Println("\n== Firewall ==") +func (v *Validator) checkFirewallDisabled(ctx context.Context, w io.Writer) { + printHeader(w, "Firewall") fwHosts := v.lab.HostsWithVuln("disable_firewall") if len(fwHosts) == 0 { - v.addResult("SKIP", "Firewall", "No disable_firewall vulns configured", "") + v.addResult(w, "SKIP", "Firewall", "No disable_firewall vulns configured", "") return } @@ -754,15 +759,15 @@ func (v *Validator) checkFirewallDisabled(ctx context.Context) { `Get-NetFirewallProfile | Where-Object { $_.Enabled -eq $true } | Select-Object -ExpandProperty Name`) enabledProfiles := parseOutputLines(output) if len(enabledProfiles) == 0 { - v.addResult("PASS", "Firewall", fmt.Sprintf("Firewall disabled on %s", hostLabel), "") + v.addResult(w, "PASS", "Firewall", fmt.Sprintf("Firewall disabled on %s", hostLabel), "") } else { - v.addResult("FAIL", "Firewall", fmt.Sprintf("Firewall still enabled on %s (profiles: %s)", hostLabel, strings.Join(enabledProfiles, ", ")), "") + v.addResult(w, "FAIL", "Firewall", fmt.Sprintf("Firewall still enabled on %s (profiles: %s)", hostLabel, strings.Join(enabledProfiles, ", ")), "") } } } -func (v *Validator) checkPasswordPolicy(ctx context.Context) { - fmt.Println("\n== Password Policy ==") +func (v *Validator) checkPasswordPolicy(ctx context.Context, w io.Writer) { + printHeader(w, "Password Policy") for _, role := range v.lab.DCs() { host := strings.ToUpper(role) @@ -773,7 +778,7 @@ func (v *Validator) checkPasswordPolicy(ctx context.Context) { `$p = Get-ADDefaultDomainPasswordPolicy; Write-Output "$($p.ComplexityEnabled)|$($p.MinPasswordLength)|$($p.LockoutThreshold)"`) parts := strings.Split(strings.TrimSpace(output), "|") if len(parts) < 3 { - v.addResult("WARN", "PasswordPolicy", fmt.Sprintf("Could not read password policy on %s", host), "") + v.addResult(w, "WARN", "PasswordPolicy", fmt.Sprintf("Could not read password policy on %s", host), "") continue } domain := v.lab.DomainForHost(strings.ToLower(host)) @@ -783,14 +788,14 @@ func (v *Validator) checkPasswordPolicy(ctx context.Context) { complexity := parts[0] minLen := parts[1] if strings.EqualFold(complexity, "false") { - v.addResult("PASS", "PasswordPolicy", fmt.Sprintf("Password complexity disabled in %s (weak policy)", domain), "") + v.addResult(w, "PASS", "PasswordPolicy", fmt.Sprintf("Password complexity disabled in %s (weak policy)", domain), "") } else { - v.addResult("INFO", "PasswordPolicy", fmt.Sprintf("Password complexity enabled in %s", domain), "") + v.addResult(w, "INFO", "PasswordPolicy", fmt.Sprintf("Password complexity enabled in %s", domain), "") } if minLen == "0" || minLen == "1" || minLen == "2" || minLen == "3" { - v.addResult("PASS", "PasswordPolicy", fmt.Sprintf("Min password length is %s in %s (weak)", minLen, domain), "") + v.addResult(w, "PASS", "PasswordPolicy", fmt.Sprintf("Min password length is %s in %s (weak)", minLen, domain), "") } else { - v.addResult("INFO", "PasswordPolicy", fmt.Sprintf("Min password length is %s in %s", minLen, domain), "") + v.addResult(w, "INFO", "PasswordPolicy", fmt.Sprintf("Min password length is %s in %s", minLen, domain), "") } } } diff --git a/cli/internal/validate/validator.go b/cli/internal/validate/validator.go index 1c4cf25d..a6c19676 100644 --- a/cli/internal/validate/validator.go +++ b/cli/internal/validate/validator.go @@ -1,12 +1,15 @@ package validate import ( + "bytes" "context" "encoding/json" "fmt" + "io" "log/slog" "os" "strings" + "sync" "time" daws "github.com/dreadnode/dreadgoad/internal/aws" @@ -35,6 +38,7 @@ type Report struct { // Validator runs vulnerability checks against GOAD instances. type Validator struct { + mu sync.Mutex client *daws.Client log *slog.Logger env string @@ -78,7 +82,7 @@ func (v *Validator) DiscoverHosts(ctx context.Context) error { host := strings.ToUpper(role) if strings.Contains(name, host) { v.hosts[host] = inst.InstanceID - v.addResult("PASS", "Discovery", fmt.Sprintf("Found %s", host), inst.InstanceID) + v.addResult(os.Stdout, "PASS", "Discovery", fmt.Sprintf("Found %s", host), inst.InstanceID) } } } @@ -87,46 +91,79 @@ func (v *Validator) DiscoverHosts(ctx context.Context) error { for _, role := range v.lab.DCs() { host := strings.ToUpper(role) if _, ok := v.hosts[host]; !ok { - v.addResult("FAIL", "Discovery", fmt.Sprintf("Missing %s", host), "not found") + v.addResult(os.Stdout, "FAIL", "Discovery", fmt.Sprintf("Missing %s", host), "not found") return fmt.Errorf("required host %s not found", host) } } return nil } +// maxConcurrentChecks limits how many check categories run in parallel. +// This bounds concurrent SSM calls to avoid throttling. +const maxConcurrentChecks = 5 + +// checkFunc is the signature for all check functions. +type checkFunc func(context.Context, io.Writer) + +// runChecks executes check functions concurrently, printing each check's +// output in submission order as it completes (per-check buffered channels). +func (v *Validator) runChecks(ctx context.Context, checks []checkFunc) { + chs := make([]chan []byte, len(checks)) + sem := make(chan struct{}, maxConcurrentChecks) + + for i, fn := range checks { + chs[i] = make(chan []byte, 1) + go func(ch chan<- []byte, f checkFunc) { + sem <- struct{}{} + defer func() { <-sem }() + var buf bytes.Buffer + f(ctx, &buf) + ch <- buf.Bytes() + }(chs[i], fn) + } + + for _, ch := range chs { + os.Stdout.Write(<-ch) + } +} + // RunQuickChecks runs a subset of critical checks. func (v *Validator) RunQuickChecks(ctx context.Context) { - v.checkCredentialDiscovery(ctx) - v.checkNetworkMisconfigs(ctx) - v.checkMSSQL(ctx) - v.checkADCS(ctx) - v.checkDomainTrusts(ctx) - v.checkServices(ctx) - v.checkScheduledTasks(ctx) + v.runChecks(ctx, []checkFunc{ + v.checkCredentialDiscovery, + v.checkNetworkMisconfigs, + v.checkMSSQL, + v.checkADCS, + v.checkDomainTrusts, + v.checkServices, + v.checkScheduledTasks, + }) } // RunAllChecks executes all vulnerability validation checks. func (v *Validator) RunAllChecks(ctx context.Context) { - v.checkCredentialDiscovery(ctx) - v.checkKerberosAttacks(ctx) - v.checkNetworkMisconfigs(ctx) - v.checkAnonymousSMB(ctx) - v.checkDelegation(ctx) - v.checkMachineAccountQuota(ctx) - v.checkMSSQL(ctx) - v.checkADCS(ctx) - v.checkACLPermissions(ctx) - v.checkDomainTrusts(ctx) - v.checkSIDFiltering(ctx) - v.checkServices(ctx) - v.checkScheduledTasks(ctx) - v.checkLLMNR(ctx) - v.checkGPOAbuse(ctx) - v.checkGMSA(ctx) - v.checkLAPS(ctx) - v.checkSMBShares(ctx) - v.checkFirewallDisabled(ctx) - v.checkPasswordPolicy(ctx) + v.runChecks(ctx, []checkFunc{ + v.checkCredentialDiscovery, + v.checkKerberosAttacks, + v.checkNetworkMisconfigs, + v.checkAnonymousSMB, + v.checkDelegation, + v.checkMachineAccountQuota, + v.checkMSSQL, + v.checkADCS, + v.checkACLPermissions, + v.checkDomainTrusts, + v.checkSIDFiltering, + v.checkServices, + v.checkScheduledTasks, + v.checkLLMNR, + v.checkGPOAbuse, + v.checkGMSA, + v.checkLAPS, + v.checkSMBShares, + v.checkFirewallDisabled, + v.checkPasswordPolicy, + }) } // GetReport returns the current report. @@ -161,24 +198,32 @@ func (v *Validator) runPS(ctx context.Context, host, command string) string { return result.Stdout } -func (v *Validator) addResult(status, category, name, detail string) { +func (v *Validator) addResult(w io.Writer, status, category, name, detail string) { r := Result{Status: status, Category: category, Name: name, Detail: detail} - v.report.Results = append(v.report.Results, r) + v.mu.Lock() + v.report.Results = append(v.report.Results, r) switch status { case "PASS": v.report.Passed++ - color.Green(" ✓ %s", name) case "FAIL": v.report.Failed++ - color.Red(" ✗ %s", name) case "WARN": v.report.Warnings++ - color.Yellow(" ⚠ %s", name) + } + v.mu.Unlock() + + switch status { + case "PASS": + fmt.Fprint(w, color.GreenString(" ✓ %s\n", name)) + case "FAIL": + fmt.Fprint(w, color.RedString(" ✗ %s\n", name)) + case "WARN": + fmt.Fprint(w, color.YellowString(" ⚠ %s\n", name)) case "SKIP": - color.Cyan(" ⊘ %s", name) + fmt.Fprint(w, color.CyanString(" ⊘ %s\n", name)) case "INFO": - fmt.Printf(" ℹ %s\n", name) + fmt.Fprintf(w, " ℹ %s\n", name) } } diff --git a/cli/internal/validate/validator_test.go b/cli/internal/validate/validator_test.go new file mode 100644 index 00000000..467b122f --- /dev/null +++ b/cli/internal/validate/validator_test.go @@ -0,0 +1,206 @@ +package validate + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + "sync/atomic" + "testing" + "time" +) + +// fakeCheck simulates a check that takes a fixed duration and writes output. +func fakeCheck(name string, delay time.Duration, results int) checkFunc { + return func(ctx context.Context, w io.Writer) { + printHeader(w, name) + time.Sleep(delay) + for i := range results { + fmt.Fprintf(w, " result-%s-%d\n", name, i) + } + } +} + +func TestRunChecks_OrderedOutput(t *testing.T) { + v := &Validator{ + hosts: make(map[string]string), + } + + // Check C is fastest, A is slowest — output must still be A, B, C order. + checks := []checkFunc{ + fakeCheck("A", 100*time.Millisecond, 2), + fakeCheck("B", 50*time.Millisecond, 2), + fakeCheck("C", 10*time.Millisecond, 2), + } + + // Capture stdout + old := captureStdout(t) + v.runChecks(context.Background(), checks) + output := old.restore() + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) == 0 { + t.Fatal("no output produced") + } + + // Verify order: A header must come before B header, B before C. + aIdx := indexOf(lines, "== A ==") + bIdx := indexOf(lines, "== B ==") + cIdx := indexOf(lines, "== C ==") + + if aIdx == -1 || bIdx == -1 || cIdx == -1 { + t.Fatalf("missing headers in output:\n%s", output) + } + if aIdx >= bIdx || bIdx >= cIdx { + t.Errorf("output not in submission order: A@%d B@%d C@%d\noutput:\n%s", aIdx, bIdx, cIdx, output) + } + + // Verify A's results come before B's header (grouped, not interleaved). + aResult := indexOf(lines, "result-A-1") + if aResult == -1 || aResult >= bIdx { + t.Errorf("A results should be grouped before B header: result@%d B@%d", aResult, bIdx) + } +} + +func TestRunChecks_Concurrent(t *testing.T) { + v := &Validator{ + hosts: make(map[string]string), + } + + const numChecks = 5 + const checkDuration = 100 * time.Millisecond + + checks := make([]checkFunc, numChecks) + for i := range numChecks { + name := fmt.Sprintf("check-%d", i) + checks[i] = fakeCheck(name, checkDuration, 1) + } + + old := captureStdout(t) + start := time.Now() + v.runChecks(context.Background(), checks) + elapsed := time.Since(start) + old.restore() + + // If sequential: numChecks * 100ms = 500ms. + // If concurrent (limit=5, all fit): ~100ms. + // Allow generous margin but must be faster than sequential. + maxExpected := checkDuration * time.Duration(numChecks) * 80 / 100 + if elapsed >= maxExpected { + t.Errorf("checks appear sequential: took %v, expected well under %v", elapsed, maxExpected) + } +} + +func TestRunChecks_AllResultsCollected(t *testing.T) { + v := &Validator{ + hosts: make(map[string]string), + report: Report{ + Date: time.Now().UTC().Format(time.RFC3339), + }, + } + + var count atomic.Int32 + checks := make([]checkFunc, 10) + for i := range 10 { + checks[i] = func(ctx context.Context, w io.Writer) { + v.addResult(w, "PASS", "Test", fmt.Sprintf("check-%d", count.Add(1)), "") + } + } + + old := captureStdout(t) + v.runChecks(context.Background(), checks) + old.restore() + + report := v.GetReport() + if report.Passed != 10 { + t.Errorf("expected 10 passed, got %d", report.Passed) + } + if report.Total != 10 { + t.Errorf("expected 10 total, got %d", report.Total) + } +} + +func TestRunChecks_SemaphoreLimitsConcurrency(t *testing.T) { + v := &Validator{ + hosts: make(map[string]string), + } + + var running atomic.Int32 + var maxRunning atomic.Int32 + + checks := make([]checkFunc, 10) + for i := range 10 { + checks[i] = func(ctx context.Context, w io.Writer) { + cur := running.Add(1) + // Track peak concurrency + for { + prev := maxRunning.Load() + if cur <= prev || maxRunning.CompareAndSwap(prev, cur) { + break + } + } + time.Sleep(50 * time.Millisecond) + running.Add(-1) + fmt.Fprintf(w, "done\n") + } + } + + old := captureStdout(t) + v.runChecks(context.Background(), checks) + old.restore() + + peak := maxRunning.Load() + if peak > int32(maxConcurrentChecks) { + t.Errorf("peak concurrency %d exceeded limit %d", peak, maxConcurrentChecks) + } + if peak < 2 { + t.Errorf("peak concurrency %d suggests no parallelism", peak) + } +} + +// --- helpers --- + +func indexOf(lines []string, substr string) int { + for i, l := range lines { + if strings.Contains(l, substr) { + return i + } + } + return -1 +} + +// stdoutCapture temporarily redirects os.Stdout to a pipe. +type stdoutCapture struct { + orig *os.File + r, w *os.File + done chan string + t *testing.T +} + +func captureStdout(t *testing.T) *stdoutCapture { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdout + os.Stdout = w + + done := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + + return &stdoutCapture{orig: orig, r: r, w: w, done: done, t: t} +} + +func (c *stdoutCapture) restore() string { + c.t.Helper() + c.w.Close() + os.Stdout = c.orig + return <-c.done +} From b076d7724839fd968e41e2667c9a1940f1fbb74d Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 8 Apr 2026 12:55:09 -0600 Subject: [PATCH 4/5] test: improve stdout capture in tests with error handling **Added:** - Introduced a `captureResult` struct to carry both output and error from goroutine handling stdout capture **Changed:** - Updated stdout capturing logic to use `captureResult`, enabling error propagation from the goroutine to the test - Modified `restore` to fail the test with `t.Fatalf` if an error occurred during output capture **Removed:** - Removed use of string-only channels for capturing output, ensuring errors are not silently ignored --- cli/internal/validate/validator_test.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/cli/internal/validate/validator_test.go b/cli/internal/validate/validator_test.go index 467b122f..255f2841 100644 --- a/cli/internal/validate/validator_test.go +++ b/cli/internal/validate/validator_test.go @@ -175,10 +175,15 @@ func indexOf(lines []string, substr string) int { type stdoutCapture struct { orig *os.File r, w *os.File - done chan string + done chan captureResult t *testing.T } +type captureResult struct { + output string + err error +} + func captureStdout(t *testing.T) *stdoutCapture { t.Helper() r, w, err := os.Pipe() @@ -188,11 +193,11 @@ func captureStdout(t *testing.T) *stdoutCapture { orig := os.Stdout os.Stdout = w - done := make(chan string) + done := make(chan captureResult) go func() { var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - done <- buf.String() + _, err := io.Copy(&buf, r) + done <- captureResult{output: buf.String(), err: err} }() return &stdoutCapture{orig: orig, r: r, w: w, done: done, t: t} @@ -202,5 +207,9 @@ func (c *stdoutCapture) restore() string { c.t.Helper() c.w.Close() os.Stdout = c.orig - return <-c.done + res := <-c.done + if res.err != nil { + c.t.Fatalf("capturing stdout: %v", res.err) + } + return res.output } From f6d20cb4445ee860e8412e334033bd13adad24c4 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Wed, 8 Apr 2026 13:23:53 -0600 Subject: [PATCH 5/5] refactor: explicitly ignore fmt and io.Writer return values for clarity Changed: - Updated all fmt.Fprintf, fmt.Fprint, and os.Stdout.Write calls to explicitly ignore returned values by assigning them to blank identifiers, clarifying intent to discard errors and comply with static analysis tools - Modified test code to also explicitly ignore errors from fmt.Fprintf and io.Writer Close calls, improving consistency and code readability --- cli/internal/validate/checks.go | 2 +- cli/internal/validate/validator.go | 12 ++++++------ cli/internal/validate/validator_test.go | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cli/internal/validate/checks.go b/cli/internal/validate/checks.go index 9e5a7ed5..4c43d1de 100644 --- a/cli/internal/validate/checks.go +++ b/cli/internal/validate/checks.go @@ -8,7 +8,7 @@ import ( ) func printHeader(w io.Writer, header string) { - fmt.Fprintf(w, "\n== %s ==\n", header) + _, _ = fmt.Fprintf(w, "\n== %s ==\n", header) } func (v *Validator) checkCredentialDiscovery(ctx context.Context, w io.Writer) { diff --git a/cli/internal/validate/validator.go b/cli/internal/validate/validator.go index a6c19676..e80658cf 100644 --- a/cli/internal/validate/validator.go +++ b/cli/internal/validate/validator.go @@ -123,7 +123,7 @@ func (v *Validator) runChecks(ctx context.Context, checks []checkFunc) { } for _, ch := range chs { - os.Stdout.Write(<-ch) + _, _ = os.Stdout.Write(<-ch) } } @@ -215,15 +215,15 @@ func (v *Validator) addResult(w io.Writer, status, category, name, detail string switch status { case "PASS": - fmt.Fprint(w, color.GreenString(" ✓ %s\n", name)) + _, _ = fmt.Fprint(w, color.GreenString(" ✓ %s\n", name)) case "FAIL": - fmt.Fprint(w, color.RedString(" ✗ %s\n", name)) + _, _ = fmt.Fprint(w, color.RedString(" ✗ %s\n", name)) case "WARN": - fmt.Fprint(w, color.YellowString(" ⚠ %s\n", name)) + _, _ = fmt.Fprint(w, color.YellowString(" ⚠ %s\n", name)) case "SKIP": - fmt.Fprint(w, color.CyanString(" ⊘ %s\n", name)) + _, _ = fmt.Fprint(w, color.CyanString(" ⊘ %s\n", name)) case "INFO": - fmt.Fprintf(w, " ℹ %s\n", name) + _, _ = fmt.Fprintf(w, " ℹ %s\n", name) } } diff --git a/cli/internal/validate/validator_test.go b/cli/internal/validate/validator_test.go index 255f2841..e0c3ab07 100644 --- a/cli/internal/validate/validator_test.go +++ b/cli/internal/validate/validator_test.go @@ -18,7 +18,7 @@ func fakeCheck(name string, delay time.Duration, results int) checkFunc { printHeader(w, name) time.Sleep(delay) for i := range results { - fmt.Fprintf(w, " result-%s-%d\n", name, i) + _, _ = fmt.Fprintf(w, " result-%s-%d\n", name, i) } } } @@ -143,7 +143,7 @@ func TestRunChecks_SemaphoreLimitsConcurrency(t *testing.T) { } time.Sleep(50 * time.Millisecond) running.Add(-1) - fmt.Fprintf(w, "done\n") + _, _ = fmt.Fprintf(w, "done\n") } } @@ -205,7 +205,7 @@ func captureStdout(t *testing.T) *stdoutCapture { func (c *stdoutCapture) restore() string { c.t.Helper() - c.w.Close() + _ = c.w.Close() os.Stdout = c.orig res := <-c.done if res.err != nil {