Using PowerShell Runspaces to Generate GPO Reports

Stephen Mathews here to talk to you about generating Group Policy Object reports quickly and efficiently. I had a customer ask me if there was an easy way to find all the RunOnce GUIDs in their GPOs. The customer was concerned that they would generate duplicate GUIDs in their deployment of Group Policy Preferences. Yes, there is a chance this could happen… after about 70k years. For a fascinating look into GUID randomness, please check out
GUID guide, part three.

However, the customer’s fears were not unwarranted, because there is still a very real threat of duplicate GUIDs while copying GPOs and GPPs. This problem is documented here Ein Mal und nie wieder – GP Preferences “Apply once” Option. Since I can’t read German, I’m going to give my explanation. The GUIDs are generated automatically when the setting “Apply once and do not reapply” is used inside GPP. All the RunOnce GUIDs are stored in their corresponding Client Side Extension XML files which are nested inside their respective GPO in the SYSVOL\POLICIES share. After Group Policy executes on the target machine the GUID is populated under the “<HIVE>\Software\Microsoft\Group Policy\Client\RunOnce” registry key. A client machine will only have the RunOnce GUIDs from the GPOs that it applies – it will not have them for out-of-scope or filtered policies. So you’d have to parse all the XML files in the SYSVOL\POLICES share – unfortunately the individual GPP XMLs don’t have a parent GPO reference inside them. This means you’ll also have to parse the file path to pull out the GPO GUID and then use a Group Policy or Active Directory call to turn it into a human readable name.


Luckily the PowerShell Group Policy module does the hard work for you. By using the aptly name Get-GPOReport cmdlet, you can output an entire GPO report that includes all of its CSE XMLs into one either XML or HTML file. The cmdlet by itself will output the report directly into the shell in string format, which you can capture inside a variable or you can cast it directly to an object. For this project I chose to leave them as strings since I’ll be parsing them and I can cast the string to an XML object as needed.

$GPOReportStr Get-GPOReport -Name <NAME> -ReportType Xml
[xml]$GPOReportXMLGet-GPOReport -Name <NAME> -ReportType Xml

You can also generate the report directly from a GPO object using the GenerateReport method.

$GPOReport = (Get-GPO -Name <NAME>).GenerateReport(“XML”)

Here’s the simplest way to collect all the reports.

$GPOReports Get-GPOReport -All -ReportType Xml

Here’s two other options for serially collecting the reports, first with the cmdlet and second with the method. The method will be faster because the object is already created from Get-GPO, the cmdlet will create another object which requires additional processing.

$GPOReports = @()
Get-GPO -All |
Foreach-Object {
$GPOReports += (Get-GPOReport -Guid $_.Id -ReportType Xml)
$GPOReports = @()
Get-GPO -All |
Foreach-Object {
 $GPOReports += $_.GenerateReport(“XML”)

Due to the sheer number of GPOs, processing these serially can take an unacceptable amount of time. We can counteract this time requirement by using a multithreading technique, such as Start-Job, Workflows, and Runspaces. This Parallel processing with PowerShell article compares multithreading techniques as well. I’ll show each one of these, starting with Start-Job.

***WARNING – This exhausted my system’s memory and required a restart. See screenshot.***

$GPOReports = @()
foreach ($GPO in (Get-GPO -All)) {
Start-Job -Name $GPO.DisplayName -ScriptBlock {
Param ($GPO)
Get-GPOReport -Guid $GPO.Id -ReportType Xml
} -Argumentlist $GPO
$GPOReports Get-Job Wait-Job | Receive-Job

The limitation with Start-Job is the overhead required for the job to be performed, which increases the individual processing time. This becomes a net gain by running the jobs concurrently, although you can only realize the time savings if the job finishes! This did not occur in my tests; I consistently ran out of memory before they completed. While you can counteract the memory exhaustion with throttling, it wouldn’t be a fair comparison to the Workflows and Runspaces, which I did not throttle either.

The next technique uses PowerShell Workflows.

$GPOReports = @()
$GPOs Get-GPO -All
workflow GPOReports {
param ($GPOs)
foreach -parallel ($GPO in $GPOs) {
 Get-GPOReport -Guid $GPO.Id -ReportType Xml
$GPOReports [string[]](GPOReports -GPOs $GPOs

While the Workflows completed successfully and were much faster than the serial processing techniques. I just had to compare it to PowerShell Runspaces. This was my first foray into the subject, so please forgive my oversight. Boe Prox has written about them extensively, please check out his 4-part Hey, Scripting Guy! blog series called Beginning Use of PowerShell Runspaces.

$GPOs Get-GPO -All
$GPOReports = @()
$Invokes = @()
$Instances = @()
$RunspacePool [runspacefactory]::CreateRunspacePool(1,$GPOs.Count)
foreach ($GPO in $GPOs) {
$ScriptBlock = {
 Param ($GPO)
   Try {Get-GPOReport -Guid $GPO.Id -ReportType Xml -ErrorAction Stop}
 Catch {$_}
$Instance [powershell]::Create()
 $Instance.RunspacePool = $RunspacePool
$Instance.AddScript($ScriptBlock).AddArgument($GPO) Out-Null
 $Invoke $Instance.BeginInvoke()
 $Invokes += $Invoke
 $Instances += $Instance
do {
$Invokes |
 Where-Object {$_} |
 ForEach-Object {
 $i $Invokes.IndexOf($_)
 If ($Invokes[$i].IsCompleted) {
 $GPOReports += $Instances[$i].EndInvoke($Invokes[$i])
 $Instances[$i= $null
 $Invokes[$i= $null
} until (($Invokes.IsCompleted -notcontains $false) -and ($Invokes.IsCompleted.Count -le 0))

Here’s my results, after retrieving over 500 GPO reports using the above methods; you can see Runspaces were the clear winner.

Now that we have all the reports stored in a variable, I can answer my customer’s question. I will create a hashtable that has the GPO name with the value set to all of its RunOnce GUIDs. I find these by parsing the report string for “FilterRunOnce” and then do some string manipulation using the Split() method.

$RunOnceIds = @{}
$GPOReports |
Where-Object {$_ -match “filterrunonce”} |
 ForEach-Object {
 $Name = ([xml]$_).GPO.Name
 [string[]]$IDs=$_.Split(“`n”) |
 Select-String “filterrunonce” |
 ForEach-Object {
              $_.ToString().Split(‘{}’) Select-Object -Skip -First 1
$Values [string[]]$RunOnceIds.Values.Split() Sort-Object
$Values |
Group-Object |
Where-Object {$_.Count -gt 1} |
 Select-Object -Property @{Name=“Duplicate”;Expression={$_.Name}}

As you can see, there are indeed duplicate GUIDs. Now to find the offending GPOs.

$RunOnceIds.GetEnumerator() Where-Object {$_.Value.Contains(<GUID>)}

Fortunately, you can regenerate a new GUID by reapplying the “Apply once and do not reapply” setting. Another note about RunOnce, it populates the registry even if the preference fails to execute. For example, if a GPP sets a registry key value from 0 to 1, but the registry security permissions denied the change, the RunOnce entry is still tattooed in the registry and the GPO won’t attempt to change it again. So you can reset the GUID to reapply your RunOnce settings again without recreating them. And finally since you have all your RunOnce GUIDs (from $Values variable above), you can actually cleanup the entries inside your client machines’ Group Policy RunOnce keys if you’re so inclined.

I hope you enjoyed our foray into the different methods of collecting GPO reports and using the different multithreading techniques inside PowerShell. I’m now a Runspace believer and can’t wait to use them again. Until next time, thanks for reading!





Disclaimer: The sample scripts are not supported under any Microsoft standard support program or service. The sample scripts are provided AS IS without warranty of any kind. Microsoft further disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose. The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. In no event shall Microsoft, its authors, or anyone else involved in the creation, production, or delivery of the scripts be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss) arising out of the use of or inability to use the sample scripts or documentation, even if Microsoft has been advised of the possibility of such damages.