Windows Server Security Audit Script
Audit Windows Server 2022 against CIS Benchmarks with PowerShell.
Introduction
30 Second Summary
Every organization needs to verify that its servers meet security standards. Without regular checks, misconfigurations pile up silently and create vulnerabilities that attackers exploit.
In this project, you will build a PowerShell script that audits your Windows Server 2022 security settings against CIS Benchmarks and generates a professional HTML compliance report with pass/fail indicators.
What You'll Build
A PowerShell security audit tool that checks Windows Server 2022 settings across four categories (password policy, account lockout, firewall profiles, and audit policy) and outputs a styled HTML report showing compliance status for each check.
By the end of this project, you'll have:
- A working PowerShell security audit script that checks Windows Server 2022 against CIS Benchmark Level 1 recommendations.
- A JSON baseline configuration file that defines expected values for each security check, making the tool reusable and customizable.
- A professional HTML compliance report with pass/fail indicators, compliance percentage, and server metadata.
- Secret Mission: Add a services audit check that flags unnecessary services running on the server based on CIS recommendations.
Are there any prerequisites?
You need basic PowerShell scripting experience (cmdlets, variables, functions) and a Windows Server 2022 VM running in Hyper-V on your Windows 11 host. If you have completed a project like AD user provisioning with PowerShell, you are ready for this one.
Before We Start
Before you can audit your Windows Server's security settings, you need a proper workspace set up on your host machine.
In this step, you will install a code editor, create the project folder structure, and confirm your Windows Server 2022 VM is accessible.
In this step, get ready to:
- Install Notepad++ with PowerShell syntax highlighting.
- Create the project folder and files at C:\SecurityAudit.
- Verify your Windows Server 2022 VM is running and PowerShell 5.1 is available.
Install Notepad++ on your Windows 11 host
You need a text editor with syntax highlighting to write and edit your PowerShell script comfortably. Notepad++ is a free, lightweight source code editor for Windows that supports PowerShell out of the box.
- Open your browser and navigate to the Notepad++ downloads page:
- https://notepad-plus-plus.org/downloads/
- Click the latest version at the top of the list.
- Under Download Notepad++ x64, click the Installer link to download the .exe file.
- Run the downloaded installer and accept all default settings by clicking Next through each screen, then Install.
Once installation finishes, confirm PowerShell syntax highlighting works.
- Press the Windows key to open your search bar.
- Type Notepad++ and press Enter to open it.
- In the menu bar, click Language.
- Scroll down the language list and select PowerShell.
You should see the syntax highlighting mode change in the bottom-left status bar to PowerShell.
Why Notepad++?
Notepad++ gives you colour-coded syntax highlighting for PowerShell scripts, making it much easier to spot typos in your code. You could use any editor, but Notepad++ is free, fast, and widely used by Windows sysadmins.
Don't see PowerShell in the Language menu?
Make sure you downloaded the latest version of Notepad++. Older versions may list PowerShell under a different sub-menu. If you still cannot find it, try saving a file with a .ps1 extension. Notepad++ will automatically apply PowerShell highlighting to .ps1 files.
Need more help? Help me find PowerShell syntax highlighting in Notepad++.
Create the project folder structure
Your security audit tool needs a dedicated folder to hold the script, the JSON baseline configuration, and the generated reports.
- Press the Windows key, type PowerShell, and press Enter to open a PowerShell window.
- Create the project folder, files, and Reports subfolder by running these commands:
# Create the main project folder
New-Item -ItemType Directory -Path "C:\SecurityAudit" -Force
# Create the script file and baseline config file
New-Item -ItemType File -Path "C:\SecurityAudit\Invoke-SecurityAudit.ps1" -Force
New-Item -ItemType File -Path "C:\SecurityAudit\baseline.json" -Force
# Create the Reports subfolder for HTML output
New-Item -ItemType Directory -Path "C:\SecurityAudit\Reports" -Force
What do these commands do?
New-Item -ItemType Directory creates a new folder at the path you specify. New-Item -ItemType File creates an empty file. The -Force flag means the command will succeed even if the folder already exists.
- Confirm the files exist by running:
Get-ChildItem C:\SecurityAudit
You should see Invoke-SecurityAudit.ps1, baseline.json, and a Reports folder listed in the output.
Seeing 'Access is denied'?
If you get an access error creating files at C:\, you need to run PowerShell as Administrator. Close your current PowerShell window, right-click the PowerShell icon in your search results, and select Run as administrator.
Still stuck? Help me fix the access denied error when creating my project folder.
Verify your Windows Server 2022 VM is running
The audit script will run on your Windows Server 2022 VM, not on your Windows 11 host. You need to confirm the VM is accessible and has the correct PowerShell version.
- Press the Windows key, type Hyper-V Manager, and press Enter to open it.
- In the Virtual Machines list, confirm your Windows Server 2022 VM shows a State of Running.
- Double-click the VM name to open the Hyper-V console window (or connect via RDP).
- Open a PowerShell session on the VM and run this command to check your PowerShell version:
$PSVersionTable.PSVersion
You should see a version number starting with 5.1, confirming PowerShell 5.1 is available on the server.
Why PowerShell 5.1?
Windows Server 2022 ships with PowerShell 5.1 pre-installed. The audit commands you will use (secedit, auditpol, Get-NetFirewallProfile) are all built-in to Windows Server and work natively in PowerShell 5.1.
VM not showing as Running?
If your VM state shows Off or Saved, right-click the VM name in Hyper-V Manager and select Start. Wait a minute or two for it to boot before connecting.
Cannot connect? Help me connect to my Windows Server 2022 VM in Hyper-V.
Your workspace is ready. Next up, you will build the JSON baseline configuration that defines what your audit script checks against.
Build the JSON Baseline Configuration
Your project folder is ready with the files and folders in place. Now it's time to define what "secure" actually means for your server.
The CIS Benchmarks are industry-standard security guidelines that tell you exactly how a server should be configured. Your audit script needs a reference point to compare against, and that reference point lives in a JSON configuration file called baseline.json.
In this step, get ready to:
- Define the JSON structure for CIS Benchmark Level 1 expected values.
- Populate all four audit categories with specific security thresholds.
- Validate the JSON file is well-formed using PowerShell.
Open baseline.json in Notepad++
The baseline file acts as your script's "answer key." Each entry defines what a CIS-compliant server should look like for a specific setting.
- Switch back to Notepad++ on your Windows 11 host.
- Click File, then Open.
- Navigate to C:\SecurityAudit and open baseline.json.
- Go to Language in the menu bar, then select JSON for syntax highlighting.
Define the CIS Benchmark Level 1 checks
The baseline contains four sections, each targeting a different security category that your script will audit. Every check has a name, description, expected value, and the CIS reference ID that maps back to the official benchmark document.
- Paste the following JSON into your empty baseline.json file:
{
"PasswordPolicy": [
{
"Name": "MinimumPasswordLength",
"Description": "Ensure minimum password length is set to 14 or more characters",
"Expected": 14,
"CISReference": "1.1.4"
},
{
"Name": "PasswordHistorySize",
"Description": "Ensure password history is set to 24 or more passwords",
"Expected": 24,
"CISReference": "1.1.1"
},
{
"Name": "MaximumPasswordAge",
"Description": "Ensure maximum password age is set to 365 or fewer days but not 0",
"Expected": 365,
"CISReference": "1.1.2"
},
{
"Name": "MinimumPasswordAge",
"Description": "Ensure minimum password age is set to 1 or more days",
"Expected": 1,
"CISReference": "1.1.3"
},
{
"Name": "PasswordComplexity",
"Description": "Ensure password must meet complexity requirements is Enabled",
"Expected": 1,
"CISReference": "1.1.5"
},
{
"Name": "ClearTextPassword",
"Description": "Ensure store passwords using reversible encryption is Disabled",
"Expected": 0,
"CISReference": "1.1.7"
}
],
"AccountLockout": [
{
"Name": "LockoutDuration",
"Description": "Ensure account lockout duration is set to 15 or more minutes",
"Expected": 15,
"CISReference": "1.2.1"
},
{
"Name": "LockoutBadCount",
"Description": "Ensure account lockout threshold is set to 5 or fewer invalid logon attempts but not 0",
"Expected": 5,
"CISReference": "1.2.2"
},
{
"Name": "ResetLockoutCount",
"Description": "Ensure reset account lockout counter is set to 15 or more minutes",
"Expected": 15,
"CISReference": "1.2.4"
}
],
"FirewallProfiles": [
{
"Name": "Domain",
"Description": "Ensure Windows Firewall Domain profile is enabled with inbound blocked",
"ExpectedEnabled": true,
"ExpectedInboundAction": "Block",
"CISReference": "9.1.1"
},
{
"Name": "Private",
"Description": "Ensure Windows Firewall Private profile is enabled with inbound blocked",
"ExpectedEnabled": true,
"ExpectedInboundAction": "Block",
"CISReference": "9.2.1"
},
{
"Name": "Public",
"Description": "Ensure Windows Firewall Public profile is enabled with inbound blocked",
"ExpectedEnabled": true,
"ExpectedInboundAction": "Block",
"CISReference": "9.3.1"
}
],
"AuditPolicy": [
{
"Name": "Audit Policy Change",
"Description": "Ensure Audit Policy Change is set to include Success",
"Expected": "Success",
"CISReference": "17.7.1"
},
{
"Name": "Authentication Policy Change",
"Description": "Ensure Authentication Policy Change is set to include Success",
"Expected": "Success",
"CISReference": "17.7.2"
}
]
}
What does each section check?
- PasswordPolicy (6 checks): Minimum length of 14 characters, history of 24 passwords, max age of 365 days, min age of 1 day, complexity enabled, and reversible encryption disabled.
- AccountLockout (3 checks): Lock accounts for 15+ minutes after 5 bad attempts, and reset the counter after 15 minutes.
- FirewallProfiles (3 checks): Domain, Private, and Public profiles must all be enabled with inbound connections blocked by default.
- AuditPolicy (2 checks): Audit Policy Change and Authentication Policy Change must both log successful events.
Why JSON for the baseline?
Storing expected values in a separate JSON file means you can update thresholds without touching your script code. If your organization adopts stricter policies, you edit one file instead of hunting through PowerShell logic.
- Save baseline.json in Notepad++ by pressing Ctrl+S.
Validate the JSON structure in PowerShell
A single missing comma or bracket in JSON will break your script later. PowerShell's ConvertFrom-Json cmdlet can catch syntax errors now before they cause confusing failures at runtime.
- Open PowerShell on your Windows 11 host by pressing the Windows key, typing PowerShell, and pressing Enter.
- Navigate to your project folder and validate the JSON by running these commands:
cd C:\SecurityAudit
Get-Content baseline.json | ConvertFrom-Json
- You should see PowerShell print the four top-level properties (PasswordPolicy, AccountLockout, FirewallProfiles, AuditPolicy) with their values displayed as arrays of objects.
Seeing a ConvertFrom-Json error?
- Check for missing commas between objects in your arrays. Each object (except the last in an array) needs a trailing comma after its closing }.
- Make sure every opening { has a matching } and every [ has a matching ]. Notepad++ highlights matching brackets when you click on one.
- Verify that all property names and string values are wrapped in double quotes. JSON does not allow single quotes.
Help me debug my baseline.json syntax error
✔️ Awesome, I've got everything!
Great. Double check you've saved your file in Notepad++.
ⓧ I'd like to double check the full code
Here is the complete baseline.json file. Compare it against yours in Notepad++:
{
"PasswordPolicy": [
{
"Name": "MinimumPasswordLength",
"Description": "Ensure minimum password length is set to 14 or more characters",
"Expected": 14,
"CISReference": "1.1.4"
},
{
"Name": "PasswordHistorySize",
"Description": "Ensure password history is set to 24 or more passwords",
"Expected": 24,
"CISReference": "1.1.1"
},
{
"Name": "MaximumPasswordAge",
"Description": "Ensure maximum password age is set to 365 or fewer days but not 0",
"Expected": 365,
"CISReference": "1.1.2"
},
{
"Name": "MinimumPasswordAge",
"Description": "Ensure minimum password age is set to 1 or more days",
"Expected": 1,
"CISReference": "1.1.3"
},
{
"Name": "PasswordComplexity",
"Description": "Ensure password must meet complexity requirements is Enabled",
"Expected": 1,
"CISReference": "1.1.5"
},
{
"Name": "ClearTextPassword",
"Description": "Ensure store passwords using reversible encryption is Disabled",
"Expected": 0,
"CISReference": "1.1.7"
}
],
"AccountLockout": [
{
"Name": "LockoutDuration",
"Description": "Ensure account lockout duration is set to 15 or more minutes",
"Expected": 15,
"CISReference": "1.2.1"
},
{
"Name": "LockoutBadCount",
"Description": "Ensure account lockout threshold is set to 5 or fewer invalid logon attempts but not 0",
"Expected": 5,
"CISReference": "1.2.2"
},
{
"Name": "ResetLockoutCount",
"Description": "Ensure reset account lockout counter is set to 15 or more minutes",
"Expected": 15,
"CISReference": "1.2.4"
}
],
"FirewallProfiles": [
{
"Name": "Domain",
"Description": "Ensure Windows Firewall Domain profile is enabled with inbound blocked",
"ExpectedEnabled": true,
"ExpectedInboundAction": "Block",
"CISReference": "9.1.1"
},
{
"Name": "Private",
"Description": "Ensure Windows Firewall Private profile is enabled with inbound blocked",
"ExpectedEnabled": true,
"ExpectedInboundAction": "Block",
"CISReference": "9.2.1"
},
{
"Name": "Public",
"Description": "Ensure Windows Firewall Public profile is enabled with inbound blocked",
"ExpectedEnabled": true,
"ExpectedInboundAction": "Block",
"CISReference": "9.3.1"
}
],
"AuditPolicy": [
{
"Name": "Audit Policy Change",
"Description": "Ensure Audit Policy Change is set to include Success",
"Expected": "Success",
"CISReference": "17.7.1"
},
{
"Name": "Authentication Policy Change",
"Description": "Ensure Authentication Policy Change is set to include Success",
"Expected": "Success",
"CISReference": "17.7.2"
}
]
}
Your baseline is locked in. Next up, you'll write the PowerShell functions that read this baseline and compare each value against your server's actual security settings.
Write the Security Audit Functions
You have a baseline configuration file defining what your server's security settings should look like. Now you need the code that checks what they actually look like.
In this step, you will write three PowerShell functions that query your Windows Server's live security settings and compare them against the CIS Benchmarks baseline. Each function targets a different security category: password and lockout policy, firewall profiles, and audit policy.
In this step, get ready to:
- Write the script scaffolding that loads your baseline JSON and prepares for audit results.
- Build a function that uses secedit to check password and account lockout policies.
- Build functions for firewall profile and audit policy compliance.
Create the script scaffolding and password policy function
Before writing any audit logic, the script needs a foundation. It must declare that it requires administrator privileges, accept parameters for the baseline file path and report output folder, and load the baseline JSON into memory.
- Open Invoke-SecurityAudit.ps1 in Notepad++ (it should still be empty from the previous step).
- Paste the following script header and baseline-loading scaffolding:
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Windows Server 2022 Security Audit Script
.DESCRIPTION
Checks local security settings against CIS Benchmark Level 1 recommendations
and generates an HTML compliance report.
.NOTES
Run this script as Administrator on your Windows Server 2022 machine.
#>
param(
[string]$BaselinePath = ".\baseline.json",
[string]$ReportPath = ".\Reports"
)
# --- Load Baseline ---
if (-not (Test-Path $BaselinePath)) {
Write-Error "Baseline file not found: $BaselinePath"
exit 1
}
$Baseline = Get-Content $BaselinePath -Raw | ConvertFrom-Json
# --- Initialize Results Array ---
$Results = @()
What does this code do?
#Requires -RunAsAdministrator forces the script to fail immediately if it is not run in an elevated session. The audit commands (secedit, auditpol) need admin privileges to read security settings.
The param() block defines two parameters with sensible defaults so the script works out of the box from the C:\SecurityAudit folder. $Results is an empty array that each audit function will append its findings to.
- Save Invoke-SecurityAudit.ps1.
You will not see any output yet. The next chunk adds the first audit function directly below this scaffolding.
The password policy and account lockout checks both live inside Windows local security policy. The secedit /export command dumps this policy to a temporary INF file, and PowerShell regex parses the key-value pairs from it.
- In Invoke-SecurityAudit.ps1, paste the following function directly below the $Results = @() line:
# --- Function: Test Password and Account Lockout Policy ---
function Test-SecurityPolicy {
param([object]$Baseline)
$tempFile = "$env:TEMP\secpol_export.inf"
secedit /export /areas securitypolicy /cfg $tempFile /quiet | Out-Null
if (-not (Test-Path $tempFile)) {
Write-Warning "Failed to export security policy."
return @()
}
$policyContent = Get-Content $tempFile -Raw
$results = @()
foreach ($check in $Baseline.PasswordPolicy) {
$pattern = "$($check.Name)\s*=\s*(.+)"
$match = [regex]::Match($policyContent, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
$actualInt = [int]$actual
switch ($check.Name) {
"MinimumPasswordLength" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"PasswordHistorySize" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"MaximumPasswordAge" { if ($actualInt -le $check.Expected -and $actualInt -ne 0) { $status = "PASS" } }
"MinimumPasswordAge" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"PasswordComplexity" { if ($actualInt -eq $check.Expected) { $status = "PASS" } }
"ClearTextPassword" { if ($actualInt -eq $check.Expected) { $status = "PASS" } }
}
}
$results += [PSCustomObject]@{
Category = "Password Policy"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
foreach ($check in $Baseline.AccountLockout) {
$pattern = "$($check.Name)\s*=\s*(.+)"
$match = [regex]::Match($policyContent, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
$actualInt = [int]$actual
switch ($check.Name) {
"LockoutDuration" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"LockoutBadCount" { if ($actualInt -le $check.Expected -and $actualInt -ne 0) { $status = "PASS" } }
"ResetLockoutCount" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
}
}
$results += [PSCustomObject]@{
Category = "Account Lockout"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
return $results
}
What does this function do?
secedit /export /areas securitypolicy /cfg $tempFile /quiet exports the local security policy into a temporary INF file. The INF file contains key-value pairs like MinimumPasswordLength = 14 under a [System Access] section.
For each check in the baseline, the function builds a regex pattern to find the matching key, extracts the actual value, then applies the correct comparison logic. Some checks use -ge (must be at least X), while others use -le (must be at most X) or -eq (must be exactly X).
Each result is stored as a PSCustomObject with a consistent shape (Category, CheckName, Expected, Actual, Status, CISReference) so all functions produce uniform output for the HTML report.
- Save Invoke-SecurityAudit.ps1.
You will not see output yet. The next function checks firewall profiles using a different approach.
Why secedit instead of net accounts?
The net accounts command only shows a subset of password policy settings. It cannot retrieve values like PasswordComplexity or ClearTextPassword. The secedit /export command produces a full INF file containing every local security policy setting, making it the standard approach for security audit scripts.
Add the firewall compliance function
Windows Firewall has three profiles: Domain, Private, and Public. CIS Benchmarks require all three to be enabled with inbound connections blocked by default. PowerShell's Get-NetFirewallProfile cmdlet returns these settings directly as objects, so no file parsing is needed.
- In Invoke-SecurityAudit.ps1, paste the following function directly below the closing } of Test-SecurityPolicy:
# --- Function: Test Firewall Profiles ---
function Test-FirewallCompliance {
param([object]$Baseline)
$results = @()
$profiles = Get-NetFirewallProfile
foreach ($check in $Baseline.FirewallProfiles) {
$profile = $profiles | Where-Object { $_.Name -eq $check.Name }
if ($null -eq $profile) {
$results += [PSCustomObject]@{
Category = "Firewall"
CheckName = $check.Description
Expected = "Enabled: $($check.ExpectedEnabled), Inbound: $($check.ExpectedInboundAction)"
Actual = "Profile not found"
Status = "FAIL"
CISReference = $check.CISReference
}
continue
}
$enabledMatch = $profile.Enabled -eq $check.ExpectedEnabled
$inboundMatch = $profile.DefaultInboundAction -eq $check.ExpectedInboundAction
$status = if ($enabledMatch -and $inboundMatch) { "PASS" } else { "FAIL" }
$results += [PSCustomObject]@{
Category = "Firewall"
CheckName = $check.Description
Expected = "Enabled: $($check.ExpectedEnabled), Inbound: $($check.ExpectedInboundAction)"
Actual = "Enabled: $($profile.Enabled), Inbound: $($profile.DefaultInboundAction)"
Status = $status
CISReference = $check.CISReference
}
}
return $results
}
What does this function do?
Get-NetFirewallProfile returns objects with Enabled (Boolean) and DefaultInboundAction (Block/Allow) properties. The function loops through each profile in the baseline (Domain, Private, Public) and checks both properties match the expected values.
If a profile is missing entirely, the function records a FAIL with a clear "Profile not found" message so you can troubleshoot later.
- Save Invoke-SecurityAudit.ps1.
You will not see output yet. The final function checks audit policy settings.
Seeing red syntax highlighting in Notepad++?
If Notepad++ highlights parts of your code in red, check for mismatched curly braces {} or missing closing quotes. Each foreach and if block must have a matching opening and closing brace.
Also confirm your Notepad++ language is set to PowerShell (Language > PowerShell in the menu bar).
Help me fix PowerShell syntax errors in my firewall audit function.
Add the audit policy function
Windows Advanced Audit Policy controls what security events get logged. The auditpol /get /category:* command outputs all subcategories and their current settings. This function parses that text output with regex to find specific subcategories and check whether they are set to the expected value.
- In Invoke-SecurityAudit.ps1, paste the following function directly below the closing } of Test-FirewallCompliance:
# --- Function: Test Audit Policy ---
function Test-AuditPolicyCompliance {
param([object]$Baseline)
$results = @()
$auditOutput = auditpol /get /category:* 2>&1 | Out-String
foreach ($check in $Baseline.AuditPolicy) {
$pattern = "$([regex]::Escape($check.Name))\s+(Success and Failure|Success|Failure|No Auditing)"
$match = [regex]::Match($auditOutput, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
if ($actual -eq $check.Expected -or $actual -eq "Success and Failure") {
$status = "PASS"
}
}
$results += [PSCustomObject]@{
Category = "Audit Policy"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
return $results
}
What does this function do?
auditpol /get /category:* dumps every audit subcategory and its current setting (Success, Failure, Success and Failure, or No Auditing). The output is plain text, so the function uses [regex]::Escape() on the subcategory name to safely handle spaces and special characters in the pattern.
The pass logic accepts both an exact match (e.g. "Success") and "Success and Failure" because if a server audits both success and failure events, it still meets the CIS requirement for auditing success events.
- Save Invoke-SecurityAudit.ps1.
- Confirm the script has no syntax errors by opening PowerShell on your Windows 11 host and running this command:
$errors = $null
[void][System.Management.Automation.PSParser]::Tokenize((Get-Content "C:\SecurityAudit\Invoke-SecurityAudit.ps1" -Raw), [ref]$errors)
if ($errors.Count -eq 0) { Write-Host "No syntax errors found." -ForegroundColor Green } else { $errors }
You should see "No syntax errors found." printed in green.
Seeing syntax errors in the output?
The error output will include a line number. Open Invoke-SecurityAudit.ps1 in Notepad++ and jump to that line (Ctrl+G). Common causes include missing closing braces, unescaped double quotes inside strings, or a stray character from copy-pasting.
Help me debug a syntax error in my PowerShell security audit script.
✔️ Awesome, I've got everything!
Great. Double check you have saved Invoke-SecurityAudit.ps1 and that the syntax check passed with zero errors.
ⓧ I'd like to double check the full code
Here is the complete Invoke-SecurityAudit.ps1 file at this point:
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Windows Server 2022 Security Audit Script
.DESCRIPTION
Checks local security settings against CIS Benchmark Level 1 recommendations
and generates an HTML compliance report.
.NOTES
Run this script as Administrator on your Windows Server 2022 machine.
#>
param(
[string]$BaselinePath = ".\baseline.json",
[string]$ReportPath = ".\Reports"
)
# --- Load Baseline ---
if (-not (Test-Path $BaselinePath)) {
Write-Error "Baseline file not found: $BaselinePath"
exit 1
}
$Baseline = Get-Content $BaselinePath -Raw | ConvertFrom-Json
# --- Initialize Results Array ---
$Results = @()
# --- Function: Test Password and Account Lockout Policy ---
function Test-SecurityPolicy {
param([object]$Baseline)
$tempFile = "$env:TEMP\secpol_export.inf"
secedit /export /areas securitypolicy /cfg $tempFile /quiet | Out-Null
if (-not (Test-Path $tempFile)) {
Write-Warning "Failed to export security policy."
return @()
}
$policyContent = Get-Content $tempFile -Raw
$results = @()
foreach ($check in $Baseline.PasswordPolicy) {
$pattern = "$($check.Name)\s*=\s*(.+)"
$match = [regex]::Match($policyContent, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
$actualInt = [int]$actual
switch ($check.Name) {
"MinimumPasswordLength" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"PasswordHistorySize" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"MaximumPasswordAge" { if ($actualInt -le $check.Expected -and $actualInt -ne 0) { $status = "PASS" } }
"MinimumPasswordAge" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"PasswordComplexity" { if ($actualInt -eq $check.Expected) { $status = "PASS" } }
"ClearTextPassword" { if ($actualInt -eq $check.Expected) { $status = "PASS" } }
}
}
$results += [PSCustomObject]@{
Category = "Password Policy"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
foreach ($check in $Baseline.AccountLockout) {
$pattern = "$($check.Name)\s*=\s*(.+)"
$match = [regex]::Match($policyContent, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
$actualInt = [int]$actual
switch ($check.Name) {
"LockoutDuration" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"LockoutBadCount" { if ($actualInt -le $check.Expected -and $actualInt -ne 0) { $status = "PASS" } }
"ResetLockoutCount" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
}
}
$results += [PSCustomObject]@{
Category = "Account Lockout"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
return $results
}
# --- Function: Test Firewall Profiles ---
function Test-FirewallCompliance {
param([object]$Baseline)
$results = @()
$profiles = Get-NetFirewallProfile
foreach ($check in $Baseline.FirewallProfiles) {
$profile = $profiles | Where-Object { $_.Name -eq $check.Name }
if ($null -eq $profile) {
$results += [PSCustomObject]@{
Category = "Firewall"
CheckName = $check.Description
Expected = "Enabled: $($check.ExpectedEnabled), Inbound: $($check.ExpectedInboundAction)"
Actual = "Profile not found"
Status = "FAIL"
CISReference = $check.CISReference
}
continue
}
$enabledMatch = $profile.Enabled -eq $check.ExpectedEnabled
$inboundMatch = $profile.DefaultInboundAction -eq $check.ExpectedInboundAction
$status = if ($enabledMatch -and $inboundMatch) { "PASS" } else { "FAIL" }
$results += [PSCustomObject]@{
Category = "Firewall"
CheckName = $check.Description
Expected = "Enabled: $($check.ExpectedEnabled), Inbound: $($check.ExpectedInboundAction)"
Actual = "Enabled: $($profile.Enabled), Inbound: $($profile.DefaultInboundAction)"
Status = $status
CISReference = $check.CISReference
}
}
return $results
}
# --- Function: Test Audit Policy ---
function Test-AuditPolicyCompliance {
param([object]$Baseline)
$results = @()
$auditOutput = auditpol /get /category:* 2>&1 | Out-String
foreach ($check in $Baseline.AuditPolicy) {
$pattern = "$([regex]::Escape($check.Name))\s+(Success and Failure|Success|Failure|No Auditing)"
$match = [regex]::Match($auditOutput, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
if ($actual -eq $check.Expected -or $actual -eq "Success and Failure") {
$status = "PASS"
}
}
$results += [PSCustomObject]@{
Category = "Audit Policy"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
return $results
}
Your three audit functions are ready. Next up, you will build the HTML report generator and wire everything together to produce a professional compliance report.
Generate the HTML Compliance Report
Your three audit functions can now pull real settings from the server and compare them against the baseline. The missing piece is presenting those results in a way that is easy to read and share.
In this step, you'll build a report function that transforms the raw results array into a styled HTML file with color-coded pass/fail indicators. Then you'll wire up the main execution block and run the full audit on your Windows Server 2022 VM.
In this step, get ready to:
- Create an HTML report generation function with embedded CSS styling.
- Add server metadata and compliance statistics to the report header.
- Run the complete audit script on your Windows Server 2022 VM and verify the HTML output.
Create the HTML report generation function
The report function takes the collected results array and produces a complete HTML page. It calculates compliance statistics, embeds CSS for visual styling, builds a summary header, and outputs the file.
- In Notepad++, open Invoke-SecurityAudit.ps1 from your C:\SecurityAudit folder.
- Scroll to the very bottom of the file, below the closing brace of the Test-AuditPolicyCompliance function.
- Add the following New-ComplianceReport function by copying this code below the last function:
# --- Function: Generate HTML Report ---
function New-ComplianceReport {
param(
[array]$Results,
[string]$OutputPath
)
$totalChecks = $Results.Count
$passCount = ($Results | Where-Object { $_.Status -eq "PASS" }).Count
$failCount = $totalChecks - $passCount
$compliancePercent = if ($totalChecks -gt 0) { [math]::Round(($passCount / $totalChecks) * 100, 1) } else { 0 }
$cssStyle = @"
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; background-color: #f5f5f5; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
.summary { background: #fff; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.summary p { margin: 5px 0; font-size: 14px; }
.pass-rate { font-size: 24px; font-weight: bold; }
table { border-collapse: collapse; width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background-color: #2c3e50; color: white; padding: 12px 15px; text-align: left; }
td { padding: 10px 15px; border-bottom: 1px solid #eee; }
tr:hover { background-color: #f8f9fa; }
.PASS { color: #27ae60; font-weight: bold; }
.FAIL { color: #e74c3c; font-weight: bold; }
</style>
"@
$summaryHtml = @"
<div class="summary">
<p>Server: $($env:COMPUTERNAME)</p>
<p>Scan Date: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")</p>
<p>Total Checks: $totalChecks | Passed: $passCount | Failed: $failCount</p>
<p class="pass-rate">Compliance: $compliancePercent%</p>
</div>
"@
$htmlBody = $Results | ForEach-Object {
$statusClass = $_.Status
[PSCustomObject]@{
Category = $_.Category
Check = $_.CheckName
Expected = $_.Expected
Actual = $_.Actual
Status = "<span class='$statusClass'>$($_.Status)</span>"
CISReference = $_.CISReference
}
} | ConvertTo-Html -Fragment
$htmlReport = ConvertTo-Html -Head $cssStyle -Body "$summaryHtml $htmlBody" -Title "Security Audit Report"
$htmlReport | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "Report saved to: $OutputPath" -ForegroundColor Green
}
What does this function do?
This function handles four responsibilities:
- Statistics calculation: $totalChecks, $passCount, $failCount, and $compliancePercent summarize the audit results into numbers you can display.
- CSS styling: The $cssStyle here-string embeds a full stylesheet. The .PASS class renders green text and .FAIL renders red text.
- Summary header: $summaryHtml injects the server name (from $env:COMPUTERNAME), scan date, counts, and overall compliance percentage above the results table.
- Two-pass HTML generation: ConvertTo-Html -Fragment produces just the table markup. A second ConvertTo-Html call wraps it into a full page with the -Head (CSS) and -Body (summary + table) parameters.
- Save Invoke-SecurityAudit.ps1.
You won't see a change yet. The function is defined but nothing calls it until you add the main execution block next.
Wire up the main execution block
The main execution block ties everything together. It calls each audit function, collects the results, and passes them to your report function.
- In Invoke-SecurityAudit.ps1, scroll to the very bottom of the file (below the New-ComplianceReport function you just added).
- Add the following main execution block:
# --- Main Execution ---
Write-Host "Starting security audit..." -ForegroundColor Cyan
$Results += Test-SecurityPolicy -Baseline $Baseline
$Results += Test-FirewallCompliance -Baseline $Baseline
$Results += Test-AuditPolicyCompliance -Baseline $Baseline
if (-not (Test-Path $ReportPath)) {
New-Item -ItemType Directory -Path $ReportPath -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$reportFile = Join-Path $ReportPath "SecurityAudit_$timestamp.html"
New-ComplianceReport -Results $Results -OutputPath $reportFile
Write-Host "`nAudit complete. $($Results.Count) checks performed." -ForegroundColor Cyan
Write-Host "Pass: $(($Results | Where-Object {$_.Status -eq 'PASS'}).Count) | Fail: $(($Results | Where-Object {$_.Status -eq 'FAIL'}).Count)" -ForegroundColor Yellow
What does this block do?
- Calls each audit function (Test-SecurityPolicy, Test-FirewallCompliance, Test-AuditPolicyCompliance) and appends results to the $Results array.
- Creates the Reports directory if it does not already exist.
- Generates a timestamped filename like SecurityAudit_20260703_141500.html so each run produces a unique report.
- Calls New-ComplianceReport to generate the HTML file and prints a summary to the console.
- Save Invoke-SecurityAudit.ps1.
Seeing a syntax error when you save?
- Make sure the here-string closing markers ("@) in the New-ComplianceReport function start at column 1 with no leading spaces. PowerShell requires here-string terminators to be at the very beginning of the line.
- Check that you pasted the main execution block outside and below the closing brace of New-ComplianceReport. If it is inside the function, it will not run at script level.
Help me fix a syntax error in my Invoke-SecurityAudit.ps1 script.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete Invoke-SecurityAudit.ps1 as it should look at this point:
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Windows Server 2022 Security Audit Script
.DESCRIPTION
Checks local security settings against CIS Benchmark Level 1 recommendations
and generates an HTML compliance report.
.NOTES
Run this script as Administrator on your Windows Server 2022 machine.
#>
param(
[string]$BaselinePath = ".\baseline.json",
[string]$ReportPath = ".\Reports"
)
# --- Load Baseline ---
if (-not (Test-Path $BaselinePath)) {
Write-Error "Baseline file not found: $BaselinePath"
exit 1
}
$Baseline = Get-Content $BaselinePath -Raw | ConvertFrom-Json
# --- Initialize Results Array ---
$Results = @()
# --- Function: Test Password and Account Lockout Policy ---
function Test-SecurityPolicy {
param([object]$Baseline)
$tempFile = "$env:TEMP\secpol_export.inf"
secedit /export /areas securitypolicy /cfg $tempFile /quiet | Out-Null
if (-not (Test-Path $tempFile)) {
Write-Warning "Failed to export security policy."
return @()
}
$policyContent = Get-Content $tempFile -Raw
$results = @()
foreach ($check in $Baseline.PasswordPolicy) {
$pattern = "$($check.Name)\s*=\s*(.+)"
$match = [regex]::Match($policyContent, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
$actualInt = [int]$actual
switch ($check.Name) {
"MinimumPasswordLength" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"PasswordHistorySize" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"MaximumPasswordAge" { if ($actualInt -le $check.Expected -and $actualInt -ne 0) { $status = "PASS" } }
"MinimumPasswordAge" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"PasswordComplexity" { if ($actualInt -eq $check.Expected) { $status = "PASS" } }
"ClearTextPassword" { if ($actualInt -eq $check.Expected) { $status = "PASS" } }
}
}
$results += [PSCustomObject]@{
Category = "Password Policy"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
foreach ($check in $Baseline.AccountLockout) {
$pattern = "$($check.Name)\s*=\s*(.+)"
$match = [regex]::Match($policyContent, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
$actualInt = [int]$actual
switch ($check.Name) {
"LockoutDuration" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
"LockoutBadCount" { if ($actualInt -le $check.Expected -and $actualInt -ne 0) { $status = "PASS" } }
"ResetLockoutCount" { if ($actualInt -ge $check.Expected) { $status = "PASS" } }
}
}
$results += [PSCustomObject]@{
Category = "Account Lockout"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
Remove-Item $tempFile -Force -ErrorAction SilentlyContinue
return $results
}
# --- Function: Test Firewall Profiles ---
function Test-FirewallCompliance {
param([object]$Baseline)
$results = @()
$profiles = Get-NetFirewallProfile
foreach ($check in $Baseline.FirewallProfiles) {
$profile = $profiles | Where-Object { $_.Name -eq $check.Name }
if ($null -eq $profile) {
$results += [PSCustomObject]@{
Category = "Firewall"
CheckName = $check.Description
Expected = "Enabled: $($check.ExpectedEnabled), Inbound: $($check.ExpectedInboundAction)"
Actual = "Profile not found"
Status = "FAIL"
CISReference = $check.CISReference
}
continue
}
$enabledMatch = $profile.Enabled -eq $check.ExpectedEnabled
$inboundMatch = $profile.DefaultInboundAction -eq $check.ExpectedInboundAction
$status = if ($enabledMatch -and $inboundMatch) { "PASS" } else { "FAIL" }
$results += [PSCustomObject]@{
Category = "Firewall"
CheckName = $check.Description
Expected = "Enabled: $($check.ExpectedEnabled), Inbound: $($check.ExpectedInboundAction)"
Actual = "Enabled: $($profile.Enabled), Inbound: $($profile.DefaultInboundAction)"
Status = $status
CISReference = $check.CISReference
}
}
return $results
}
# --- Function: Test Audit Policy ---
function Test-AuditPolicyCompliance {
param([object]$Baseline)
$results = @()
$auditOutput = auditpol /get /category:* 2>&1 | Out-String
foreach ($check in $Baseline.AuditPolicy) {
$pattern = "$([regex]::Escape($check.Name))\s+(Success and Failure|Success|Failure|No Auditing)"
$match = [regex]::Match($auditOutput, $pattern)
$actual = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "Not Found" }
$status = "FAIL"
if ($actual -ne "Not Found") {
if ($actual -eq $check.Expected -or $actual -eq "Success and Failure") {
$status = "PASS"
}
}
$results += [PSCustomObject]@{
Category = "Audit Policy"
CheckName = $check.Description
Expected = $check.Expected
Actual = $actual
Status = $status
CISReference = $check.CISReference
}
}
return $results
}
# --- Function: Generate HTML Report ---
function New-ComplianceReport {
param(
[array]$Results,
[string]$OutputPath
)
$totalChecks = $Results.Count
$passCount = ($Results | Where-Object { $_.Status -eq "PASS" }).Count
$failCount = $totalChecks - $passCount
$compliancePercent = if ($totalChecks -gt 0) { [math]::Round(($passCount / $totalChecks) * 100, 1) } else { 0 }
$cssStyle = @"
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 20px; background-color: #f5f5f5; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
.summary { background: #fff; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.summary p { margin: 5px 0; font-size: 14px; }
.pass-rate { font-size: 24px; font-weight: bold; }
table { border-collapse: collapse; width: 100%; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background-color: #2c3e50; color: white; padding: 12px 15px; text-align: left; }
td { padding: 10px 15px; border-bottom: 1px solid #eee; }
tr:hover { background-color: #f8f9fa; }
.PASS { color: #27ae60; font-weight: bold; }
.FAIL { color: #e74c3c; font-weight: bold; }
</style>
"@
$summaryHtml = @"
<div class="summary">
<p>Server: $($env:COMPUTERNAME)</p>
<p>Scan Date: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")</p>
<p>Total Checks: $totalChecks | Passed: $passCount | Failed: $failCount</p>
<p class="pass-rate">Compliance: $compliancePercent%</p>
</div>
"@
$htmlBody = $Results | ForEach-Object {
$statusClass = $_.Status
[PSCustomObject]@{
Category = $_.Category
Check = $_.CheckName
Expected = $_.Expected
Actual = $_.Actual
Status = "<span class='$statusClass'>$($_.Status)</span>"
CISReference = $_.CISReference
}
} | ConvertTo-Html -Fragment
$htmlReport = ConvertTo-Html -Head $cssStyle -Body "$summaryHtml $htmlBody" -Title "Security Audit Report"
$htmlReport | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "Report saved to: $OutputPath" -ForegroundColor Green
}
# --- Main Execution ---
Write-Host "Starting security audit..." -ForegroundColor Cyan
$Results += Test-SecurityPolicy -Baseline $Baseline
$Results += Test-FirewallCompliance -Baseline $Baseline
$Results += Test-AuditPolicyCompliance -Baseline $Baseline
if (-not (Test-Path $ReportPath)) {
New-Item -ItemType Directory -Path $ReportPath -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$reportFile = Join-Path $ReportPath "SecurityAudit_$timestamp.html"
New-ComplianceReport -Results $Results -OutputPath $reportFile
Write-Host "`nAudit complete. $($Results.Count) checks performed." -ForegroundColor Cyan
Write-Host "Pass: $(($Results | Where-Object {$_.Status -eq 'PASS'}).Count) | Fail: $(($Results | Where-Object {$_.Status -eq 'FAIL'}).Count)" -ForegroundColor Yellow
Run the complete audit on your Windows Server 2022 VM
Your script is complete. Now you need to run it on the Windows Server 2022 VM where the security policies actually live.
- Copy the entire C:\SecurityAudit folder from your Windows 11 host into your Windows Server 2022 VM. You can do this via the Hyper-V Enhanced Session clipboard, a shared folder, or by copying files through RDP.
- On your Windows Server 2022 VM, open PowerShell as Administrator. Right-click the Start menu and select Windows PowerShell (Admin).
- Navigate to the project folder by running this command:
cd C:\SecurityAudit
- Run the audit script:
.\Invoke-SecurityAudit.ps1
You should see console output showing the audit progress and a summary of checks performed, passes, and failures.
- Open the generated HTML report in a browser. Run this command to launch it:
Get-ChildItem .\Reports\*.html | Sort-Object LastWriteTime -Descending | Select-Object -First 1 | Invoke-Item
Your browser opens the report. You should see a styled page with the server name, scan date, compliance percentage, and a table of 11 checks with green PASS or red FAIL indicators.
What should I see in the report?
The report header shows your server name, the current date and time, total checks (11), pass/fail counts, and a bold compliance percentage. Below it, a table lists each check by category (Password Policy, Account Lockout, Firewall, Audit Policy) with the expected value, actual value, status, and CIS reference number.
If your server has default settings, you will likely see several FAIL entries. That is expected. The script is detecting where your server does not meet CIS recommendations.
Script fails with an access denied error?
- The #Requires -RunAsAdministrator directive enforces elevated privileges. Make sure you launched PowerShell with Run as Administrator. Both secedit and auditpol require admin access to read security policies.
- If you see a file not found error for baseline.json, confirm your current directory is C:\SecurityAudit by running pwd. The script uses relative paths.
Help me troubleshoot my security audit script failing on Windows Server 2022.
Secret mission
Add Services Audit
Your audit script covers four categories, but CIS Benchmarks flag a fifth: unnecessary services running on the server. Services like Print Spooler have been exploited in real-world attacks (PrintNightmare, anyone?). Can you extend your tool to flag them automatically?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- C:\SecurityAudit folder (Invoke-SecurityAudit.ps1, baseline.json, Reports subfolder with HTML reports).
- Notepad++ installed on your Windows 11 host.
- Windows Server 2022 VM running in Hyper-V (pre-existing, not created by this project).
Keep everything running
No action needed. Choose this if you plan to continue developing the audit script or adding more CIS checks.
- Your Windows Server 2022 VM stays running in Hyper-V, ready for more testing.
- Your C:\SecurityAudit folder remains intact with all scripts, baseline configuration, and generated reports.
- Notepad++ stays installed for future scripting work.
Pause - I'll come back to this later
Shut down your VM to free up system resources while keeping all project files in place.
- Open Hyper-V Manager on your Windows 11 host.
- Right-click your Windows Server 2022 VM and select Shut Down.
- Your C:\SecurityAudit folder and Notepad++ remain untouched for when you return.
Delete - I don't want to use this again
Remove all project files from your machine. Your Windows Server 2022 VM was pre-existing, so only delete it if you no longer need it for other work.
- Delete the project folder by opening PowerShell on your Windows 11 host and running:
Remove-Item -Recurse -Force C:\SecurityAudit
- Uninstall Notepad++ by opening Settings, selecting Apps, then Installed apps. Find Notepad++ in the list, click the three-dot menu, and select Uninstall.
- If you want to remove the Windows Server 2022 VM entirely, open Hyper-V Manager, right-click the VM, select Delete, then manually delete its virtual hard disk files from the storage location shown in the VM settings.
Nice Work!
Nice Work!
Nice work! You just built a production-ready security audit tool that checks your Windows Server 2022 VM against CIS Benchmarks and outputs a professional compliance report.
You've learned how to:
- Built a PowerShell security audit script using secedit, auditpol, and Get-NetFirewallProfile to check server settings against CIS Benchmark Level 1 recommendations.
- Created a JSON-based baseline configuration file that makes the tool reusable and customizable for different compliance standards.
- Generated a professional HTML compliance report with pass/fail indicators and an overall compliance percentage using ConvertTo-Html.
- Secret Mission: Extended the tool to audit unnecessary services like Print Spooler, adding a fifth compliance category to the report.
Ready to quiz yourself?