All Posts Automation

Automating Windows Server Compliance Checks with PowerShell

Your compliance auditor asks to see evidence that your Windows Servers are configured according to security best practices.

Automated Windows Server compliance checking with PowerShell audits server configurations against CIS benchmarks — covering 400+ security settings including password policies, firewall rules, registry values, and audit policies — and generates HTML evidence reports in minutes instead of the 2+ hours per server that manual checking requires. Every business in Volusia County handling sensitive data faces automated compliance reporting requirements from HIPAA, PCI DSS, or SOC 2, and PowerShell scripts provide the documented, repeatable proof that auditors demand.

Your compliance auditor asks to see evidence that your Windows Servers are configured according to security best practices. You open the server, click through a dozen settings panels, compare values against a printed checklist, and take screenshots as evidence. Two hours later, you’ve audited one server. You have four more. The auditor needs this quarterly.

Automated Windows Server compliance checking uses PowerShell scripts to audit server configurations against security benchmarks like CIS (Center for Internet Security), compare actual settings against recommended values, and generate HTML reports documenting pass/fail results — without manual verification. The scripts check registry keys, group policies, service states, firewall rules, audit policies, and file permissions, producing a comprehensive compliance report in minutes instead of hours.

Every business in Volusia County that handles sensitive data faces this requirement. Medical practices in New Smyrna Beach need HIPAA compliance. Retailers in Daytona Beach need PCI DSS. Service companies in Port Orange need SOC 2. All of these frameworks require documented evidence that servers are configured securely. The question isn’t whether you need schedule a compliance review — it’s whether you do them manually or automate them.

In this guide, I’ll walk you through building a complete compliance audit system with PowerShell. We’ll check real CIS benchmark settings, generate professional HTML reports, and schedule the audits to run automatically.

What CIS Benchmarks Actually Check

Before we write code, let me clarify what we’re auditing. CIS benchmarks are prescriptive — they specify exact values for specific settings. Not “use a strong password policy” but “set minimum password length to 14 characters.” Not “enable auditing” but “enable audit policy for Account Logon events with Success and Failure.” If this resonates, our post on How to Encrypt Your Business Data in Transit and at Rest (Plain English) goes deeper into the specifics.

The Windows Server 2022 CIS benchmark (version 2.0.0) contains over 400 individual recommendations across these categories:

  • Account Policies — password length, complexity, lockout thresholds
  • Local Policies — user rights assignments, security options
  • Event Log — maximum log sizes, retention policies
  • System Services — which services should be disabled
  • Registry Settings — hundreds of security-relevant registry values
  • Firewall — Windows Firewall profile configurations
  • Advanced Audit Policy — granular audit settings

You don’t need to check all 400+ settings. Start with the Level 1 recommendations — these are the settings that CIS considers essential for any server without causing significant functionality impact. Level 2 adds defense-in-depth settings that may affect compatibility.

The Compliance Check Script

Here’s a comprehensive PowerShell compliance audit script that checks the most critical CIS benchmark settings and generates an HTML report:

<#
.SYNOPSIS
    Automated CIS benchmark compliance checker for Windows Server.
.DESCRIPTION
    Checks Windows Server configuration against CIS benchmark
    recommendations (Level 1) and generates an HTML report.
    Covers account policies, audit policies, security options,
    firewall configuration, and critical registry settings.
.NOTES
    Run as Administrator on the target server.
    Based on CIS Microsoft Windows Server 2022 Benchmark v2.0.0.
    Deploy via: Intune, GPO, Task Scheduler, or manual execution.
#>

param(
    [string]$OutputDir = "C:\ComplianceReports",
    [string]$ServerName = $env:COMPUTERNAME,
    [switch]$SkipHTMLReport
)

# --- Setup ---
$ErrorActionPreference = "Continue"
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportFile = Join-Path $OutputDir "compliance-$ServerName-$timestamp.html"
$results = [System.Collections.ArrayList]::new()

function Add-Check {
    param(
        [string]$Category,
        [string]$CheckID,
        [string]$Description,
        [string]$Expected,
        [string]$Actual,
        [string]$Status  # PASS, FAIL, WARN, ERROR
    )
    [void]$results.Add([PSCustomObject]@{
        Category    = $Category
        CheckID     = $CheckID
        Description = $Description
        Expected    = $Expected
        Actual      = $Actual
        Status      = $Status
    })
}

Write-Host "CIS Benchmark Compliance Check" -ForegroundColor Cyan
Write-Host "Server: $ServerName"
Write-Host "Time:   $(Get-Date)`n"

# ========================================
# SECTION 1: Account Policies
# ========================================
Write-Host "Checking Account Policies..." -ForegroundColor Yellow

$netAccounts = net accounts 2>&1
$historyMatch = $netAccounts | Select-String "Length of password history"
$historyValue = if ($historyMatch) {
    [int]($historyMatch.ToString() -replace '\D', '')
} else { 0 }

Add-Check -Category "Account Policies" `
    -CheckID "1.1.1" `
    -Description "Enforce password history (>= 24)" `
    -Expected ">= 24" `
    -Actual "$historyValue" `
    -Status $(if ($historyValue -ge 24) { "PASS" } else { "FAIL" })

$minLenMatch = $netAccounts | Select-String "Minimum password length"
$minLen = if ($minLenMatch) {
    [int]($minLenMatch.ToString() -replace '\D', '')
} else { 0 }

Add-Check -Category "Account Policies" `
    -CheckID "1.1.3" `
    -Description "Minimum password length (>= 14)" `
    -Expected ">= 14" `
    -Actual "$minLen characters" `
    -Status $(if ($minLen -ge 14) { "PASS" } else { "FAIL" })

$lockThreshMatch = $netAccounts | Select-String "Lockout threshold"
$lockThresh = if ($lockThreshMatch) {
    [int]($lockThreshMatch.ToString() -replace '\D', '')
} else { 0 }

Add-Check -Category "Account Policies" `
    -CheckID "1.2.2" `
    -Description "Account lockout threshold (1-5 attempts)" `
    -Expected "1-5" `
    -Actual "$lockThresh attempts" `
    -Status $(if ($lockThresh -ge 1 -and $lockThresh -le 5) { "PASS" } else { "FAIL" })

# ========================================
# SECTION 2: Windows Firewall
# ========================================
Write-Host "Checking Firewall Configuration..." -ForegroundColor Yellow

$fwProfiles = @("Domain", "Private", "Public")
foreach ($profile in $fwProfiles) {
    $fwState = Get-NetFirewallProfile -Name $profile -ErrorAction SilentlyContinue

    Add-Check -Category "Firewall" `
        -CheckID "9.1.x" `
        -Description "$profile profile: Firewall enabled" `
        -Expected "True" `
        -Actual "$($fwState.Enabled)" `
        -Status $(if ($fwState.Enabled) { "PASS" } else { "FAIL" })

    Add-Check -Category "Firewall" `
        -CheckID "9.1.x" `
        -Description "$profile profile: Inbound default Block" `
        -Expected "Block" `
        -Actual "$($fwState.DefaultInboundAction)" `
        -Status $(if ($fwState.DefaultInboundAction -eq "Block") { "PASS" } else { "FAIL" })
}

# ========================================
# SECTION 3: Audit Policies
# ========================================
Write-Host "Checking Audit Policies..." -ForegroundColor Yellow

$auditChecks = @(
    @{SubCategory="Credential Validation"; Expected="Success and Failure"},
    @{SubCategory="User Account Management"; Expected="Success and Failure"},
    @{SubCategory="Logon"; Expected="Success and Failure"},
    @{SubCategory="Audit Policy Change"; Expected="Success and Failure"}
)

$auditPolOutput = auditpol /get /category:* 2>&1
foreach ($check in $auditChecks) {
    $line = $auditPolOutput | Select-String $check.SubCategory | Select-Object -First 1
    $actual = if ($line) {
        ($line.ToString().Trim() -split '\s{2,}')[-1]
    } else { "Not Found" }

    $pass = $actual -like "*$($check.Expected)*" -or
            ($check.Expected -eq "Success" -and $actual -match "Success")

    Add-Check -Category "Audit Policy" `
        -CheckID "17.x" `
        -Description "Audit: $($check.SubCategory)" `
        -Expected $check.Expected `
        -Actual $actual `
        -Status $(if ($pass) { "PASS" } else { "FAIL" })
}

# ========================================
# SECTION 4: Security-Critical Registry Settings
# ========================================
Write-Host "Checking Registry Settings..." -ForegroundColor Yellow

$regChecks = @(
    @{
        Path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
        Name = "EnableLUA"
        Expected = 1
        Description = "UAC: Admin Approval Mode enabled"
    },
    @{
        Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa"
        Name = "LmCompatibilityLevel"
        Expected = 5
        Description = "Network security: NTLMv2 only"
    },
    @{
        Path = "HKLM:\SYSTEM\CurrentControlSet\Services\LanManServer\Parameters"
        Name = "RequireSecuritySignature"
        Expected = 1
        Description = "SMB signing required (server)"
    },
    @{
        Path = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest"
        Name = "UseLogonCredential"
        Expected = 0
        Description = "WDigest authentication disabled"
    }
)

foreach ($reg in $regChecks) {
    $actual = (Get-ItemProperty -Path $reg.Path -Name $reg.Name -ErrorAction SilentlyContinue).$($reg.Name)

    Add-Check -Category "Registry" `
        -CheckID "CIS-REG" `
        -Description $reg.Description `
        -Expected "$($reg.Expected)" `
        -Actual $(if ($null -eq $actual) { "Not Set" } else { "$actual" }) `
        -Status $(if ($actual -eq $reg.Expected) { "PASS" } elseif ($null -eq $actual) { "WARN" } else { "FAIL" })
}

# ========================================
# SECTION 5: Critical Services
# ========================================
Write-Host "Checking Services..." -ForegroundColor Yellow

$disabledServices = @(
    @{Name = "RemoteRegistry"; Description = "Remote Registry should be disabled"},
    @{Name = "SSDPSRV"; Description = "SSDP Discovery should be disabled"}
)

foreach ($svc in $disabledServices) {
    $service = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue
    $startType = if ($service) {
        (Get-WmiObject Win32_Service -Filter "Name='$($svc.Name)'" -ErrorAction SilentlyContinue).StartMode
    } else { "NotInstalled" }

    $pass = $startType -eq "Disabled" -or $startType -eq "NotInstalled"

    Add-Check -Category "Services" `
        -CheckID "CIS-SVC" `
        -Description $svc.Description `
        -Expected "Disabled" `
        -Actual $startType `
        -Status $(if ($pass) { "PASS" } else { "FAIL" })
}

# ========================================
# GENERATE HTML REPORT
# ========================================
if (-not $SkipHTMLReport) {
    $passCount = ($results | Where-Object { $_.Status -eq "PASS" }).Count
    $failCount = ($results | Where-Object { $_.Status -eq "FAIL" }).Count
    $warnCount = ($results | Where-Object { $_.Status -eq "WARN" }).Count
    $totalCount = $results.Count
    $passRate = [math]::Round(($passCount / $totalCount) * 100, 1)

    $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Compliance Report - $ServerName</title>
<style>
  body { font-family: 'Segoe UI', sans-serif; margin: 2rem; background: #f5f5f5; }
  .header { background: #1a1a2e; color: white; padding: 2rem; border-radius: 8px; margin-bottom: 2rem; }
  .summary { display: flex; gap: 1rem; margin-bottom: 2rem; }
  .card { background: white; padding: 1.5rem; border-radius: 8px; flex: 1; text-align: center; }
  .card .number { font-size: 2.5rem; font-weight: bold; }
  .pass { color: #27ae60; } .fail { color: #e74c3c; } .warn { color: #f39c12; }
  table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; }
  th { background: #2c3e50; color: white; padding: 0.75rem; text-align: left; }
  td { padding: 0.75rem; border-bottom: 1px solid #eee; }
  .status-pass { background: #d4edda; color: #155724; padding: 0.25rem 0.75rem; border-radius: 4px; font-weight: bold; }
  .status-fail { background: #f8d7da; color: #721c24; padding: 0.25rem 0.75rem; border-radius: 4px; font-weight: bold; }
  .status-warn { background: #fff3cd; color: #856404; padding: 0.25rem 0.75rem; border-radius: 4px; font-weight: bold; }
</style>
</head>
<body>
<div class="header">
  <h1>CIS Benchmark Compliance Report</h1>
  <p>Server: $ServerName | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</p>
</div>
<div class="summary">
  <div class="card"><div class="number">$totalCount</div>Total Checks</div>
  <div class="card"><div class="number pass">$passCount</div>Passed</div>
  <div class="card"><div class="number fail">$failCount</div>Failed</div>
  <div class="card"><div class="number warn">$warnCount</div>Warnings</div>
  <div class="card"><div class="number">$passRate%</div>Pass Rate</div>
</div>
<table>
<tr><th>Category</th><th>Check ID</th><th>Description</th><th>Expected</th><th>Actual</th><th>Status</th></tr>
"@

    foreach ($r in $results) {
        $statusClass = switch ($r.Status) {
            "PASS" { "status-pass" } "FAIL" { "status-fail" } "WARN" { "status-warn" } default { "" }
        }
        $html += "<tr><td>$($r.Category)</td><td>$($r.CheckID)</td><td>$($r.Description)</td><td>$($r.Expected)</td><td>$($r.Actual)</td><td><span class='$statusClass'>$($r.Status)</span></td></tr>`n"
    }

    $html += "</table></body></html>"
    $html | Out-File -FilePath $reportFile -Encoding UTF8
    Write-Host "Report saved: $reportFile" -ForegroundColor Green
}

Write-Host "`n=== Compliance Summary ===" -ForegroundColor Cyan
Write-Host "Passed: $passCount | Failed: $failCount | Warnings: $warnCount | Pass Rate: $passRate%"

Let me walk through what this script checks and why each setting matters. The account policies section verifies that password policies meet the CIS baseline — 14-character minimum length, 24 passwords remembered in history, lockout after 5 failed attempts. These aren’t arbitrary numbers. A 14-character minimum makes brute-force attacks computationally impractical. The 24-password history prevents users from cycling through a short list of passwords.

The firewall section checks that Windows Firewall is enabled on all three profiles with a default inbound action of Block. This check fails more often than you’d expect — particularly on servers where someone disabled the firewall “temporarily” to troubleshoot a connectivity issue and never turned it back on.

The registry settings section checks security-critical values like NTLMv2 enforcement and WDigest disablement. When WDigest is enabled, it stores user passwords in plain text in memory, which credential-dumping tools like Mimikatz exploit. The CIS benchmark requires it to be disabled, and the script verifies this with a single registry check. Our guide to Employee Offboarding Security Checklist: Automate It So Nothing Gets Missed walks through this in more detail.

The HTML report is the evidence your auditor needs. It includes a summary with pass/fail counts and a detailed table showing every check result.

Scheduling the Audit

Wrap the compliance check in a Windows Task Scheduler job that runs weekly:

$action = New-ScheduledTaskAction `
    -Execute "powershell.exe" `
    -Argument "-ExecutionPolicy Bypass -File C:\Scripts\compliance-check.ps1 -OutputDir C:\ComplianceReports"

$trigger = New-ScheduledTaskTrigger `
    -Weekly -DaysOfWeek Monday -At "06:00"

Register-ScheduledTask `
    -TaskName "CIS Compliance Audit" `
    -Action $action `
    -Trigger $trigger `
    -User "SYSTEM" `
    -RunLevel Highest `
    -Description "Weekly CIS benchmark compliance check"

Monday at 6 AM, the audit runs and saves its HTML report. When your auditor asks for evidence, you point them at the folder. When something changes unexpectedly, you compare last week’s report to this week’s.

Remediating Failures

The compliance check tells you what’s wrong. Here’s a remediation script that fixes the most common failures: For a deeper technical dive, see our article on AI agent memory and context management.

param([switch]$WhatIf)

$prefix = if ($WhatIf) { "[WHATIF] " } else { "" }

Write-Host "${prefix}Setting account policies..." -ForegroundColor Yellow
if (-not $WhatIf) {
    net accounts /minpwlen:14 /maxpwage:365 /minpwage:1 /uniquepw:24 /lockoutthreshold:5
}

Write-Host "${prefix}Enabling Windows Firewall..." -ForegroundColor Yellow
if (-not $WhatIf) {
    Set-NetFirewallProfile -Profile Domain,Public,Private `
        -Enabled True -DefaultInboundAction Block -DefaultOutboundAction Allow
}

$registryFixes = @(
    @{Path="HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"; Name="EnableLUA"; Value=1; Type="DWord"},
    @{Path="HKLM:\SYSTEM\CurrentControlSet\Control\Lsa"; Name="LmCompatibilityLevel"; Value=5; Type="DWord"},
    @{Path="HKLM:\SYSTEM\CurrentControlSet\Services\LanManServer\Parameters"; Name="RequireSecuritySignature"; Value=1; Type="DWord"},
    @{Path="HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest"; Name="UseLogonCredential"; Value=0; Type="DWord"}
)

foreach ($fix in $registryFixes) {
    Write-Host "${prefix}Setting $($fix.Name) = $($fix.Value)" -ForegroundColor Yellow
    if (-not $WhatIf) {
        if (-not (Test-Path $fix.Path)) { New-Item -Path $fix.Path -Force | Out-Null }
        Set-ItemProperty -Path $fix.Path -Name $fix.Name -Value $fix.Value -Type $fix.Type
    }
}

$servicesToDisable = @("RemoteRegistry", "SSDPSRV")
foreach ($svc in $servicesToDisable) {
    if (Get-Service -Name $svc -ErrorAction SilentlyContinue) {
        Write-Host "${prefix}Disabling $svc"
        if (-not $WhatIf) {
            Set-Service -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue
            Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue
        }
    }
}

Write-Host "${prefix}Remediation complete. Run compliance check to verify." -ForegroundColor Green

The -WhatIf flag shows what the script would change without changing anything. Run .\remediate.ps1 -WhatIf first, review the output, then run without the flag to apply. Always run the compliance check again after remediation to verify that your fixes actually worked.

FAQ

What are CIS benchmarks for Windows Server?

CIS benchmarks are consensus-based security configuration guides with specific, testable recommendations for Windows Server settings covering account policies, audit policies, firewall rules, registry configurations, and services. They are accepted by HIPAA, PCI DSS, SOC 2, and other compliance frameworks.

Can PowerShell automate compliance checks?

Yes. PowerShell can query every Windows Server configuration setting and compare actual values against CIS benchmark recommendations, producing detailed pass/fail reports in minutes.

How often should compliance checks run?

Weekly at minimum, daily for servers handling sensitive data, and after every configuration change. Automated checks via Task Scheduler ensure consistent monitoring.

Do I need commercial tools for compliance auditing?

No. PowerShell handles the core audit functionality natively. Commercial tools add dashboards and historical trending, but the scripts in this guide perform the same checks.

What compliance frameworks require server hardening?

HIPAA, PCI DSS, SOC 2, NIST 800-53, and CMMC all require documented server hardening. CIS benchmarks are accepted as evidence of a security baseline across all these frameworks.

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.