Using ConfigMgr Compliance to Manage Security Configuration Baselines (Part 3) | Quisitive
Using ConfigMgr Compliance to Manage Security Configuration Baselines (Part 3)
February 7, 2021
Quisitive
This is the third part of our blog series on using ConfgMgr compliance to manage security configuration baselines.

If you haven’t already done so, below are links to the previous posts in this series to give you a chance to go back and read them to get caught up.

And now… on to part 3!

Where we are so far…

In the previous post (part 2) we did the following:

  • Exported (backed up) the Group Policies we wanted to create ConfigMgr Configuration Items from.
  • Generated all of the INF files using the exported Group Policies.

Next, we need to generate two things that we will need for the ConfigMgr Configuration Items.

  • Generate the scripts
    • Setting discovery scripts
    • Setting remediation scripts

Generating the Scripts

We have some choices here, we can manually create each PowerShell script by hand for each setting we need to monitor and enforce compliance on, or we can be smart about it and leverage a single script with functions in it to write the scrips we need for us. Since I dislike having to type so much, we opted for the latter.

For our script, it needs to do the following actions:

  1. Get all of the INF files we generated
  2. Process each one
  3. Output a PowerShell script for setting discovery
  4. Output a PowerShell script for setting remediation

If we ran the script from part 2, all of our INF files should reside in a directory structure in the same path as the script. This makes it very easy to get all of our INF files. We can simply add these three lines at the top of our script.

$CurrentPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$CustomINFPath = Join-Path -Path $CurrentPath -ChildPath 'Custom-INF-Files'
$CustomINFFiles = Get-ChildItem -Path $CustomINFPath -Filter "*.inf" -Recurse

This will get all of the files with the INF file extension under the directory ‘Custom-INF-Files’ and store them in the variable ‘$CustomINFFiles’.

Next, we need to process all of the custom INF files which means we will need a ‘Foreach’ loop… like this.

reach loop examplePowerShell
Foreach ($CustomINFFile in $CustomINFFiles){
    # Code to perform processing goes here
}

So far we have the INF files, and the ‘Foreach’ loop, now we need to add functionality that will actually do what we need. The easiest way would be to leverage functions (or snippets of code already produced and easily located on the internet. Other ways might be to see if people we know have already done something close to what we want to do. Thankfully, we didn’t need to reinvent the wheel in this area.

Since we can’t do file copies from within Configuration Items, we needed a way to copy the INF file for the setting we are checking. To do this we read the INF file into a base64 encoded string and write the string back to a file like this.


# Read the file to base64
$gptTmpl = "<FolderName\<FileName>.inf"
$b = [System.Text.Encoding]::UTF8.GetBytes($gptTmpl)
$b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($gptTmpl))
 
# write the file from base64
$newSettingsFile = "<FolderName\<FileName>.ini"
$b64 = "<EncodedString>"
$b = [System.Convert]::FromBase64String($b64)
$gptTmplString = [System.Text.Encoding]::UTF8.GetString($b)
$gptTmplString | Out-File $newSettingsFile -Force

With a little help from DR Scripto’s Microsoft Dev Blog post ‘Use PowerShell to Work with Any INI File‘ we get the following function.

function Get-IniContent ($filePath)
{
    $ini = @{}
    switch -regex -file $FilePath
    {
        “^\[(.+)\]” # Section
        {
            $section = $matches[1]
            $ini[$section] = @{}
            $CommentCount = 0
        }
        “^(;.*)$” # Comment
        {
            $value = $matches[1]
            $CommentCount = $CommentCount + 1
            $name = “Comment” + $CommentCount
            $ini[$section][$name] = $value
        }
        “(.+?)\s*=(.*)” # Key
        {
            $name,$value = $matches[1..2]
            $ini[$section][$name] = $value
        }
    }
    return $ini
}

With some assistance from Nathan Ziehnert (Twitter: @theznerd), we were able to modify the function from Dr Scripto to create the code needed for our discovery scripts. Sample below…

function Get-IniContent ($filePath)
{
    ## Borrowed from: https://devblogs.microsoft.com/scripting/use-powershell-to-work-with-any-ini-file/
    $ini = @{}
    switch -regex -file $filePath
    {
        '^\[(.+)\]' # Section
        {
            $section = $matches[1]
            $ini[$section] = @{}
            $CommentCount = 0
        }
        '^(;.*)$' # Comment
        {
            $value = $matches[1]
            $CommentCount = $CommentCount + 1
            $name = 'Comment' + $CommentCount
            $ini[$section][$name] = $value
        }
        '(.+?)\s*=(.*)' # Key
        {
            $name,$value = $matches[1..2]
            $ini[$section][$name] = $value
        }
        '^".*$' # Service Key
        {
            $name = $matches[0].Split(',')[0].Replace('"','')
            $value = $matches[0]
            $ini[$section][$name] = $value
        }
    }
    return $ini
}
$b64 = "<base64string>"
$newSettingsFile = "$ENV:Temp\NewSet.ini"
$existingSettingsFile = "$ENV:Temp\ExistingSet.ini"
$b = [System.Convert]::FromBase64String($b64)
$gptTmplString = [System.Text.Encoding]::UTF8.GetString($b)
$gptTmplString | Out-File $newSettingsFile -Force
secedit /export /cfg $existingSettingsFile /quiet
$existingSettings = Get-IniContent -filePath $existingSettingsFile
$newSettings = Get-IniContent -filePath $newSettingsFile
$matches = $true
foreach($key in $newSettings.Keys | Where-Object {$_ -ne "Unicode" -and $_ -ne "Version"})
{
    foreach($subKey in $newSettings[$key].Keys)
    {
        try
        {
            if($existingSettings[$key][$subKey] -and $newSettings[$key][$subKey] -ne $existingSettings[$key][$subKey])
            {
                if((@(Compare-Object ($newSettings[$key][$subKey].Split(",").Replace(" ","") | Sort-Object) ($existingSettings[$key][$subKey].Split(",").Replace(" ","") | Sort-Object)).Length -eq 0) -and $key -eq 'Privilege Rights')
                {}else{$matches = $false}
            }
        }
        catch
        {
            $matches = $false
        }
    }
}
[void](Remove-Item -Path $newSettingsFile -Force)
return $matches

Now we need to perform the remediation. Since we’ve chosen to use ‘secedit.exe’ that is what our PowerShell script will run to import the setting that we’ve determined to be “Compliant” with our organization’s security policy. We’ll first write the file from base64 and then run ‘secedit.exe’ to import it into the ‘secedit.sdb’ (local security policy database) on the target system.

$newSettingsFile = "$ENV:Temp\NewSet.ini"
$b64 = "<base64string>"
$b = [System.Convert]::FromBase64String($b64)
$gptTmplString = [System.Text.Encoding]::UTF8.GetString($b)
$gptTmplString | Out-File $newSettingsFile -Force
secedit /configure /db secedit.sdb /cfg $newSettingsFile /quiet
[void](Remove-Item -Path $newSettingsFile -Force)

Now we just need to pull the function together while being careful to escape special characters where needed so that our discovery and remediation scripts will be written and function properly. (In the interest of maintaining focus, I’m not going to get into the explanation of escaping special characters in PowerShell at this time.) Our final function for generation of our discover and remediation scripts looks like this. (Use the toolbar above the snippet to expand or copy the code)

function New-SecurityPolicyScripts ($gptTmpl)
{
    # Initial Settings to Compare and/or Remediate
    $b  = [System.Text.Encoding]::UTF8.GetBytes($gptTmpl)
    $b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($gptTmpl))
 
    # Create a detection script for the security policy
    # The script exports the current security configuration to a temporary file,
    # exports the expected security configuration to a temporary file, and then
    # compares the two for deviation from the expected security configuration.
    $powershellDetectionScript = @"
        function Get-IniContent (`$filePath)
        {
            ## Borrowed from: https://devblogs.microsoft.com/scripting/use-powershell-to-work-with-any-ini-file/
            `$ini = @{}
            switch -regex -file `$filePath
            {
                '^\[(.+)\]' # Section
                {
                    `$section = `$matches[1]
                    `$ini[`$section] = @{}
                    `$CommentCount = 0
                }
                '^(;.*)`$' # Comment
                {
                    `$value = `$matches[1]
                    `$CommentCount = `$CommentCount + 1
                    `$name = 'Comment' + `$CommentCount
                    `$ini[`$section][`$name] = `$value
                }
                '(.+?)\s*=(.*)' # Key
                {
                    `$name,`$value = `$matches[1..2]
                    `$ini[`$section][`$name] = `$value
                }
                '^".*`$' # Service Key
                {
                    `$name = `$matches[0].Split(',')[0].Replace('"','')
                    `$value = `$matches[0]
                    `$ini[`$section][`$name] = `$value
                }
            }
            return `$ini
        }
        `$b64 = "$b64"
        `$newSettingsFile = "`$ENV:Temp\NewSet.ini"
        `$existingSettingsFile = "`$ENV:Temp\ExistingSet.ini"
        `$b = [System.Convert]::FromBase64String(`$b64)
        `$gptTmplString = [System.Text.Encoding]::UTF8.GetString(`$b)
        `$gptTmplString | Out-File `$newSettingsFile -Force
        secedit /export /cfg `$existingSettingsFile /quiet
        `$existingSettings = Get-IniContent -filePath `$existingSettingsFile
        `$newSettings = Get-IniContent -filePath `$newSettingsFile
        `$matches = `$true
        foreach(`$key in `$newSettings.Keys | Where-Object {`$_ -ne "Unicode" -and `$_ -ne "Version"})
        {
            foreach(`$subKey in `$newSettings[`$key].Keys)
            {
                try
                {
                    if(`$existingSettings[`$key][`$subKey] -and `$newSettings[`$key][`$subKey] -ne `$existingSettings[`$key][`$subKey])
                    {
                        if((@(Compare-Object (`$newSettings[`$key][`$subKey].Split(",").Replace(" ","") | Sort-Object) (`$existingSettings[`$key][`$subKey].Split(",").Replace(" ","") | Sort-Object)).Length -eq 0) -and `$key -eq 'Privilege Rights')
                        {}else{`$matches = `$false}
                    }
                }
                catch
                {
                    `$matches = `$false
                }
            }
        }
        [void](Remove-Item -Path `$newSettingsFile -Force)
        return `$matches
"@
 
    # Create a remediation script for the security policy
    # The script exports the security settings to a temporary file and then
    # imports the security settings into the workstation.
    $powershellRemediationScript = @"
        `$newSettingsFile = "`$ENV:Temp\NewSet.ini"
        `$b64 = "$b64"
        `$b = [System.Convert]::FromBase64String(`$b64)
        `$gptTmplString = [System.Text.Encoding]::UTF8.GetString(`$b)
        `$gptTmplString | Out-File `$newSettingsFile -Force
        secedit /configure /db secedit.sdb /cfg `$newSettingsFile /quiet
        [void](Remove-Item -Path `$newSettingsFile -Force)
"@
    return ($powershellDetectionScript,$powershellRemediationScript)
}

We needed to actually output the generated script files now, so I just used a 3 line function that can be used to write text into any file that accepts text or strings as content.

Function PS1-Write ($PS1File,$PS1String){
    Add-Content $PS1File -Value $PS1String
}

Putting it together

All we need to do now is take our code snippets, add them to the Foreach loop and add a few things to make it all work for us, then save the file as ‘Generate-CMConfigItemScripts.ps1’

(Use the toolbar above the snippet to expand or copy the code)

# Take a security policy (GptTmpl.inf) file and convert it to a detection script
# and remediation script. The GptTmpl.inf is converted to base64 so that it can
# be stored as part of the configuration item.
function New-SecurityPolicyScripts ($gptTmpl)
{
    # Initial Settings to Compare and/or Remediate
    $b  = [System.Text.Encoding]::UTF8.GetBytes($gptTmpl)
    $b64 = [Convert]::ToBase64String([IO.File]::ReadAllBytes($gptTmpl))
 
    # Create a detection script for the security policy
    # The script exports the current security configuration to a temporary file,
    # exports the expected security configuration to a temporary file, and then
    # compares the two for deviation from the expected security configuration.
    $powershellDetectionScript = @"
        function Get-IniContent (`$filePath)
        {
            ## Borrowed from: https://devblogs.microsoft.com/scripting/use-powershell-to-work-with-any-ini-file/
            `$ini = @{}
            switch -regex -file `$filePath
            {
                '^\[(.+)\]' # Section
                {
                    `$section = `$matches[1]
                    `$ini[`$section] = @{}
                    `$CommentCount = 0
                }
                '^(;.*)`$' # Comment
                {
                    `$value = `$matches[1]
                    `$CommentCount = `$CommentCount + 1
                    `$name = 'Comment' + `$CommentCount
                    `$ini[`$section][`$name] = `$value
                }
                '(.+?)\s*=(.*)' # Key
                {
                    `$name,`$value = `$matches[1..2]
                    `$ini[`$section][`$name] = `$value
                }
                '^".*`$' # Service Key
                {
                    `$name = `$matches[0].Split(',')[0].Replace('"','')
                    `$value = `$matches[0]
                    `$ini[`$section][`$name] = `$value
                }
            }
            return `$ini
        }
        `$b64 = "$b64"
        `$newSettingsFile = "`$ENV:Temp\NewSet.ini"
        `$existingSettingsFile = "`$ENV:Temp\ExistingSet.ini"
        `$b = [System.Convert]::FromBase64String(`$b64)
        `$gptTmplString = [System.Text.Encoding]::UTF8.GetString(`$b)
        `$gptTmplString | Out-File `$newSettingsFile -Force
        secedit /export /cfg `$existingSettingsFile /quiet
        `$existingSettings = Get-IniContent -filePath `$existingSettingsFile
        `$newSettings = Get-IniContent -filePath `$newSettingsFile
        `$matches = `$true
        foreach(`$key in `$newSettings.Keys | Where-Object {`$_ -ne "Unicode" -and `$_ -ne "Version"})
        {
            foreach(`$subKey in `$newSettings[`$key].Keys)
            {
                try
                {
                    if(`$existingSettings[`$key][`$subKey] -and `$newSettings[`$key][`$subKey] -ne `$existingSettings[`$key][`$subKey])
                    {
                        if((@(Compare-Object (`$newSettings[`$key][`$subKey].Split(",").Replace(" ","") | Sort-Object) (`$existingSettings[`$key][`$subKey].Split(",").Replace(" ","") | Sort-Object)).Length -eq 0) -and `$key -eq 'Privilege Rights')
                        {}else{`$matches = `$false}
                    }
                }
                catch
                {
                    `$matches = `$false
                }
            }
        }
        [void](Remove-Item -Path `$newSettingsFile -Force)
        return `$matches
"@
 
    # Create a remediation script for the security policy
    # The script exports the security settings to a temporary file and then
    # imports the security settings into the workstation.
    $powershellRemediationScript = @"
        `$newSettingsFile = "`$ENV:Temp\NewSet.ini"
        `$b64 = "$b64"
        `$b = [System.Convert]::FromBase64String(`$b64)
        `$gptTmplString = [System.Text.Encoding]::UTF8.GetString(`$b)
        `$gptTmplString | Out-File `$newSettingsFile -Force
        secedit /configure /db secedit.sdb /cfg `$newSettingsFile /quiet
        [void](Remove-Item -Path `$newSettingsFile -Force)
"@
    return ($powershellDetectionScript,$powershellRemediationScript)
}
 
Function PS1-Write ($PS1File,$PS1String){
    Add-Content $PS1File -Value $PS1String
}
 
$CurrentPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$CustomINFPath = Join-Path -Path $CurrentPath -ChildPath 'Custom-INF-Files'
 
$CustomINFFiles = Get-ChildItem -Path $CustomINFPath -Filter "*.inf" -Recurse # | Select -First 1
#$CustomINFFiles.Count
Foreach ($CustomINFFile in $CustomINFFiles){
    $DiscoveryPS1 = @()
    $RemediationPS1 = @()
    $INFFilePath = $CustomINFFile.FullName
    $INFDir = $INFFilePath.Replace("\$($CustomINFFile.Name)","")
    $ScriptsDir = Join-Path -Path $INFDir -ChildPath 'Scripts'
    If (-not(Test-Path -Path $ScriptsDir)){
        New-Item -Path $INFDir -Name 'Scripts' -ItemType directory -Force
    }
    $BaseScriptName = $CustomINFFile.Name
    $BaseScriptName = $BaseScriptName.Replace('.inf','.ps1')
    $DiscoveryScriptPath = Join-Path -Path $ScriptsDir -ChildPath "Discovery-$($BaseScriptName)"
    $RemediationScriptPath = Join-Path -Path $ScriptsDir -ChildPath "Remediation-$($BaseScriptName)"
    If (Test-Path -Path $DiscoveryScriptPath){
        Remove-Item -Path $DiscoveryScriptPath -Force
    }
        If (Test-Path -Path $RemediationScriptPath){
        Remove-Item -Path $RemediationScriptPath -Force
    }
    $CIScripts = New-SecurityPolicyScripts -gptTmpl "$($INFFilePath)"
    $DiscoveryPS1 = $CIScripts[0]
    $RemediationPS1 = $CIScripts[1]
    PS1-Write -PS1File $DiscoveryScriptPath -PS1String "$($DiscoveryPS1)"
    PS1-Write -PS1File $RemediationScriptPath -PS1String "$($RemediationPS1)"    
}

After running the script we should now have all of the pieces needed to create our ConfigMgr Configuration Items and the settings within them.

In the next and final part, we’ll go through creating the Configuration Items and the Configuration Baselines with remediation enabled…with PowerShell.

Have a great week! See you soon!