Disclaimer: I do not accept responsibility for any issues arising from scripts being run without adequate understanding. It is the user's responsibility to review and assess any code before execution. More information

CIS-CAT v4 Benchmarks and the issues in Cloud-Managed Environments


CIS-CAT Assessor v4 is designed to check compliance by reading local system configuration — most of which is expected to exist as registry keys, local policies, or WMI values.

This assumption falls apart in modern environments where endpoint security is cloud-managed (Intune, Defender for Endpoint, MEM, Azure AD) because:

  • Many Defender, BitLocker, Firewall, Credential Guard, SmartScreen, and Windows Update policies are applied via MDM, not Group Policy.
  • MDM does not always create registry keys or local policy values, yet the system is still compliant and enforced.
  • CIS-CAT v4 only checks the local endpoint using hard-coded checks defined in the benchmark — it does not query Intune, PowerShell CSP, WMI providers, or Defender APIs.
Result? A device that is fully compliant in reality will still score 40–60% in CIS-CAT.

To make this worse, v4:

  • Does not support exceptions or exclusions (unlike v3).
  • Cannot detect cloud policies properly.
  • Produces HTML reports that appear to show system failure, even when it is secure.

Why You Cannot Fully Trust the Intune Portal for Compliance Status

When using Intune (Microsoft Endpoint Manager) to manage Windows 10/11 devices, the compliance and configuration status you see in the web portal cannot always be trusted as an accurate reflection of the actual state of the device.

There are several reasons why:

1. Policy Shows “Conflict”, But Device is Actually Compliant

You’ll regularly see policies marked as Conflict in the Intune admin portal.
But when you click into the policy → Device status → The same device reports “Succeeded”.

This raises two questions:

  • Did it apply or not?
  • Is the conflict real or is the portal UI confused?

In many cases, this is due to:

  • Multiple policy types targeting the same setting (e.g., Intune MDM policy vs Security Baseline vs Configuration Profile).
  • CSP policy and legacy ADMX templates overlapping.
  • Out-of-order reporting timestamps from device → Azure → Portal rendering.

2. Policy Shows “Error”, But Inside Shows “Succeeded”

Another bug you’ll see:

  • At the overview level, a policy shows Error (Failed to apply).
  • You go deeper into the policy → Device report → The exact same device is listed under Status: Succeeded.

This is not the device failing; this is the Intune portal UI being unreliable.

3. Intune Portal ≠ Local Reality

This is why portal-based compliance is not enough, you must verify the local configuration directly on the device, using PowerShell, WMI, Defender API, BitLocker APIs, etc.

This is exactly why the local verification PowerShell scripts are required — because the HTML report from CIS and the Intune web portal can both be wrong, while the device itself is configured correctly.

Required Permissions to Check a Device Locally

Most Intune-managed devices are:

  • Azure AD Joined / Entra ID Joined
  • MDM-Controlled (Intune)
  • NOT domain-joined
  • And critically — the user may not be a local administrator

To run local PowerShell scripts that check Defender, Firewall, BitLocker, or Security CSP values, you must have local administrator rights on the device.

How to get local admin rights on Intune devices

You need to be assigned the following role in Entra ID (Azure AD): Azure AD Joined Device Local Administrator and it needs to be activated.

This role (or any of the below) grants local administrator rights on all Azure AD-joined devices:

Without this role:

  • PowerShell cannot access BitLocker status (access denied).
  • Defender commands will return Access Denied / Insufficient Privileges.
  • Checking Secure Boot, Firewall, Credential Guard will fail.
  • CIS-CAT won’t be able to run local checks either.

Why Not Use CIS-CAT v3?

You might think “Fine, I’ll use v3 and exceptions”… except you will not be fine, let look at why:

Problem CIS-CAT v3
Exceptions allowed? ✅ Yes
Supports Windows 11 benchmarks? ❌ No
Supports latest CIS Benchmarks v4.x? ❌ No
Cloud/Intune-aware checks? ❌ No
BitLocker / Defender / Credential Guard modern CSP detection? ❌ No

So you end up stuck between the metaphorical rock and a hard place:

  • v3 → Exceptions but outdated, wrong benchmark, wrong OS.
  • v4 → Correct benchmark but wrong detection method and no exclusions.

Verify Locally with PowerShell

If the benchmark engine cannot detect modern policy enforcement, you must manually check system state using PowerShell, with your local administrator role activated.

These checks read real system components:

  1. Not registry-based where registry is meaningless (cloud-controlled)
  2. Not Intune API-based — this is purely local verification on the machine
  3. Uses Defender, BitLocker, Firewall, WMI, CIM, and Windows Security APIs

PowerShell — Local Validation Script

# ===========================
# LOCAL SECURITY VALIDATION
# For devices managed by Intune
# ===========================

$result = @{}

# --- System Info ---
$result.System = @{
    ComputerName = $env:COMPUTERNAME
    OSVersion    = (Get-CimInstance Win32_OperatingSystem).Version
    Build        = (Get-CimInstance Win32_OperatingSystem).BuildNumber
    UptimeHours  = [math]::Round((New-TimeSpan -Start (Get-CimInstance Win32_OperatingSystem).LastBootUpTime).TotalHours,1)
}

# --- Microsoft Defender Status ---
try {
    $mp = Get-MpComputerStatus
    $result.Defender = @{
        AntivirusEnabled          = $mp.AntivirusEnabled
        RealTimeProtectionEnabled = $mp.RealTimeProtectionEnabled
        BehaviorMonitorEnabled    = $mp.BehaviorMonitorEnabled
        AMServiceEnabled          = $mp.AMServiceEnabled
        NISServiceEnabled         = $mp.NISServiceEnabled
        EngineVersion             = $mp.AMProductVersion
        SignatureVersion          = $mp.AntivirusSignatureVersion
    }
} catch {
    $result.Defender = "Defender not available or tamper protected"
}

# --- Defender Antivirus Preferences (Exclusions, Tamper Protection unavailable locally) ---
try {
    $pref = Get-MpPreference
    $result.DefenderPreferences = @{
        ExclusionPaths       = $pref.ExclusionPath
        ExclusionExtensions  = $pref.ExclusionExtension
        MAPSReporting        = $pref.MAPSReporting
        SubmitSamples        = $pref.SubmitSamplesConsent
    }
} catch {}

# --- Windows Firewall Profiles ---
$result.Firewall = Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction

# --- BitLocker Drive Encryption ---
try {
    $bit = Get-BitLockerVolume
    $result.BitLocker = foreach ($v in $bit) {
        [pscustomobject]@{
            MountPoint      = $v.MountPoint
            VolumeStatus    = $v.VolumeStatus
            Protection      = $v.ProtectionStatus
            Encryption      = "$($v.EncryptionPercentage)%"
        }
    }
} catch {
    $result.BitLocker = "BitLocker not supported or no admin rights"
}

# --- Secure Boot / UEFI ---
try {
    $sb = Confirm-SecureBootUEFI
    $result.SecureBoot = $sb
} catch {
    $result.SecureBoot = "Not UEFI or cannot confirm (remote/VM/non-admin)"
}

# --- Windows Update Service & Config ---
$result.WindowsUpdate = @{
    ServiceState = (Get-Service wuauserv).Status
    LastUpdate   = (Get-WindowsUpdateLog | Select-Object -Last 1)
}

# --- Output as JSON for evidence ---
$file = "local_security_check_$($result.System.ComputerName)_$(Get-Date -f 'yyyyMMdd_HHmm').json"
$result | ConvertTo-Json -Depth 5 | Out-File $file -Encoding UTF8

Write-Host "Local check complete. Saved to $file" -ForegroundColor Green

Once you have manually verified the actual settings, by using the above script or your own, then you need to obtain the baseline report from CIS-CAT Pro, so lets do that next.

Running CIS-CAT to get the Benchmark

I will be using the CLI to complete the options, rather than the GUI, and I have saved the Assessor to a folder called c:\Assessor which means the CLI option will be in that folder, so first we need to run the report to get the HTML output as below:

cd C:\Assessor
.\Assessor-CLI.bat -b "benchmarks\CIS_Microsoft_Windows_11_Enterprise_Benchmark_v4.0.0-xccdf.xml" -html -p "Level 2 – Windows 11 Enterprise" -r "reports"

This will then proceed to run the assessment that will be missing all your cloud settings as they are not save to the registry which is where CIS-CAT Pro looks for the "evidence" so this report the baseline we need to edit.

Cropping the Baseline Report

The next action is to trim the baseline report, the full report contains full details of the settings and how to set these settings with an explanation which means the resulting HTML is over 12MB which is awful, so as we are focussed on the % score we need to trim this report to only show the summary table.

The rest of the report does not need to be there for editing, so lets get a script to complete that exact action, the script is below:

Script : TrimReport.ps1

 # --- CONFIG ---
$reportHtmlPath = Get-ChildItem "reports\Win11_L2_BitLocker_Report*.html" |
                  Sort-Object LastWriteTime -Descending | Select-Object -First 1

if (-not $reportHtmlPath) {
    Write-Host "No HTML report found!" -ForegroundColor Red
    exit
}

Write-Host "Processing report: $($reportHtmlPath.FullName)" -ForegroundColor Cyan

$html = Get-Content $reportHtmlPath.FullName -Raw

# --- EXTRACT SUMMARY TABLE ---
# Look for any table containing "Pass" and "Fail" in headers
$summaryPattern = "<table[\s\S]*?<th[^>]*>\s*Pass\s*</th>[\s\S]*?<th[^>]*>\s*Fail\s*</th>[\s\S]*?</table>"

$summaryMatch = [regex]::Match($html, $summaryPattern, [Text.RegularExpressions.RegexOptions]::IgnoreCase)

if (-not $summaryMatch.Success) {
    Write-Host "Summary table not found!" -ForegroundColor Red
    exit
}

$summaryTableHtml = $summaryMatch.Value

# --- EXTRACT COVER SHEET INFO ---
# Keep everything from <body> to just before the summary table
$bodyPattern = "(<body[^>]*>[\s\S]*?)(?=" + [regex]::Escape($summaryTableHtml) + ")"

$bodyMatch = [regex]::Match($html, $bodyPattern, [Text.RegularExpressions.RegexOptions]::IgnoreCase)

if (-not $bodyMatch.Success) {
    Write-Host "Body/Cover info not found!" -ForegroundColor Red
    exit
}

$coverHtml = $bodyMatch.Value

# --- EXTRACT HEAD ---
$headPattern = "(<head[^>]*>[\s\S]*?</head>)"
$headMatch = [regex]::Match($html, $headPattern, [Text.RegularExpressions.RegexOptions]::IgnoreCase)

# --- BUILD TRIMMED HTML ---
$trimmedHtml = "<!DOCTYPE html>`n"
if ($headMatch.Success) {
    $trimmedHtml += $headMatch.Value
}
$trimmedHtml += "`n<body>`n"
$trimmedHtml += $coverHtml
$trimmedHtml += $summaryTableHtml
$trimmedHtml += "`n</body>`n</html>"

# --- SAVE TRIMMED REPORT ---
$outputFile = $reportHtmlPath.FullName -replace '\.html$', '_SUMMARY.html'
$trimmedHtml | Set-Content $outputFile

Write-Host "Trimmed summary report saved to: $outputFile" -ForegroundColor Green 

This script will need to be placed in the C:\Assessor folder (of the location of the Assessor software) then you can run the script from that folder and it will look in the reports folder for the name of the benchmark export we obtained with the last command.

This will then produce the same cover page as below:


However unlike the original report this will

You can see the difference between the original and the "trimmed" version that is much easier to work with:


This means the report will end with the summary table as you can see below, it removed all the other details not required for the scoring, here as CISCAT-Pro cannot effectively scan the "cloud" based settings the score is less than optimal:


Recalculating the % score

We now have the summary table from the report which includes all the grades and percentages then an overall percentage for the build, however this does not take into account your cloud settings so nexy you need to confirm those policies with the Powershell script (this is above) then when you have the confirmed the settings you can then exclude those "rules" from the summary table to reflect the actual status of the laptop.

Important : I would suggest only people that are validated and authorised to override the report are allowed to use this script else you will find people will override all the settings when not confirmed to "artificially inflate" the score

First you need to know which sections from your earlier Powershell script are enforced, then you need to find these rules in the report and take a note of all the numbers, so as an example for Bitlocker this is what the report looks like:


If the Powershell command has confirmed that Bitlocker is enabled with fixed and operating system drives then you need the codes 18.10.10, 18.10.10.1, 18.10.10.2 - this will be used in the script below under the $rulestofix variables - this will amend the score to the correct values.

Script : FixReport.ps1

# Fix CIS Report
$summaryHtmlPath = Get-ChildItem "reports\*_SUMMARY.html" | Sort-Object LastWriteTime -Descending | Select-Object -First 1

if (-not $summaryHtmlPath) {
    Write-Host "No summary HTML found!" -ForegroundColor Red
    exit
}

Write-Host "Processing: $($summaryHtmlPath.FullName)" -ForegroundColor Cyan

# Read file line by line
$lines = Get-Content $summaryHtmlPath.FullName

# Rules to fix once verified by admin!
$rulesToFix = @(
    "18.10.10",
    "18.10.10.1", 
    "18.10.10.2",
    "18.10.43.1",
    "18.10.43.2", 
    "18.10.43.3",
    "18.10.43.4",
    "18.10.43.5",
    "18.10.43.6",
    "9",
    "9.1",
    "9.2", 
    "9.3"
)

$totalFailsMoved = 0
$totalPointsMoved = 0
$rulesFixed = 0

# Process line by line
for ($i = 0; $i -lt $lines.Count; $i++) {
    $line = $lines[$i]
    
    # Check if this line contains one of our rules
    foreach ($rule in $rulesToFix) {
        # Match the exact rule number pattern
        if ($line -match ">$rule\s*<" -and $line -match "href=") {
            Write-Host "`nFound rule $rule at line $i" -ForegroundColor Yellow
            
            # Find the start of this row
            $rowStart = $i
            while ($rowStart -gt 0 -and $lines[$rowStart] -notmatch "<tr>") {
                $rowStart--
            }
            
            # Extract values from the next lines
            $values = @()
            for ($j = $i + 1; $j -lt $lines.Count -and $j -lt ($i + 20); $j++) {
                if ($lines[$j] -match 'class="numeric' -and $lines[$j] -match '>([^<]+)</td>') {
                    $values += $Matches[1]
                }
                if ($values.Count -ge 9) { break }
            }
            
            if ($values.Count -ge 9) {
                # Parse values
                $pass = [int]$values[0]
                $fail = [int]$values[1]
                $passPoints = [double]$values[6]
                $totalPoints = [double]$values[7]
                $score = $values[8]
                
                Write-Host "  Current: Pass=$pass, Fail=$fail, PassPts=$passPoints, TotalPts=$totalPoints, Score=$score" -ForegroundColor Cyan
                
                # Check if we need to fix
                if ($fail -gt 0 -or ($totalPoints -gt 0 -and $passPoints -lt $totalPoints)) {
                    # Update values
                    $newPass = $pass + $fail
                    $newFail = 0
                    $newPassPoints = $totalPoints
                    $newScore = if ($totalPoints -gt 0) { "100%" } else { "0%" }
                    
                    # Replace values in the lines
                    $valueIndex = 0
                    for ($j = $i + 1; $j -lt $lines.Count -and $valueIndex -lt 9; $j++) {
                        if ($lines[$j] -match 'class="numeric' -and $lines[$j] -match '>([^<]+)</td>') {
                            $oldValue = $Matches[1]
                            switch ($valueIndex) {
                                0 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newPass</td>" }
                                1 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newFail</td>" }
                                6 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newPassPoints</td>" }
                                8 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newScore</td>" }
                            }
                            $valueIndex++
                        }
                    }
                    
                    Write-Host "  Updated: Pass=$newPass, Fail=$newFail, PassPts=$newPassPoints, Score=$newScore" -ForegroundColor Green
                    
                    $totalFailsMoved += $fail
                    $totalPointsMoved += ($totalPoints - $passPoints)
                    $rulesFixed++
                } else {
                    Write-Host "  No changes needed" -ForegroundColor Gray
                }
            } else {
                Write-Host "  Could not extract values" -ForegroundColor Red
            }
            
            break  # Move to next rule
        }
    }
}

# Fix the total row
Write-Host "`nUpdating totals..." -ForegroundColor Yellow

for ($i = 0; $i -lt $lines.Count; $i++) {
    if ($lines[$i] -match ">Total</th>") {
        Write-Host "Found totals at line $i" -ForegroundColor Green
        
        # Extract total values
        $values = @()
        for ($j = $i + 1; $j -lt $lines.Count -and $j -lt ($i + 20); $j++) {
            if ($lines[$j] -match 'class="numeric.*bold' -and $lines[$j] -match '>([^<]+)</td>') {
                $values += $Matches[1]
            }
            if ($values.Count -ge 9) { break }
        }
        
        if ($values.Count -ge 9) {
            $oldPass = [int]$values[0]
            $oldFail = [int]$values[1]
            $oldPassPoints = [double]$values[6]
            $oldTotalPoints = [double]$values[7]
            
            Write-Host "  Current: Pass=$oldPass, Fail=$oldFail, PassPts=$oldPassPoints, TotalPts=$oldTotalPoints" -ForegroundColor Cyan
            
            # Calculate new totals
            $newTotalPass = $oldPass + $totalFailsMoved
            $newTotalFail = $oldFail - $totalFailsMoved
            $newTotalPassPoints = $oldPassPoints + $totalPointsMoved
            $newTotalScore = [math]::Round(($newTotalPassPoints / $oldTotalPoints) * 100, 0).ToString() + "%"
            
            # Update the lines
            $valueIndex = 0
            for ($j = $i + 1; $j -lt $lines.Count -and $valueIndex -lt 9; $j++) {
                if ($lines[$j] -match 'class="numeric.*bold' -and $lines[$j] -match '>([^<]+)</td>') {
                    $oldValue = $Matches[1]
                    switch ($valueIndex) {
                        0 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newTotalPass</td>" }
                        1 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newTotalFail</td>" }
                        6 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newTotalPassPoints</td>" }
                        8 { $lines[$j] = $lines[$j] -replace ">$oldValue</td>", ">$newTotalScore</td>" }
                    }
                    $valueIndex++
                }
            }
            
            Write-Host "  New: Pass=$newTotalPass, Fail=$newTotalFail, PassPts=$newTotalPassPoints, Score=$newTotalScore" -ForegroundColor Green
        }
        
        break
    }
}

# Save the result
$outputFile = $summaryHtmlPath.FullName -replace '_SUMMARY\.html$', '_FIXED_WORKING.html'
$lines | Set-Content $outputFile -Encoding UTF8

Write-Host "`n========== SUMMARY ==========" -ForegroundColor Cyan
Write-Host "Rules fixed: $rulesFixed" -ForegroundColor Green
Write-Host "Tests moved: $totalFailsMoved" -ForegroundColor Green
Write-Host "Points added: $totalPointsMoved" -ForegroundColor Green
Write-Host "Output: $outputFile" -ForegroundColor Green
Write-Host "=============================" -ForegroundColor Cyan

Approved "fixed" report

I have opted to use a watermarked report the includes the name of the approver and a hash value to confirm its official and its protected from editing the website in F12 (DevTools) and that will invalidate the bookmark this is keep the authenticity of the report.

I personally like the approval version with a watermark and a timestamp with the user that has approved the rule overrides this also generated a hash as well so if its tampered it know this edit has occurred.

This is what the reports look like when the watermarked report is run as the header:


You can then see the integrity starting 5 seconds after the html form has loaded:


If you try to edit certain part of the HTML or remove the watermark after a couple of seconds you will see this:


You can check the problem in the console log, which can be completed with F12 then the Console icon as below:



This will tell you that the computer name mismatches as you can see below:


This is protected as the script that "authorises" the exclusions to create this files, stores the score, percentages, username and time in a hashed value, when these are changed they alter the hash and the report is invalid.

Previous Post Next Post

نموذج الاتصال