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.
- Using ConfigMgr Compliance to Manage Security Configuration Baselines (Part 1)
- Using ConfigMgr Compliance to Manage Security Configuration Baselines (Part 2)
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:
- Get all of the INF files we generated
- Process each one
- Output a PowerShell script for setting discovery
- 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!
n part 1, we set the stage for the work we are about to do. We briefly went over the items that led up to our decisions. In the next parts, we’ll walk you through what we did. If you would like, you can go back and read Using ConfigMgr Compliance to Manage Security Configuration Baselines (Part 1) to get caught up.
Active Directory Group Policy
We need to get the settings that were already configured within the domain so that we can create the needed INF file templates for the non-registry policy settings.
To do this, let’s fire up an elevated PowerShell session and do the following:
If you know the name of the GPO you are looking for, you can simply export it to the desired location of your choice. Like this…
Backup-GPO -Domain contoso.com -Name "Default Workstation Policy" -Path "C:\Temp\GPOExports\MyPolicy"
If you don’t know the name of the policy you are looking for, you can get the names using the following…
(Get-GPO -Domain contoso.com -All).DisplayName
Or, if we only know part of the GPO name, we can search for all of those that have the portion of the name we remember in it. Example – to get all GPOs that contain the word ‘Default’ in the name…
(Get-GPO -Domain contoso.com -All | ?{$_.DisplayName -like "*Default*"}).DisplayName
But what if we want to have a choice of exporting ALL Group Policies, or just those with a specific word or term in their name? Well, we would script that. The script might look something like this (The script below is the same script we used for our customer. I’m just placing it here for others to use if they wish.) By the way, you can also copy the code below and save it as ‘Export-GroupPolicyObjects.ps1’. It can be used to backup GPOs in the future as well.
[CmdletBinding(DefaultParameterSetName = 'AllGPO')]
param
(
[Parameter(Mandatory = $true)]
[string]$Domain,
[Parameter(ParameterSetName = 'AllGPO',
Mandatory = $false)]
[switch]$AllGPO,
[Parameter(ParameterSetName = 'SearchGPO',
Mandatory = $false)]
[switch]$SearchGPO,
[Parameter(ParameterSetName = 'SearchGPO',
Mandatory = $true)]
[string]$SearchGPOName
)
# Function to export GPOs from a domain
Function Export-GPO ($GPOName, $TargetDomain)
{
# Define the GPO Export root
$ExportPath = "$($env:SystemDrive)\Temp\GPOExports"
Write-Verbose -Message "GPO Export root is '$($ExportPath)'"
# Define the Export path for the GPO
$GPOExportPath = Join-Path -Path $ExportPath -ChildPath $GPO.DisplayName
Write-Verbose -Message "GPO Export destination path is '$($GPOExportPath)'"
# Check that the export path exists, create it if it is not there
$CheckPath = Test-Path -Path $GPOExportPath
Write-Verbose -Message "GPO Export destination path exists: $($CheckPath)"
If (-not ($CheckPath))
{
Write-Verbose -Message "Creating path - '$($GPOExportPath)'"
$CreatePath = New-Item -Path $ExportPath -Name $GPOName -ItemType directory -Force
}
# Export the GPO
Write-Verbose -Message "Exporting GPO '$($GPOName)' to '$($GPOExportPath)'"
$ExportGPO = Get-GPO -Domain $TargetDomain -Name "$($GPOName)" | Backup-GPO -Path "$($GPOExportPath)"
}
# If 'All' is specified get all of the GPOs, else get only the GPOs that have the search term in the name from '$SearchGPOName'
If ($AllGPO)
{
# Get all of the GPOs in the domain
Write-Verbose -Message "Getting all Group Policy Objects from the domain '$($Domain)'"
$GPOs = Get-GPO -Domain $Domain -All
Write-Verbose -Message "Found $($GPOs.count) Group Policy Objects in '$($Domain)'"
}
Else
{
# Get the GPOs that contain the value specified at the command-line for '$SearchGPOName'
Write-Verbose -Message "Getting all Group Policy Objects with '$($SearchGPOName)' in the name from the domain '$($Domain)'"
$GPOs = Get-GPO -Domain $Domain -All | ?{ $_.DisplayName -like "*$($SearchGPOName)*" }
Write-Verbose -Message "Found $($GPOs.count) Group Policy Objects in '$($Domain)'"
}
# Process the list of GPOs
Foreach ($GPO in $GPOs) {
# Define the GPO Name for the function
$GPOName = "$($GPO.DisplayName)"
Write-Verbose -Message "Processing GPO '$($GPOName)'"
# Call the export-gpo function
Write-Verbose -Message "Calling function 'Export-GPO' for '$($GPOName)' in domain '$($Domain)'"
$ProcessGPO = Export-GPO -GPOName $GPOName -TargetDomain $Domain
}
p
.\Export-GroupPolicyObjects.ps1 -Domain contoso.com -SearchGPO -SearchGPOName Workstation -Verbose
p
param
(
[Parameter(Mandatory = $true)]
[string]$GPOExportDir
)
function parseInfFile
{
# Funtion code borrowed from https://www.dev4sys.com/2016/05/how-to-parse-ini-file-in-powershell.html and renamed for use with INF files
[CmdletBinding()]
param (
[Parameter(Position = 0)]
[String]$Inputfile
)
if ($Inputfile -eq "")
{
Write-Error "Inf File Parser: No file specified or selected to parse."
Break
}
else
{
$ContentFile = Get-Content $Inputfile
# commented Section
$COMMENT_CHARACTERS = ";"
# match section header
$HEADER_REGEX = "\[+[A-Z0-9._ %<>/#+-]+\]"
$OccurenceOfComment = 0
$ContentComment = $ContentFile | Where { ($_ -match "^\s*$COMMENT_CHARACTERS") -or ($_ -match "^$COMMENT_CHARACTERS") } | % {
[PSCustomObject]@{
Comment = $_;
Index = [Array]::IndexOf($ContentFile, $_)
}
$OccurenceOfComment++
}
$COMMENT_INF = @()
foreach ($COMMENT_ELEMENT in $ContentComment) {
$COMMENT_OBJ = New-Object PSObject
$COMMENT_OBJ | Add-Member -type NoteProperty -name Index -value $COMMENT_ELEMENT.Index
$COMMENT_OBJ | Add-Member -type NoteProperty -name Comment -value $COMMENT_ELEMENT.Comment
$COMMENT_INI += $COMMENT_OBJ
}
$CONTENT_USEFUL = $ContentFile | Where { ($_ -notmatch "^\s*$COMMENT_CHARACTERS") -or ($_ -notmatch "^$COMMENT_CHARACTERS") }
$ALL_SECTION_HASHTABLE = $CONTENT_USEFUL | Where { $_ -match $HEADER_REGEX } | % { [PSCustomObject]@{ Section = $_; Index = [Array]::IndexOf($CONTENT_USEFUL, $_) } }
#$ContentUncomment | Select-String -AllMatches $HEADER_REGEX | Select-Object -ExpandProperty Matches
$SECTION_INF = @()
foreach ($SECTION_ELEMENT in $ALL_SECTION_HASHTABLE) {
$SECTION_OBJ = New-Object PSObject
$SECTION_OBJ | Add-Member -type NoteProperty -name Index -value $SECTION_ELEMENT.Index
$SECTION_OBJ | Add-Member -type NoteProperty -name Section -value $SECTION_ELEMENT.Section
$SECTION_INF += $SECTION_OBJ
}
$INF_FILE_CONTENT = @()
$NBR_OF_SECTION = $SECTION_INF.count
$NBR_MAX_LINE = $CONTENT_USEFUL.count
#*********************************************
# select each lines and value of each section
#*********************************************
for ($i = 1; $i -le $NBR_OF_SECTION; $i++)
{
if ($i -ne $NBR_OF_SECTION)
{
if (($SECTION_INF[$i - 1].Index + 1) -eq ($SECTION_INF[$i].Index))
{
$CONVERTED_OBJ = @() #There is nothing between the two section
}
else
{
$SECTION_STRING = $CONTENT_USEFUL | Select-Object -Index (($SECTION_INF[$i - 1].Index + 1) .. ($SECTION_INF[$i].Index - 1)) | Out-String
$CONVERTED_OBJ = convertfrom-stringdata -stringdata $SECTION_STRING
}
}
else
{
if (($SECTION_INF[$i - 1].Index + 1) -eq $NBR_MAX_LINE)
{
$CONVERTED_OBJ = @() #There is nothing between the two section
}
else
{
$SECTION_STRING = $CONTENT_USEFUL | Select-Object -Index (($SECTION_INF[$i - 1].Index + 1) .. ($NBR_MAX_LINE - 1)) | Out-String
$CONVERTED_OBJ = convertfrom-stringdata -stringdata $SECTION_STRING
}
}
$CURRENT_SECTION = New-Object PSObject
$CURRENT_SECTION | Add-Member -Type NoteProperty -Name Section -Value $SECTION_INF[$i-1].Section
$CURRENT_SECTION | Add-Member -Type NoteProperty -Name Content -Value $CONVERTED_OBJ
$INF_FILE_CONTENT += $CURRENT_SECTION
}
return $INF_FILE_CONTENT
}
}
# Function to write lines in the custom INF files
Function INFWrite
{
Param ([String]$INFString)
Add-Content $INFFile -Value $INFString
}
# Function to output the custom INF files
Function Create-CustomINF ($INFPart, $GPOName)
{
$Section = $INFPart.Section
Switch ($Section)
{
'[Registry Values]' {
Write-Host " Processing INF section '$($Section)'..."
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
$Contents = $INFPart.Content
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$Names = $Key.Split('\')
$Name = $Names[$Names.Count - 1]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Name).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Service General Setting]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[System Access]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Privilege Rights]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Registry Keys]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Kerberos Policy]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
}
}
$CurrentPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$CustomINFPath = Join-Path -Path $CurrentPath -ChildPath 'Custom-INF-Files'
If (-not (Test-Path -Path $CustomINFPath))
{
$CreateDir = New-Item -Path $CurrentPath -Name 'Custom-INF-Files' -ItemType directory
}
$GPOFiles = Get-ChildItem -Path "$($GPOExportDir)" -Filter "GptTmpl.inf" -Recurse
Write-Host "$($GPOFiles.Count) files are available to process" -ForegroundColor Green
Foreach ($GPOFile in $GPOFiles) {
$PolName = ($GPOFile.FullName).Split('\')
$GPOName = $PolName[$PolName.Count - 9]
Write-Host " Processing INF file for GPO '$($GPOName)' if found..." -ForegroundColor Green
$INFParts = parseInfFile -Inputfile $GPOFile.FullName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
$INFParts = $INFParts | Sort-Object Section -Descending
Foreach ($INFPart in $INFParts) {
$GPOShortName = $GPOName.Replace(" ", "_")
If (-not (Test-Path -Path "$($CustomINFPath)\$($GPOShortName)"))
{
$CreateDir = New-Item -Path $CustomINFPath -Name "$($GPOShortName)" -ItemType directory
}
Create-CustomINF -INFPart $INFPart -GPOName $GPOName
}
}
If we run the above script with the following command-line
.\Export-GroupPolicyObjects.ps1 -Domain contoso.com -SearchGPO -SearchGPOName Workstation -Verbose
We see this output…

OK, so now we have our GPO exported to “C:\Temp\GPOExports\Default Workstation Policy”. Let’s go take a look at the INF file. The file we need from the GPO is ‘GptTmpl.inf’. It should be located in “C:\Temp\GPOExports\<GPOName>\<GPOGuid>\DomainSysvol\GPO\Machine\Microsoft\Windows NT\SecEdit”.
First, let’s tackle the User Rights Assignments since they won’t be converted to ConfigMgr Configuration Items with the script.
Open the ‘GptTmpl.inf’ file with notepad to edit it. We need to remove all of the lines we don’t need.
You’ll notice that the INF is broken up into sections with each section header specified between the square brackets “[ ]”. There are three (3) sections we are interested in. They are:
- Unicode
- Version
- Privilege Rights
All of the other sections can be removed. Once this is done, you should have a file that looks similar to below.

Depending upon your organization’s security requirements, there may be more or less entries. Save the file as ‘UserRights.inf’ in the folder “C:\Temp\INF Files” so that we can create our individual custom INF files for use in our compliance remediation scripts.
Another way to create the custom INF files for our use is to leverage a script that will go through and generate them for us. The script code below will do just that. Just copy and save the code as “Generate-AllCustomINFFiles.ps1”
(
[Parameter(Mandatory = $true)]
[string]$GPOExportDir
)
function parseInfFile
{
# Funtion code borrowed from https://www.dev4sys.com/2016/05/how-to-parse-ini-file-in-powershell.html and renamed for use with INF files
[CmdletBinding()]
param (
[Parameter(Position = 0)]
[String]$Inputfile
)
if ($Inputfile -eq "")
{
Write-Error "Inf File Parser: No file specified or selected to parse."
Break
}
else
{
$ContentFile = Get-Content $Inputfile
# commented Section
$COMMENT_CHARACTERS = ";"
# match section header
$HEADER_REGEX = "\[+[A-Z0-9._ %<>/#+-]+\]"
$OccurenceOfComment = 0
$ContentComment = $ContentFile | Where { ($_ -match "^\s*$COMMENT_CHARACTERS") -or ($_ -match "^$COMMENT_CHARACTERS") } | % {
[PSCustomObject]@{
Comment = $_;
Index = [Array]::IndexOf($ContentFile, $_)
}
$OccurenceOfComment++
}
$COMMENT_INF = @()
foreach ($COMMENT_ELEMENT in $ContentComment) {
$COMMENT_OBJ = New-Object PSObject
$COMMENT_OBJ | Add-Member -type NoteProperty -name Index -value $COMMENT_ELEMENT.Index
$COMMENT_OBJ | Add-Member -type NoteProperty -name Comment -value $COMMENT_ELEMENT.Comment
$COMMENT_INI += $COMMENT_OBJ
}
$CONTENT_USEFUL = $ContentFile | Where { ($_ -notmatch "^\s*$COMMENT_CHARACTERS") -or ($_ -notmatch "^$COMMENT_CHARACTERS") }
$ALL_SECTION_HASHTABLE = $CONTENT_USEFUL | Where { $_ -match $HEADER_REGEX } | % { [PSCustomObject]@{ Section = $_; Index = [Array]::IndexOf($CONTENT_USEFUL, $_) } }
#$ContentUncomment | Select-String -AllMatches $HEADER_REGEX | Select-Object -ExpandProperty Matches
$SECTION_INF = @()
foreach ($SECTION_ELEMENT in $ALL_SECTION_HASHTABLE) {
$SECTION_OBJ = New-Object PSObject
$SECTION_OBJ | Add-Member -type NoteProperty -name Index -value $SECTION_ELEMENT.Index
$SECTION_OBJ | Add-Member -type NoteProperty -name Section -value $SECTION_ELEMENT.Section
$SECTION_INF += $SECTION_OBJ
}
$INF_FILE_CONTENT = @()
$NBR_OF_SECTION = $SECTION_INF.count
$NBR_MAX_LINE = $CONTENT_USEFUL.count
#*********************************************
# select each lines and value of each section
#*********************************************
for ($i = 1; $i -le $NBR_OF_SECTION; $i++)
{
if ($i -ne $NBR_OF_SECTION)
{
if (($SECTION_INF[$i - 1].Index + 1) -eq ($SECTION_INF[$i].Index))
{
$CONVERTED_OBJ = @() #There is nothing between the two section
}
else
{
$SECTION_STRING = $CONTENT_USEFUL | Select-Object -Index (($SECTION_INF[$i - 1].Index + 1) .. ($SECTION_INF[$i].Index - 1)) | Out-String
$CONVERTED_OBJ = convertfrom-stringdata -stringdata $SECTION_STRING
}
}
else
{
if (($SECTION_INF[$i - 1].Index + 1) -eq $NBR_MAX_LINE)
{
$CONVERTED_OBJ = @() #There is nothing between the two section
}
else
{
$SECTION_STRING = $CONTENT_USEFUL | Select-Object -Index (($SECTION_INF[$i - 1].Index + 1) .. ($NBR_MAX_LINE - 1)) | Out-String
$CONVERTED_OBJ = convertfrom-stringdata -stringdata $SECTION_STRING
}
}
$CURRENT_SECTION = New-Object PSObject
$CURRENT_SECTION | Add-Member -Type NoteProperty -Name Section -Value $SECTION_INF[$i-1].Section
$CURRENT_SECTION | Add-Member -Type NoteProperty -Name Content -Value $CONVERTED_OBJ
$INF_FILE_CONTENT += $CURRENT_SECTION
}
return $INF_FILE_CONTENT
}
}
# Function to write lines in the custom INF files
Function INFWrite
{
Param ([String]$INFString)
Add-Content $INFFile -Value $INFString
}
# Function to output the custom INF files
Function Create-CustomINF ($INFPart, $GPOName)
{
$Section = $INFPart.Section
Switch ($Section)
{
'[Registry Values]' {
Write-Host " Processing INF section '$($Section)'..."
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
$Contents = $INFPart.Content
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$Names = $Key.Split('\')
$Name = $Names[$Names.Count - 1]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Name).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Service General Setting]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[System Access]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Privilege Rights]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Registry Keys]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
'[Kerberos Policy]' {
Write-Host " Processing INF section '$($Section)'..."
$Contents = $INFPart.Content
$SectionName = $Section.Replace("[", "")
$SectionName = $SectionName.Replace("]", "")
Foreach ($Key in $Contents.Keys) {
$Setting = $Key
$SettingValue = $Contents[$Key]
$INFFile = Join-Path -Path "$($CustomINFPath)\$($GPOShortName)" -ChildPath "$($SectionName)-$($Key).inf"
INFWrite -INFString '[Unicode]'
INFWrite -INFString 'Unicode=yes'
INFWrite -INFString '[Version]'
INFWrite -INFString 'signature="$CHICAGO$"'
INFWrite -INFString 'Revision=1'
INFWrite -INFString "$($Section)"
INFWrite -INFString "$($Key) = $($SettingValue)"
}
}
}
}
$CurrentPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$CustomINFPath = Join-Path -Path $CurrentPath -ChildPath 'Custom-INF-Files'
If (-not (Test-Path -Path $CustomINFPath))
{
$CreateDir = New-Item -Path $CurrentPath -Name 'Custom-INF-Files' -ItemType directory
}
$GPOFiles = Get-ChildItem -Path "$($GPOExportDir)" -Filter "GptTmpl.inf" -Recurse
Write-Host "$($GPOFiles.Count) files are available to process" -ForegroundColor Green
Foreach ($GPOFile in $GPOFiles) {
$PolName = ($GPOFile.FullName).Split('\')
$GPOName = $PolName[$PolName.Count - 9]
Write-Host " Processing INF file for GPO '$($GPOName)' if found..." -ForegroundColor Green
$INFParts = parseInfFile -Inputfile $GPOFile.FullName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
$INFParts = $INFParts | Sort-Object Section -Descending
Foreach ($INFPart in $INFParts) {
$GPOShortName = $GPOName.Replace(" ", "_")
If (-not (Test-Path -Path "$($CustomINFPath)\$($GPOShortName)"))
{
$CreateDir = New-Item -Path $CustomINFPath -Name "$($GPOShortName)" -ItemType directory
}
Create-CustomINF -INFPart $INFPart -GPOName $GPOName
}
}
When you run the above script, the screen output should look something like this…

Now that we have this much ready to go, we can move on to generating our discovery scripts for the ConfigMgr Configuration Items.
Below are links to the other posts in this series.
- Using ConfigMgr Compliance to Manage Security Configuration Baselines (Part 1)
- Using ConfigMgr Compliance to Manage Security Configuration Baselines (Part 3)
You want me to do what?
The alarm clock is screaming at me to get the heck out of bed. I snooze it twice, then finally get up. Once I’ve dressed, I cruise to the kitchen for some much-needed coffee because I stayed up waaay too late the night before. After coffee, I walk to my home office where my laptop awaits, along with a likely plethora of emails to respond to before continuing work on a client’s project. It seems like just another day, nothing new… on cruise control.
As soon as I sit down, I check Teams to see what’s going on and if anybody needs any immediate assistance with some technical issue they are experiencing. Wouldn’t you know it, my manager is pinging me to discuss an upcoming project for one of our clients.
Manager: “Hey! We have a project slated to start next week, and I think it’s right up your alley!”
…I’ve heard this before…
Me: “Awesome! What needs to be done?”
Manager: “Our customer needs to have some ConfigMgr work done around Compliance Settings.”
…Compliance Settings in ConfigMgr? This is going to be another quick and easy engagement…nothing too difficult or strange to worry about…
Me: “Great! I’m in.”
…famous last words…
There was much more conversation than that, but I will spare you the details and just cut to the kick-off call with the customer…
Customer: “We have a security audit approaching, and we need to apply security settings to our servers and workstations. As well as be able to document and convey our strategy moving forward according to our security specifications.”
Me: “No problem, what exactly are we looking at doing? Don’t you already have Active Directory GPO to set these?”
Customer: “We do… but moving forward as an organization we are moving away from GPO for many of the ‘STIG’ settings and want to leverage ConfigMgr to accomplish our goals.”
Me: “STIG settings?”
Customer: “Yup.”
…I’ve not had to do much with STIG settings in the past because these were always handled by the various security teams. Surely I was selected for this project by mistake, right? But I like to think that I’m an intelligent guy, I can figure this out..let’s roll with it…
Me: “OK, do you have the settings rules identified and your organizational settings documented?”
Customer: “We do, in total there are close to 3 to 5 HUNDRED security settings that need to be checked, monitored, and remediated when needed on an ongoing basis for Windows Server 2016 alone. We need the same done for Windows Server 2008, 2012, 2012 R2, 2016, 2019, and Windows 10.”
…Did he just say “3 to 5 HUNDRED security settings for Windows Server 2016 alone”??… holy cow that’s a lot…
Me (as if it didn’t even phase me): “No problem, when does it need to be done?”
Customer: “We’d like to have this done yesterday or as soon as possible, because the Federal audit begins in about a month.”
…Hmmm…That’s 3 to 5 hundred settings for each of 6 operating systems…That means a potential of 3,000 settings to check, remediate, and report on in a month for a Federal audit…What have I gotten myself into here?…
Me: “I’m not sure that we can get up to 3,000 settings done in under 4 weeks, but we’re going to try. If we don’t make it by the time the audit starts, we definitely will have a documented strategy in place that we can communicate.”
Customer: “Excellent! That will work. Is Monday good for you as a start day?”
Me: “Works for me!”
The whole time we were on the call, I was searching the internet for everything that I could find about applying STIG settings with ConfigMgr Compliance. Let me just say this…there is a serious lack of documentation around this…
I made the commitment, I better deliver. Time to get to work.
What are some of the ways that I can do this?
Below are simply a few of the ways that we can get this job done. These are in no way intended to be the final word on the subject.
Active Directory Group Policy
We can also create a backup of the policies and then grab the settings we need from the ‘GptTmpl.inf’ file. With the settings we need, we can create INF files to be used to effect the settings desired using ‘secedit.exe’.
During my searches, I found the Microsoft Security Compliance Toolkit. The description of the toolkit from Microsoft says that it is a set of tools that allow administrators to “download, analyze, test, edit and store Microsoft-recommended security configuration baselines for Windows and other Microsoft products, while comparing them against other security configurations.”
After having downloaded the toolkit, I found that it is preconfigured Group Policy Objects that can be imported into the domain. Once imported, we are able to modify the policies to match what internal security teams have determined to be the best for their organizations. This is all well and good, except that many of these settings may not be tested in an environment and carry with them the potential to cause widespread issues upon deployment if they aren’t vetted first. Remember, each environment is different. Different applications, jurisdictions, requirements, work styles, people, and cultures. Also these preconfigured policies may have far more settings configured than what is needed by a particular organization.
Further, if an organization already has a large number of policy objects and filters within the domain that are deployed . The aggregation of these policies and filters can conflict (if they are not planned and modeled properly), and cause performance issues in applying the policies. This can result in policy processing timeouts that are seen by incomplete application of the settings, or policies not being applied at all; as reflected by the errors reported in client event logs.
Tattoo the Local Registry
The term is exactly as it sounds. It means to manual create or import registry settings that remain static on a destination system.
Registry settings will work for a great number of things except policies like renaming guest accounts, renaming administrator accounts, password complexity, and user rights assignments to name a few. If your organization decides that a change or modification is needed, all that needs to be done is to push out the settings with a deployment tool or script that applies the changes through PowerShell remoting, or WinRM. However, if the setting is determined to need removal, we might run into some difficulties if we don’t have a toolset that allows us to perform this action in bulk. The recommendation is to avoid registry tattooing; if it cannot be avoided, only tattoo settings that we know will NEVER change after they are initially set.
Manually Edit the Local Security Policy
Using the command ‘secpol.msc /s’ at the command prompt (elevated), will open up the “Local Security Policy” editor. From here we can go through each of the desired settings and make the changes necessary to effect the security posture as defined by our organization. Of note here is the ability to set a configuration as needed, then export that configuration from the command-line using ‘secedit.exe’. Once we have the configuration exported, we can then import that same configuration onto other systems using different command-line options for ‘secedit.exe’.
This is great if we only have a few dozen systems to manage. When we begin talking about hundreds or even thousands of machines, we need to be looking for a better way (or combining this with other methods).
Use PowerShell Desired State Configuration (DSC)
In Microsoft Docs, there is an article called “Quickstart: Convert Policy into DSC” that describes how to convert Group Policy backups into DSC. This works great if we are converting the canned policies from the Microsoft Security Compliance Toolkit. Mileage may vary when we try to use it on a GPO backup from and existing policy within our domain. The command ‘ConvertFrom-GPO’ within the ‘BaselineManagement’ PowerShell module doesn’t seem to handle the quotes and apostrophes within the legal banner and header text in the GPO correctly. As of January 2021, this has been an issue for almost three years with no movement toward getting it fixed. To get around the issue, you’ll need to modify the ‘GptTmpl.inf’ file within the GPO backup or remove the settings from a copy of the GPO prior to making the backup.
Once we are able to successfully run the ‘ConvertFrom-GPO’ command, a MOF file is created with all of the settings contained in the GPO. This MOF file can then be used to apply our DSC configuration to our target systems.
Please note that to successfully apply the configuration, two additional modules will be needed on the target systems:
Of course, this method is not without its potential issues. If your environment leverages FIPS policies as part of the security configuration, the PowerShell modules needed will not be able to be installed onto target systems. We’ll need to figure out a way to download and install those modules offline in order to have success.
OK, so what now? What’s my path?
Pick a Pony and Ride That Thing!
Ultimately, we chose to do a combination of the following:
- Active Directory Group Policy
- Grab the ‘GptTmpl.inf’ files from GPO backups
- Create custom INF files for each of the non-registry policy settings we need
- Create PowerShell discovery scripts to determine compliance with the settings
- Create PowerShell remediation scripts that run ‘secedit.exe’ to import the custom INF files we created
- Create ConfigMgr Configuration Items for registry policies using a PowerShell script “Convert-GPOtoCI”
- Create ConfigMgr Configuration Items for non-registry policies and leverage the custom discovery & remediation scripts we’ve created
We decided to do the work this way because:
- In order to install PowerShell modules onto the target systems, we would have needed a rollout plan for that including change management because of the potential impact on the environment
- FIPS policies were in place and modules could not be downloaded from the gallery
- ‘Secedit.exe’ was on every target Operating System within scope
- We had time constraints that didn’t allow addition of project effort and scope at the time
With the decision made, we needed to get moving. Time was not our friend.