25 September 2020

Check Boot Environment for BIOS or UEFI

One of my recent projects is to convert our remaining legacy systems from BIOS to UEFI. While setting up the task sequence, I needed to be able to test the system to make sure it was not already UEFI so the task sequence would end if it was. 

The PowerShell script reads the setupact.log file and extracts if it is configured as BIOS or UEFI. I have included an unknown message in the event the log file does not exist or is inaccessible, which is what I encountered on one machine. For setting it up in a Configuration Manager task sequence, I set the sequence to look for a return code of 0, else it will return either a 1 or 2, which will fail the task sequence, and allow the admin to know why it failed from the console with the error code being returned.

You can download the script from my GitHub site.


 <#  
      .SYNOPSIS  
           Check Boot Environment  
        
      .DESCRIPTION  
           This script reads the setupact.log file to determine if the system is configured for BIOS or UEFI.   
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:       9/25/2020 11:59 AM  
           Created by:       Mick Pletcher  
           Filename:         BootEnvironment.ps1  
           ===========================================================================  
 #>  
   
 Try {  
      $Output = (Get-Content -Path (((Get-ChildItem -Path ($env:windir + '\Panther') -Recurse -Filter setupact.log -ErrorAction SilentlyContinue)[0]).FullName) -ErrorAction SilentlyContinue | Where-Object {$_ -like "*Detected boot environment*"}).Replace("Detected boot environment:", "~").Split("~")[1].Trim()  
      If ($Output -eq 'BIOS') {  
           Write-Output 'BIOS'  
           Exit 0  
      } elseif ($Output -eq 'UEFI') {  
           Write-Output 'UEFI'  
           Exit 1  
      }  
 } Catch {  
      Write-Output 'Unknown'  
      Exit 2  
 }  
   

24 September 2020

Bitlocker Non-Compliance Reporting

As part of the suite of security tools I am writing, this will query the configuration manager SQL database for a list of machines that are not Bitlocker encrypted. There are reports in the configuration manager for this, but not everyone in my organization has access to the configuration manager console, and we wanted a detailed report sent out on a regular basis so that it is in their mailbox with high importance, which is also not available through ConfigMgr. 

This tool was written to include the computer name, model, chassis, drive letter, bitlocker status, last hardware inventory scan, and last logon time. The last hardware scan and last logon time give the admins an idea as to the accuracy of the system being reported. The tool was written so that it can be used with Azure Automation or Orchestrator, and even with a scheduled task if needed. The output is formatted for a clean appearance in an outlook email. 

You can download the script from my GitHub site.



 <#  
      .SYNOPSIS  
           Bitlocker Encryption Reporting  
        
      .DESCRIPTION  
           This script queries the Configuration Manager SQL database for a list of machines that are not Bitlocker Encrypted. It is limited to non-desktop chassis, which can be changed by modifying the SQL query. The script is designed to output the data so that it can be used with Orchestrator, Azure Automation, or a scheduled task.  
        
      .PARAMETER SQLServer  
           Name of the SQL server  
        
      .PARAMETER SQLDatabase  
           Name of the SQL database  
        
      .NOTES  
           ===========================================================================  
           Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:      9/21/2020 3:35 PM  
           Created by:      Mick Pletcher  
           Filename:        BitlockerEncryptionReporting.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLServer,  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLDatabase
 )  
   
 $Query = "SELECT ResultTableName FROM dbo.v_Collections"  
 $Collection = Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query $Query  
 #SQL query to retrieve the list of machines  
 $Query = "SELECT DISTINCT Name as ComputerName, dbo.Computer_System_DATA.Model00 AS Model, dbo.System_Enclosure_DATA.ChassisTypes00 AS Chassis, dbo.ENCRYPTABLE_VOLUME_DATA.DriveLetter00 AS DriveLetter, ProtectionStatus00 AS BitlockerStatus, LastHardwareScan, ADLastLogonTime AS LastLogonTime FROM dbo.Computer_System_DATA INNER JOIN dbo.ENCRYPTABLE_VOLUME_DATA ON dbo.Computer_System_DATA.MachineID = dbo.ENCRYPTABLE_VOLUME_DATA.MachineID INNER JOIN dbo.v_GS_ENCRYPTABLE_VOLUME ON dbo.v_GS_ENCRYPTABLE_VOLUME.ResourceID = dbo.ENCRYPTABLE_VOLUME_DATA.MachineID INNER JOIN dbo._RES_COLL_SMS00001 ON dbo.ENCRYPTABLE_VOLUME_DATA.MachineID = dbo._RES_COLL_SMS00001.MachineID INNER JOIN dbo.System_Enclosure_DATA ON dbo._RES_COLL_SMS00001.MachineID = dbo.System_Enclosure_DATA.MachineID INNER JOIN dbo.v_GS_VOLUME ON dbo.System_Enclosure_DATA.MachineID = dbo.v_GS_VOLUME.ResourceID WHERE (((dbo.System_Enclosure_DATA.ChassisTypes00 = 8) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 9) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 10) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 12) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 14) OR (dbo.System_Enclosure_DATA.ChassisTypes00 = 31)) AND (dbo.ENCRYPTABLE_VOLUME_DATA.DriveLetter00 = 'C:') AND (dbo.ENCRYPTABLE_VOLUME_DATA.ProtectionStatus00 = 0)) ORDER BY Name"  
 $Report = Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query $Query  
 #If the report has no machines, then exit this script with an error code 1 so that the automation tool the link will not continue to the email task  
 If ($Report -ne $null) {  
      $Array = @()  
      foreach ($Item in $Report) {  
           $SysObj = New-Object -TypeName System.Management.Automation.PSObject  
           $SysObj | Add-Member -MemberType NoteProperty -Name ComputerName -Value $Item.ComputerName  
           $SysObj | Add-Member -MemberType NoteProperty -Name Model -Value $Item.Model  
           $SysObj | Add-Member -MemberType NoteProperty -Name Chassis -Value $Item.Chassis  
           $SysObj | Add-Member -MemberType NoteProperty -Name DriveLetter -Value $Item.DriveLetter  
           $SysObj | Add-Member -MemberType NoteProperty -Name BitlockerStatus -Value $Item.BitlockerStatus  
           $SysObj | Add-Member -MemberType NoteProperty -Name LastHardwareScan -Value $Item.LastHardwareScan  
           $SysObj | Add-Member -MemberType NoteProperty -Name LastLogonTime -Value $Item.LastLogonTime  
           $Array += $SysObj  
      }  
      #Bitlocker reporting fields from the query  
      $Fields = @("Computer Name", "Model", "Chassis", "Drive Letter", "Bitlocker Status", "Last Hardware Scan", "Last Logon Time")  
      #Title row  
      $Output = ($Fields[0] + [char]9 + [char]9 + $Fields[1] + [char]9 + [char]9 + [char]9 + [char]9 + $Fields[2] + [char]9 + [char]9 + $Fields[3] + [char]9 + $Fields[4] + [char]9 + [char]9 + $Fields[5] + [char]9 + $Fields[6] + [char]13)  
      #Add each entry while formatting the computername column as to the width of the computername  
      foreach ($Item in $Array) {  
           If ($Item.ComputerName.Length -le 3) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9 + [char]9 + [char]9 + [char]9  
           } elseif ($Item.ComputerName.Length -le 7) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9 + [char]9 + [char]9  
           } elseif ($Item.ComputerName.Length -le 11) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9 + [char]9  
           } elseif ($Item.ComputerName.Length -le 15) {  
                $ComputerName = $Item.ComputerName + [char]9 + [char]9  
           } else {  
                $ComputerName = $Item.ComputerName + [char]9  
           }  
           $Output += $ComputerName + $Item.Model + [char]9 + [char]9 + [char]9 + $Item.Chassis + [char]9 + [char]9 + $Item.DriveLetter + [char]9 + [char]9 + $Item.BitlockerStatus + [char]9 + [char]9 + [char]9 + $Item.LastHardwareScan + [char]9 + $Item.LastLogonTime + [char]13  
      }  
      #Write the output so it can be collected from the automation tool  
      Write-Output -InputObject $Output  
 } else {  
      Exit 1  
 }  
   

15 September 2020

Preventing Windows 10 Apps from Reappearing after an In-Place or Feature Upgrade

In the imaging process I devised, most of the Windows 10 Built-In apps, such as mail and maps, are removed. My company uses the SAC edition of Windows 10 enterprise. This requires us to perform an in-place upgrade every 6 months. In order to prevent the built-in apps from reappearing, you can add specific registry keys that permanently deprovision the apps. 

The first thing you will need to do is to get a list of the apps you want to deprovision. To do so, execute the following PowerShell one-liner to get a list of the apps with the display name on the right with the associated package name on the left. The package name is what will be needed. 

 Get-AppxProvisionedPackage -Online | Select DisplayName, PackageName | Sort-Object -Property DisplayName | Export-Csv -Path Apps.txt -Force -Encoding UTF8 -NoTypeInformation  

It is best to edit the CSV file in excel. Once you open the generated text file up, delete those items you do NOT want the OS to remove, and then delete the DisplayName column. The CSV file is now ready to be used with the PowerShell script. You will need to rerun the above PowerShell script each time an OS upgrade is available and has been installed to get any new additions. 

The script will read the contents of the text file and then adds those as a registry key located under HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore\Deprovisioned. You can find out more information on the registry keys and how they work here

The following script will only need to be executed once. You can download it from my GitHub site


 <#  
      .SYNOPSIS  
           Deprovision Specified Windows 10 Apps  
        
      .DESCRIPTION  
           This script will add the appropriate registry keys to deprovision the built-in Windows 10 applications specified in the associated text file, or hardcoded in the script.  
        
      .PARAMETER AppListFile  
           File containing list of Windows 10 files to deprovision  
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:       9/15/2020 9:56 AM  
           Created by:       Mick Pletcher  
           Filename:         Windows10AppDeprovisioning.ps1  
           ===========================================================================  
 #>  
   
 [CmdletBinding()]  
   
 param  
 (  
      [string]$AppListFile  
 )  
   
 #Get list of Windows 10 Applications to uninstall from text file  
 $Applications = Get-Content -Path $AppListFile  
 #Deprovisioned Registry Key  
 $RegKey = 'REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore\Deprovisioned'  
 #Create deprovisioned registry key if it does not exist  
 If (!(Test-Path $RegKey)) {  
      New-Item -Path $RegKey -Force | Out-Null  
 }  
 #Add list of Apps from the imported text file to the deprovisioned registry key  
 foreach ($App in $Applications) {  
      #Install registry key if it does not exist  
      If (!(Test-Path ($RegKey + '\' + $App))) {  
           New-Item -Path ($RegKey + '\' + $App) -Force | Out-Null  
      }  
 }  
   

09 September 2020

Remotely Pushing Windows Updates via Command Line to Windows 10 Machines

Normally, windows updates are pushed to machines using Configuration Manager in an enterprise environment. There are occasions though when they must manually be pushed, such as when a system continues to fail via ConfigMgr and troubleshooting is required. The first tool I use is the PSWindowsUpdate PowerShell module. This allows me to remotely push updates via PowerShell and PSEXEC, in which I can watch the results. 

To use the PSWindowsUpdate module, it must be installed first. I do so by installing the module on all machines during the build process, but it can also be pushed through ConfigMgr or you can use PSEXEC.exe to install it. The command line I use is:

 powershell.exe -executionpolicy bypass -command "&{Install-Module -Name PSWindowsUpdate -Force -Confirm:$false}"  

Once it is installed on the remote machine, I use the following PowerShell one-liner in the configuration manager Scripts section to remotely execute it. 

 Install-WindowsUpdate -IgnoreReboot -AcceptAll;Start-Process -FilePath ($env:windir + '\system32\UsoClient.exe') -ArgumentList 'ScanInstallWait StartInstall'  
The issue I have seen with just using the PSWindowsUpdate module with 1903 and later versions of Windows 10 is that it will trigger the updates to download, but will not always install them, especially feature updates. The UsoClient.exe will trigger the updates to install. This command-line executable is the same as going to the Check for Windows Updates screen and clicking the button to install updates. You can find more info on UsoClient here

If you are trying to get a feature update to install remotely, you will need to execute UsoClient.exe ScanInstallWait StartInstall at least a couple of times before it starts to install after the update has been downloaded. 

01 September 2020

AD Group Member Reporting

This tool queries specified AD groups for new users that have been added to the group within a specified number of days. The script is written so that it can be used with Azure Automation, Orchestrator, or even a scheduled task, with the addition of the send-mailmessage cmdlet. The intent of this is to track if say a user needs to be temporarily added to the domain admins group for software installation, but then the help desk forgets to remove them. It can also be a tracking tool in the event a cyber-incursion is happening and a compromised account gets added to that group. Obviously, if a user who is already in that group is compromised, then this script does not impact that type of event. This script was made possible by using the function posted over at PowerShellBros with a few modifications to it.

The script exits with a write-output statement if any new users appear in the report. The Write-Output allows for the data to be sent to the next step in say Orchestrator. If no new users appear, then the script exits with an error code 1. This is so the link in Azure Automation or Orchestrator can be set to only proceed to send an email if there was an error code 0, which would be the Write-Output statement. 

You can download the script from my GitHub repository located here


 <#  
      .SYNOPSIS  
           AD Security Group User Additions  
        
      .DESCRIPTION  
           This script retrieves a list of users added to a specified AD security group, which includes the date it was last modified. This info can be used to track whether a new user has been recently added to a security group, especially a group that elevates priviledges. This can be used as a tool to help fight cyber-crime.  
        
      .PARAMETER NumberOfDays  
           Number of days to look back for users having been added to the designated AD security group  
        
      .PARAMETER SecurityGroup  
           Name of the AD security group  
        
      .NOTES  
           ===========================================================================  
           Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:      9/1/2020 11:03 AM  
           Created by:      Mick Pletcher  
           Filename:        ADGroupUserInfo.ps1  
           ===========================================================================  
 #>  
   
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [int]$NumberOfDays,  
      [ValidateNotNullOrEmpty()]  
      [string]$SecurityGroup  
 )  
   
 Function Get-ADGroupMemberDate {  
        
      [cmdletbinding()]  
      Param (  
           [parameter(ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, Mandatory = $True)]  
           [string]$Group  
             
      )  
        
      Begin {  
           [regex]$pattern = '^(?<State>\w+)\s+member(?:\s(?<DateTime>\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})\s+(?:.*\\)?(?<DC>\w+|(?:(?:\w{8}-(?:\w{4}-){3}\w{12})))\s+(?:\d+)\s+(?:\d+)\s+(?<Modified>\d+))?'  
           $DomainController = ($env:LOGONSERVER -replace "\\\\")  
           If (!$DomainController) {  
                Throw "Computer from which script is run is not joined to domain and domain controller can not be identified."  
                Break  
           }  
      }  
      Process {  
           Write-Verbose "Checking distinguished name of the group $Group"  
           Try {  
                $distinguishedName = (Get-ADGroup -Identity $Group).DistinguishedName  
           } Catch {  
                Write-Warning "$group can not be found!"  
                Break  
           }  
           $RepadminMetaData = (repadmin /showobjmeta $DomainController $distinguishedName | Select-String "^\w+\s+member" -Context 2)  
           $Array = @()  
           ForEach ($rep in $RepadminMetaData) {  
                If ($rep.line -match $pattern) {  
                     $object = New-Object PSObject -Property @{  
                          Username = [regex]::Matches($rep.context.postcontext, "CN=(?<Username>.*?),.*") | ForEach-Object {  
                               $_.Groups['Username'].Value  
                          }  
                          LastModified = If ($matches.DateTime) {  
                               [datetime]$matches.DateTime  
                          } Else {  
                               $Null  
                          }  
                          DomainController = $matches.dc  
                          Group      = $group  
                          State      = $matches.state  
                          ModifiedCounter = $matches.modified  
                     }  
                     $Array += $object  
                }  
           }  
     Return $Array  
      }  
      End {  
      }  
 }  
   
 $Users = Get-ADGroupMemberDate -Group $SecurityGroup | Select Username, LastModified  
 $NewUsers = @()  
 #Find users added to the AD Group within the designated number of days  
 Foreach ($User in $Users) {  
   If ((New-TimeSpan -Start $User.LastModified -End (Get-Date)).Days -lt $NumberOfDays) {  
     $NewUsers += $User  
   }  
 }  
 If ($NewUsers.Count -gt 0) {  
   Write-Output $NewUsers  
 } else {  
   Exit 1  
 }  
   

28 August 2020

Multiple Machine Logon Reporting

This tool is designed to report if a user profile has been logged into a defined number of machines or greater over a specified period of time. The purpose of the tool is to:
  • Detect a cybercriminal who may have gotten one user credential and is now using it to probe machines across the domain
  • Detect a disgruntled employee who may be logging into multiple machines for destructive reasons
The tool works by querying the ConfigMgr SQL server for a list of machines users have logged into. This list comes from the primary devices association. Every user that logs into a machine is added to this list. The list that appears in the GUI of ConfigMgr does not display every login that is stored in the SQL database. I am not sure of the timeframe in which systems disappear from the GUI list. 

If you are interested in just the SQL Query, here it is. The only things that should need to be changed are the -14 and 4. The -14 is the number of days back you want the report to be run and the 4 is the number of machines a user can log in to without appearing on the report.

 SELECT UniqueUserName, COUNT(UniqueUserName) as Logins FROM dbo.v_UserMachineRelationship WHERE dbo.v_UserMachineRelationship.CreationTime >= DATEADD(day,-14, GETDATE()) GROUP BY UniqueUserName HAVING (COUNT(UniqueUserName) > 4) ORDER BY Logins DESC  

This is the PowerShell script that connects to the SQL server to retrieve the list. The script creates a simple list of users and the number of machines they logged into and outputs that in pure text. This can be used in Microsoft Orchestrator or Azure Automation to automatically generate a report. It also generates a detailed report in a CSV file that can be attached to the same automatic email if the recipients want to look at the details that show every machine logged into along with the date stamp. If there are no users to report, the script deletes the last saved CSV file and exits with an error code 1. The purpose of error code one was for the Orchestrator link. The link checks the return code and only proceeds to the email activity if there was an error code 0 returned. As a note for Orchestrator, I have the script run on the SQL server so the PowerShell cmdlets are present. 
 <#  
      .SYNOPSIS  
           SCCM Endpoint Report  
      .DESCRIPTION  
           This script is intended to be used to detect if a user logs into multiple machines within a designated time period, which can be a sign of an intruder.This script will query Configuration Manager using the designated Number of machines and how many days back for a list of machines that have logged into more than the designated number. It will return a list of machines using Write-Output so the output can also be used in Orchestrator or SMA. The list it returns is a simple list that displays the username and the number of machines it was logged into. There is also a more detailed list generated that contains the list of all machines it was logged into. This list can be attached to an email to be sent with the simple list if desired.  
      .PARAMETER SQLServer  
           Name of the SQL server  
      .PARAMETER SQLDatabase  
           Name of the SQL database  
      .PARAMETER NumberOfMachines  
           Maximum number of machines a user can login to before appearing on this report  
      .PARAMETER NumberOfDays  
           This is a string value because it requires a negative value for the number of days you want to report to go back.  
      .PARAMETER Report  
           This is the specified name of the detailed user login report to be generated. This must include both the full path and .csv as part of the name.  
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:       8/19/2020 3:58 PM  
           Created by:       Mick Pletcher  
           Filename:         UserLoginReport.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLServer,  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLDatabase,  
      [ValidateNotNullOrEmpty()]  
      [int]$NumberOfMachines,  
      [ValidateNotNullOrEmpty()]  
      [string]$NumberOfDays,  
      [ValidateNotNullOrEmpty()]  
      [string]$Report = 'c:\UserLoginDetailedReport.csv'  
 )  
 $SimpleReport = Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query ("SELECT UniqueUserName, COUNT(UniqueUserName) as Logins FROM dbo.v_UserMachineRelationship WHERE dbo.v_UserMachineRelationship.CreationTime >= DATEADD(day," + $NumberOfDays + ", GETDATE()) GROUP BY UniqueUserName HAVING (COUNT(UniqueUserName) > " + $NumberOfMachines + ") ORDER BY Logins DESC")  
 If ($SimpleReport -ne $null) {  
      $DetailedReport = @()  
      foreach ($User in $SimpleReport.UniqueUserName) {  
           $DetailedReport += Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query ("SELECT UniqueUserName, MachineResourceName, CreationTime FROM dbo.v_UserMachineRelationship WHERE UniqueUserName = " + [char]39 + $User.Trim() + [char]39 + " AND dbo.v_UserMachineRelationship.CreationTime >= DATEADD(day," + $NumberOfDays + ", GETDATE()) ORDER BY CreationTime ASC")  
      }  
      $DetailedReport | Export-Csv -Path $Report -Force -Encoding UTF8 -NoTypeInformation  
      Write-Output $SimpleReport  
 } else {  
      Remove-Item -Path $Report -Force -ErrorAction SilentlyContinue  
      Exit 1  
 }  

27 August 2020

Which Configuration Manager PowerShell Module should I use?

If you use PowerShell with Configuration Manager, you likely know that there are two PowerShell modules named ConfigurationManager.psd1. One is located at %PROGRAMFILES%\Microsoft Configuration Manager\AdminConsole\bin and the other one is located at %PROGRAMFILES%\Microsoft Configuration Manager\AdminConsole\bin\ConfigurationManager. If you are trying to figure out which one to use, the %PROGRAMFILES%\Microsoft Configuration Manager\AdminConsole\bin is the most logical one. The other module just references the first one as a nested module with no additional benefits. 

21 August 2020

Deleting the Windows 10 Recovery Partition

We use VMWare for our servers and sometimes virtualized desktops. One of the issues we ran into was expanding the disk space on a virtual machine. When disk space was added, the C: drive could not be expanded because of the reserve drive "being in the way". The additional space would have to be made into a separate drive.

To fix this issue, I deleted the Windows 10 recovery partition, which on all of our builds is the fourth partition. Our VMWare virtual machines are backed up in real-time, thereby doing away with the need for the recovery drive  It is important to go into diskpart and make sure your machines are built and configured like ours. This will not work if they are not. The picture below is how all of our systems are partitioned during the build process. I have integrated the PowerShell one-liner below as a task sequence to run 

The following PowerShell one-liner will delete the partition 4.

 Remove-Partition -DiskNumber ((Get-Disk).Number) -PartitionNumber ((Get-Partition -DiskNumber ((Get-Disk).Number) | Where-Object {$_.Type -eq 'Recovery'}).PartitionNumber) -PassThru -Confirm:$false  

12 June 2020

Legacy Distribution Point Cleanup

This script will clean up the legacy items left over after a distribution point has been deleted in the configuration manager console. This is sometimes necessary if the same server is going to be used to re-push the distribution point back down to it. The script will not proceed if the DP registry keys are not present so that it can find where the distribution point directory exists to be deleted. 

You can download the script from my GitHub site.


 <#  
      .SYNOPSIS  
           ConfigMgr Distribution Point Cleanup  
        
      .DESCRIPTION  
           This script will cleanup the remnants of a distribution point after it has been deleted in Configuration Manager. This is sometimes necessary when needing to repush a distribution point to the same server.   
        
      .NOTES  
           ===========================================================================  
           Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:      6/12/2020 2:01 PM  
           Created by:      Mick Pletcher  
           Filename:        DPCleanup.ps1  
           ===========================================================================  
 #>  
   
 [CmdletBinding()]  
 param ()  
   
 #Uninstall ConfigMgr Client  
 If ((Test-Path ($env:windir + '\ccmsetup\ccmsetup.exe')) -eq $true) {  
   Start-Process ($env:windir + '\ccmsetup\ccmsetup.exe') -ArgumentList "/uninstall" -Wait  
 }  
 #Retrieve the path to the distribution point directories  
 If ((Test-Path 'REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\SMS\DP') -eq $true) {  
   $ContentLibraryPath = ((Get-ItemProperty -Path 'REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\SMS\DP').ContentLibraryPath).split('\')[0] + '\'  
 } else {  
   Write-Host 'Cannot find path to distribution point content'  
   Exit 1  
 }  
 #Delete the distribution point directories  
 (Get-ChildItem -Path $ContentLibraryPath | Where-Object {($_.Name -like 'SMS*') -or ($_.Name -like 'SCCM*')}) | ForEach-Object {Remove-item -Path $_.FullName -Recurse -Force}  
 #Delete registry keys associated with ConfigMgr  
 If ((Test-Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\SMS") -eq $true) {  
   Remove-Item -Path "REGISTRY::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\SMS" -Recurse -Force -ErrorAction SilentlyContinue  
 }  
   

19 May 2020

Using PowerShell to list Zerto Unprotected Systems

This script will connect to the Zerto server and retrieve a list of systems that Zerto is not backing up. The script has been written so that it can function in the Azure Automation or Orchestrator environment. It can also be used as a scheduled task or executed manually. It excludes desktop operating systems.

You will need to install both the activedirectory and zertoapiwrapper modules before using this script.

You can download the script from my GitHub site.


 <#  
      .SYNOPSIS  
           Unprotected Systems Report  
        
      .DESCRIPTION  
           This script will retrieve a list of systems from the Zerto server that are not being backed up. Desktop operating systems and systems not in AD are excluded, as Zerto is typically used for server backup.  
        
      .NOTES  
           ===========================================================================  
           Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:      5/19/2020 5:24 PM  
           Created by:      Mick Pletcher  
           Filename:        ZertoUnprotectedSystems.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param ()  
   
 Import-Module -Name zertoapiwrapper  
 Import-Module -Name activedirectory  
 $Cred = New-Object System.Management.Automation.PSCredential ("domain\username", $password)  
 Connect-ZertoServer -zertoServer prodzerto.wallerlaw.int -credential $Cred  
 $List = (Get-ZertoUnprotectedVm).VmName | Sort-Object  
 $UnprotectedServers = @()  
 Foreach ($System in $List) {  
   Try {  
     $ADComputer = Get-ADComputer $System -Properties OperatingSystem  
     If (($ADComputer.OperatingSystem -notlike '*Windows 7 Enterprise') -and ($ADComputer.OperatingSystem -notlike '*Windows 10 Enterprise')) {  
       $UnprotectedServers += $System  
     }  
   } catch {  
   
   }  
 }  
 #Use this if the script is being used in Azure Automation or Orchestrator  
 Write-Output $UnprotectedServers  
 #Use this if the script is being executed manually, or as a scheduled task.   
 #Send-MailMessage -From '' -To '' -Subject 'Zerto Unprotected Servers' -Body ($UnprotectedServers | Out-String) -Priority High -SmtpServer ''  

12 May 2020

Populate VMWare Virtual Systems to a ConfigMgr Collection

During the COVID19 pandemic, one of my projects has been to build a new configuration manager server at our newer production site for optimal performance. During this process, we needed to be able to differentiate what servers were on our disaster recovery VMWare server, and what servers were on the production VMWare server. We quickly learned that some of the virtual servers could not be differentiated. The only solution we came up with was to use PowerShell to connect to each of the VMWare servers and retrieve a list of machines, which would then populate the Configuration Manager collection.

The following script does just what I described above. You will need to create a collection in Configuration Manager and populate all of the parameter fields. The ConfigMgrModule will need the full UNC path to the PowerShell ConfigMgr Module. The name of the module is ConfigurationManager.psd1. You can do a get-childitem to locate where the .PSD1 file is located. The ConfigMgrSiteDescription parameter can be anything. I suggest executing this on your Configuration Manager server if you set it up as a scheduled task. It can also be executed using Orchestrator or Azure Automation.

NOTE: You will need to download and install the VMWare PowerShell module before executing this script. The following cmdlet will install it.

 Find-Module -Name VMWare.PowerCLI | Install-Module -force -AllowClobber  


You can download this script from my GitHub site

 <#  
      .SYNOPSIS  
           Add VMWare systems to specified ConfigMgr collection   
        
      .DESCRIPTION  
           This script will query the VMware server for a list of machines. It will then query Configuration Manager to verify which of those machines are present. Finally, it adds the list of machines to a collection, which is assigned to a specific distribution point.  
        
      .PARAMETER ServerCollection  
           List of servers in Configuration Manager to compare with the list of servers from VMWare.  
        
      .PARAMETER ConfigMgrSiteCode  
           Three letter Configuration Manager site code  
        
      .PARAMETER DistributionPointCollection  
           Name of the collection containing a list of systems on the  
        
      .PARAMETER VMWareServer  
           Name of the VMWare server  
        
      .PARAMETER ConfigMgrModule  
           UNC path including file name of the configuration manager module  
        
      .PARAMETER ConfigMgrServer  
           FQDN of SCCM Server  
        
      .PARAMETER ConfigMgrSiteDescription  
           Description of the ConfigMgr Server  
        
      .NOTES  
           ===========================================================================  
           Created with:    SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:      5/11/2020 9:50 AM  
           Created by:      Mick Pletcher  
           Filename:        VMWareConfigMgr.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$AllServersCollection = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$ConfigMgrSiteCode = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$DistributionPointCollection = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$VMWareServer = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$ConfigMgrModule = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$ConfigMgrServer = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$ConfigMgrSiteDescription = ''  
 )  
   
 #Import PowerCLI module  
 Import-Module -Name VMWare.PowerCLI -ErrorAction SilentlyContinue | Out-Null  
 #Collection ID of the collection containing a list of all servers  
 $CollectionID = (Get-WmiObject -Namespace ("root\SMS\site_" + $ConfigMgrSiteCode) -Query ("select * from SMS_Collection Where SMS_Collection.Name=" + [char]39 + $AllServersCollection + [char]39) -ComputerName PRODCM).MemberClassName  
 #Retrieve the list of all servers  
 $MEMCMServerList = Get-WmiObject -Namespace ("root\SMS\site_" + $ConfigMgrSiteCode) -Query "select * FROM $CollectionID" -ComputerName PRODCM | Sort-Object -Property Name  
 #Establish a connection with teh VMWare server  
 Connect-VIServer -Server $VMWareServer | Out-Null  
 $VerifiedServers = @()  
 #Get the list of servers from the VMWare server that are also in Configuration Manager  
 (Get-VM).Name | Sort-Object | ForEach-Object {  
      If ($_ -in $MEMCMServerList.Name) {  
           $VerifiedServers += $_  
      }  
 }  
 #CollectionID of the collection containing a list of all servers in VMWare  
 $CollectionID = (Get-WmiObject -Namespace ("root\SMS\site_" + $ConfigMgrSiteCode) -Query ("select * from SMS_Collection Where SMS_Collection.Name=" + [char]39 + $DistributionPointCollection + [char]39) -ComputerName PRODCM).MemberClassName  
 #Retrieve list of all servers in VMWare ConfigMgr collection  
 $MEMCMServerList = Get-WmiObject -Namespace ("root\SMS\site_" + $ConfigMgrSiteCode) -Query "select * FROM $CollectionID" -ComputerName PRODCM | Sort-Object -Property Name  
 $MissingSystems = @()  
 #Retrieve the list of servers that are not in the ConfigMgr collection  
 $VerifiedServers | ForEach-Object {  
      If ($_ -notin $MEMCMServerList.Name) {  
           $MissingSystems += $_  
      }  
 }  
 If ($MissingSystems -ne $null) {  
      #Import ConfigMgr PowerShell Module  
      Import-Module -Name $ConfigMgrModule -Force  
      #Map drive to ConfigMgr server  
      New-PSDrive -Name $ConfigMgrSiteCode -PSProvider 'AdminUI.PS.Provider\CMSite' -Root $ConfigMgrServer -Description $ConfigMgrSiteDescription | Out-Null  
      #Change current directory to ConfigMgr mapped drive  
      Set-Location -Path ($ConfigMgrSiteCode + ':')  
      #Add missing systems to the specified collection  
      $MissingSystems | ForEach-Object {  
           Add-CMDeviceCollectionDirectMembershipRule -CollectionName $DistributionPointCollection -ResourceID (Get-CMDevice -Name $_).ResourceID  
      }  
 }  

05 May 2020

Configuration Manager 1910 Upgrade Tips and Issues I Encountered

We have a new datacenter and the configuration manager server needed to be moved to that location. The setup of Configuration Manager is not too difficult. I did though run into several gotchas along the way.


  • The first one was a warning that read 'configuration for SQL server memory usage'. I needed at least 8 GB of RAM allocated to the SQL database. The following SQL query executed on the ConfigMgr SQL server will rectify that issue. 

 sp_configure 'show advanced options', 1;  
 GO  
 RECONFIGURE;  
 GO  
 sp_configure 'min server memory', 8192;  
 GO  
 RECONFIGURE;  
 GO  


  • I ran into the SQL server native client version warning. I fixed that by downloading the SQL Server 2012 Feature Pack which contains sqlncli.msi. That is the native client installer. SQL Server 2019 is the version being used for ConfigMgr 1910. The sqlncli.msi from the 2012 Feature Pack is compatible with SQL Server 2019. 



  • When I started using the job migration tool, it seemed to migrate the packages and applications without issue until I went to push them to the new distribution points. That is when I saw the distribution status was still present in the pie charts of the content status. I had to go back and delete the imported items, and then remove them from the distribution points of the old server before migrating them back to the new ConfigMgr server. Once I did that, the newly migrated objects no longer showed false pie charts.

  • The final error I got was 'failed to create virtual directory' when I began building my new distribution points. I did not use the old ones, as I wanted them to all be newly built like the ConfigMgr and SQL servers were. The fix to this was to reboot the distribution point server. Once it came back up, the error was gone. 

  • I did not know whether I should trying and change the configuration manager clients to point to the new server, or just reinstall them. I ended up running the install client and checking the box to uninstall the old client. That worked perfectly. I chose this method because it would then have all new logs in the %windir%\ccm\logs directory for the new client and nothing left over from the old client. 

I must say that Microsoft has done a fabulous job with the installation process of the new Configuration Manager. This was by far the easiest installation/upgrade I have ever done. I am sure there are a lot more errors and messages others will encounter as every environment is different. The new server is now built and is up and running. 

01 May 2020

ConfigMgr Pending Reboot Report

We wanted a list of servers that are waiting for a reboot. Thankfully, ConfigMgr has a pending restart field that allows admins to see when systems are waiting for a reboot. Our server team wanted that list to be automatically generated and emailed to them daily. Other than myself, others that have the ConfigMgr console rarely look at it since they wear many hats in IT.

I could not find any PowerShell cmdlets in the ConfigMgr module for viewing a pending restart. Thankfully, Eswar Koneti's blog shows the information is stored in sms_combineddeviceresources.

After learning that part, I decided it would be dramatically faster to query the SQL database directly. The table the information resides in is dbo.vSMS_CombinedDeviceResources and is under ClientState. ClientState will have a value of zero if no pending reboot, and a value between 2 and 15 if there is a pending reboot. The list of those values is in the above blog link.

In the PowerShell script below, there are three parameters you will need to populate. The first is $Collection that contains the name of the collection you want to query. $SQLServer is the name of the SQL server. Finally, $SQLDatabase is the name of the ConfigMgr SQL database. You can populate them either at the command line, or hard code the data in the parameter field of the script.

I wrote the script in a way that it can be easily implemented into Azure Automation, SMA, or Orchestrator. The script will output the list of machines waiting for a reboot using the Write-Output and Exit with a return code of 1 if there is nothing to report. The exit code 1 is used with Orchestrator or SMA for the Link between the PowerShell script and the email. The link would not continue to the email task if there were an exit code of 1, as shown below.

NOTE: For this to access the SQL server, the script must be executed on the SQL server, or on a machine that has the SQL console. This is required, so PowerShell has access to the SQL PowerShell module.


You can download this script from my GitHub site.


 <#  
      .SYNOPSIS  
           ConfigMgr Reboot Report  
        
      .DESCRIPTION  
           This script will query ConfigMgr for a list of machines that are waiting for a reboot.  
        
      .PARAMETER Collection  
           Name of the collection to query  
        
      .PARAMETER SQLServer  
           Name of the SQL server  
        
      .PARAMETER SQLDatabase  
           A description of the SQLDatabase parameter.  
        
      .PARAMETER SQLInstance  
           Name of the SQL Database  
        
      .PARAMETER SCCMFQDN  
           Fully Qualified Domain Name of the ConfigMgr server  
        
      .NOTES  
           ===========================================================================  
           Created with:     SAPIEN Technologies, Inc., PowerShell Studio 2017 v5.4.142  
           Created on:       05/01/2020 09:39 AM  
           Created by:       Mick Pletcher  
           Filename:         ConfigMgrRebootReport.ps1  
           ===========================================================================  
 #>  
 [CmdletBinding()]  
 param  
 (  
      [ValidateNotNullOrEmpty()]  
      [string]$Collection = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLServer = '',  
      [ValidateNotNullOrEmpty()]  
      [string]$SQLDatabase = ''  
 )  
   
 $RebootListQuery = 'SELECT * FROM dbo.vSMS_CombinedDeviceResources WHERE ClientState <> 0'  
 $RebootList = (Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query $RebootListQuery).Name | Sort-Object  
 $CollectionQuery = 'SELECT * FROM' + [char]32 + 'dbo.' + ((Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query ('SELECT ResultTableName FROM dbo.v_Collections WHERE CollectionName = ' + [char]39 + $Collection + [char]39)).ResultTableName)  
 $CollectionList = (Invoke-Sqlcmd -ServerInstance $SQLServer -Database $SQLDatabase -Query $CollectionQuery).Name | Sort-Object  
 $List = @()  
 $RebootList | ForEach-Object { If ($_ -in $CollectionList) { $List += $_ } }  
 If ($List -ne '') {  
      Write-Output $List  
 } else {  
      Exit 1  
 }  
   

20 April 2020

Configuration Manager Default Antimalware Policy

While building my new Configuration Manager server, MECM, I took screenshots of the default antimalware policy settings in the event settings in it ever got changed. The default should not be changed. A new policy should be created and deployed.

Scheduled Scans


Scan Settings


Default Actions


Real-time Protection


Exclusion Settings


Advanced


Threat Overrides



Cloud Protection Service


Security Intelligence Updates



13 April 2020

Configuration Manager Default Client Settings

I just started building a completely new configuration manager server. While setting it up, I remembered that I wished in the past that I had all of the original default client settings because some did get changed. If you have ever made changes directly to the default client settings and cannot remember what the original settings were, then here is a screenshot of each section.

Background Intelligent Transfer



Client Cache Settings



Client Policy


Cloud Services



Compliance Settings



Computer Agent



Computer Restart



Delivery Optimization



Endpoint Protection



Enrollment



Hardware Inventory



Metered Internet Connections



Power Management



Remote Tools



Software Center



Software Deployment



Software Inventory



Software Metering



Software Updates



State Messaging



User and Device Affinity



Windows Analytics


21 February 2020

Upgrading to both Windows 10 1903 and 1909 with Configuration Manager

The time has come to do another creator update to the corporate systems. We skipped the 1903 upgrade because of the Windows 7 to Windows 10 deployment that completed just after the release of 1903. It was too soon to start another creator update project, so we waited until 1909. One of the new features we are wanting is the reserved space that was introduced in 1903. This reserves seven gigabytes of data for future updates and upgrades. The feature is set by default when an OS is installed during a build. It has to be turned on after the OS is deployed during an upgrade via a registry key. The problem is that once the key is set to one, the reserved space does not take effect until the next creator update. Another major issue I had was that the task sequence can only execute one OS upgrade at a time. This is because once the upgraded OS is installed, it will continue running the same task sequence during the upgrade process. The screenshot below shows where it executes the rest of the task sequence. There is no way to circumvent this process. If it continues with the following OS install, the task sequence errors out.


To get both OSs installed within the same task sequence, I set up the EndTS variable that stops the second install if the first one has executed. This means the same task sequence will need to be run twice to install both versions. The trick is to set the Rerun behavior to Rerun if succeeded on the previous attempt, as shown below. This allows for the task sequence to be executed a second time. It will skip over the first upgrade, 1903, and go right to 1909. If 1909 is already installed and the task sequence reruns, the top-level folder sequence will prevent it from executing any other tasks. The other trick to stop this from running again after both installs are complete is to have the collection filter out systems that have 1909 installed.



The first thing you want to do is to import the desired Windows 10 versions as an Operating System Upgrade in the configuration manager. Once the OS Upgrades are imported, one thing you might want to consider is copying the content to a distribution share so that you can access the package in remote offices in the event of an OS upgrade failure, as shown below. This will allow you to be able to manually execute the install on a failed machine remotely for further troubleshooting.


Next will be creating the task sequence. You will want to create a new task sequence to upgrade an operating system from an upgrade package.


Stepping through the process of creating this task sequence is self-explanatory. I am going to include screenshots of each screen.










Here is what the task sequence looks like after I got it set up and added all of the necessary features. Your task sequence will likely be somewhat different, as every company has a unique configuration.




This is the conditional WMI query I use for the top-level Upgrade folder. This is so if the latest OS version is already installed, then it will skip over all other task sequences.


The next thing I have it do is to set the task sequence variable EndTS to 0. This variable is used to skip over the second OS upgrade if the first one runs. It does this by including a task sequence under the first OS upgrade that sets the variable to one. When the second OS upgrade task sequence is executed, there is a rule to only run if the EndTS equals zero.



The PowerShell 7 upgrade is next and simply run the PowerShell 7 application installation.



Here are the specs for the Check Readiness for Upgrade:



One of the specifications we have is that laptops are left in the office and docked. The following PowerShell script does just that as a condition for continuing the task sequence:



I had a lot of problems getting the cleanmgr.exe to work and was never successful. I saw on several sites where others had the same issue. I ran across Fabian Castagna's PowerShell script that does much of the same as cleanmgr.exe does. I could never get it to delete the windows.old folder, but I have a solution further on in this blog.


For the first upgrade, Upgrade the Operating System to 1903, I used the WMI filter so that it will only install it if the OS is older than 1903.



The next step is installing the OS. This step readies the system for the actual upgrade that takes place in the proceeding reboot.



The reboot step will restart the system, at which point the actual upgrade will take place. The OS does not load in this step. I have a 60 second wait time along with a message in the event a user is on the system at 1:00 am when the task sequence starts.


The next sequence is to Enable Reserved Storage. This is where a registry key is set that enables this feature upon the next upgrade.

REG ADD HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\ReserveManager /v ShippedWithReserves /t REG_DWORD /d 1 /f


The next sequence reruns the disk cleanup script.


Next comes deleting the Windows.old directory. I worked on this for quite a while. It is not easy to do if you can't automate cleanmgr.exe. It ended up being a hit or miss thing. Sometime the script I had written would work, other times, it either was unsuccessful, or it crashed the OS when it deleted the files still being used as a service. What I came up with was to remove the entire folder at once within WinPE. The OS does not load, so no files are in use, thereby allowing for the complete deletion.

The first thing is to boot into WinPE.


The next step is to delete the folder using the following PowerShell code:


 $Drive = Get-PSDrive | Where-Object {($_.Provider -like 'Microsoft.PowerShell.Core\FileSystem*') -and ($_.Description -like 'Windows*')}  
 If (Test-Path ($Drive.Root + 'Windows.old')) {  
      $Argument = '/c RMDIR.exe' + [char]32 + '/S /Q' + [char]32 + $Drive.Root + 'Windows.old'  
      $ExitCode = (Start-Process -FilePath ($env:windir + '\system32\cmd.exe') -ArgumentList $Argument -PassThru -WindowStyle Minimized -Wait).ExitCode  
 }  



Next comes a reboot using the currently installed OS.



Now comes setting the EndTS variable to 1, so the following OS upgrade does not execute.


The next step is the 1909 upgrade. The only differences in this compared to the 1903 update are:

  • The Task sequence variable will be a condition set for the top-level folder of this task sequence
  • Enable Reserved Storage task sequence is not needed
  • End Task Sequence is also not required. 
Here are the conditions to run this set of task sequences that are set on the Upgrade the Operating System to the 1909 folder. We don't want this to execute unless 1903 is installed, and we don't want it to run if the task sequence just installed 1903.